mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
Build releases once and promote verified candidates
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
+78
@@ -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.
|
||||
+10
-5
@@ -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`.
|
||||
|
||||
@@ -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 }}`,
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
|
||||
@@ -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}."
|
||||
|
||||
Reference in New Issue
Block a user