diff --git a/.github/workflows/README.md b/.github/workflows/README.md index 3b347eb5d..a1719d505 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -36,9 +36,11 @@ six-hour default. Each workflow declares its default `GITHUB_TOKEN` permissions explicitly, and both workflow defaults and job-level overrides enumerate scopes instead of using `read-all`, `write-all`, or dynamic grants. Workflow inputs, secrets, -`github.token`, and attacker-controlled GitHub event metadata are passed to -`run` steps through `env`; they are data and must never be interpolated into -the generated shell program. +`github.token`, step and job outputs, and attacker-controlled GitHub event +metadata are passed to `run` steps through `env`; they are data and must never +be interpolated into the generated shell program. Outputs remain data even +when an intermediate step parsed or validated them, because later substitution +would turn their value back into shell source. Workflows triggered by `pull_request` cannot reference confidential repository secrets. Canonical governance therefore keeps its pull-request checks local to diff --git a/.github/workflows/canonical-governance.yml b/.github/workflows/canonical-governance.yml index fc9dd1b05..4f4387fc2 100644 --- a/.github/workflows/canonical-governance.yml +++ b/.github/workflows/canonical-governance.yml @@ -71,9 +71,11 @@ jobs: - name: Run canonical completion guard against changed commits shell: bash + env: + WORKFLOW_OUTPUT_1: ${{ steps.diff.outputs.range }} run: | set -euo pipefail - range="${{ steps.diff.outputs.range }}" + range="${WORKFLOW_OUTPUT_1}" if [ -z "${range}" ]; then printf '' | python3 scripts/release_control/canonical_completion_guard.py --files-from-stdin exit 0 diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index 7c594b811..a08f80963 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -146,17 +146,23 @@ jobs: - name: Resolve required release branch id: branch_policy + env: + WORKFLOW_OUTPUT_1: ${{ steps.extract.outputs.version }} + WORKFLOW_OUTPUT_2: ${{ steps.extract.outputs.source_branch }} run: | - REQUIRED_BRANCH="$(python3 scripts/release_control/control_plane.py --branch-for-version "${{ steps.extract.outputs.version }}")" - if [ "${{ steps.extract.outputs.source_branch }}" != "$REQUIRED_BRANCH" ]; then - echo "::error::Invalid release line. Version ${{ steps.extract.outputs.version }} must run from ${REQUIRED_BRANCH}, but workflow ref is ${{ steps.extract.outputs.source_branch }}." + REQUIRED_BRANCH="$(python3 scripts/release_control/control_plane.py --branch-for-version "${WORKFLOW_OUTPUT_1}")" + if [ "${WORKFLOW_OUTPUT_2}" != "$REQUIRED_BRANCH" ]; then + echo "::error::Invalid release line. Version ${WORKFLOW_OUTPUT_1} must run from ${REQUIRED_BRANCH}, but workflow ref is ${WORKFLOW_OUTPUT_2}." exit 1 fi echo "required_branch=${REQUIRED_BRANCH}" >> "$GITHUB_OUTPUT" - echo "[OK] Governed release branch for ${{ steps.extract.outputs.version }} is ${REQUIRED_BRANCH}" + echo "[OK] Governed release branch for ${WORKFLOW_OUTPUT_1} is ${REQUIRED_BRANCH}" - name: Validate release-note visual plan id: visual_plan + env: + WORKFLOW_OUTPUT_1: ${{ steps.extract.outputs.historical_asset_backfill_only }} + WORKFLOW_OUTPUT_2: ${{ steps.extract.outputs.version }} run: | set -euo pipefail PLAN_FILE=$(mktemp) @@ -171,9 +177,9 @@ jobs: count --plan "$PLAN_FILE") COMPARISON_TAG="" if [ "$CAPTURE_COUNT" -gt 0 ] && \ - [ "${{ steps.extract.outputs.historical_asset_backfill_only }}" != "true" ]; then + [ "${WORKFLOW_OUTPUT_1}" != "true" ]; then COMPARISON_TAG=$(./scripts/generate-release-notes.sh \ - --resolve-base "${{ steps.extract.outputs.version }}") + --resolve-base "${WORKFLOW_OUTPUT_2}") fi echo "capture_count=${CAPTURE_COUNT}" >> "$GITHUB_OUTPUT" echo "comparison_tag=${COMPARISON_TAG}" >> "$GITHUB_OUTPUT" @@ -181,9 +187,11 @@ jobs: - name: Validate VERSION file if: ${{ steps.extract.outputs.historical_asset_backfill_only != 'true' }} + env: + WORKFLOW_OUTPUT_1: ${{ steps.extract.outputs.version }} run: | FILE_VERSION=$(cat VERSION | tr -d '\n') - REQUESTED_VERSION="${{ steps.extract.outputs.version }}" + REQUESTED_VERSION="${WORKFLOW_OUTPUT_1}" if [ "$FILE_VERSION" != "$REQUESTED_VERSION" ]; then echo "::error::VERSION file ($FILE_VERSION) does not match requested version ($REQUESTED_VERSION)." echo "The VERSION file must be updated and committed before running release." @@ -196,10 +204,11 @@ jobs: env: MOBILE_RELEASE_DECISION: ${{ github.event.inputs.mobile_release_decision }} MOBILE_RELEASE_EVIDENCE: ${{ github.event.inputs.mobile_release_evidence }} + WORKFLOW_OUTPUT_1: ${{ steps.extract.outputs.version }} run: | set -euo pipefail python3 scripts/release_control/mobile_release_gate.py \ - --version "${{ steps.extract.outputs.version }}" \ + --version "${WORKFLOW_OUTPUT_1}" \ --decision "${MOBILE_RELEASE_DECISION}" \ --evidence "${MOBILE_RELEASE_EVIDENCE}" \ --github-annotations @@ -778,13 +787,15 @@ jobs: - name: Capture comparison and candidate views if: ${{ needs.prepare.outputs.visual_capture_count != '0' }} + env: + WORKFLOW_OUTPUT_1: ${{ needs.prepare.outputs.visual_comparison_tag }} run: | set -euo pipefail PLAN_FILE=$(mktemp) jq -er '.inputs.release_screenshot_plan' "$GITHUB_EVENT_PATH" > "$PLAN_FILE" bash scripts/capture-release-note-visuals.sh \ "$PLAN_FILE" \ - "${{ needs.prepare.outputs.visual_comparison_tag }}" \ + "${WORKFLOW_OUTPUT_1}" \ release-note-visuals - name: Upload release-note visual artifact @@ -840,18 +851,34 @@ jobs: path: release-note-visuals - name: Verify immutable release candidate + env: + WORKFLOW_OUTPUT_1: ${{ needs.prepare.outputs.version }} run: | python3 scripts/release_candidate_manifest.py verify-local \ --release-dir release \ --manifest release-candidate-manifest/release-candidate.json \ - --version "${{ needs.prepare.outputs.version }}" \ + --version "${WORKFLOW_OUTPUT_1}" \ --source-sha "${GITHUB_SHA}" - name: Prepare release notes id: generate_notes + env: + WORKFLOW_OUTPUT_1: ${{ needs.prepare.outputs.version }} + WORKFLOW_OUTPUT_2: ${{ needs.prepare.outputs.tag }} + WORKFLOW_OUTPUT_3: ${{ needs.prepare.outputs.release_stage }} + WORKFLOW_OUTPUT_4: ${{ needs.prepare.outputs.promoted_from_tag }} + WORKFLOW_OUTPUT_5: ${{ needs.prepare.outputs.rollback_tag }} + WORKFLOW_OUTPUT_6: ${{ needs.prepare.outputs.rollback_command }} + WORKFLOW_OUTPUT_7: ${{ needs.prepare.outputs.ga_date }} + WORKFLOW_OUTPUT_8: ${{ needs.prepare.outputs.v5_eos_date }} + WORKFLOW_OUTPUT_9: ${{ needs.prepare.outputs.hotfix_exception }} + WORKFLOW_OUTPUT_10: ${{ needs.prepare.outputs.hotfix_reason }} + WORKFLOW_OUTPUT_11: ${{ needs.prepare.outputs.require_windows_signing }} + WORKFLOW_OUTPUT_12: ${{ needs.prepare.outputs.unsigned_windows_exception }} + WORKFLOW_OUTPUT_13: ${{ needs.prepare.outputs.unsigned_windows_reason }} run: | set -euo pipefail - VERSION="${{ needs.prepare.outputs.version }}" + VERSION="${WORKFLOW_OUTPUT_1}" NOTES_FILE=$(mktemp) if ! jq -er '.inputs.release_notes | select(type == "string" and length > 0)' \ "$GITHUB_EVENT_PATH" > "$NOTES_FILE"; then @@ -870,25 +897,25 @@ jobs: python3 scripts/release_control/release_note_visuals.py render \ --plan "$VISUAL_PLAN_FILE" \ --repository "${{ github.repository }}" \ - --tag "${{ needs.prepare.outputs.tag }}" \ + --tag "${WORKFLOW_OUTPUT_2}" \ --output "$VISUAL_MARKDOWN_FILE" python3 scripts/release_control/render_release_body.py \ --version "$VERSION" \ --release-notes-file "$NOTES_FILE" \ --release-visuals-file "$VISUAL_MARKDOWN_FILE" \ --output "$RENDERED_NOTES_FILE" \ - --promotion-channel "${{ needs.prepare.outputs.release_stage }}" \ - --candidate-tag "${{ needs.prepare.outputs.tag }}" \ - --promoted-prerelease-tag "${{ needs.prepare.outputs.promoted_from_tag }}" \ - --rollback-target "${{ needs.prepare.outputs.rollback_tag }}" \ - --rollback-command "${{ needs.prepare.outputs.rollback_command }}" \ - --planned-ga-date "${{ needs.prepare.outputs.ga_date }}" \ - --planned-v5-eos-date "${{ needs.prepare.outputs.v5_eos_date }}" \ - --hotfix-exception "${{ needs.prepare.outputs.hotfix_exception }}" \ - --hotfix-reason "${{ needs.prepare.outputs.hotfix_reason }}" \ - --require-windows-signing "${{ needs.prepare.outputs.require_windows_signing }}" \ - --unsigned-windows-exception "${{ needs.prepare.outputs.unsigned_windows_exception }}" \ - --unsigned-windows-reason "${{ needs.prepare.outputs.unsigned_windows_reason }}" + --promotion-channel "${WORKFLOW_OUTPUT_3}" \ + --candidate-tag "${WORKFLOW_OUTPUT_2}" \ + --promoted-prerelease-tag "${WORKFLOW_OUTPUT_4}" \ + --rollback-target "${WORKFLOW_OUTPUT_5}" \ + --rollback-command "${WORKFLOW_OUTPUT_6}" \ + --planned-ga-date "${WORKFLOW_OUTPUT_7}" \ + --planned-v5-eos-date "${WORKFLOW_OUTPUT_8}" \ + --hotfix-exception "${WORKFLOW_OUTPUT_9}" \ + --hotfix-reason "${WORKFLOW_OUTPUT_10}" \ + --require-windows-signing "${WORKFLOW_OUTPUT_11}" \ + --unsigned-windows-exception "${WORKFLOW_OUTPUT_12}" \ + --unsigned-windows-reason "${WORKFLOW_OUTPUT_13}" # Customer-facing improvements provide the compact pre-update preview. # Historical Highlights sections remain supported for older packets. @@ -910,8 +937,9 @@ jobs: id: existing_release env: GH_TOKEN: ${{ github.token }} + WORKFLOW_OUTPUT_1: ${{ needs.prepare.outputs.tag }} run: | - TAG="${{ needs.prepare.outputs.tag }}" + TAG="${WORKFLOW_OUTPUT_1}" EXISTING_RELEASE=$(gh api "repos/${{ github.repository }}/releases?per_page=100" --paginate | jq -sc --arg tag "$TAG" 'add | map(select(.tag_name == $tag)) | first // empty') RELEASE_ID=$(echo "$EXISTING_RELEASE" | jq -r '.id // empty') RELEASE_URL=$(echo "$EXISTING_RELEASE" | jq -r '.html_url // empty') @@ -928,13 +956,18 @@ jobs: - name: Create tag env: GH_TOKEN: ${{ github.token }} + WORKFLOW_OUTPUT_1: ${{ needs.prepare.outputs.tag }} + WORKFLOW_OUTPUT_2: ${{ steps.existing_release.outputs.release_id }} + WORKFLOW_OUTPUT_3: ${{ steps.existing_release.outputs.release_is_draft }} + WORKFLOW_OUTPUT_4: ${{ steps.existing_release.outputs.release_published_at }} + WORKFLOW_OUTPUT_5: ${{ steps.existing_release.outputs.release_activation_committed }} run: | - TAG="${{ needs.prepare.outputs.tag }}" + TAG="${WORKFLOW_OUTPUT_1}" HEAD_SHA=$(git rev-parse HEAD) - EXISTING_RELEASE_ID="${{ steps.existing_release.outputs.release_id }}" - EXISTING_RELEASE_DRAFT="${{ steps.existing_release.outputs.release_is_draft }}" - EXISTING_RELEASE_PUBLISHED_AT="${{ steps.existing_release.outputs.release_published_at }}" - EXISTING_RELEASE_ACTIVATION_COMMITTED="${{ steps.existing_release.outputs.release_activation_committed }}" + EXISTING_RELEASE_ID="${WORKFLOW_OUTPUT_2}" + EXISTING_RELEASE_DRAFT="${WORKFLOW_OUTPUT_3}" + EXISTING_RELEASE_PUBLISHED_AT="${WORKFLOW_OUTPUT_4}" + EXISTING_RELEASE_ACTIVATION_COMMITTED="${WORKFLOW_OUTPUT_5}" REMOTE_TAG_SHA=$(git ls-remote --tags origin "refs/tags/${TAG}" | awk '{print $1}') @@ -969,18 +1002,27 @@ jobs: id: create_release env: GH_TOKEN: ${{ github.token }} + WORKFLOW_OUTPUT_1: ${{ needs.prepare.outputs.tag }} + WORKFLOW_OUTPUT_2: ${{ steps.generate_notes.outputs.notes_file }} + WORKFLOW_OUTPUT_3: ${{ needs.prepare.outputs.is_prerelease }} + WORKFLOW_OUTPUT_4: ${{ steps.existing_release.outputs.release_id }} + WORKFLOW_OUTPUT_5: ${{ steps.existing_release.outputs.release_url }} + WORKFLOW_OUTPUT_6: ${{ steps.existing_release.outputs.release_is_draft }} + WORKFLOW_OUTPUT_7: ${{ steps.existing_release.outputs.release_published_at }} + WORKFLOW_OUTPUT_8: ${{ steps.existing_release.outputs.release_activation_committed }} + WORKFLOW_OUTPUT_9: ${{ needs.prepare.outputs.version }} run: | set -euo pipefail - TAG="${{ needs.prepare.outputs.tag }}" - NOTES_FILE="${{ steps.generate_notes.outputs.notes_file }}" - IS_PRERELEASE="${{ needs.prepare.outputs.is_prerelease }}" + TAG="${WORKFLOW_OUTPUT_1}" + NOTES_FILE="${WORKFLOW_OUTPUT_2}" + IS_PRERELEASE="${WORKFLOW_OUTPUT_3}" HEAD_SHA=$(git rev-parse HEAD) - RELEASE_ID="${{ steps.existing_release.outputs.release_id }}" - RELEASE_URL="${{ steps.existing_release.outputs.release_url }}" - IS_DRAFT="${{ steps.existing_release.outputs.release_is_draft }}" - PUBLISHED_AT="${{ steps.existing_release.outputs.release_published_at }}" - ACTIVATION_COMMITTED="${{ steps.existing_release.outputs.release_activation_committed }}" + RELEASE_ID="${WORKFLOW_OUTPUT_4}" + RELEASE_URL="${WORKFLOW_OUTPUT_5}" + IS_DRAFT="${WORKFLOW_OUTPUT_6}" + PUBLISHED_AT="${WORKFLOW_OUTPUT_7}" + ACTIVATION_COMMITTED="${WORKFLOW_OUTPUT_8}" RELEASE_PAYLOAD=$(mktemp) RELEASE_JSON_FILE=$(mktemp) ACTUAL_BODY_FILE=$(mktemp) @@ -1041,7 +1083,7 @@ jobs: fi python3 scripts/release_control/render_release_body.py \ - --version "${{ needs.prepare.outputs.version }}" \ + --version "${WORKFLOW_OUTPUT_9}" \ --validate-body-file "$ACTUAL_BODY_FILE" \ --expected-body-file "$NOTES_FILE" @@ -1054,8 +1096,9 @@ jobs: - name: Upload checksums env: GH_TOKEN: ${{ github.token }} + WORKFLOW_OUTPUT_1: ${{ needs.prepare.outputs.tag }} run: | - TAG="${{ needs.prepare.outputs.tag }}" + TAG="${WORKFLOW_OUTPUT_1}" release_upload_with_retry() { local attempt=1 local max_attempts=5 @@ -1096,9 +1139,10 @@ jobs: if: ${{ needs.prepare.outputs.visual_capture_count != '0' }} env: GH_TOKEN: ${{ github.token }} + WORKFLOW_OUTPUT_1: ${{ needs.prepare.outputs.tag }} run: | set -euo pipefail - TAG="${{ needs.prepare.outputs.tag }}" + TAG="${WORKFLOW_OUTPUT_1}" PLAN_FILE=$(mktemp) jq -er '.inputs.release_screenshot_plan' "$GITHUB_EVENT_PATH" > "$PLAN_FILE" release_upload_with_retry() { @@ -1130,8 +1174,9 @@ jobs: - name: Upload release assets env: GH_TOKEN: ${{ github.token }} + WORKFLOW_OUTPUT_1: ${{ needs.prepare.outputs.tag }} run: | - TAG="${{ needs.prepare.outputs.tag }}" + TAG="${WORKFLOW_OUTPUT_1}" release_upload_with_retry() { local attempt=1 local max_attempts=5 @@ -1236,13 +1281,18 @@ jobs: - name: Stop after staging (draft only) if: ${{ github.event.inputs.draft_only == 'true' }} - run: 'echo "Draft-only mode: ${{ steps.create_release.outputs.release_url }}"' + env: + WORKFLOW_OUTPUT_1: ${{ steps.create_release.outputs.release_url }} + run: 'echo "Draft-only mode: ${WORKFLOW_OUTPUT_1}"' - name: Summary + env: + WORKFLOW_OUTPUT_1: ${{ needs.prepare.outputs.tag }} + WORKFLOW_OUTPUT_2: ${{ steps.create_release.outputs.release_url }} run: | echo "[SUCCESS] Release assets staged behind an unpublished draft." - echo "Release: ${{ needs.prepare.outputs.tag }}" - echo "URL: ${{ steps.create_release.outputs.release_url }}" + echo "Release: ${WORKFLOW_OUTPUT_1}" + echo "URL: ${WORKFLOW_OUTPUT_2}" backfill_release_assets: needs: @@ -1288,19 +1338,23 @@ jobs: GH_TOKEN: ${{ github.token }} PULSE_UPDATE_SIGNING_KEY: ${{ secrets.PULSE_UPDATE_SIGNING_KEY }} PULSE_UPDATE_SIGNING_PUBLIC_KEY: ${{ vars.PULSE_UPDATE_SIGNING_PUBLIC_KEY }} + WORKFLOW_OUTPUT_1: ${{ needs.prepare.outputs.tag }} run: | - ./scripts/backfill-release-assets.sh --tag "${{ needs.prepare.outputs.tag }}" --repo "${{ github.repository }}" + ./scripts/backfill-release-assets.sh --tag "${WORKFLOW_OUTPUT_1}" --repo "${{ github.repository }}" - name: Validate published release packet env: PULSE_UPDATE_SIGNING_PUBLIC_KEY: ${{ vars.PULSE_UPDATE_SIGNING_PUBLIC_KEY }} + WORKFLOW_OUTPUT_1: ${{ needs.prepare.outputs.tag }} run: | - ./scripts/validate-published-release.sh "${{ needs.prepare.outputs.tag }}" "${{ github.repository }}" + ./scripts/validate-published-release.sh "${WORKFLOW_OUTPUT_1}" "${{ github.repository }}" - name: Summary + env: + WORKFLOW_OUTPUT_1: ${{ needs.prepare.outputs.tag }} run: | echo "[SUCCESS] Historical release assets repaired" - echo "Release: ${{ needs.prepare.outputs.tag }}" + echo "Release: ${WORKFLOW_OUTPUT_1}" publish_docker: needs: diff --git a/.github/workflows/install-sh-smoke.yml b/.github/workflows/install-sh-smoke.yml index bb96cb605..8c11b5a0d 100644 --- a/.github/workflows/install-sh-smoke.yml +++ b/.github/workflows/install-sh-smoke.yml @@ -356,5 +356,7 @@ jobs: 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 ${{ steps.inputs.outputs.tag }}" + echo "::notice::install.sh smoke passed for tag ${WORKFLOW_OUTPUT_1}" diff --git a/.github/workflows/publish-docker.yml b/.github/workflows/publish-docker.yml index 4cd4fd1c1..50f7bc0e5 100644 --- a/.github/workflows/publish-docker.yml +++ b/.github/workflows/publish-docker.yml @@ -216,18 +216,20 @@ jobs: - name: Output image information env: IMAGE_KIND: ${{ matrix.image }} + WORKFLOW_OUTPUT_1: ${{ steps.version.outputs.tag }} + WORKFLOW_OUTPUT_2: ${{ steps.version.outputs.version }} run: | echo "✅ Exact-version ${IMAGE_KIND} Docker image staged successfully!" if [[ "${IMAGE_KIND}" == "server" ]]; then echo "Server images (linux/amd64, linux/arm64):" - echo " - rcourtman/pulse:${{ steps.version.outputs.tag }}" - echo " - rcourtman/pulse:${{ steps.version.outputs.version }}" + echo " - rcourtman/pulse:${WORKFLOW_OUTPUT_1}" + echo " - rcourtman/pulse:${WORKFLOW_OUTPUT_2}" echo "Pulse Agent binaries ship as release assets, not as a Docker image." echo "See the GitHub release page for pulse-agent-{darwin,freebsd,linux,windows}-{amd64,arm64,...}." else echo "Control-plane images (linux/amd64, linux/arm64):" - echo " - rcourtman/pulse-control-plane:${{ steps.version.outputs.tag }}" - echo " - rcourtman/pulse-control-plane:${{ steps.version.outputs.version }}" + echo " - rcourtman/pulse-control-plane:${WORKFLOW_OUTPUT_1}" + echo " - rcourtman/pulse-control-plane:${WORKFLOW_OUTPUT_2}" fi echo "Floating aliases are promoted separately at the activation barrier." diff --git a/.github/workflows/publish-helm-chart.yml b/.github/workflows/publish-helm-chart.yml index 106ff1af9..87a638bcc 100644 --- a/.github/workflows/publish-helm-chart.yml +++ b/.github/workflows/publish-helm-chart.yml @@ -129,21 +129,26 @@ jobs: git checkout --detach "refs/tags/${RELEASE_TAG}" - name: Align chart metadata links + env: + WORKFLOW_OUTPUT_1: ${{ steps.versions.outputs.chart_version }} run: | python3 scripts/sync_chart_release_metadata.py \ --chart deploy/helm/pulse/Chart.yaml \ - --version "${{ steps.versions.outputs.chart_version }}" \ + --version "${WORKFLOW_OUTPUT_1}" \ --repo "${{ github.repository }}" - name: Helm lint (strict) run: helm lint deploy/helm/pulse --strict - name: Package chart + env: + WORKFLOW_OUTPUT_1: ${{ steps.versions.outputs.chart_version }} + WORKFLOW_OUTPUT_2: ${{ steps.versions.outputs.app_version }} run: | mkdir -p dist helm package deploy/helm/pulse \ - --version "${{ steps.versions.outputs.chart_version }}" \ - --app-version "${{ steps.versions.outputs.app_version }}" \ + --version "${WORKFLOW_OUTPUT_1}" \ + --app-version "${WORKFLOW_OUTPUT_2}" \ --destination dist - name: Upload packaged chart artifact @@ -215,7 +220,9 @@ jobs: if: github.event_name == 'release' env: GITHUB_TOKEN: ${{ github.token }} + WORKFLOW_OUTPUT_1: ${{ steps.versions.outputs.release_tag }} + WORKFLOW_OUTPUT_2: ${{ steps.versions.outputs.chart_version }} run: | - gh release upload "${{ steps.versions.outputs.release_tag }}" \ - dist/pulse-${{ steps.versions.outputs.chart_version }}.tgz \ + gh release upload "${WORKFLOW_OUTPUT_1}" \ + "dist/pulse-${WORKFLOW_OUTPUT_2}.tgz" \ --clobber diff --git a/.github/workflows/qualify-secure-runtime-release.yml b/.github/workflows/qualify-secure-runtime-release.yml index 12b074989..3165f99a4 100644 --- a/.github/workflows/qualify-secure-runtime-release.yml +++ b/.github/workflows/qualify-secure-runtime-release.yml @@ -296,6 +296,7 @@ jobs: QUALIFIED_COMMIT: ${{ steps.source.outputs.commit }} RELEASE_ID: ${{ steps.release.outputs.release_id }} TAG: ${{ steps.release.outputs.tag }} + WORKFLOW_OUTPUT_1: ${{ steps.lab.outputs.elapsed_seconds }} run: | set -euo pipefail release_dir="$RUNNER_TEMP/secure-runtime-verified" @@ -322,7 +323,7 @@ jobs: --collector-v2-signature "${release_dir}/pulse-secure-runtime-collector-v2-linux-amd64.sig" \ --collector-v3-signature "${release_dir}/pulse-secure-runtime-collector-v3-linux-amd64.sig" \ --collector-v4-signature "${release_dir}/pulse-agent-linux-amd64.sig" \ - --elapsed-seconds "${{ steps.lab.outputs.elapsed_seconds }}" \ + --elapsed-seconds "${WORKFLOW_OUTPUT_1}" \ --release-candidate-tag "${TAG}" \ --release-repository "${GITHUB_REPOSITORY}" \ --release-id "${RELEASE_ID}" \ @@ -335,7 +336,9 @@ jobs: - name: Stop disposable systemd host if: ${{ always() && steps.lab.outputs.container != '' }} - run: docker rm --force "${{ steps.lab.outputs.container }}" + env: + WORKFLOW_OUTPUT_1: ${{ steps.lab.outputs.container }} + run: docker rm --force "${WORKFLOW_OUTPUT_1}" - name: Upload immutable RC qualification evidence if: ${{ success() }} diff --git a/.github/workflows/recover-demo-server.yml b/.github/workflows/recover-demo-server.yml index e53bcfff4..63f0c2329 100644 --- a/.github/workflows/recover-demo-server.yml +++ b/.github/workflows/recover-demo-server.yml @@ -58,9 +58,11 @@ jobs: - name: Resolve target-compatible recovery profile id: runtime_profile + env: + WORKFLOW_OUTPUT_1: ${{ steps.stable.outputs.tag }} run: | set -euo pipefail - TAG="${{ steps.stable.outputs.tag }}" + TAG="${WORKFLOW_OUTPUT_1}" PROFILE_OUTPUT="$(bash .github/scripts/resolve-demo-runtime-profile.sh "refs/tags/${TAG}")" printf '%s\n' "$PROFILE_OUTPUT" >> "$GITHUB_OUTPUT" printf 'Resolved stable demo recovery profile for %s:\n%s\n' "$TAG" "$PROFILE_OUTPUT" diff --git a/.github/workflows/recover-release-activation.yml b/.github/workflows/recover-release-activation.yml index e353557db..7896e4b18 100644 --- a/.github/workflows/recover-release-activation.yml +++ b/.github/workflows/recover-release-activation.yml @@ -188,10 +188,11 @@ jobs: env: GH_TOKEN: ${{ github.token }} TAG: ${{ inputs.tag }} + WORKFLOW_OUTPUT_1: ${{ steps.qualify.outputs.source_sha }} run: | set -euo pipefail ./scripts/verify-release-helm-chart.sh \ - "${TAG}" "${{ steps.qualify.outputs.source_sha }}" \ + "${TAG}" "${WORKFLOW_OUTPUT_1}" \ "${GITHUB_REPOSITORY}" >> "$GITHUB_OUTPUT" - name: Set up Docker Buildx @@ -215,10 +216,11 @@ jobs: env: GH_TOKEN: ${{ github.token }} TAG: ${{ inputs.tag }} + WORKFLOW_OUTPUT_1: ${{ steps.qualify.outputs.source_sha }} run: | set -euo pipefail ./scripts/verify-release-container-images.sh \ - "${TAG}" "${{ steps.qualify.outputs.source_sha }}" \ + "${TAG}" "${WORKFLOW_OUTPUT_1}" \ "${{ github.repository }}" >> "$GITHUB_OUTPUT" - name: Dispatch a fresh durable convergence owner diff --git a/.github/workflows/release-dry-run.yml b/.github/workflows/release-dry-run.yml index e28ca8509..dd4f923e2 100644 --- a/.github/workflows/release-dry-run.yml +++ b/.github/workflows/release-dry-run.yml @@ -152,6 +152,7 @@ jobs: HOTFIX_REASON_INPUT: ${{ inputs.hotfix_reason }} UNSIGNED_WINDOWS_EXCEPTION_INPUT: ${{ inputs.unsigned_windows_exception }} UNSIGNED_WINDOWS_REASON_INPUT: ${{ inputs.unsigned_windows_reason }} + WORKFLOW_OUTPUT_1: ${{ steps.branch_policy.outputs.required_branch }} run: | set -euo pipefail @@ -166,7 +167,7 @@ jobs: IS_PRERELEASE="true" fi - REQUIRED_BRANCH="${{ steps.branch_policy.outputs.required_branch }}" + REQUIRED_BRANCH="${WORKFLOW_OUTPUT_1}" if [ "${GITHUB_REF_NAME}" != "$REQUIRED_BRANCH" ]; then echo "::error::Rehearsal version ${VERSION} requires branch ${REQUIRED_BRANCH}, but workflow ran on ${GITHUB_REF_NAME}." @@ -226,6 +227,7 @@ jobs: EVENT_NAME: ${{ github.event_name }} MOBILE_RELEASE_DECISION: ${{ inputs.mobile_release_decision }} MOBILE_RELEASE_EVIDENCE: ${{ inputs.mobile_release_evidence }} + WORKFLOW_OUTPUT_1: ${{ steps.rehearsal.outputs.version }} run: | set -euo pipefail DECISION="${MOBILE_RELEASE_DECISION:-}" @@ -235,7 +237,7 @@ jobs: EVIDENCE="Scheduled release dry-run watchdog; no mobile release packet is being dispatched." fi python3 scripts/release_control/mobile_release_gate.py \ - --version "${{ steps.rehearsal.outputs.version }}" \ + --version "${WORKFLOW_OUTPUT_1}" \ --decision "${DECISION}" \ --evidence "${EVIDENCE}" \ --github-annotations @@ -372,6 +374,20 @@ jobs: NOTE: ${{ inputs.note }} REHEARSAL_CONCLUSION: ${{ steps.rehearsal.conclusion }} JOB_CONCLUSION: ${{ job.status }} + WORKFLOW_OUTPUT_1: ${{ steps.rehearsal.outputs.version }} + WORKFLOW_OUTPUT_2: ${{ steps.rehearsal.outputs.tag }} + WORKFLOW_OUTPUT_3: ${{ steps.rehearsal.outputs.is_prerelease == 'true' && 'rc' || 'stable' }} + WORKFLOW_OUTPUT_4: ${{ steps.rehearsal.outputs.promoted_from_tag }} + WORKFLOW_OUTPUT_5: ${{ steps.rehearsal.outputs.rollback_tag }} + WORKFLOW_OUTPUT_6: ${{ steps.rehearsal.outputs.rollback_command }} + WORKFLOW_OUTPUT_7: ${{ steps.rehearsal.outputs.soak_hours }} + WORKFLOW_OUTPUT_8: ${{ steps.rehearsal.outputs.ga_date }} + WORKFLOW_OUTPUT_9: ${{ steps.rehearsal.outputs.v5_eos_date }} + WORKFLOW_OUTPUT_10: ${{ steps.rehearsal.outputs.hotfix_exception }} + WORKFLOW_OUTPUT_11: ${{ steps.rehearsal.outputs.hotfix_reason }} + WORKFLOW_OUTPUT_12: ${{ steps.rehearsal.outputs.require_windows_signing }} + WORKFLOW_OUTPUT_13: ${{ steps.rehearsal.outputs.unsigned_windows_exception }} + WORKFLOW_OUTPUT_14: ${{ steps.rehearsal.outputs.unsigned_windows_reason }} run: | mkdir -p release-dry-run SUMMARY_FILE="release-dry-run/rc-to-ga-rehearsal-summary.md" @@ -399,35 +415,35 @@ jobs: echo "" echo "- Workflow run: ${RUN_URL}" echo "- Branch: ${GITHUB_REF_NAME}" - echo "- Version: ${{ steps.rehearsal.outputs.version }}" - echo "- Candidate stable tag: ${{ steps.rehearsal.outputs.tag }}" - echo "- Promotion channel: ${{ steps.rehearsal.outputs.is_prerelease == 'true' && 'rc' || 'stable' }}" - if [ -n "${{ steps.rehearsal.outputs.promoted_from_tag }}" ]; then - echo "- Promoted prerelease tag: ${{ steps.rehearsal.outputs.promoted_from_tag }}" + echo "- Version: ${WORKFLOW_OUTPUT_1}" + echo "- Candidate stable tag: ${WORKFLOW_OUTPUT_2}" + echo "- Promotion channel: ${WORKFLOW_OUTPUT_3}" + if [ -n "${WORKFLOW_OUTPUT_4}" ]; then + echo "- Promoted prerelease tag: ${WORKFLOW_OUTPUT_4}" fi - if [ -n "${{ steps.rehearsal.outputs.rollback_tag }}" ]; then - echo "- Rollback target: ${{ steps.rehearsal.outputs.rollback_tag }}" + if [ -n "${WORKFLOW_OUTPUT_5}" ]; then + echo "- Rollback target: ${WORKFLOW_OUTPUT_5}" fi - if [ -n "${{ steps.rehearsal.outputs.rollback_command }}" ]; then - echo "- Rollback command: \`${{ steps.rehearsal.outputs.rollback_command }}\`" + if [ -n "${WORKFLOW_OUTPUT_6}" ]; then + echo "- Rollback command: \`${WORKFLOW_OUTPUT_6}\`" fi - if [ -n "${{ steps.rehearsal.outputs.soak_hours }}" ]; then - echo "- Prerelease soak hours at rehearsal time: ${{ steps.rehearsal.outputs.soak_hours }}" + if [ -n "${WORKFLOW_OUTPUT_7}" ]; then + echo "- Prerelease soak hours at rehearsal time: ${WORKFLOW_OUTPUT_7}" fi - if [ -n "${{ steps.rehearsal.outputs.ga_date }}" ]; then - echo "- Planned GA date: ${{ steps.rehearsal.outputs.ga_date }}" + if [ -n "${WORKFLOW_OUTPUT_8}" ]; then + echo "- Planned GA date: ${WORKFLOW_OUTPUT_8}" fi - if [ -n "${{ steps.rehearsal.outputs.v5_eos_date }}" ]; then - echo "- Planned v5 end-of-support date: ${{ steps.rehearsal.outputs.v5_eos_date }}" + if [ -n "${WORKFLOW_OUTPUT_9}" ]; then + echo "- Planned v5 end-of-support date: ${WORKFLOW_OUTPUT_9}" fi - echo "- Hotfix exception: ${{ steps.rehearsal.outputs.hotfix_exception }}" - if [ -n "${{ steps.rehearsal.outputs.hotfix_reason }}" ]; then - echo "- Hotfix reason: ${{ steps.rehearsal.outputs.hotfix_reason }}" + echo "- Hotfix exception: ${WORKFLOW_OUTPUT_10}" + if [ -n "${WORKFLOW_OUTPUT_11}" ]; then + echo "- Hotfix reason: ${WORKFLOW_OUTPUT_11}" fi - echo "- Windows Authenticode required: ${{ steps.rehearsal.outputs.require_windows_signing }}" - echo "- Unsigned Windows exception: ${{ steps.rehearsal.outputs.unsigned_windows_exception }}" - if [ -n "${{ steps.rehearsal.outputs.unsigned_windows_reason }}" ]; then - echo "- Unsigned Windows reason: ${{ steps.rehearsal.outputs.unsigned_windows_reason }}" + echo "- Windows Authenticode required: ${WORKFLOW_OUTPUT_12}" + echo "- Unsigned Windows exception: ${WORKFLOW_OUTPUT_13}" + if [ -n "${WORKFLOW_OUTPUT_14}" ]; then + echo "- Unsigned Windows reason: ${WORKFLOW_OUTPUT_14}" fi if [ -n "${NOTE}" ]; then echo "- Operator note: ${NOTE}" diff --git a/.github/workflows/update-demo-server.yml b/.github/workflows/update-demo-server.yml index ec5739ac3..86ec066d1 100644 --- a/.github/workflows/update-demo-server.yml +++ b/.github/workflows/update-demo-server.yml @@ -145,9 +145,11 @@ jobs: fetch-tags: true - name: Validate governed release line for selected tag + env: + WORKFLOW_OUTPUT_1: ${{ steps.target.outputs.tag }} run: | set -euo pipefail - TAG="${{ steps.target.outputs.tag }}" + TAG="${WORKFLOW_OUTPUT_1}" VERSION="${TAG#v}" if [ "$(git rev-parse --is-shallow-repository)" = "true" ]; then @@ -171,9 +173,11 @@ jobs: - name: Resolve target-compatible demo runtime profile id: runtime_profile + env: + WORKFLOW_OUTPUT_1: ${{ steps.target.outputs.tag }} run: | set -euo pipefail - TAG="${{ steps.target.outputs.tag }}" + TAG="${WORKFLOW_OUTPUT_1}" TAG_REF="refs/tags/${TAG}" PROFILE_OUTPUT="$(bash .github/scripts/resolve-demo-runtime-profile.sh "${TAG_REF}")" printf '%s\n' "$PROFILE_OUTPUT" >> "$GITHUB_OUTPUT" @@ -197,10 +201,13 @@ jobs: - name: Check release type env: VERIFY_ONLY: ${{ inputs.verify_only }} + WORKFLOW_OUTPUT_1: ${{ needs.resolve.outputs.tag }} + WORKFLOW_OUTPUT_2: ${{ needs.resolve.outputs.target }} + WORKFLOW_OUTPUT_3: ${{ needs.resolve.outputs.environment_name }} run: | - echo "Tag: ${{ needs.resolve.outputs.tag }}" - echo "Target: ${{ needs.resolve.outputs.target }}" - echo "Environment: ${{ needs.resolve.outputs.environment_name }}" + echo "Tag: ${WORKFLOW_OUTPUT_1}" + echo "Target: ${WORKFLOW_OUTPUT_2}" + echo "Environment: ${WORKFLOW_OUTPUT_3}" echo "Verification only: ${VERIFY_ONLY}" - name: Validate demo environment configuration @@ -298,9 +305,11 @@ jobs: - name: Wait for release assets if: inputs.verify_only != true + env: + WORKFLOW_OUTPUT_1: ${{ needs.resolve.outputs.tag }} run: | set -euo pipefail - TAG="${{ needs.resolve.outputs.tag }}" + TAG="${WORKFLOW_OUTPUT_1}" echo "Waiting for activated release assets to be available..." MAX_ATTEMPTS=30 @@ -335,9 +344,10 @@ jobs: if: inputs.verify_only != true env: PULSE_UPDATE_SIGNING_PUBLIC_KEY: ${{ vars.PULSE_UPDATE_SIGNING_PUBLIC_KEY }} + WORKFLOW_OUTPUT_1: ${{ needs.resolve.outputs.tag }} run: | set -euo pipefail - TAG="${{ needs.resolve.outputs.tag }}" + TAG="${WORKFLOW_OUTPUT_1}" TRUSTED_SSH_PUBLIC_KEY="$( go run ./scripts/release_update_key.go public-key-ssh \ --public-key "${PULSE_UPDATE_SIGNING_PUBLIC_KEY}" \ @@ -396,9 +406,11 @@ jobs: - name: Resolve demo service identity id: config + env: + WORKFLOW_OUTPUT_1: ${{ needs.resolve.outputs.target }} run: | set -euo pipefail - TARGET="${{ needs.resolve.outputs.target }}" + TARGET="${WORKFLOW_OUTPUT_1}" SERVICE_NAME="${DEMO_SERVICE_NAME:-}" if [ -z "$SERVICE_NAME" ]; then SERVICE_NAME="pulse" @@ -414,9 +426,10 @@ jobs: env: DEMO_SERVER_HOST: ${{ secrets.DEMO_SERVER_HOST }} DEMO_SERVER_USER: ${{ secrets.DEMO_SERVER_USER }} + WORKFLOW_OUTPUT_1: ${{ needs.resolve.outputs.tag }} run: | set -euo pipefail - TARGET="${{ needs.resolve.outputs.tag }}" + TARGET="${WORKFLOW_OUTPUT_1}" TARGET_STRIPPED="${TARGET#v}" CURRENT=$(ssh -i ~/.ssh/id_ed25519 "$DEMO_SERVER_USER@$DEMO_SERVER_HOST" \ "curl -fsS ${DEMO_LOCAL_BASE_URL}/api/version | jq -r .version") @@ -439,9 +452,10 @@ jobs: env: DEMO_SERVER_HOST: ${{ secrets.DEMO_SERVER_HOST }} DEMO_SERVER_USER: ${{ secrets.DEMO_SERVER_USER }} + WORKFLOW_OUTPUT_1: ${{ steps.config.outputs.service_name }} run: | set -euo pipefail - SERVICE_NAME="${{ steps.config.outputs.service_name }}" + SERVICE_NAME="${WORKFLOW_OUTPUT_1}" REMOTE_SCRIPT=$(cat <<'EOF' set -euo pipefail SERVICE_NAME="$1" @@ -542,10 +556,12 @@ jobs: env: DEMO_SERVER_HOST: ${{ secrets.DEMO_SERVER_HOST }} DEMO_SERVER_USER: ${{ secrets.DEMO_SERVER_USER }} + WORKFLOW_OUTPUT_1: ${{ needs.resolve.outputs.tag }} + WORKFLOW_OUTPUT_2: ${{ steps.config.outputs.service_name }} run: | set -euo pipefail - TAG="${{ needs.resolve.outputs.tag }}" - SERVICE_NAME="${{ steps.config.outputs.service_name }}" + TAG="${WORKFLOW_OUTPUT_1}" + SERVICE_NAME="${WORKFLOW_OUTPUT_2}" REMOTE_SCRIPT=$(cat <<'EOF' set -euo pipefail TAG="$1" @@ -566,22 +582,36 @@ jobs: env: DEMO_SERVER_HOST: ${{ secrets.DEMO_SERVER_HOST }} DEMO_SERVER_USER: ${{ secrets.DEMO_SERVER_USER }} + WORKFLOW_OUTPUT_1: ${{ steps.config.outputs.service_name }} + WORKFLOW_OUTPUT_2: ${{ needs.resolve.outputs.mock_nodes }} + WORKFLOW_OUTPUT_3: ${{ needs.resolve.outputs.mock_vms_per_node }} + WORKFLOW_OUTPUT_4: ${{ needs.resolve.outputs.mock_lxcs_per_node }} + WORKFLOW_OUTPUT_5: ${{ needs.resolve.outputs.mock_docker_hosts }} + WORKFLOW_OUTPUT_6: ${{ needs.resolve.outputs.mock_docker_containers }} + WORKFLOW_OUTPUT_7: ${{ needs.resolve.outputs.mock_generic_hosts }} + WORKFLOW_OUTPUT_8: ${{ needs.resolve.outputs.mock_k8s_clusters }} + WORKFLOW_OUTPUT_9: ${{ needs.resolve.outputs.mock_k8s_nodes }} + WORKFLOW_OUTPUT_10: ${{ needs.resolve.outputs.mock_k8s_pods }} + WORKFLOW_OUTPUT_11: ${{ needs.resolve.outputs.mock_k8s_deployments }} + WORKFLOW_OUTPUT_12: ${{ needs.resolve.outputs.mock_seed_duration }} + WORKFLOW_OUTPUT_13: ${{ needs.resolve.outputs.mock_sample_interval }} + WORKFLOW_OUTPUT_14: ${{ needs.resolve.outputs.mock_update_interval }} run: | set -euo pipefail - SERVICE_NAME="${{ steps.config.outputs.service_name }}" - MOCK_NODES="${{ needs.resolve.outputs.mock_nodes }}" - MOCK_VMS_PER_NODE="${{ needs.resolve.outputs.mock_vms_per_node }}" - MOCK_LXCS_PER_NODE="${{ needs.resolve.outputs.mock_lxcs_per_node }}" - MOCK_DOCKER_HOSTS="${{ needs.resolve.outputs.mock_docker_hosts }}" - MOCK_DOCKER_CONTAINERS="${{ needs.resolve.outputs.mock_docker_containers }}" - MOCK_GENERIC_HOSTS="${{ needs.resolve.outputs.mock_generic_hosts }}" - MOCK_K8S_CLUSTERS="${{ needs.resolve.outputs.mock_k8s_clusters }}" - MOCK_K8S_NODES="${{ needs.resolve.outputs.mock_k8s_nodes }}" - MOCK_K8S_PODS="${{ needs.resolve.outputs.mock_k8s_pods }}" - MOCK_K8S_DEPLOYMENTS="${{ needs.resolve.outputs.mock_k8s_deployments }}" - MOCK_SEED_DURATION="${{ needs.resolve.outputs.mock_seed_duration }}" - MOCK_SAMPLE_INTERVAL="${{ needs.resolve.outputs.mock_sample_interval }}" - MOCK_UPDATE_INTERVAL="${{ needs.resolve.outputs.mock_update_interval }}" + SERVICE_NAME="${WORKFLOW_OUTPUT_1}" + MOCK_NODES="${WORKFLOW_OUTPUT_2}" + MOCK_VMS_PER_NODE="${WORKFLOW_OUTPUT_3}" + MOCK_LXCS_PER_NODE="${WORKFLOW_OUTPUT_4}" + MOCK_DOCKER_HOSTS="${WORKFLOW_OUTPUT_5}" + MOCK_DOCKER_CONTAINERS="${WORKFLOW_OUTPUT_6}" + MOCK_GENERIC_HOSTS="${WORKFLOW_OUTPUT_7}" + MOCK_K8S_CLUSTERS="${WORKFLOW_OUTPUT_8}" + MOCK_K8S_NODES="${WORKFLOW_OUTPUT_9}" + MOCK_K8S_PODS="${WORKFLOW_OUTPUT_10}" + MOCK_K8S_DEPLOYMENTS="${WORKFLOW_OUTPUT_11}" + MOCK_SEED_DURATION="${WORKFLOW_OUTPUT_12}" + MOCK_SAMPLE_INTERVAL="${WORKFLOW_OUTPUT_13}" + MOCK_UPDATE_INTERVAL="${WORKFLOW_OUTPUT_14}" REMOTE_SCRIPT=$(cat <<'EOF' set -euo pipefail SERVICE_NAME="${1:-pulse}" @@ -725,6 +755,7 @@ jobs: env: DEMO_SERVER_HOST: ${{ secrets.DEMO_SERVER_HOST }} DEMO_SERVER_USER: ${{ secrets.DEMO_SERVER_USER }} + WORKFLOW_OUTPUT_1: ${{ needs.resolve.outputs.tag }} run: | set -euo pipefail sleep 5 @@ -734,7 +765,7 @@ jobs: echo "Demo server is now running version: $VERSION" - TAG="${{ needs.resolve.outputs.tag }}" + TAG="${WORKFLOW_OUTPUT_1}" TAG_STRIPPED="${TAG#v}" if [ "$VERSION" != "$TAG" ] && [ "$VERSION" != "$TAG_STRIPPED" ]; then echo "::error::Version mismatch! Expected $TAG but got $VERSION" diff --git a/.github/workflows/validate-release-assets.yml b/.github/workflows/validate-release-assets.yml index c5c15a4c8..1359548cd 100644 --- a/.github/workflows/validate-release-assets.yml +++ b/.github/workflows/validate-release-assets.yml @@ -128,45 +128,50 @@ jobs: continue-on-error: true env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + WORKFLOW_OUTPUT_1: ${{ steps.context.outputs.release_id }} + WORKFLOW_OUTPUT_2: ${{ steps.context.outputs.version }} run: | set -euo pipefail RELEASE_JSON_FILE=$(mktemp) RELEASE_BODY_FILE=$(mktemp) - gh api "repos/${{ github.repository }}/releases/${{ steps.context.outputs.release_id }}" \ + gh api "repos/${{ github.repository }}/releases/${WORKFLOW_OUTPUT_1}" \ > "$RELEASE_JSON_FILE" jq -r '.body // ""' "$RELEASE_JSON_FILE" > "$RELEASE_BODY_FILE" python3 scripts/release_control/render_release_body.py \ - --version "${{ steps.context.outputs.version }}" \ + --version "${WORKFLOW_OUTPUT_2}" \ --validate-body-file "$RELEASE_BODY_FILE" - name: Quarantine malformed release body if: steps.context.outputs.should_run == 'true' && steps.context.outputs.draft == 'true' && steps.body_integrity.outcome == 'failure' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + WORKFLOW_OUTPUT_1: ${{ steps.context.outputs.tag }} + WORKFLOW_OUTPUT_2: ${{ steps.context.outputs.target_commitish }} + WORKFLOW_OUTPUT_3: ${{ steps.context.outputs.release_id }} run: | set -euo pipefail PATCH_PAYLOAD=$(mktemp) RELEASE_JSON_FILE=$(mktemp) jq -n \ - --arg tag "${{ steps.context.outputs.tag }}" \ - --arg target_commitish "${{ steps.context.outputs.target_commitish }}" \ + --arg tag "${WORKFLOW_OUTPUT_1}" \ + --arg target_commitish "${WORKFLOW_OUTPUT_2}" \ '{draft: true, tag_name: $tag, target_commitish: $target_commitish}' \ > "$PATCH_PAYLOAD" - gh api "repos/${{ github.repository }}/releases/${{ steps.context.outputs.release_id }}" \ + gh api "repos/${{ github.repository }}/releases/${WORKFLOW_OUTPUT_3}" \ -X PATCH \ --input "$PATCH_PAYLOAD" > "$RELEASE_JSON_FILE" ACTUAL_RELEASE_TAG=$(jq -r '.tag_name // empty' "$RELEASE_JSON_FILE") ACTUAL_TARGET_COMMITISH=$(jq -r '.target_commitish // empty' "$RELEASE_JSON_FILE") ACTUAL_DRAFT=$(jq -r '.draft // false' "$RELEASE_JSON_FILE") - if [ "$ACTUAL_RELEASE_TAG" != "${{ steps.context.outputs.tag }}" ]; then - echo "::error::Body-integrity quarantine detached release tag ${ACTUAL_RELEASE_TAG}; expected ${{ steps.context.outputs.tag }}." + if [ "$ACTUAL_RELEASE_TAG" != "${WORKFLOW_OUTPUT_1}" ]; then + echo "::error::Body-integrity quarantine detached release tag ${ACTUAL_RELEASE_TAG}; expected ${WORKFLOW_OUTPUT_1}." exit 1 fi - if [ "$ACTUAL_TARGET_COMMITISH" != "${{ steps.context.outputs.target_commitish }}" ]; then - echo "::error::Body-integrity quarantine changed target_commitish ${ACTUAL_TARGET_COMMITISH}; expected ${{ steps.context.outputs.target_commitish }}." + if [ "$ACTUAL_TARGET_COMMITISH" != "${WORKFLOW_OUTPUT_2}" ]; then + echo "::error::Body-integrity quarantine changed target_commitish ${ACTUAL_TARGET_COMMITISH}; expected ${WORKFLOW_OUTPUT_2}." exit 1 fi if [ "$ACTUAL_DRAFT" != "true" ]; then @@ -177,7 +182,7 @@ jobs: curl --fail-with-body --silent --show-error -X POST \ -H "Authorization: token $GH_TOKEN" \ -H "Accept: application/vnd.github+json" \ - "https://api.github.com/repos/${{ github.repository }}/statuses/${{ steps.context.outputs.target_commitish }}" \ + "https://api.github.com/repos/${{ github.repository }}/statuses/${WORKFLOW_OUTPUT_2}" \ -d '{ "state": "failure", "target_url": "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}", @@ -196,12 +201,13 @@ jobs: if: steps.context.outputs.should_run == 'true' && steps.body_integrity.outcome == 'success' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + WORKFLOW_OUTPUT_1: ${{ steps.context.outputs.release_id }} run: | set -euo pipefail gh api --paginate \ - "repos/${{ github.repository }}/releases/${{ steps.context.outputs.release_id }}/assets?per_page=100" \ + "repos/${{ github.repository }}/releases/${WORKFLOW_OUTPUT_1}/assets?per_page=100" \ --slurp > "$RUNNER_TEMP/release-assets.json" - gh api "repos/${{ github.repository }}/releases/${{ steps.context.outputs.release_id }}" \ + gh api "repos/${{ github.repository }}/releases/${WORKFLOW_OUTPUT_1}" \ --jq '.body // ""' > "$RUNNER_TEMP/release-body.md" count="$(jq '[.[][]] | length' "$RUNNER_TEMP/release-assets.json")" if [ "$count" -eq 0 ]; then @@ -215,15 +221,17 @@ jobs: id: download env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + WORKFLOW_OUTPUT_1: ${{ steps.context.outputs.tag }} + WORKFLOW_OUTPUT_2: ${{ steps.context.outputs.release_id }} run: | - echo "Downloading all assets from release ${{ steps.context.outputs.tag }}..." + echo "Downloading all assets from release ${WORKFLOW_OUTPUT_1}..." mkdir -p release cd release # Get asset info (id and name) - API works with GITHUB_TOKEN for draft releases # Use --paginate to handle releases with >30 assets - ASSETS_JSON=$(gh api --paginate "repos/${{ github.repository }}/releases/${{ steps.context.outputs.release_id }}/assets") + ASSETS_JSON=$(gh api --paginate "repos/${{ github.repository }}/releases/${WORKFLOW_OUTPUT_2}/assets") if [ "$(echo "$ASSETS_JSON" | jq '. | length')" -eq 0 ]; then echo "::error::No assets found in release" @@ -264,8 +272,10 @@ jobs: if: steps.context.outputs.should_run == 'true' && steps.body_integrity.outcome == 'success' && inputs.candidate_manifest_artifact == '' id: docker continue-on-error: true + env: + WORKFLOW_OUTPUT_1: ${{ steps.context.outputs.tag }} run: | - IMAGE="rcourtman/pulse:${{ steps.context.outputs.tag }}" + IMAGE="rcourtman/pulse:${WORKFLOW_OUTPUT_1}" echo "Attempting to pull Docker image: $IMAGE" echo "Docker Hub CDN propagation can take 2-5 minutes after push..." echo "" @@ -307,6 +317,10 @@ jobs: id: validate env: CANDIDATE_MANIFEST_ARTIFACT: ${{ inputs.candidate_manifest_artifact }} + WORKFLOW_OUTPUT_1: ${{ steps.context.outputs.version }} + WORKFLOW_OUTPUT_2: ${{ steps.context.outputs.target_commitish }} + WORKFLOW_OUTPUT_3: ${{ steps.docker.outputs.image_available }} + WORKFLOW_OUTPUT_4: ${{ steps.docker.outputs.image }} run: | set +e set -o pipefail @@ -318,19 +332,19 @@ jobs: python3 scripts/release_candidate_manifest.py verify-release \ --manifest release-candidate-manifest/release-candidate.json \ --assets-json "$RUNNER_TEMP/release-assets.json" \ - --version "${{ steps.context.outputs.version }}" \ - --source-sha "${{ steps.context.outputs.target_commitish }}" \ + --version "${WORKFLOW_OUTPUT_1}" \ + --source-sha "${WORKFLOW_OUTPUT_2}" \ --release-body-file "$RUNNER_TEMP/release-body.md" 2>&1 | tee "$OUTPUT_FILE" - elif [ "${{ steps.docker.outputs.image_available }}" = "true" ]; then + elif [ "${WORKFLOW_OUTPUT_3}" = "true" ]; then echo "Running full validation (Docker + assets)..." scripts/validate-release.sh \ - "${{ steps.context.outputs.version }}" \ - "${{ steps.docker.outputs.image }}" \ + "${WORKFLOW_OUTPUT_1}" \ + "${WORKFLOW_OUTPUT_4}" \ "release" 2>&1 | tee "$OUTPUT_FILE" else echo "Running assets-only validation (Docker image not available)..." scripts/validate-release.sh \ - "${{ steps.context.outputs.version }}" \ + "${WORKFLOW_OUTPUT_1}" \ --skip-docker \ "release" 2>&1 | tee "$OUTPUT_FILE" fi @@ -355,11 +369,12 @@ jobs: if: steps.context.outputs.should_run == 'true' && steps.body_integrity.outcome == 'success' && steps.validate.outputs.validation_passed == 'true' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + WORKFLOW_OUTPUT_1: ${{ steps.context.outputs.target_commitish }} run: | curl --fail-with-body --silent --show-error -X POST \ -H "Authorization: token $GH_TOKEN" \ -H "Accept: application/vnd.github+json" \ - "https://api.github.com/repos/${{ github.repository }}/statuses/${{ steps.context.outputs.target_commitish }}" \ + "https://api.github.com/repos/${{ github.repository }}/statuses/${WORKFLOW_OUTPUT_1}" \ -d '{ "state": "success", "target_url": "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}", @@ -371,11 +386,16 @@ jobs: if: steps.context.outputs.should_run == 'true' && steps.context.outputs.draft == 'true' && steps.body_integrity.outcome == 'success' && steps.validate.outputs.validation_passed == 'true' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + WORKFLOW_OUTPUT_1: ${{ steps.context.outputs.draft }} + WORKFLOW_OUTPUT_2: ${{ steps.context.outputs.release_id }} + WORKFLOW_OUTPUT_3: ${{ steps.context.outputs.version }} + WORKFLOW_OUTPUT_4: ${{ steps.context.outputs.tag }} + WORKFLOW_OUTPUT_5: ${{ steps.context.outputs.target_commitish }} run: | set -euo pipefail echo "✅ Validation passed - updating release description" - INITIAL_STATE="${{ steps.context.outputs.draft }}" + INITIAL_STATE="${WORKFLOW_OUTPUT_1}" if [ "$INITIAL_STATE" = "true" ]; then HEADER_LINE="## ✅ Release Asset Validation: PASSED" STATUS_LINE="**Status**: Ready for publication ✅" @@ -394,11 +414,11 @@ jobs: PATCH_RESPONSE=$(mktemp) PATCHED_BODY_FILE=$(mktemp) - gh api "repos/${{ github.repository }}/releases/${{ steps.context.outputs.release_id }}" \ + gh api "repos/${{ github.repository }}/releases/${WORKFLOW_OUTPUT_2}" \ > "$CURRENT_RELEASE_JSON" jq -r '.body // ""' "$CURRENT_RELEASE_JSON" > "$CURRENT_BODY_FILE" python3 scripts/release_control/render_release_body.py \ - --version "${{ steps.context.outputs.version }}" \ + --version "${WORKFLOW_OUTPUT_3}" \ --validate-body-file "$CURRENT_BODY_FILE" \ --output "$CLEAN_BODY_FILE" @@ -431,30 +451,30 @@ jobs: cat "$CLEAN_BODY_FILE" >> "$NEW_BODY_FILE" jq -n \ --rawfile body "$NEW_BODY_FILE" \ - --arg tag "${{ steps.context.outputs.tag }}" \ - --arg target_commitish "${{ steps.context.outputs.target_commitish }}" \ + --arg tag "${WORKFLOW_OUTPUT_4}" \ + --arg target_commitish "${WORKFLOW_OUTPUT_5}" \ '{body: $body, tag_name: $tag, target_commitish: $target_commitish}' \ > "$PATCH_PAYLOAD" curl --fail-with-body --silent --show-error -X PATCH \ -H "Authorization: token $GH_TOKEN" \ -H "Accept: application/vnd.github+json" \ - "https://api.github.com/repos/${{ github.repository }}/releases/${{ steps.context.outputs.release_id }}" \ + "https://api.github.com/repos/${{ github.repository }}/releases/${WORKFLOW_OUTPUT_2}" \ --data-binary "@$PATCH_PAYLOAD" > "$PATCH_RESPONSE" ACTUAL_RELEASE_TAG=$(jq -r '.tag_name // empty' "$PATCH_RESPONSE") ACTUAL_TARGET_COMMITISH=$(jq -r '.target_commitish // empty' "$PATCH_RESPONSE") - if [ "$ACTUAL_RELEASE_TAG" != "${{ steps.context.outputs.tag }}" ]; then - echo "::error::Validation release body update detached release tag ${ACTUAL_RELEASE_TAG}; expected ${{ steps.context.outputs.tag }}." + if [ "$ACTUAL_RELEASE_TAG" != "${WORKFLOW_OUTPUT_4}" ]; then + echo "::error::Validation release body update detached release tag ${ACTUAL_RELEASE_TAG}; expected ${WORKFLOW_OUTPUT_4}." exit 1 fi - if [ "$ACTUAL_TARGET_COMMITISH" != "${{ steps.context.outputs.target_commitish }}" ]; then - echo "::error::Validation release body update changed target_commitish ${ACTUAL_TARGET_COMMITISH}; expected ${{ steps.context.outputs.target_commitish }}." + if [ "$ACTUAL_TARGET_COMMITISH" != "${WORKFLOW_OUTPUT_5}" ]; then + echo "::error::Validation release body update changed target_commitish ${ACTUAL_TARGET_COMMITISH}; expected ${WORKFLOW_OUTPUT_5}." exit 1 fi jq -r '.body // ""' "$PATCH_RESPONSE" > "$PATCHED_BODY_FILE" python3 scripts/release_control/render_release_body.py \ - --version "${{ steps.context.outputs.version }}" \ + --version "${WORKFLOW_OUTPUT_3}" \ --validate-body-file "$PATCHED_BODY_FILE" \ --expected-body-file "$CLEAN_BODY_FILE" @@ -462,11 +482,12 @@ jobs: if: steps.context.outputs.should_run == 'true' && steps.body_integrity.outcome == 'success' && (failure() || steps.validate.outputs.validation_passed == 'false') env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + WORKFLOW_OUTPUT_1: ${{ steps.context.outputs.target_commitish }} run: | curl --fail-with-body --silent --show-error -X POST \ -H "Authorization: token $GH_TOKEN" \ -H "Accept: application/vnd.github+json" \ - "https://api.github.com/repos/${{ github.repository }}/statuses/${{ steps.context.outputs.target_commitish }}" \ + "https://api.github.com/repos/${{ github.repository }}/statuses/${WORKFLOW_OUTPUT_1}" \ -d '{ "state": "failure", "target_url": "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}", @@ -478,11 +499,12 @@ jobs: if: steps.context.outputs.should_run == 'true' && steps.context.outputs.draft == 'true' && steps.body_integrity.outcome == 'success' && (failure() || steps.validate.outputs.validation_passed == 'false') env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + WORKFLOW_OUTPUT_1: ${{ steps.context.outputs.release_id }} run: | echo "❌ Validation failed - deleting all release assets" ASSET_IDS=$(curl -s -H "Authorization: token $GH_TOKEN" \ - "https://api.github.com/repos/${{ github.repository }}/releases/${{ steps.context.outputs.release_id }}/assets" \ + "https://api.github.com/repos/${{ github.repository }}/releases/${WORKFLOW_OUTPUT_1}/assets" \ | jq -r '.[].id') if [ -n "$ASSET_IDS" ]; then @@ -503,6 +525,11 @@ jobs: if: steps.context.outputs.should_run == 'true' && steps.context.outputs.draft == 'true' && steps.body_integrity.outcome == 'success' && (failure() || steps.validate.outputs.validation_passed == 'false') env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + WORKFLOW_OUTPUT_1: ${{ steps.context.outputs.release_id }} + WORKFLOW_OUTPUT_2: ${{ steps.context.outputs.version }} + WORKFLOW_OUTPUT_3: ${{ steps.validate.outputs.VALIDATION_OUTPUT }} + WORKFLOW_OUTPUT_4: ${{ steps.context.outputs.tag }} + WORKFLOW_OUTPUT_5: ${{ steps.context.outputs.target_commitish }} run: | set -euo pipefail echo "❌ Validation failed - updating release description" @@ -519,15 +546,15 @@ jobs: PATCH_RESPONSE=$(mktemp) PATCHED_BODY_FILE=$(mktemp) - gh api "repos/${{ github.repository }}/releases/${{ steps.context.outputs.release_id }}" \ + gh api "repos/${{ github.repository }}/releases/${WORKFLOW_OUTPUT_1}" \ > "$CURRENT_RELEASE_JSON" jq -r '.body // ""' "$CURRENT_RELEASE_JSON" > "$CURRENT_BODY_FILE" python3 scripts/release_control/render_release_body.py \ - --version "${{ steps.context.outputs.version }}" \ + --version "${WORKFLOW_OUTPUT_2}" \ --validate-body-file "$CURRENT_BODY_FILE" \ --output "$CLEAN_BODY_FILE" - VALIDATION_OUTPUT="${{ steps.validate.outputs.VALIDATION_OUTPUT }}" + VALIDATION_OUTPUT="${WORKFLOW_OUTPUT_3}" if [ -n "$VALIDATION_OUTPUT" ]; then ERRORS=$(echo "$VALIDATION_OUTPUT" | grep -i '\[ERROR\]' | head -20 || echo "See workflow logs for details") else @@ -579,30 +606,30 @@ jobs: cat "$CLEAN_BODY_FILE" >> "$NEW_BODY_FILE" jq -n \ --rawfile body "$NEW_BODY_FILE" \ - --arg tag "${{ steps.context.outputs.tag }}" \ - --arg target_commitish "${{ steps.context.outputs.target_commitish }}" \ + --arg tag "${WORKFLOW_OUTPUT_4}" \ + --arg target_commitish "${WORKFLOW_OUTPUT_5}" \ '{body: $body, tag_name: $tag, target_commitish: $target_commitish}' \ > "$PATCH_PAYLOAD" curl --fail-with-body --silent --show-error -X PATCH \ -H "Authorization: token $GH_TOKEN" \ -H "Accept: application/vnd.github+json" \ - "https://api.github.com/repos/${{ github.repository }}/releases/${{ steps.context.outputs.release_id }}" \ + "https://api.github.com/repos/${{ github.repository }}/releases/${WORKFLOW_OUTPUT_1}" \ --data-binary "@$PATCH_PAYLOAD" > "$PATCH_RESPONSE" ACTUAL_RELEASE_TAG=$(jq -r '.tag_name // empty' "$PATCH_RESPONSE") ACTUAL_TARGET_COMMITISH=$(jq -r '.target_commitish // empty' "$PATCH_RESPONSE") - if [ "$ACTUAL_RELEASE_TAG" != "${{ steps.context.outputs.tag }}" ]; then - echo "::error::Validation failure body update detached release tag ${ACTUAL_RELEASE_TAG}; expected ${{ steps.context.outputs.tag }}." + if [ "$ACTUAL_RELEASE_TAG" != "${WORKFLOW_OUTPUT_4}" ]; then + echo "::error::Validation failure body update detached release tag ${ACTUAL_RELEASE_TAG}; expected ${WORKFLOW_OUTPUT_4}." exit 1 fi - if [ "$ACTUAL_TARGET_COMMITISH" != "${{ steps.context.outputs.target_commitish }}" ]; then - echo "::error::Validation failure body update changed target_commitish ${ACTUAL_TARGET_COMMITISH}; expected ${{ steps.context.outputs.target_commitish }}." + if [ "$ACTUAL_TARGET_COMMITISH" != "${WORKFLOW_OUTPUT_5}" ]; then + echo "::error::Validation failure body update changed target_commitish ${ACTUAL_TARGET_COMMITISH}; expected ${WORKFLOW_OUTPUT_5}." exit 1 fi jq -r '.body // ""' "$PATCH_RESPONSE" > "$PATCHED_BODY_FILE" python3 scripts/release_control/render_release_body.py \ - --version "${{ steps.context.outputs.version }}" \ + --version "${WORKFLOW_OUTPUT_2}" \ --validate-body-file "$PATCHED_BODY_FILE" \ --expected-body-file "$CLEAN_BODY_FILE" diff --git a/scripts/check_workflow_trust.py b/scripts/check_workflow_trust.py index e83ee1908..f3198722f 100644 --- a/scripts/check_workflow_trust.py +++ b/scripts/check_workflow_trust.py @@ -24,11 +24,16 @@ USES_RE = re.compile( ) RUN_RE = re.compile(rf"^(\s*)(?:-\s*)?{_yaml_key('run')}\s*:\s*(.*)$") EXPRESSION_RE = re.compile(r"\$\{\{(.*?)\}\}") -# Workflow-call and dispatch inputs are data, not shell source. Secrets include -# github.token because Actions makes that credential available independently of -# an explicit secrets.GITHUB_TOKEN reference. +# Workflow-call and dispatch inputs are data, not shell source. Step and job +# outputs are data too: they can carry event or input values across an +# otherwise-safe intermediate step. Secrets include github.token because +# Actions makes that credential available independently of an explicit +# secrets.GITHUB_TOKEN reference. SHELL_DATA_CONTEXT_RE = re.compile( - r"(? list[Finding]: Finding( path, script_index + 1, - "workflow inputs and secrets must enter run scripts through env", + "workflow inputs, secrets, and step/job outputs " + "must enter run scripts through env", ) ) elif UNTRUSTED_GITHUB_CONTEXT_RE.search(expression): diff --git a/scripts/tests/test_workflow_trust.py b/scripts/tests/test_workflow_trust.py index f2befd5cd..5c19ecb4f 100644 --- a/scripts/tests/test_workflow_trust.py +++ b/scripts/tests/test_workflow_trust.py @@ -390,6 +390,28 @@ jobs: 5, ) + def test_rejects_step_and_job_outputs_in_generated_shell(self) -> None: + findings = self.audit( + """permissions: {} +jobs: + unsafe: + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - run: echo "${{ steps.parse.outputs.version }}" + - run: echo "${{ needs.prepare.outputs.release_tag }}" + - run: echo "${{ steps['parse']['outputs']['version'] }}" + - env: + VERSION: ${{ steps.parse.outputs.version }} + RELEASE_TAG: ${{ needs.prepare.outputs.release_tag }} + run: printf '%s %s\\n' "$VERSION" "$RELEASE_TAG" +""" + ) + self.assertEqual( + sum("must enter run scripts through env" in finding for finding in findings), + 3, + ) + def test_rejects_untrusted_github_metadata_in_generated_shell(self) -> None: findings = self.audit( """on: [pull_request]