Build releases once and promote verified candidates

This commit is contained in:
rcourtman
2026-07-09 22:21:34 +01:00
parent b26716b3c9
commit 8dda0b6efa
16 changed files with 896 additions and 200 deletions
@@ -0,0 +1,137 @@
name: Build Release Candidate
on:
workflow_call:
inputs:
version:
description: 'Version number without the leading v'
required: true
type: string
outputs:
artifact_name:
description: 'Immutable release candidate artifact name'
value: ${{ jobs.build.outputs.artifact_name }}
manifest_artifact_name:
description: 'Release candidate manifest artifact name'
value: ${{ jobs.build.outputs.manifest_artifact_name }}
permissions:
contents: read
jobs:
build:
name: Build and Validate Signed Candidate
runs-on: ubuntu-24.04
timeout-minutes: 35
outputs:
artifact_name: ${{ steps.identity.outputs.artifact_name }}
manifest_artifact_name: ${{ steps.identity.outputs.manifest_artifact_name }}
steps:
- name: Resolve candidate identity
id: identity
run: |
set -euo pipefail
echo "artifact_name=release-candidate-${GITHUB_SHA}-${{ inputs.version }}" >> "$GITHUB_OUTPUT"
echo "manifest_artifact_name=release-candidate-manifest-${GITHUB_SHA}-${{ inputs.version }}" >> "$GITHUB_OUTPUT"
- name: Checkout repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
fetch-depth: 0
- name: Validate candidate identity
run: |
set -euo pipefail
test "$(tr -d '\n' < VERSION)" = "${{ inputs.version }}"
test "$(git rev-parse HEAD)" = "${GITHUB_SHA}"
- name: Set up Go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version-file: go.mod
cache: true
- name: Set up Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: 'frontend-modern/package-lock.json'
- name: Install release prerequisites
run: |
sudo apt-get update
sudo apt-get install -y zip
- name: Set up Helm
uses: azure/setup-helm@dda3372f752e03dde6b3237bc9431cdc2f7a02a2 # v5.0.0
with:
version: 'v3.15.2'
- name: Install Syft
run: |
set -euo pipefail
SYFT_VERSION="1.42.4"
SYFT_ARCHIVE="syft_${SYFT_VERSION}_linux_amd64.tar.gz"
SYFT_SHA256="590650c2743b83f327d1bf9bec64f6f83b7fec504187bb84f500c862bf8f2a0f"
TMP_DIR="$(mktemp -d)"
trap 'rm -rf "$TMP_DIR"' EXIT
curl -fsSL "https://github.com/anchore/syft/releases/download/v${SYFT_VERSION}/${SYFT_ARCHIVE}" \
-o "${TMP_DIR}/${SYFT_ARCHIVE}"
printf '%s %s\n' "${SYFT_SHA256}" "${TMP_DIR}/${SYFT_ARCHIVE}" | sha256sum --check --
tar -xzf "${TMP_DIR}/${SYFT_ARCHIVE}" -C "${TMP_DIR}" syft
install -m 0755 "${TMP_DIR}/syft" /usr/local/bin/syft
- name: Build signed release candidate
run: ./scripts/build-release.sh "${{ inputs.version }}"
env:
PULSE_LICENSE_PUBLIC_KEY: ${{ secrets.PULSE_LICENSE_PUBLIC_KEY }}
PULSE_UPDATE_SIGNING_KEY: ${{ secrets.PULSE_UPDATE_SIGNING_KEY }}
PULSE_UPDATE_SIGNING_PUBLIC_KEY: ${{ vars.PULSE_UPDATE_SIGNING_PUBLIC_KEY }}
- name: Validate installer signing key pins
env:
PULSE_UPDATE_SIGNING_PUBLIC_KEY: ${{ vars.PULSE_UPDATE_SIGNING_PUBLIC_KEY }}
run: |
set -euo pipefail
TRUSTED_SSH_PUBLIC_KEY="$(
go run ./scripts/release_update_key.go public-key-ssh \
--public-key "${PULSE_UPDATE_SIGNING_PUBLIC_KEY}" \
--comment pulse-installer
)"
for installer in install.sh scripts/pulse-auto-update.sh release/pulse-auto-update.sh; do
grep -F "PINNED_RELEASE_SSH_PUBLIC_KEY=\"${TRUSTED_SSH_PUBLIC_KEY}\"" "${installer}" >/dev/null || {
echo "::error::${installer} does not trust the configured release signing key."
exit 1
}
done
- name: Validate complete candidate locally
run: ./scripts/validate-release.sh "${{ inputs.version }}" --skip-docker
- name: Create immutable candidate manifest
run: |
python3 scripts/release_candidate_manifest.py create \
--release-dir release \
--version "${{ inputs.version }}" \
--source-sha "${GITHUB_SHA}" \
--output release-candidate-manifest/release-candidate.json
- name: Upload immutable release candidate
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: ${{ steps.identity.outputs.artifact_name }}
path: release/
if-no-files-found: error
retention-days: 1
compression-level: 0
overwrite: true
- name: Upload candidate manifest
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: ${{ steps.identity.outputs.manifest_artifact_name }}
path: release-candidate-manifest/release-candidate.json
if-no-files-found: error
retention-days: 1
overwrite: true
+59 -135
View File
@@ -84,7 +84,6 @@ jobs:
hotfix_reason: ${{ steps.promotion.outputs.hotfix_reason }}
promotion_mode: ${{ steps.promotion.outputs.promotion_mode }}
is_stable_patch: ${{ steps.promotion.outputs.is_stable_patch }}
preflight_run_url: ${{ steps.preflight.outputs.run_url }}
historical_asset_backfill_only: ${{ steps.extract.outputs.historical_asset_backfill_only }}
steps:
- name: Extract version
@@ -209,35 +208,16 @@ jobs:
echo "[OK] Promotion policy validated for ${TAG}"
- name: Require recent exact-SHA stable patch preflight
id: preflight
if: ${{ steps.extract.outputs.historical_asset_backfill_only != 'true' && steps.promotion.outputs.is_stable_patch == 'true' }}
env:
GH_TOKEN: ${{ github.token }}
VERSION: ${{ steps.extract.outputs.version }}
run: |
set -euo pipefail
CUTOFF="$(date -u -d '24 hours ago' '+%Y-%m-%dT%H:%M:%SZ')"
RUN="$(gh api "repos/${{ github.repository }}/actions/workflows/release-dry-run.yml/runs?event=workflow_dispatch&status=success&head_sha=${GITHUB_SHA}&per_page=20" \
| jq -c \
--arg sha "$GITHUB_SHA" \
--arg title "Release Dry Run v${VERSION}" \
--arg cutoff "$CUTOFF" \
'[.workflow_runs[] | select(
.conclusion == "success"
and .head_sha == $sha
and .display_title == $title
and .created_at >= $cutoff
)] | sort_by(.created_at) | last // empty')"
if [ -z "$RUN" ]; then
echo "::error::Stable patch release requires a successful Release Dry Run for v${VERSION} at exact commit ${GITHUB_SHA} within the last 24 hours. Run ./scripts/trigger-stable-patch.sh --dry-run ${VERSION}, wait for success, then dispatch once."
exit 1
fi
RUN_URL="$(jq -r '.html_url' <<<"$RUN")"
echo "run_url=${RUN_URL}" >> "$GITHUB_OUTPUT"
echo "[OK] Exact-SHA release preflight passed: ${RUN_URL}"
build_release_candidate:
name: Build Immutable Release Candidate
needs: prepare
if: ${{ needs.prepare.outputs.historical_asset_backfill_only != 'true' }}
permissions:
contents: read
uses: ./.github/workflows/build-release-candidate.yml
secrets: inherit
with:
version: ${{ needs.prepare.outputs.version }}
# Frontend checks run in parallel with backend tests
frontend_checks:
@@ -268,9 +248,24 @@ jobs:
- name: Check frontend copy-paste duplication
run: npm --prefix frontend-modern run lint:cpd
- name: Build verified frontend bundle
run: npm --prefix frontend-modern run build
- name: Upload verified frontend bundle
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: release-frontend-${{ github.sha }}
path: frontend-modern/dist/
if-no-files-found: error
retention-days: 1
compression-level: 0
overwrite: true
# Backend tests run in parallel with frontend checks
backend_tests:
needs: prepare
needs:
- prepare
- frontend_checks
if: ${{ needs.prepare.outputs.historical_asset_backfill_only != 'true' }}
runs-on: ubuntu-24.04
timeout-minutes: 30
@@ -280,25 +275,11 @@ jobs:
- name: Checkout repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Set up Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: 'frontend-modern/package-lock.json'
- name: Restore frontend build cache
id: frontend-cache
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
- name: Download verified frontend bundle
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
path: frontend-modern/dist
key: frontend-build-${{ hashFiles('frontend-modern/package-lock.json', 'frontend-modern/src/**/*', 'frontend-modern/index.html', 'frontend-modern/postcss.config.cjs', 'frontend-modern/tailwind.config.cjs') }}
- name: Build frontend (if not cached)
if: steps.frontend-cache.outputs.cache-hit != 'true'
run: |
npm --prefix frontend-modern ci
npm --prefix frontend-modern run build
name: release-frontend-${{ github.sha }}
- name: Copy frontend to embed location
run: |
@@ -508,7 +489,7 @@ jobs:
integration_tests:
needs:
- prepare
- backend_tests
- frontend_checks
if: ${{ needs.prepare.outputs.historical_asset_backfill_only != 'true' && needs.prepare.outputs.is_prerelease != 'true' }}
runs-on: ubuntu-24.04
timeout-minutes: 45
@@ -525,11 +506,11 @@ jobs:
cache: 'npm'
cache-dependency-path: 'frontend-modern/package-lock.json'
- name: Restore frontend build cache
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
- name: Download verified frontend bundle
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
path: frontend-modern/dist
key: frontend-build-${{ hashFiles('frontend-modern/package-lock.json', 'frontend-modern/src/**/*', 'frontend-modern/index.html', 'frontend-modern/postcss.config.cjs', 'frontend-modern/tailwind.config.cjs') }}
name: release-frontend-${{ github.sha }}
- name: Copy frontend to embed location
run: |
@@ -661,13 +642,14 @@ jobs:
create_release:
needs:
- prepare
- build_release_candidate
- frontend_checks
- backend_tests
- docker_build
- helm_smoke
- integration_tests
# Run if integration_tests passed OR was skipped (prereleases)
if: ${{ needs.prepare.outputs.historical_asset_backfill_only != 'true' && always() && needs.frontend_checks.result == 'success' && needs.backend_tests.result == 'success' && needs.docker_build.result == 'success' && needs.helm_smoke.result == 'success' && (needs.integration_tests.result == 'success' || needs.integration_tests.result == 'skipped') }}
if: ${{ needs.prepare.outputs.historical_asset_backfill_only != 'true' && always() && needs.build_release_candidate.result == 'success' && needs.frontend_checks.result == 'success' && needs.backend_tests.result == 'success' && needs.docker_build.result == 'success' && needs.helm_smoke.result == 'success' && (needs.integration_tests.result == 'success' || needs.integration_tests.result == 'skipped') }}
runs-on: ubuntu-24.04
timeout-minutes: 30
permissions:
@@ -685,80 +667,25 @@ jobs:
with:
fetch-depth: 0
- name: Set up Go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
- name: Download immutable release candidate
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
go-version-file: go.mod
cache: true
name: ${{ needs.build_release_candidate.outputs.artifact_name }}
path: release
- name: Set up Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
- name: Download release candidate manifest
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: 'frontend-modern/package-lock.json'
name: ${{ needs.build_release_candidate.outputs.manifest_artifact_name }}
path: release-candidate-manifest
- name: Install dependencies
- name: Verify immutable release candidate
run: |
sudo apt-get update
sudo apt-get install -y zip
- name: Set up Helm
uses: azure/setup-helm@dda3372f752e03dde6b3237bc9431cdc2f7a02a2 # v5.0.0
with:
version: 'v3.15.2'
- name: Install Syft
run: |
set -euo pipefail
SYFT_VERSION="1.42.4"
SYFT_ARCHIVE="syft_${SYFT_VERSION}_linux_amd64.tar.gz"
SYFT_SHA256="590650c2743b83f327d1bf9bec64f6f83b7fec504187bb84f500c862bf8f2a0f"
TMP_DIR="$(mktemp -d)"
trap 'rm -rf "$TMP_DIR"' EXIT
curl -fsSL "https://github.com/anchore/syft/releases/download/v${SYFT_VERSION}/${SYFT_ARCHIVE}" \
-o "${TMP_DIR}/${SYFT_ARCHIVE}"
printf '%s %s\n' "${SYFT_SHA256}" "${TMP_DIR}/${SYFT_ARCHIVE}" | sha256sum --check --
tar -xzf "${TMP_DIR}/${SYFT_ARCHIVE}" -C "${TMP_DIR}" syft
install -m 0755 "${TMP_DIR}/syft" /usr/local/bin/syft
syft version
- name: Build release artifacts
run: |
echo "Building release ${{ needs.prepare.outputs.tag }}..."
./scripts/build-release.sh ${{ needs.prepare.outputs.version }}
env:
PULSE_LICENSE_PUBLIC_KEY: ${{ secrets.PULSE_LICENSE_PUBLIC_KEY }}
PULSE_UPDATE_SIGNING_KEY: ${{ secrets.PULSE_UPDATE_SIGNING_KEY }}
PULSE_UPDATE_SIGNING_PUBLIC_KEY: ${{ vars.PULSE_UPDATE_SIGNING_PUBLIC_KEY }}
- name: Validate installer signing key pins
env:
PULSE_UPDATE_SIGNING_PUBLIC_KEY: ${{ vars.PULSE_UPDATE_SIGNING_PUBLIC_KEY }}
run: |
set -euo pipefail
TRUSTED_SSH_PUBLIC_KEY="$(
go run ./scripts/release_update_key.go public-key-ssh \
--public-key "${PULSE_UPDATE_SIGNING_PUBLIC_KEY}" \
--comment pulse-installer
)"
for installer in install.sh scripts/pulse-auto-update.sh release/pulse-auto-update.sh; do
grep -F "PINNED_RELEASE_SSH_PUBLIC_KEY=\"${TRUSTED_SSH_PUBLIC_KEY}\"" "${installer}" >/dev/null || {
echo "::error::${installer} does not trust the configured release signing key."
exit 1
}
done
- name: Post-build health check
run: |
if [ -x ./pulse ]; then
./pulse --version
elif [ -x ./cmd/pulse/pulse ]; then
./cmd/pulse/pulse --version
fi
python3 scripts/release_candidate_manifest.py verify-local \
--release-dir release \
--manifest release-candidate-manifest/release-candidate.json \
--version "${{ needs.prepare.outputs.version }}" \
--source-sha "${GITHUB_SHA}"
- name: Attest release assets
uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0
@@ -1155,9 +1082,9 @@ jobs:
validate_release_assets:
needs:
- prepare
- build_release_candidate
- create_release
- publish_docker
if: ${{ always() && needs.prepare.result == 'success' && needs.create_release.result == 'success' && needs.prepare.outputs.historical_asset_backfill_only != 'true' && (github.event.inputs.draft_only == 'true' || needs.publish_docker.result == 'success') }}
if: ${{ always() && needs.prepare.result == 'success' && needs.build_release_candidate.result == 'success' && needs.create_release.result == 'success' && needs.prepare.outputs.historical_asset_backfill_only != 'true' }}
permissions:
contents: write
issues: write
@@ -1170,6 +1097,7 @@ jobs:
release_id: ${{ needs.create_release.outputs.release_id }}
draft: ${{ github.event.inputs.draft_only == 'true' }}
target_commitish: ${{ needs.create_release.outputs.target_commitish }}
candidate_manifest_artifact: ${{ needs.build_release_candidate.outputs.manifest_artifact_name }}
# End-to-end install.sh smoke against the just-published release. Catches
# runtime regressions in the documented Proxmox-LXC / systemd install flow
@@ -1243,15 +1171,15 @@ jobs:
# when it fails the floating tags don't advance and customers pulling
# rcourtman/pulse:latest stay on whatever the previous successful release
# tagged. Calling promote-floating-tags as workflow_call after
# validate_release_assets succeeds (which itself waits for the docker
# image to be pullable) guarantees the floating tags advance. Draft-only
# runs must not promote floating tags because the release is still in
# private promotion state.
# validate_release_assets and publish_docker succeed guarantees the floating
# tags advance. Draft-only runs must not promote floating tags because the
# release is still in private promotion state.
promote_floating_tags:
needs:
- prepare
- publish_docker
- validate_release_assets
if: ${{ always() && needs.prepare.result == 'success' && needs.validate_release_assets.result == 'success' && needs.prepare.outputs.historical_asset_backfill_only != 'true' && github.event.inputs.draft_only != 'true' }}
if: ${{ always() && needs.prepare.result == 'success' && needs.publish_docker.result == 'success' && needs.validate_release_assets.result == 'success' && needs.prepare.outputs.historical_asset_backfill_only != 'true' && github.event.inputs.draft_only != 'true' }}
permissions:
contents: read
packages: write
@@ -1271,8 +1199,8 @@ jobs:
publish_private_pro_runtime:
needs:
- prepare
- validate_release_assets
if: ${{ always() && needs.prepare.result == 'success' && needs.validate_release_assets.result == 'success' && needs.prepare.outputs.historical_asset_backfill_only != 'true' && github.event.inputs.draft_only != 'true' && startsWith(needs.prepare.outputs.version, '6.') }}
- create_release
if: ${{ always() && needs.prepare.result == 'success' && needs.create_release.result == 'success' && needs.prepare.outputs.historical_asset_backfill_only != 'true' && github.event.inputs.draft_only != 'true' && startsWith(needs.prepare.outputs.version, '6.') }}
runs-on: ubuntu-24.04
timeout-minutes: 150
steps:
@@ -1398,7 +1326,6 @@ jobs:
DRAFT_ONLY: ${{ github.event.inputs.draft_only }}
VERSION: ${{ needs.prepare.outputs.version }}
IS_PRERELEASE: ${{ needs.prepare.outputs.is_prerelease }}
PREFLIGHT_RUN_URL: ${{ needs.prepare.outputs.preflight_run_url }}
CREATE_RESULT: ${{ needs.create_release.result }}
DOCKER_RESULT: ${{ needs.publish_docker.result }}
VALIDATE_RESULT: ${{ needs.validate_release_assets.result }}
@@ -1436,6 +1363,3 @@ jobs:
fi
echo "Release verdict passed for v${VERSION}."
if [ -n "${PREFLIGHT_RUN_URL:-}" ]; then
echo "Stable patch preflight: ${PREFLIGHT_RUN_URL}"
fi
+10
View File
@@ -59,6 +59,16 @@ permissions:
contents: read
jobs:
build_release_candidate:
name: Build Immutable Release Candidate
if: ${{ github.event_name == 'workflow_dispatch' }}
permissions:
contents: read
uses: ./.github/workflows/build-release-candidate.yml
secrets: inherit
with:
version: ${{ inputs.version }}
dry-run:
name: Preflight Release Checks (No Publish)
runs-on: ubuntu-24.04
+46 -5
View File
@@ -23,6 +23,11 @@ on:
description: 'Commit SHA associated with the release'
required: true
type: string
candidate_manifest_artifact:
description: 'Same-run immutable candidate manifest artifact for fast digest verification'
required: false
default: ''
type: string
release:
types: [edited]
workflow_dispatch:
@@ -47,6 +52,11 @@ on:
description: 'Commit SHA associated with the release'
required: true
type: string
candidate_manifest_artifact:
description: 'Optional immutable candidate manifest artifact name'
required: false
default: ''
type: string
permissions:
contents: read
@@ -109,8 +119,31 @@ jobs:
cat context.env >> "$GITHUB_OUTPUT"
cat context.env
- name: Download all release assets
- name: Download immutable candidate manifest
if: steps.context.outputs.should_run == 'true' && inputs.candidate_manifest_artifact != ''
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: ${{ inputs.candidate_manifest_artifact }}
path: release-candidate-manifest
- name: Fetch release asset metadata
if: steps.context.outputs.should_run == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
gh api --paginate \
"repos/${{ github.repository }}/releases/${{ steps.context.outputs.release_id }}/assets?per_page=100" \
--slurp > "$RUNNER_TEMP/release-assets.json"
count="$(jq '[.[][]] | length' "$RUNNER_TEMP/release-assets.json")"
if [ "$count" -eq 0 ]; then
echo "::error::No assets found in release"
exit 1
fi
echo "Found ${count} published release assets."
- name: Download all release assets for legacy validation
if: steps.context.outputs.should_run == 'true' && inputs.candidate_manifest_artifact == ''
id: download
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -156,11 +189,11 @@ jobs:
ls -lh
- name: Install Docker
if: steps.context.outputs.should_run == 'true'
if: steps.context.outputs.should_run == 'true' && inputs.candidate_manifest_artifact == ''
run: docker --version
- name: Pull Docker image (with retry logic)
if: steps.context.outputs.should_run == 'true'
if: steps.context.outputs.should_run == 'true' && inputs.candidate_manifest_artifact == ''
id: docker
continue-on-error: true
run: |
@@ -204,13 +237,21 @@ jobs:
- name: Run validation script
if: steps.context.outputs.should_run == 'true'
id: validate
env:
CANDIDATE_MANIFEST_ARTIFACT: ${{ inputs.candidate_manifest_artifact }}
run: |
set +e
echo "Running validation script..."
chmod +x scripts/validate-release.sh
OUTPUT_FILE=$(mktemp)
if [ "${{ steps.docker.outputs.image_available }}" = "true" ]; then
if [ -n "${CANDIDATE_MANIFEST_ARTIFACT}" ]; then
echo "Validating GitHub's stored SHA-256 digests against the immutable candidate manifest..."
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 }}" 2>&1 | tee "$OUTPUT_FILE"
elif [ "${{ steps.docker.outputs.image_available }}" = "true" ]; then
echo "Running full validation (Docker + assets)..."
scripts/validate-release.sh \
"${{ steps.context.outputs.version }}" \
@@ -894,22 +894,58 @@ Companion drill:
`go test ./scripts/installtests -run 'Test(Demo|DeployDemo|UpdateDemo|Release)' -count=1`
- Manual scenario:
1. Push the exact candidate commit to the governed stable branch.
2. Dispatch `./scripts/trigger-stable-patch.sh --dry-run <version>` and do not
publish another release.
2. For a no-public-release rehearsal, dispatch
`./scripts/trigger-stable-patch.sh --dry-run <version>`.
3. Confirm the run passes `Verify Current Stable Demo Path (No Mutation)` on
a GitHub-hosted runner, including Tailscale ping, TCP/22, SSH host identity,
current stable version, frontend parity, public health, and browser smoke.
4. Confirm the public demo version and health remain unchanged after the run.
- Pass when:
The exact-SHA dry run succeeds without host mutation, routine patch metadata
rejects the documented RC-required risk paths, and the publish DAG awaits
Docker, demo, public verification, and the definitive terminal verdict.
The no-public-release rehearsal succeeds without host mutation, routine
patch metadata rejects the documented RC-required risk paths, and the single
publish DAG performs exact-SHA candidate checks before awaiting Docker, demo,
public verification, and the definitive terminal verdict.
- Latest exercised record:
`docs/release-control/v6/internal/records/stable-patch-unattended-release-path-2026-07-09.md`
- Block release if:
The exact-SHA dry run is missing or older than 24 hours, the no-mutation demo
path fails, demo deployment is detached from the release DAG, or routine mode
can bypass a same-version RC or an RC-required runtime change.
The integrated candidate checks can be bypassed, the no-mutation demo path
fails when rehearsed, demo deployment is detached from the release DAG, or
routine mode can bypass a same-version RC or an RC-required runtime change.
## Gate: `single-build-release-promotion-path`
- Owner lanes: `L1`
- Risk covered:
A normal release can repeat expensive compilation after successful checks,
serialize integration behind backend tests, download the complete release
packet after upload, or delay independent Docker, asset, and private-runtime
work. The release may be unattended but still consume most of a working day.
- Minimum evidence tier: `real-external-e2e`
- Canonical proof commands:
1. Push the exact implementation commit to the governed release branch.
2. Dispatch `Release Dry Run` for the current version and exact SHA. Do not
create or modify a public release.
3. Confirm `Build Immutable Release Candidate` builds, locally validates, and
uploads both the candidate and manifest artifacts.
4. Confirm the release checks and candidate build overlap, and confirm the
no-mutation demo path passes on GitHub-hosted infrastructure.
5. Run the candidate-manifest unit tests, release-promotion policy tests, and
installer/release workflow contract tests.
6. Confirm `v6.0.5` release timestamps and asset count remain unchanged and
the public demo remains healthy on stable `6.0.5`.
- Pass when:
The external rehearsal proves the canonical candidate builder, local tests
pin candidate-only publication and GitHub digest validation, and the static
release DAG contains no duplicate release build, backend-to-integration
serialization, full-download standard validator, or avoidable post-release
serialization.
- Latest exercised record:
`docs/release-control/v6/internal/records/single-build-release-promotion-path-2026-07-09.md`
- Block release if:
Publication rebuilds release assets, the candidate manifest does not pin the
exact SHA and complete asset set, standard validation downloads the full
release packet, independent post-release jobs are serialized, or the
definitive verdict can pass without all applicable downstream results.
## Gate Ownership Rule
@@ -163,6 +163,33 @@ Cloud, and self-hosted production users.
and retain the prior governed release-pipeline rehearsal evidence as
automation lineage rather than claiming the post-RC7 changes were RC-tested.
## Single-Build Release Path
1. Every normal RC, stable, and patch release is initiated once through
`create-release.yml`. The workflow builds one signed candidate for the exact
pushed SHA while frontend, backend, Docker, Helm, and integration checks run
in parallel. No tag, draft, or public release mutation occurs until those
checks and the candidate build pass.
2. The signed candidate is uploaded as a one-day Actions artifact with a
machine-readable manifest that pins source SHA, version, filename, size, and
SHA-256 for every release asset. Publication downloads and verifies that
exact candidate; it must not rebuild release binaries or installers.
3. Standard post-publication asset verification compares the candidate
manifest with GitHub's server-side release-asset SHA-256 digests. It must
not re-download the multi-gigabyte release packet merely to recompute hashes
already proven before upload. Manual and release-edit repair validation may
retain the full-download fallback when no same-run candidate manifest exists.
4. Docker publication, release-asset verification, and the private Pro build
begin independently as soon as the release exists. Helm, floating tags,
install smoke, stable demo deployment, and private paid-runtime promotion
retain their required dependencies, and `Definitive Release Verdict` still
fails unless every applicable terminal result passes.
5. `Release Dry Run` remains the no-public-release rehearsal surface. It calls
the same candidate builder and no-mutation demo verification, but a separate
dry run is not required before a normal release because the single publish
workflow performs the exact-SHA preflight before crossing the publication
boundary.
## Routine Stable Patch Path
1. A normal stable patch may omit a same-version RC only when all of these are
@@ -176,12 +203,13 @@ Cloud, and self-hosted production users.
- the canonical stable release-notes packet exists;
- the mobile-impact gate either proves no mobile-facing change or records
current candidate evidence; and
- `Release Dry Run` passed for the exact candidate SHA within the previous
24 hours. That run must include the no-mutation stable-demo path check.
- the integrated exact-SHA candidate build and release checks pass before
the workflow creates or publishes the release.
2. `scripts/trigger-stable-patch.sh` is the standard operator entrypoint. Run
it once with `--dry-run`, monitor asynchronously, then run it once without
`--dry-run`. It derives rollback and release notes, refuses local-only or
dirty state, and supplies the workflow metadata without interactive prompts.
it once without `--dry-run`; it derives rollback and release notes, refuses
local-only or dirty state, and supplies workflow metadata without
interactive prompts. `--dry-run` is optional and exists only for an explicit
no-public-release rehearsal.
3. Creating a same-version RC or touching an RC-required path moves the patch
onto the RC promotion path. The resolver enforces that boundary. Do not use
the routine helper to relabel a risky patch as routine.
@@ -0,0 +1,78 @@
# Single-Build Release Promotion Path - 2026-07-09
## Scope
Make the normal RC, stable, and patch release path one unattended workflow
whose public promotion and definitive verdict complete materially faster than
the v6.0.5 path without weakening release gates.
## v6.0.5 Timing Baseline
- The successful public release run `29022145812` started at
`2026-07-09T13:38:55Z` and did not finish its awaited private-runtime work
until `2026-07-09T15:45:54Z`.
- Pre-publication checks consumed approximately 36 minutes because integration
tests waited for the 20-minute backend suite.
- `create_release` then consumed another 21 minutes. Its signed release build
alone took 18 minutes and 44 seconds even though the same SHA had already
completed release checks.
- Post-publication asset validation consumed 22 minutes and 29 seconds because
it downloaded the complete 213-asset, multi-gigabyte release packet.
- Private Pro build and promotion consumed 46 minutes and 35 seconds, but did
not start until release-asset validation finished.
- Stable patch operation also required a separate 37-minute exact-SHA dry run,
making the safe operator path additive rather than a single release run.
## Canonical Correction
1. `.github/workflows/build-release-candidate.yml` builds and locally validates
one signed candidate for the exact pushed SHA. It emits a one-day candidate
artifact plus a small manifest containing version, tag, source SHA,
filenames, sizes, and SHA-256 values.
2. Candidate construction runs in parallel with frontend, backend, Docker,
Helm, and integration checks. Frontend lint/build runs once and provides one
verified bundle to backend and integration jobs. Backend and integration no
longer serialize on each other.
3. `create_release` downloads and verifies the immutable candidate instead of
rebuilding release assets. Tag, draft, and publication mutations remain
downstream of every required check.
4. Standard post-upload validation compares the candidate manifest with
GitHub's server-side release-asset SHA-256 digests and sizes. The legacy
full-download validator remains available for manual repair or release-edit
validation when no same-run manifest exists.
5. Docker publication, candidate-backed release-asset validation, and private
Pro publication start independently after release creation. Floating tags
still require both Docker and asset validation; install smoke, Helm, stable
demo, paid-runtime promotion, and the definitive verdict retain their
applicable safety dependencies.
6. `Release Dry Run` calls the same candidate builder for a no-public-release
rehearsal. Normal release publication does not require a separate dry run
because `create-release.yml` contains the exact-SHA candidate and test gates
before publication.
## Timing Contract
Using the v6.0.5 timings as the baseline:
- the public release boundary should normally be reached within 35 minutes of
dispatch rather than after a separate rehearsal plus approximately 58
minutes of checks and rebuilding;
- the definitive cross-product verdict should normally complete within 80
minutes, with the private Pro build as the expected critical path rather than
serial release-asset downloads; and
- operator attention remains limited to preparing the release packet and one
dispatch. Runner queueing or external registry degradation may extend wall
time, but no standard job may reintroduce duplicate release builds, complete
packet downloads, or avoidable serial dependencies.
## Verification
Pending one pushed `Release Dry Run` for the exact implementation SHA. The run
must build and validate the signed candidate, upload both candidate artifacts,
pass release checks, complete no-mutation demo verification, and leave the
published `v6.0.5` release and demo runtime unchanged.
## Current Verdict
Blocked until the pushed no-public-release rehearsal succeeds and its measured
job timings are recorded here.
@@ -79,7 +79,8 @@ deployment, and one definitive release verdict.
then emits a terminal `Definitive Release Verdict`.
- Routine stable patch release resolution no longer fabricates RC ceremony,
but it fails closed for documented high-risk runtime changes, an existing
same-version RC, stale rollback lineage, or a missing exact-SHA dry run.
same-version RC, stale rollback lineage, or failed integrated exact-SHA
candidate checks.
- `scripts/trigger-stable-patch.sh` is the noninteractive operator entrypoint.
## End-to-End Rehearsal
@@ -111,7 +112,11 @@ change is required for the currently verified `tag:infra` path.
## Current Verdict
Passed. A routine stable patch now requires one recent exact-SHA preflight and
one noninteractive publish dispatch. The publish DAG awaits release promotion,
Docker publication, stable demo deployment, definitive public verification,
and its terminal verdict; manual SSH is not part of the standard path.
Passed. A routine stable patch now uses one noninteractive publish dispatch
whose exact-SHA candidate checks run before publication. `Release Dry Run`
remains available for explicit no-public-release rehearsal. The publish DAG
awaits release promotion, Docker publication, stable demo deployment,
definitive public verification, and its terminal verdict; manual SSH is not
part of the standard path. The release-wide single-build timing contract is
tracked in
`docs/release-control/v6/internal/records/single-build-release-promotion-path-2026-07-09.md`.
+21 -1
View File
@@ -6764,9 +6764,29 @@
}
]
},
{
"id": "single-build-release-promotion-path",
"summary": "Confirm normal RC, stable, and patch releases build one exact-SHA signed candidate in parallel with checks, promote that candidate without rebuilding, validate GitHub asset digests without full packet downloads, and run independent post-publication lanes concurrently.",
"owner": "project-owner",
"blocking_level": "release-ready",
"minimum_evidence_tier": "real-external-e2e",
"status": "blocked",
"verification_doc": "docs/release-control/v6/internal/HIGH_RISK_RELEASE_VERIFICATION_MATRIX.md",
"lane_ids": [
"L1"
],
"evidence": [
{
"repo": "pulse",
"path": "docs/release-control/v6/internal/records/single-build-release-promotion-path-2026-07-09.md",
"kind": "file",
"evidence_tier": "local-rehearsal"
}
]
},
{
"id": "stable-patch-unattended-release-path",
"summary": "Confirm routine stable patches require one recent exact-SHA dry run, one noninteractive publish dispatch, awaited Docker and demo deployment, and a definitive release verdict without manual SSH recovery.",
"summary": "Confirm routine stable patches use one noninteractive publish dispatch with integrated exact-SHA candidate checks, awaited Docker and demo deployment, and a definitive release verdict without manual SSH recovery.",
"owner": "project-owner",
"blocking_level": "release-ready",
"minimum_evidence_tier": "real-external-e2e",
@@ -46,7 +46,8 @@ TLS floor in the dynamic config.
15. `internal/cloudcp/docker/manager.go`
16. `internal/cloudcp/docker/labels.go`
17. `internal/cloudcp/tenant_runtime_rollout.go`
13. `.github/workflows/create-release.yml`
13. `.github/workflows/build-release-candidate.yml`
14. `.github/workflows/create-release.yml`
14. `.github/workflows/deploy-demo-server.yml`
15. `.github/workflows/helm-pages.yml`
16. `.github/workflows/promote-floating-tags.yml`
@@ -95,7 +96,8 @@ TLS floor in the dynamic config.
59. `scripts/release_control/resolve_release_promotion.py`
60. `scripts/release_control/mobile_release_gate.py`
61. `scripts/release_control/mobile_release_gate_test.py`
62. `scripts/release_control/validate_artifact_release_line.py`
62. `scripts/release_candidate_manifest.py`
63. `scripts/release_control/validate_artifact_release_line.py`
63. `scripts/release_ldflags.sh`
64. `scripts/run_cloud_public_signup_smoke.sh`
65. `scripts/run_demo_public_browser_smoke.sh`
@@ -318,6 +320,16 @@ TLS floor in the dynamic config.
1. Add or change deployment-type detection, update planning, or apply behavior through `internal/updates/`
2. Add or change release-build metadata injection, Docker build-context allowlists, release artifact assembly, governed promotion metadata resolution, artifact release-line validation, the canonical version file, operator-facing release packet content, prerelease feedback intake wording, historical published-release integrity backfill, release asset validation status publication, download endpoint checksum/signature header proof, end-to-end install.sh smoke against the published release, or the canonical in-repo v6 upgrade guide through `scripts/build-release.sh`, `scripts/release_asset_common.sh`, `scripts/backfill-release-assets.sh`, `scripts/release_ldflags.sh`, `scripts/check-workflow-dispatch-inputs.py`, `scripts/release_control/mobile_release_gate.py`, `scripts/release_control/render_release_body.py`, `scripts/release_control/resolve_release_promotion.py`, `scripts/release_control/validate_artifact_release_line.py`, `scripts/release_control/record_rc_to_ga_rehearsal.py`, `scripts/release_control/internal/record_rc_to_ga_rehearsal.py`, `scripts/release_control/release_promotion_policy_support.py`, `.dockerignore`, `Dockerfile`, `.github/ISSUE_TEMPLATE/v6_rc_feedback.yml`, `docs/RELEASE_NOTES.md`, `docs/releases/`, `docs/UPGRADE_v6.md`, `docs/release-control/v6/internal/RELEASE_PROMOTION_POLICY.md`, `docs/release-control/v6/internal/PRE_RELEASE_CHECKLIST.md`, `docs/release-control/v6/internal/RC_TO_GA_REHEARSAL_TEMPLATE.md`, `scripts/validate-release.sh`, `scripts/validate-published-release.sh`, the operator dispatch helpers `scripts/trigger-release.sh` and `scripts/trigger-release-dry-run.sh`, and the governed release workflows `.github/workflows/backfill-release-assets.yml`, `.github/workflows/create-release.yml`, `.github/workflows/deploy-demo-server.yml`, `.github/workflows/helm-pages.yml`, `.github/workflows/install-sh-smoke.yml`, `.github/workflows/publish-docker.yml`, `.github/workflows/publish-helm-chart.yml`, `.github/workflows/promote-floating-tags.yml`, `.github/workflows/release-dry-run.yml`, `.github/workflows/update-demo-server.yml`, and `.github/workflows/validate-release-assets.yml`
Normal releases are single-build promotions. The exact pushed SHA must
produce one signed candidate through
`.github/workflows/build-release-candidate.yml` while independent release
checks run in parallel. `create-release.yml` may publish only that candidate
after `scripts/release_candidate_manifest.py` verifies its version, source
SHA, filenames, sizes, and SHA-256 values. Standard post-upload validation
must compare that manifest with GitHub's server-side asset digests instead
of downloading the complete release packet again. Historical repair and
release-edit validation may use the full-download fallback because those
paths do not have a same-run candidate manifest.
Release-facing agent-paradigm blurbs under `docs/releases/` must describe
`pulse-mcp` as a generic MCP adapter for MCP-speaking clients, not a
client-specific release artifact, and full-surface token guidance must come
@@ -3302,6 +3302,7 @@
".github/scripts/check-demo-reachability.sh",
".github/scripts/setup-demo-ssh.sh",
".github/workflows/backfill-release-assets.yml",
".github/workflows/build-release-candidate.yml",
".github/workflows/create-release.yml",
".github/workflows/deploy-demo-server.yml",
".github/workflows/helm-pages.yml",
@@ -3364,6 +3365,7 @@
"scripts/lib/hot-dev-runtime.sh",
"scripts/pulse-auto-update.sh",
"scripts/release_asset_common.sh",
"scripts/release_candidate_manifest.py",
"scripts/release_control/internal/record_rc_to_ga_rehearsal.py",
"scripts/release_control/record_rc_to_ga_rehearsal.py",
"scripts/release_control/release_promotion_policy_support.py",
@@ -3459,6 +3461,7 @@
".github/scripts/check-demo-reachability.sh",
".github/scripts/setup-demo-ssh.sh",
".github/workflows/backfill-release-assets.yml",
".github/workflows/build-release-candidate.yml",
".github/workflows/create-release.yml",
".github/workflows/helm-pages.yml",
".github/workflows/install-sh-smoke.yml",
@@ -3499,6 +3502,20 @@
"scripts/release_control/validate_artifact_release_line_test.py"
]
},
{
"id": "release-candidate-manifest-runtime",
"label": "immutable release candidate manifest proof",
"match_prefixes": [],
"match_files": [
"scripts/release_candidate_manifest.py"
],
"allow_same_subsystem_tests": false,
"test_prefixes": [],
"exact_files": [
"scripts/installtests/build_release_assets_test.go",
"scripts/release_control/release_candidate_manifest_test.py"
]
},
{
"id": "release-build-metadata-runtime",
"label": "release build metadata proof",
@@ -155,7 +155,8 @@ func TestCreateReleaseUploadsPowerShellInstaller(t *testing.T) {
`git push origin "refs/tags/${TAG}" --force`,
`-F target_commitish="${HEAD_SHA}"`,
`historical_asset_backfill_only=${HISTORICAL_ASSET_BACKFILL_ONLY}`,
`if: ${{ always() && needs.prepare.result == 'success' && needs.create_release.result == 'success' && needs.prepare.outputs.historical_asset_backfill_only != 'true' && (github.event.inputs.draft_only == 'true' || needs.publish_docker.result == 'success') }}`,
`if: ${{ always() && needs.prepare.result == 'success' && needs.build_release_candidate.result == 'success' && needs.create_release.result == 'success' && needs.prepare.outputs.historical_asset_backfill_only != 'true' }}`,
`candidate_manifest_artifact: ${{ needs.build_release_candidate.outputs.manifest_artifact_name }}`,
`if: ${{ needs.prepare.outputs.historical_asset_backfill_only == 'true' }}`,
`permissions:`,
`issues: write`,
@@ -718,7 +719,11 @@ func TestReleaseWorkflowsUseSecretSafeAttestedImageBuilds(t *testing.T) {
if err != nil {
t.Fatalf("read create-release.yml: %v", err)
}
createRelease := string(createReleaseBytes)
candidateWorkflowBytes, err := os.ReadFile(repoFile(".github", "workflows", "build-release-candidate.yml"))
if err != nil {
t.Fatalf("read build-release-candidate.yml: %v", err)
}
createRelease := string(createReleaseBytes) + "\n" + string(candidateWorkflowBytes)
createReleaseRequired := []string{
`provenance: mode=max`,
`sbom: true`,
@@ -1494,6 +1499,77 @@ func TestPublishHelmChartReachableViaWorkflowCall(t *testing.T) {
}
}
func TestReleasePipelinePromotesOneImmutableCandidate(t *testing.T) {
createBytes, err := os.ReadFile(repoFile(".github", "workflows", "create-release.yml"))
if err != nil {
t.Fatalf("read create-release.yml: %v", err)
}
candidateBytes, err := os.ReadFile(repoFile(".github", "workflows", "build-release-candidate.yml"))
if err != nil {
t.Fatalf("read build-release-candidate.yml: %v", err)
}
validationBytes, err := os.ReadFile(repoFile(".github", "workflows", "validate-release-assets.yml"))
if err != nil {
t.Fatalf("read validate-release-assets.yml: %v", err)
}
createWorkflow := string(createBytes)
candidateWorkflow := string(candidateBytes)
validationWorkflow := string(validationBytes)
createJob := workflowJobBlock(t, createWorkflow, "create_release")
backendJob := workflowJobBlock(t, createWorkflow, "backend_tests")
integrationJob := workflowJobBlock(t, createWorkflow, "integration_tests")
validationJob := workflowJobBlock(t, createWorkflow, "validate_release_assets")
privateJob := workflowJobBlock(t, createWorkflow, "publish_private_pro_runtime")
for _, needle := range []string{
`./scripts/build-release.sh "${{ inputs.version }}"`,
`scripts/validate-release.sh "${{ inputs.version }}" --skip-docker`,
`scripts/release_candidate_manifest.py create`,
`compression-level: 0`,
`retention-days: 1`,
} {
if !strings.Contains(candidateWorkflow, needle) {
t.Fatalf("build-release-candidate.yml missing single-build contract: %s", needle)
}
}
for _, needle := range []string{
`Download immutable release candidate`,
`scripts/release_candidate_manifest.py verify-local`,
`needs.build_release_candidate.outputs.artifact_name`,
} {
if !strings.Contains(createJob, needle) {
t.Fatalf("create_release missing candidate promotion contract: %s", needle)
}
}
if strings.Contains(createJob, "scripts/build-release.sh") {
t.Fatal("create_release must promote the verified candidate instead of rebuilding release assets")
}
if !strings.Contains(backendJob, "- frontend_checks") || !strings.Contains(integrationJob, "- frontend_checks") {
t.Fatal("backend and integration jobs must consume the shared verified frontend bundle")
}
if strings.Contains(integrationJob, "- backend_tests") {
t.Fatal("integration tests must run in parallel with backend tests")
}
if strings.Contains(validationJob, "- publish_docker") {
t.Fatal("release asset digest validation must run in parallel with Docker publication")
}
if !strings.Contains(privateJob, "- create_release") || strings.Contains(privateJob, "- validate_release_assets") {
t.Fatal("private Pro publication must start after release creation without waiting for asset validation")
}
for _, needle := range []string{
`inputs.candidate_manifest_artifact != ''`,
`scripts/release_candidate_manifest.py verify-release`,
`inputs.candidate_manifest_artifact == ''`,
} {
if !strings.Contains(validationWorkflow, needle) {
t.Fatalf("validate-release-assets.yml missing fast digest contract: %s", needle)
}
}
}
func TestCreateReleasePublishesPrivateProRuntime(t *testing.T) {
content, err := os.ReadFile(repoFile(".github", "workflows", "create-release.yml"))
if err != nil {
@@ -1503,7 +1579,7 @@ func TestCreateReleasePublishesPrivateProRuntime(t *testing.T) {
job := workflowJobBlock(t, workflow, "publish_private_pro_runtime")
for _, needle := range []string{
`needs.validate_release_assets.result == 'success'`,
`needs.create_release.result == 'success'`,
`github.event.inputs.draft_only != 'true'`,
`startsWith(needs.prepare.outputs.version, '6.')`,
`GH_TOKEN: ${{ secrets.WORKFLOW_PAT }}`,
+248
View File
@@ -0,0 +1,248 @@
#!/usr/bin/env python3
"""Create and verify immutable Pulse release-candidate manifests."""
from __future__ import annotations
import argparse
import hashlib
import json
import re
import sys
from pathlib import Path
from typing import Any
SCHEMA_VERSION = 1
VERSION_PATTERN = re.compile(
r"^[0-9]+\.[0-9]+\.[0-9]+(?:-(?:rc|alpha|beta)\.[0-9]+)?$"
)
def sha256_file(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def collect_assets(release_dir: Path) -> list[dict[str, Any]]:
if not release_dir.is_dir():
raise ValueError(f"release directory does not exist: {release_dir}")
assets: list[dict[str, Any]] = []
for path in sorted(release_dir.rglob("*")):
if path.is_symlink():
raise ValueError(f"release candidate must not contain symlinks: {path}")
if not path.is_file():
continue
relative = path.relative_to(release_dir).as_posix()
assets.append(
{
"name": relative,
"size": path.stat().st_size,
"sha256": sha256_file(path),
}
)
if not assets:
raise ValueError(f"release candidate is empty: {release_dir}")
return assets
def validate_version(version: str) -> None:
if not VERSION_PATTERN.fullmatch(version):
raise ValueError(f"invalid release version: {version!r}")
def create_manifest(release_dir: Path, version: str, source_sha: str) -> dict[str, Any]:
validate_version(version)
if not re.fullmatch(r"[0-9a-f]{40}", source_sha):
raise ValueError("source SHA must be a full lowercase Git commit SHA")
return {
"schema_version": SCHEMA_VERSION,
"version": version,
"tag": f"v{version}",
"source_sha": source_sha,
"assets": collect_assets(release_dir),
}
def load_manifest(path: Path) -> dict[str, Any]:
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
raise ValueError(f"cannot read release candidate manifest {path}: {exc}") from exc
if not isinstance(payload, dict):
raise ValueError("release candidate manifest must be a JSON object")
if payload.get("schema_version") != SCHEMA_VERSION:
raise ValueError(
f"unsupported release candidate manifest schema: {payload.get('schema_version')!r}"
)
if not isinstance(payload.get("assets"), list) or not payload["assets"]:
raise ValueError("release candidate manifest must contain assets")
return payload
def manifest_assets_by_name(manifest: dict[str, Any]) -> dict[str, dict[str, Any]]:
result: dict[str, dict[str, Any]] = {}
for index, asset in enumerate(manifest["assets"]):
if not isinstance(asset, dict):
raise ValueError(f"manifest asset {index} must be an object")
name = asset.get("name")
size = asset.get("size")
digest = asset.get("sha256")
if not isinstance(name, str) or not name or Path(name).name != name:
raise ValueError(f"manifest asset {index} has invalid name: {name!r}")
if not isinstance(size, int) or size < 0:
raise ValueError(f"manifest asset {name!r} has invalid size: {size!r}")
if not isinstance(digest, str) or not re.fullmatch(r"[0-9a-f]{64}", digest):
raise ValueError(f"manifest asset {name!r} has invalid SHA-256 digest")
if name in result:
raise ValueError(f"manifest contains duplicate asset: {name}")
result[name] = asset
return result
def verify_manifest_identity(
manifest: dict[str, Any], expected_version: str, expected_source_sha: str
) -> None:
if manifest.get("version") != expected_version:
raise ValueError(
f"candidate version {manifest.get('version')!r} does not match {expected_version!r}"
)
if manifest.get("tag") != f"v{expected_version}":
raise ValueError(f"candidate tag does not match v{expected_version}")
if manifest.get("source_sha") != expected_source_sha:
raise ValueError(
f"candidate source SHA {manifest.get('source_sha')!r} does not match "
f"{expected_source_sha!r}"
)
def verify_local(
release_dir: Path,
manifest: dict[str, Any],
expected_version: str,
expected_source_sha: str,
) -> None:
verify_manifest_identity(manifest, expected_version, expected_source_sha)
expected = manifest_assets_by_name(manifest)
actual = {asset["name"]: asset for asset in collect_assets(release_dir)}
if set(actual) != set(expected):
missing = sorted(set(expected) - set(actual))
extra = sorted(set(actual) - set(expected))
raise ValueError(f"candidate asset set mismatch: missing={missing}, extra={extra}")
for name, expected_asset in expected.items():
actual_asset = actual[name]
if actual_asset["size"] != expected_asset["size"]:
raise ValueError(f"candidate asset size mismatch: {name}")
if actual_asset["sha256"] != expected_asset["sha256"]:
raise ValueError(f"candidate asset digest mismatch: {name}")
def load_release_assets(path: Path) -> list[dict[str, Any]]:
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
raise ValueError(f"cannot read release asset metadata {path}: {exc}") from exc
if not isinstance(payload, list):
raise ValueError("release asset metadata must be a JSON array")
if payload and all(isinstance(item, list) for item in payload):
payload = [asset for page in payload for asset in page]
if not all(isinstance(item, dict) for item in payload):
raise ValueError("release asset metadata contains a non-object entry")
return payload
def verify_release(manifest: dict[str, Any], release_assets: list[dict[str, Any]]) -> None:
expected = manifest_assets_by_name(manifest)
actual: dict[str, dict[str, Any]] = {}
for index, asset in enumerate(release_assets):
name = asset.get("name")
if not isinstance(name, str) or not name:
raise ValueError(f"release asset {index} has no valid name")
if name in actual:
raise ValueError(f"release contains duplicate asset: {name}")
actual[name] = asset
if set(actual) != set(expected):
missing = sorted(set(expected) - set(actual))
extra = sorted(set(actual) - set(expected))
raise ValueError(f"published asset set mismatch: missing={missing}, extra={extra}")
for name, expected_asset in expected.items():
actual_asset = actual[name]
if actual_asset.get("size") != expected_asset["size"]:
raise ValueError(f"published asset size mismatch: {name}")
expected_digest = f"sha256:{expected_asset['sha256']}"
if actual_asset.get("digest") != expected_digest:
raise ValueError(
f"published asset digest mismatch: {name}; "
f"expected {expected_digest}, got {actual_asset.get('digest')!r}"
)
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
subparsers = parser.add_subparsers(dest="command", required=True)
create = subparsers.add_parser("create")
create.add_argument("--release-dir", type=Path, required=True)
create.add_argument("--version", required=True)
create.add_argument("--source-sha", required=True)
create.add_argument("--output", type=Path, required=True)
local = subparsers.add_parser("verify-local")
local.add_argument("--release-dir", type=Path, required=True)
local.add_argument("--manifest", type=Path, required=True)
local.add_argument("--version", required=True)
local.add_argument("--source-sha", required=True)
release = subparsers.add_parser("verify-release")
release.add_argument("--manifest", type=Path, required=True)
release.add_argument("--assets-json", type=Path, required=True)
release.add_argument("--version", required=True)
release.add_argument("--source-sha", required=True)
return parser.parse_args()
def main() -> int:
args = parse_args()
try:
if args.command == "create":
manifest = create_manifest(args.release_dir, args.version, args.source_sha)
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(
json.dumps(manifest, indent=2, sort_keys=True) + "\n",
encoding="utf-8",
)
print(
f"Release candidate manifest created: assets={len(manifest['assets'])} "
f"version={args.version} source_sha={args.source_sha}"
)
elif args.command == "verify-local":
manifest = load_manifest(args.manifest)
verify_local(args.release_dir, manifest, args.version, args.source_sha)
print(
f"Release candidate verified locally: assets={len(manifest['assets'])} "
f"version={args.version} source_sha={args.source_sha}"
)
else:
manifest = load_manifest(args.manifest)
verify_manifest_identity(manifest, args.version, args.source_sha)
release_assets = load_release_assets(args.assets_json)
verify_release(manifest, release_assets)
print(
f"Published release matches candidate: assets={len(manifest['assets'])} "
f"version={manifest['version']} source_sha={manifest['source_sha']}"
)
except (OSError, ValueError) as exc:
print(f"release candidate verification failed: {exc}", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,81 @@
import json
import sys
import tempfile
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from release_candidate_manifest import (
create_manifest,
load_release_assets,
verify_local,
verify_release,
)
SOURCE_SHA = "1" * 40
class ReleaseCandidateManifestTest(unittest.TestCase):
def create_release_dir(self, root: Path) -> Path:
release_dir = root / "release"
release_dir.mkdir()
(release_dir / "checksums.txt").write_text("abc\n", encoding="utf-8")
(release_dir / "pulse-v6.1.0-linux-amd64.tar.gz").write_bytes(b"archive")
return release_dir
def test_create_and_verify_local_candidate(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
release_dir = self.create_release_dir(Path(temp_dir))
manifest = create_manifest(release_dir, "6.1.0", SOURCE_SHA)
self.assertEqual(manifest["tag"], "v6.1.0")
self.assertEqual(
[asset["name"] for asset in manifest["assets"]],
["checksums.txt", "pulse-v6.1.0-linux-amd64.tar.gz"],
)
verify_local(release_dir, manifest, "6.1.0", SOURCE_SHA)
def test_verify_local_rejects_tampered_asset(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
release_dir = self.create_release_dir(Path(temp_dir))
manifest = create_manifest(release_dir, "6.1.0-rc.1", SOURCE_SHA)
(release_dir / "checksums.txt").write_text("xyz\n", encoding="utf-8")
with self.assertRaisesRegex(ValueError, "digest mismatch"):
verify_local(release_dir, manifest, "6.1.0-rc.1", SOURCE_SHA)
def test_verify_release_uses_server_side_digests(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
release_dir = self.create_release_dir(Path(temp_dir))
manifest = create_manifest(release_dir, "6.1.0", SOURCE_SHA)
release_assets = [
{
"name": asset["name"],
"size": asset["size"],
"digest": f"sha256:{asset['sha256']}",
}
for asset in manifest["assets"]
]
verify_release(manifest, release_assets)
release_assets[0]["digest"] = "sha256:" + "0" * 64
with self.assertRaisesRegex(ValueError, "published asset digest mismatch"):
verify_release(manifest, release_assets)
def test_release_metadata_loader_flattens_paginated_arrays(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
path = Path(temp_dir) / "assets.json"
path.write_text(
json.dumps([[{"name": "a"}], [{"name": "b"}]]),
encoding="utf-8",
)
self.assertEqual(
[asset["name"] for asset in load_release_assets(path)],
["a", "b"],
)
if __name__ == "__main__":
unittest.main()
@@ -529,6 +529,7 @@ class ReleasePromotionPolicyTest(unittest.TestCase):
demo_ssh_helper = read(".github/scripts/setup-demo-ssh.sh")
demo_reachability_helper = read(".github/scripts/check-demo-reachability.sh")
validation_workflow = read(".github/workflows/validate-release-assets.yml")
candidate_workflow = read(".github/workflows/build-release-candidate.yml")
helper = read("scripts/trigger-release.sh")
renderer = read("scripts/release_control/render_release_body.py")
policy = read("docs/release-control/v6/internal/RELEASE_PROMOTION_POLICY.md")
@@ -586,9 +587,11 @@ class ReleasePromotionPolicyTest(unittest.TestCase):
self.assertIn('-F target_commitish="${HEAD_SHA}"', content)
self.assertIn('historical_asset_backfill_only=${HISTORICAL_ASSET_BACKFILL_ONLY}', content)
self.assertIn(
"if: ${{ always() && needs.prepare.result == 'success' && needs.create_release.result == 'success' && needs.prepare.outputs.historical_asset_backfill_only != 'true' && (github.event.inputs.draft_only == 'true' || needs.publish_docker.result == 'success') }}",
"if: ${{ always() && needs.prepare.result == 'success' && needs.build_release_candidate.result == 'success' && needs.create_release.result == 'success' && needs.prepare.outputs.historical_asset_backfill_only != 'true' }}",
content,
)
self.assertIn("candidate_manifest_artifact:", validation_workflow)
self.assertIn("release_candidate_manifest.py verify-release", validation_workflow)
self.assertIn("if: ${{ needs.prepare.outputs.historical_asset_backfill_only == 'true' }}", content)
self.assertIn("issues: write", content)
self.assertIn("statuses: write", content)
@@ -611,9 +614,9 @@ class ReleasePromotionPolicyTest(unittest.TestCase):
self.assertIn("PULSE_UPDATE_SIGNING_KEY: ${{ secrets.PULSE_UPDATE_SIGNING_KEY }}", content)
self.assertIn("PULSE_UPDATE_SIGNING_PUBLIC_KEY: ${{ vars.PULSE_UPDATE_SIGNING_PUBLIC_KEY }}", content)
self.assertIn("PULSE_UPDATE_SIGNING_PUBLIC_KEY=${{ vars.PULSE_UPDATE_SIGNING_PUBLIC_KEY }}", content)
self.assertIn("Validate installer signing key pins", content)
self.assertIn("go run ./scripts/release_update_key.go public-key-ssh", content)
self.assertIn("does not trust the configured release signing key", content)
self.assertIn("Validate installer signing key pins", candidate_workflow)
self.assertIn("go run ./scripts/release_update_key.go public-key-ssh", candidate_workflow)
self.assertIn("does not trust the configured release signing key", candidate_workflow)
self.assertIn("TRUSTED_SSH_PUBLIC_KEY", update_demo_workflow)
self.assertIn('sed -i "s|^PINNED_RELEASE_SSH_PUBLIC_KEY=.*|PINNED_RELEASE_SSH_PUBLIC_KEY=\\"${TRUSTED_SSH_PUBLIC_KEY}\\"|" /tmp/pulse-install.sh', update_demo_workflow)
for demo_workflow in (update_demo_workflow, deploy_demo_workflow):
@@ -860,9 +863,10 @@ class ReleasePromotionPolicyTest(unittest.TestCase):
self.assertIn("Public demo is serving $PUBLIC_ASSET but the target service is serving $REMOTE_ASSET.", demo)
self.assertIn("uses: ./.github/workflows/publish-docker.yml", release_workflow)
self.assertIn("uses: ./.github/workflows/update-demo-server.yml", release_workflow)
self.assertIn("uses: ./.github/workflows/build-release-candidate.yml", release_workflow)
self.assertIn("Build Immutable Release Candidate", release_workflow)
self.assertIn("Definitive Release Verdict", release_workflow)
self.assertIn("Require recent exact-SHA stable patch preflight", release_workflow)
self.assertIn("Release Dry Run v${VERSION}", release_workflow)
self.assertNotIn("Require recent exact-SHA stable patch preflight", release_workflow)
self.assertNotIn("gh workflow run update-demo-server.yml", release_workflow)
self.assertNotIn("gh workflow run publish-docker.yml", release_workflow)
self.assertNotIn("preview-v6", preview_deploy)
@@ -1012,7 +1016,7 @@ class ReleasePromotionPolicyTest(unittest.TestCase):
" BLESS_GOVERNANCE_FIXTURES=1 python3 -m unittest release_promotion_policy_test"
)
def test_routine_stable_patch_entrypoint_is_noninteractive_and_preflight_gated(self) -> None:
def test_routine_stable_patch_entrypoint_is_noninteractive_and_integrated(self) -> None:
helper = read("scripts/trigger-stable-patch.sh")
policy = read("docs/release-control/v6/internal/RELEASE_PROMOTION_POLICY.md")
contract = read("docs/release-control/v6/internal/subsystems/deployment-installability.md")
@@ -1022,13 +1026,14 @@ class ReleasePromotionPolicyTest(unittest.TestCase):
self.assertIn("--dry-run", helper)
self.assertIn("--derive-rollback-latest-stable", helper)
self.assertIn("docs/releases/RELEASE_NOTES_v${VERSION}.md", helper)
self.assertIn("Release Dry Run v${VERSION}", helper)
self.assertIn("timedelta(hours=24)", helper)
self.assertIn(".createdAt >= $cutoff", helper)
self.assertIn("Use --dry-run only", helper)
self.assertNotIn("timedelta(hours=24)", helper)
self.assertNotIn(".createdAt >= $cutoff", helper)
self.assertIn("gh workflow run create-release.yml", helper)
self.assertIn("gh workflow run \"$WORKFLOW\"", helper)
self.assertIn("Single-Build Release Path", policy)
self.assertIn("Routine Stable Patch Path", policy)
self.assertIn("exact candidate SHA within the previous 24 hours", normalize_ws(policy))
self.assertIn("single publish workflow performs the exact-SHA preflight", normalize_ws(policy))
self.assertIn("An asynchronous dispatch or manual SSH deployment is not release completion.", normalize_ws(contract))
+3 -25
View File
@@ -11,9 +11,9 @@ usage() {
cat <<'EOF'
Usage: scripts/trigger-stable-patch.sh [--dry-run] [options] [version]
Runs the noninteractive stable patch preflight and dispatches exactly one
governed workflow. Run once with --dry-run, wait for that workflow to pass,
then run again without --dry-run to publish.
Dispatches exactly one governed workflow. The default release workflow builds
and validates an immutable candidate before publication. Use --dry-run only
when a no-public-release rehearsal is required.
Options:
--dry-run Dispatch Release Dry Run only.
@@ -167,27 +167,6 @@ if [ "$MODE" = "dry-run" ]; then
-f mobile_release_decision="$MOBILE_RELEASE_DECISION" \
-f mobile_release_evidence="$MOBILE_RELEASE_EVIDENCE"
else
PREFLIGHT_CUTOFF="$(python3 - <<'PY'
from datetime import datetime, timedelta, timezone
cutoff = datetime.now(timezone.utc) - timedelta(hours=24)
print(cutoff.strftime("%Y-%m-%dT%H:%M:%SZ"))
PY
)"
PREFLIGHT_RUN="$(gh run list \
--workflow=release-dry-run.yml \
--branch "$CURRENT_BRANCH" \
--event workflow_dispatch \
--limit 30 \
--json displayTitle,headSha,conclusion,url,createdAt \
| jq -c --arg sha "$LOCAL_SHA" --arg title "Release Dry Run v${VERSION}" --arg cutoff "$PREFLIGHT_CUTOFF" \
'[.[] | select(.headSha == $sha and .displayTitle == $title and .conclusion == "success" and .createdAt >= $cutoff)] | sort_by(.createdAt) | last // empty')"
if [ -z "$PREFLIGHT_RUN" ]; then
echo "No successful exact-SHA Release Dry Run from the last 24 hours exists for v${VERSION}." >&2
echo "Run $0 --dry-run ${VERSION}, wait for success, then rerun this command." >&2
exit 1
fi
python3 scripts/check-workflow-dispatch-inputs.py \
--workflow-path .github/workflows/create-release.yml \
--branch "$CURRENT_BRANCH" \
@@ -217,7 +196,6 @@ PY
-f mobile_release_decision="$MOBILE_RELEASE_DECISION" \
-f mobile_release_evidence="$MOBILE_RELEASE_EVIDENCE"
echo "Accepted preflight: $(jq -r '.url' <<<"$PREFLIGHT_RUN")"
fi
echo "Dispatched ${WORKFLOW:-create-release.yml} for v${VERSION} at ${LOCAL_SHA}."