Keep workflow outputs out of generated shell

Change-source: pulse-maintainer
This commit is contained in:
pulse-triage[bot]
2026-09-01 10:07:41 +01:00
parent a8f53e8847
commit faf69f76e6
14 changed files with 347 additions and 169 deletions
+5 -3
View File
@@ -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
+3 -1
View File
@@ -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
+102 -48
View File
@@ -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:
+3 -1
View File
@@ -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}"
+6 -4
View File
@@ -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."
+12 -5
View File
@@ -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
@@ -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() }}
+3 -1
View File
@@ -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"
@@ -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
+40 -24
View File
@@ -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}"
+58 -27
View File
@@ -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"
+73 -46
View File
@@ -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"
+11 -5
View File
@@ -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"(?<![\w.])(?:inputs|secrets)\b|(?<![\w.])github\.token\b"
r"(?<![\w.])(?:inputs|secrets)\b|"
r"(?<![\w.])github\.token\b|"
r"(?<![\w.])(?:steps|needs)\b"
r"(?=[^}\n]*(?:\.outputs\b|\[\s*['\"]outputs['\"]\s*\]))"
)
# GitHub documents these event fields as attacker-controlled strings. They may
# be passed through env, but interpolating them into a generated shell program
@@ -430,7 +435,8 @@ def audit_workflow(path: Path) -> 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):
+22
View File
@@ -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]