diff --git a/.github/workflows/README.md b/.github/workflows/README.md index 3ee12ee95..ace5f89c8 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -140,6 +140,13 @@ and verifies the live service health and exact version. The privileged systemd smoke environment is digest-pinned because a floating container image would otherwise be an unreviewed code path inside the release gate. +The shared `install-sh-smoke-body.yml` inherits its caller's token permissions; +keep it free of workflow- or job-level permission overrides. Continuity calls +that body directly with `contents: read`. The existing `install-sh-smoke.yml` +manual/release entry point retains `contents: write` for unpublished draft asset +GETs and forwards the same inputs to the body. Calling that draft-capable entry +point from read-only continuity prevents workflow admission before any job runs. + Future release candidates also carry `release-build-provenance.sigstore.json`, produced by the hosted `build-release-candidate.yml` job after complete candidate validation. The diff --git a/.github/workflows/install-sh-smoke-body.yml b/.github/workflows/install-sh-smoke-body.yml new file mode 100644 index 000000000..55960ab1e --- /dev/null +++ b/.github/workflows/install-sh-smoke-body.yml @@ -0,0 +1,302 @@ +name: install.sh Smoke Body (Caller Permissions) + +# No permissions declaration here: inherit the caller's explicit budget. +# Continuity supplies contents:read; the draft-capable entry point supplies +# contents:write to read unpublished assets. Reusable workflows cannot elevate +# the caller's token, even when a write-requiring job would be skipped. +on: + workflow_call: + inputs: + tag: + description: 'Release tag (e.g., v6.0.0-rc.6)' + required: true + type: string + version: + description: 'Version without v prefix (e.g., 6.0.0-rc.6)' + required: true + type: string + repository: + description: 'owner/repo to pull the release from. Defaults to the workflow repository.' + required: false + type: string + default: '' + asset_source: + description: 'Asset source: staged for a draft release, or published for the public release URL.' + required: false + type: string + default: 'published' + release_id: + description: 'Draft release ID. Required when asset_source is staged.' + required: false + type: string + default: '' + +jobs: + smoke: + runs-on: ubuntu-24.04 + timeout-minutes: 15 + steps: + - name: Checkout repository (for README key extraction) + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Resolve smoke inputs + id: inputs + env: + INPUT_TAG: ${{ inputs.tag }} + INPUT_VERSION: ${{ inputs.version }} + INPUT_REPO: ${{ inputs.repository }} + INPUT_ASSET_SOURCE: ${{ inputs.asset_source }} + INPUT_RELEASE_ID: ${{ inputs.release_id }} + DEFAULT_REPO: ${{ github.repository }} + run: | + # workflow_call and workflow_dispatch both require tag + version, + # so these should always be present when this job runs. + tag="${INPUT_TAG}" + version="${INPUT_VERSION}" + if [ -z "$tag" ] || [ -z "$version" ]; then + echo "::error::install-sh-smoke requires both tag and version inputs" + exit 1 + fi + repo="${INPUT_REPO:-$DEFAULT_REPO}" + asset_source="${INPUT_ASSET_SOURCE:-published}" + release_id="${INPUT_RELEASE_ID:-}" + case "$asset_source" in + published) ;; + staged) + if [ -z "$release_id" ]; then + echo "::error::release_id is required when asset_source=staged" + exit 1 + fi + ;; + *) + echo "::error::asset_source must be staged or published, got: $asset_source" + exit 1 + ;; + esac + python3 scripts/write_github_output.py tag "$tag" + python3 scripts/write_github_output.py version "$version" + python3 scripts/write_github_output.py repo "$repo" + python3 scripts/write_github_output.py asset_source "$asset_source" + python3 scripts/write_github_output.py release_id "$release_id" + echo "Resolved: tag=$tag version=$version repo=$repo asset_source=$asset_source release_id=${release_id:-none}" + + - name: Download install.sh + sshsig + linux-amd64 tarball + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ steps.inputs.outputs.tag }} + REPO: ${{ steps.inputs.outputs.repo }} + ASSET_SOURCE: ${{ steps.inputs.outputs.asset_source }} + RELEASE_ID: ${{ steps.inputs.outputs.release_id }} + run: | + set -euo pipefail + mkdir -p smoke-workspace + cd smoke-workspace + tarball="pulse-${TAG}-linux-amd64.tar.gz" + assets=(install.sh install.sh.sshsig "${tarball}" "${tarball}.sshsig") + + if [ "$ASSET_SOURCE" = "staged" ]; then + assets_json=$(mktemp) + gh api --paginate "repos/${REPO}/releases/${RELEASE_ID}/assets?per_page=100" \ + | jq -s 'add' > "$assets_json" + + release_state=$(gh api "repos/${REPO}/releases/${RELEASE_ID}" \ + --jq '[.tag_name, (.draft | tostring), (.published_at // "")] | @tsv') + actual_tag=$(awk -F '\t' '{print $1}' <<<"$release_state") + is_draft=$(awk -F '\t' '{print $2}' <<<"$release_state") + published_at=$(awk -F '\t' '{print $3}' <<<"$release_state") + if [ "$actual_tag" != "$TAG" ] || [ "$is_draft" != "true" ] || [ -n "$published_at" ]; then + echo "::error::Release ${RELEASE_ID} is not the unpublished draft for ${TAG}." + exit 1 + fi + + for asset_name in "${assets[@]}"; do + asset_id=$(jq -r --arg name "$asset_name" \ + 'map(select(.name == $name)) | if length == 1 then .[0].id else empty end' \ + "$assets_json") + if [ -z "$asset_id" ]; then + echo "::error::Draft release ${RELEASE_ID} does not contain exactly one ${asset_name} asset." + exit 1 + fi + gh api \ + -H 'Accept: application/octet-stream' \ + "repos/${REPO}/releases/assets/${asset_id}" > "$asset_name" + done + rm -f "$assets_json" + else + base="https://github.com/${REPO}/releases/download/${TAG}" + echo "Pulling from ${base}/" + for asset_name in "${assets[@]}"; do + curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors \ + -o "$asset_name" "${base}/${asset_name}" + done + fi + + echo "Downloaded:" + ls -la + + - name: Verify install.sh signature with README's pinned key + env: + TAG: ${{ steps.inputs.outputs.tag }} + run: | + set -euo pipefail + readme_key=$(grep -oE 'ssh-ed25519 [A-Za-z0-9+/=]+ pulse-installer' README.md | head -1) + if [ -z "$readme_key" ]; then + echo "::error::Could not extract pulse-installer key from README.md" + exit 1 + fi + echo "README pins: $readme_key" + + allowed_signers=$(mktemp) + printf 'pulse-installer %s\n' "$readme_key" > "$allowed_signers" + + cd smoke-workspace + if ! ssh-keygen -Y verify \ + -f "$allowed_signers" \ + -I pulse-installer \ + -n pulse-install \ + -s install.sh.sshsig < install.sh; then + echo "::error::Published install.sh.sshsig does not verify against the README's pinned key." + echo "::error::Either README.md is pinning the wrong key or the pipeline signed with a different key." + rm -f "$allowed_signers" + exit 1 + fi + rm -f "$allowed_signers" + echo "✓ install.sh signature verifies against README's pinned key" + + - name: Assert install.sh is the Pulse server installer + run: | + set -euo pipefail + cd smoke-workspace + if ! grep -qE '^# Pulse Installer Script' install.sh; then + echo "::error::install.sh banner is not the Pulse server installer" + exit 1 + fi + if grep -q 'Pulse Unified Agent Installer' install.sh; then + echo "::error::install.sh is the agent installer, not the server installer" + exit 1 + fi + if ! grep -qE '^[[:space:]]*--version\)' install.sh; then + echo "::error::install.sh does not handle --version" + exit 1 + fi + echo "✓ install.sh is the server installer with --version support" + + - name: Run install.sh end-to-end in a privileged systemd container + env: + TAG: ${{ steps.inputs.outputs.tag }} + VERSION: ${{ steps.inputs.outputs.version }} + run: | + set -euo pipefail + tarball="pulse-${TAG}-linux-amd64.tar.gz" + container_name="pulse-install-smoke-$$" + + # Cleanup on exit no matter what. + trap 'docker rm -f "${container_name}" >/dev/null 2>&1 || true' EXIT + + # jrei/systemd-debian:12 is a community systemd-in-Docker image used + # for Ansible / Molecule testing — small, no Pulse-specific assumptions. + # Keep the privileged test environment bound to an immutable index. + # A floating image here would let a registry retag replace code in a + # release-gating job that needs contents:write to read draft assets. + # GHA ubuntu-24.04 runners use cgroup v2 unified hierarchy; without + # --cgroupns=host the container gets its own cgroup namespace and + # systemd PID 1 exits before it can mount the cgroup tree, causing + # the container to disappear mid-boot. /run/lock must also be tmpfs + # for systemd-tmpfiles. We drop --rm so a failed boot leaves logs + # behind for diagnosis; the trap removes the container on exit. + docker run -d \ + --name "${container_name}" \ + --privileged \ + --cgroupns=host \ + --tmpfs /tmp --tmpfs /run --tmpfs /run/lock \ + -v /sys/fs/cgroup:/sys/fs/cgroup:rw \ + -v "$(pwd)/smoke-workspace:/smoke" \ + -p 7655:7655 \ + jrei/systemd-debian:12@sha256:61d70dc3e574337bd9df794674a60ae73113460fff16ab41a2d234b4a11dcd98 + + echo "Waiting for systemd to be ready inside the container..." + for i in $(seq 1 30); do + if ! docker inspect -f '{{.State.Running}}' "${container_name}" 2>/dev/null | grep -q true; then + echo "::error::Container ${container_name} is no longer running." + docker inspect -f 'ExitCode={{.State.ExitCode}} Error={{.State.Error}}' "${container_name}" || true + docker logs "${container_name}" || true + exit 1 + fi + if docker exec "${container_name}" systemctl is-system-running --wait 2>/dev/null | grep -qE '^(running|degraded)$'; then + break + fi + if [ "$i" -eq 30 ]; then + docker logs "${container_name}" || true + docker exec "${container_name}" systemctl --no-pager status || true + docker exec "${container_name}" journalctl --no-pager --lines=120 || true + echo "::error::systemd did not become ready inside the container" + exit 1 + fi + sleep 2 + done + echo "✓ systemd is up" + + echo "Installing prerequisites inside container..." + docker exec "${container_name}" bash -lc 'apt-get update -qq && apt-get install -y -qq curl ca-certificates jq sudo' + + echo "Running install.sh --archive against the published tarball..." + # docker exec without -t leaves stdin without a TTY, which install.sh's + # safe_read helper detects and falls through to defaults on every prompt. + # PULSE_INSTALL_ALLOW_DOCKER=1 opts the smoke harness past install.sh's + # Docker-environment refusal — install.sh treats the test container as + # a normal systemd host, which is the exact contract this gate exists + # to validate. + docker exec -e PULSE_INSTALL_ALLOW_DOCKER=1 "${container_name}" \ + bash -lc "cd /smoke && PULSE_INSTALL_ALLOW_DOCKER=1 bash install.sh --archive /smoke/${tarball} --disable-auto-updates" + + echo "Waiting for pulse.service to become active..." + for i in $(seq 1 60); do + state=$(docker exec "${container_name}" systemctl is-active pulse 2>/dev/null || true) + if [ "$state" = "active" ]; then + break + fi + if [ "$i" -eq 60 ]; then + docker exec "${container_name}" systemctl status pulse --no-pager || true + docker exec "${container_name}" journalctl -u pulse --no-pager --lines=80 || true + echo "::error::pulse.service did not become active within 2 minutes" + exit 1 + fi + sleep 2 + done + echo "✓ pulse.service is active" + + echo "Hitting /api/health (curl --retry handles the poll loop)..." + if ! docker exec "${container_name}" curl -fsS --retry 30 --retry-delay 2 --retry-connrefused --retry-all-errors http://127.0.0.1:7655/api/health >/dev/null; then + docker exec "${container_name}" systemctl status pulse --no-pager || true + docker exec "${container_name}" journalctl -u pulse --no-pager --lines=80 || true + echo "::error::/api/health did not respond within 60 seconds of service activation" + exit 1 + fi + echo "✓ /api/health responded 200" + + echo "Confirming installed version matches ${VERSION} via /api/version..." + # /api/health intentionally does not include version; /api/version is + # the authoritative endpoint and is one of the canonical post-upgrade + # checks documented in docs/UPGRADE_v6.md. + version_payload=$(docker exec "${container_name}" curl -fsS http://127.0.0.1:7655/api/version) + echo "Version payload: ${version_payload}" + installed_version=$(echo "${version_payload}" | jq -r '.version // empty') + if [ -z "${installed_version}" ]; then + echo "::error::/api/version did not include a version field" + exit 1 + fi + # Normalize: VERSION input is "6.0.0-rc.6", installed_version may be "v6.0.0-rc.6". + installed_version="${installed_version#v}" + if [ "${installed_version}" != "${VERSION}" ]; then + echo "::error::Installed version mismatch. Expected ${VERSION}, got ${installed_version}" + exit 1 + fi + echo "✓ Installed version matches ${VERSION}" + + - name: Smoke result + env: + WORKFLOW_OUTPUT_1: ${{ steps.inputs.outputs.tag }} + run: | + echo "::notice::install.sh smoke passed for tag ${WORKFLOW_OUTPUT_1}" diff --git a/.github/workflows/install-sh-smoke.yml b/.github/workflows/install-sh-smoke.yml index b4b6012a5..b0a06e694 100644 --- a/.github/workflows/install-sh-smoke.yml +++ b/.github/workflows/install-sh-smoke.yml @@ -92,274 +92,13 @@ concurrency: jobs: smoke: - runs-on: ubuntu-24.04 - timeout-minutes: 15 permissions: - # Unpublished draft release metadata and assets are unavailable to a - # read-scoped GITHUB_TOKEN even though this job only performs GETs. + # Unpublished draft assets require write-level repository access. contents: write - steps: - - name: Checkout repository (for README key extraction) - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - - name: Resolve smoke inputs - id: inputs - env: - INPUT_TAG: ${{ inputs.tag }} - INPUT_VERSION: ${{ inputs.version }} - INPUT_REPO: ${{ inputs.repository }} - INPUT_ASSET_SOURCE: ${{ inputs.asset_source }} - INPUT_RELEASE_ID: ${{ inputs.release_id }} - DEFAULT_REPO: ${{ github.repository }} - run: | - # workflow_call and workflow_dispatch both require tag + version, - # so these should always be present when this job runs. - tag="${INPUT_TAG}" - version="${INPUT_VERSION}" - if [ -z "$tag" ] || [ -z "$version" ]; then - echo "::error::install-sh-smoke requires both tag and version inputs" - exit 1 - fi - repo="${INPUT_REPO:-$DEFAULT_REPO}" - asset_source="${INPUT_ASSET_SOURCE:-published}" - release_id="${INPUT_RELEASE_ID:-}" - case "$asset_source" in - published) ;; - staged) - if [ -z "$release_id" ]; then - echo "::error::release_id is required when asset_source=staged" - exit 1 - fi - ;; - *) - echo "::error::asset_source must be staged or published, got: $asset_source" - exit 1 - ;; - esac - python3 scripts/write_github_output.py tag "$tag" - python3 scripts/write_github_output.py version "$version" - python3 scripts/write_github_output.py repo "$repo" - python3 scripts/write_github_output.py asset_source "$asset_source" - python3 scripts/write_github_output.py release_id "$release_id" - echo "Resolved: tag=$tag version=$version repo=$repo asset_source=$asset_source release_id=${release_id:-none}" - - - name: Download install.sh + sshsig + linux-amd64 tarball - env: - GH_TOKEN: ${{ github.token }} - TAG: ${{ steps.inputs.outputs.tag }} - REPO: ${{ steps.inputs.outputs.repo }} - ASSET_SOURCE: ${{ steps.inputs.outputs.asset_source }} - RELEASE_ID: ${{ steps.inputs.outputs.release_id }} - run: | - set -euo pipefail - mkdir -p smoke-workspace - cd smoke-workspace - tarball="pulse-${TAG}-linux-amd64.tar.gz" - assets=(install.sh install.sh.sshsig "${tarball}" "${tarball}.sshsig") - - if [ "$ASSET_SOURCE" = "staged" ]; then - assets_json=$(mktemp) - gh api --paginate "repos/${REPO}/releases/${RELEASE_ID}/assets?per_page=100" \ - | jq -s 'add' > "$assets_json" - - release_state=$(gh api "repos/${REPO}/releases/${RELEASE_ID}" \ - --jq '[.tag_name, (.draft | tostring), (.published_at // "")] | @tsv') - actual_tag=$(awk -F '\t' '{print $1}' <<<"$release_state") - is_draft=$(awk -F '\t' '{print $2}' <<<"$release_state") - published_at=$(awk -F '\t' '{print $3}' <<<"$release_state") - if [ "$actual_tag" != "$TAG" ] || [ "$is_draft" != "true" ] || [ -n "$published_at" ]; then - echo "::error::Release ${RELEASE_ID} is not the unpublished draft for ${TAG}." - exit 1 - fi - - for asset_name in "${assets[@]}"; do - asset_id=$(jq -r --arg name "$asset_name" \ - 'map(select(.name == $name)) | if length == 1 then .[0].id else empty end' \ - "$assets_json") - if [ -z "$asset_id" ]; then - echo "::error::Draft release ${RELEASE_ID} does not contain exactly one ${asset_name} asset." - exit 1 - fi - gh api \ - -H 'Accept: application/octet-stream' \ - "repos/${REPO}/releases/assets/${asset_id}" > "$asset_name" - done - rm -f "$assets_json" - else - base="https://github.com/${REPO}/releases/download/${TAG}" - echo "Pulling from ${base}/" - for asset_name in "${assets[@]}"; do - curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors \ - -o "$asset_name" "${base}/${asset_name}" - done - fi - - echo "Downloaded:" - ls -la - - - name: Verify install.sh signature with README's pinned key - env: - TAG: ${{ steps.inputs.outputs.tag }} - run: | - set -euo pipefail - readme_key=$(grep -oE 'ssh-ed25519 [A-Za-z0-9+/=]+ pulse-installer' README.md | head -1) - if [ -z "$readme_key" ]; then - echo "::error::Could not extract pulse-installer key from README.md" - exit 1 - fi - echo "README pins: $readme_key" - - allowed_signers=$(mktemp) - printf 'pulse-installer %s\n' "$readme_key" > "$allowed_signers" - - cd smoke-workspace - if ! ssh-keygen -Y verify \ - -f "$allowed_signers" \ - -I pulse-installer \ - -n pulse-install \ - -s install.sh.sshsig < install.sh; then - echo "::error::Published install.sh.sshsig does not verify against the README's pinned key." - echo "::error::Either README.md is pinning the wrong key or the pipeline signed with a different key." - rm -f "$allowed_signers" - exit 1 - fi - rm -f "$allowed_signers" - echo "✓ install.sh signature verifies against README's pinned key" - - - name: Assert install.sh is the Pulse server installer - run: | - set -euo pipefail - cd smoke-workspace - if ! grep -qE '^# Pulse Installer Script' install.sh; then - echo "::error::install.sh banner is not the Pulse server installer" - exit 1 - fi - if grep -q 'Pulse Unified Agent Installer' install.sh; then - echo "::error::install.sh is the agent installer, not the server installer" - exit 1 - fi - if ! grep -qE '^[[:space:]]*--version\)' install.sh; then - echo "::error::install.sh does not handle --version" - exit 1 - fi - echo "✓ install.sh is the server installer with --version support" - - - name: Run install.sh end-to-end in a privileged systemd container - env: - TAG: ${{ steps.inputs.outputs.tag }} - VERSION: ${{ steps.inputs.outputs.version }} - run: | - set -euo pipefail - tarball="pulse-${TAG}-linux-amd64.tar.gz" - container_name="pulse-install-smoke-$$" - - # Cleanup on exit no matter what. - trap 'docker rm -f "${container_name}" >/dev/null 2>&1 || true' EXIT - - # jrei/systemd-debian:12 is a community systemd-in-Docker image used - # for Ansible / Molecule testing — small, no Pulse-specific assumptions. - # Keep the privileged test environment bound to an immutable index. - # A floating image here would let a registry retag replace code in a - # release-gating job that needs contents:write to read draft assets. - # GHA ubuntu-24.04 runners use cgroup v2 unified hierarchy; without - # --cgroupns=host the container gets its own cgroup namespace and - # systemd PID 1 exits before it can mount the cgroup tree, causing - # the container to disappear mid-boot. /run/lock must also be tmpfs - # for systemd-tmpfiles. We drop --rm so a failed boot leaves logs - # behind for diagnosis; the trap removes the container on exit. - docker run -d \ - --name "${container_name}" \ - --privileged \ - --cgroupns=host \ - --tmpfs /tmp --tmpfs /run --tmpfs /run/lock \ - -v /sys/fs/cgroup:/sys/fs/cgroup:rw \ - -v "$(pwd)/smoke-workspace:/smoke" \ - -p 7655:7655 \ - jrei/systemd-debian:12@sha256:61d70dc3e574337bd9df794674a60ae73113460fff16ab41a2d234b4a11dcd98 - - echo "Waiting for systemd to be ready inside the container..." - for i in $(seq 1 30); do - if ! docker inspect -f '{{.State.Running}}' "${container_name}" 2>/dev/null | grep -q true; then - echo "::error::Container ${container_name} is no longer running." - docker inspect -f 'ExitCode={{.State.ExitCode}} Error={{.State.Error}}' "${container_name}" || true - docker logs "${container_name}" || true - exit 1 - fi - if docker exec "${container_name}" systemctl is-system-running --wait 2>/dev/null | grep -qE '^(running|degraded)$'; then - break - fi - if [ "$i" -eq 30 ]; then - docker logs "${container_name}" || true - docker exec "${container_name}" systemctl --no-pager status || true - docker exec "${container_name}" journalctl --no-pager --lines=120 || true - echo "::error::systemd did not become ready inside the container" - exit 1 - fi - sleep 2 - done - echo "✓ systemd is up" - - echo "Installing prerequisites inside container..." - docker exec "${container_name}" bash -lc 'apt-get update -qq && apt-get install -y -qq curl ca-certificates jq sudo' - - echo "Running install.sh --archive against the published tarball..." - # docker exec without -t leaves stdin without a TTY, which install.sh's - # safe_read helper detects and falls through to defaults on every prompt. - # PULSE_INSTALL_ALLOW_DOCKER=1 opts the smoke harness past install.sh's - # Docker-environment refusal — install.sh treats the test container as - # a normal systemd host, which is the exact contract this gate exists - # to validate. - docker exec -e PULSE_INSTALL_ALLOW_DOCKER=1 "${container_name}" \ - bash -lc "cd /smoke && PULSE_INSTALL_ALLOW_DOCKER=1 bash install.sh --archive /smoke/${tarball} --disable-auto-updates" - - echo "Waiting for pulse.service to become active..." - for i in $(seq 1 60); do - state=$(docker exec "${container_name}" systemctl is-active pulse 2>/dev/null || true) - if [ "$state" = "active" ]; then - break - fi - if [ "$i" -eq 60 ]; then - docker exec "${container_name}" systemctl status pulse --no-pager || true - docker exec "${container_name}" journalctl -u pulse --no-pager --lines=80 || true - echo "::error::pulse.service did not become active within 2 minutes" - exit 1 - fi - sleep 2 - done - echo "✓ pulse.service is active" - - echo "Hitting /api/health (curl --retry handles the poll loop)..." - if ! docker exec "${container_name}" curl -fsS --retry 30 --retry-delay 2 --retry-connrefused --retry-all-errors http://127.0.0.1:7655/api/health >/dev/null; then - docker exec "${container_name}" systemctl status pulse --no-pager || true - docker exec "${container_name}" journalctl -u pulse --no-pager --lines=80 || true - echo "::error::/api/health did not respond within 60 seconds of service activation" - exit 1 - fi - echo "✓ /api/health responded 200" - - echo "Confirming installed version matches ${VERSION} via /api/version..." - # /api/health intentionally does not include version; /api/version is - # the authoritative endpoint and is one of the canonical post-upgrade - # checks documented in docs/UPGRADE_v6.md. - version_payload=$(docker exec "${container_name}" curl -fsS http://127.0.0.1:7655/api/version) - echo "Version payload: ${version_payload}" - installed_version=$(echo "${version_payload}" | jq -r '.version // empty') - if [ -z "${installed_version}" ]; then - echo "::error::/api/version did not include a version field" - exit 1 - fi - # Normalize: VERSION input is "6.0.0-rc.6", installed_version may be "v6.0.0-rc.6". - installed_version="${installed_version#v}" - if [ "${installed_version}" != "${VERSION}" ]; then - echo "::error::Installed version mismatch. Expected ${VERSION}, got ${installed_version}" - exit 1 - fi - echo "✓ Installed version matches ${VERSION}" - - - name: Smoke result - env: - WORKFLOW_OUTPUT_1: ${{ steps.inputs.outputs.tag }} - run: | - echo "::notice::install.sh smoke passed for tag ${WORKFLOW_OUTPUT_1}" + uses: ./.github/workflows/install-sh-smoke-body.yml + with: + tag: ${{ inputs.tag }} + version: ${{ inputs.version }} + repository: ${{ inputs.repository }} + asset_source: ${{ inputs.asset_source }} + release_id: ${{ inputs.release_id }} diff --git a/.github/workflows/stable-install-continuity.yml b/.github/workflows/stable-install-continuity.yml index c0e28de16..fd0e139c5 100644 --- a/.github/workflows/stable-install-continuity.yml +++ b/.github/workflows/stable-install-continuity.yml @@ -64,7 +64,7 @@ jobs: needs: resolve permissions: contents: read - uses: ./.github/workflows/install-sh-smoke.yml + uses: ./.github/workflows/install-sh-smoke-body.yml with: tag: ${{ needs.resolve.outputs.tag }} version: ${{ needs.resolve.outputs.version }} diff --git a/scripts/installtests/build_release_assets_test.go b/scripts/installtests/build_release_assets_test.go index d65eb2e0d..51239852b 100644 --- a/scripts/installtests/build_release_assets_test.go +++ b/scripts/installtests/build_release_assets_test.go @@ -3048,12 +3048,11 @@ func TestBuildReleasePackagesPulseMcpForAllPlatforms(t *testing.T) { // cannot return. func TestInstallShSmokeWorkflowPresent(t *testing.T) { - workflowPath := repoFile(".github", "workflows", "install-sh-smoke.yml") + workflowPath := repoFile(".github", "workflows", "install-sh-smoke-body.yml") assertFileContainsAll(t, workflowPath, // Inputs and triggers. - `name: install.sh Smoke (Release Assets)`, + `name: install.sh Smoke Body (Caller Permissions)`, `workflow_call:`, - `workflow_dispatch:`, `asset_source:`, `release_id:`, // Staged cuts use authenticated draft assets; manual verification can @@ -3087,12 +3086,18 @@ func TestInstallShSmokeWorkflowPresent(t *testing.T) { workflowBytes, err := os.ReadFile(workflowPath) if err != nil { - t.Fatalf("read install-sh-smoke workflow: %v", err) + t.Fatal(err) } - smokeJob := workflowJobBlock(t, string(workflowBytes), "smoke") - if !strings.Contains(smokeJob, "contents: write") { - t.Fatal("install-sh-smoke.yml smoke job must grant contents: write to read unpublished draft release assets") + if strings.Contains(string(workflowBytes), "permissions:") { + t.Fatal("shared smoke body must inherit its caller budget, not request elevated permissions") } + assertFileContainsAll(t, repoFile(".github", "workflows", "install-sh-smoke.yml"), + `workflow_dispatch:`, + `workflow_call:`, + `contents: write`, + `uses: ./.github/workflows/install-sh-smoke-body.yml`, + `release_id: ${{ inputs.release_id }}`, + ) } func TestStableInstallContinuityReinstallsLatestReleaseReadOnly(t *testing.T) { @@ -3106,7 +3111,7 @@ func TestStableInstallContinuityReinstallsLatestReleaseReadOnly(t *testing.T) { `"repos/${REPOSITORY}/releases/latest"`, `scripts/release_control/release_continuity.py release`, `version=${tag#v}`, - `uses: ./.github/workflows/install-sh-smoke.yml`, + `uses: ./.github/workflows/install-sh-smoke-body.yml`, `tag: ${{ needs.resolve.outputs.tag }}`, `version: ${{ needs.resolve.outputs.version }}`, `asset_source: published`,