Make stable patch releases unattended

This commit is contained in:
rcourtman 2026-07-09 20:10:05 +01:00
parent 1968dc4171
commit 910418c3b2
20 changed files with 1154 additions and 122 deletions

View file

@ -61,6 +61,7 @@ concurrency:
cancel-in-progress: false
permissions:
actions: read
contents: read
jobs:
@ -81,6 +82,9 @@ jobs:
v5_eos_date: ${{ steps.promotion.outputs.v5_eos_date }}
hotfix_exception: ${{ steps.promotion.outputs.hotfix_exception }}
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
@ -205,6 +209,36 @@ 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}"
# Frontend checks run in parallel with backend tests
frontend_checks:
needs: prepare
@ -1044,30 +1078,6 @@ jobs:
if: ${{ github.event.inputs.draft_only == 'true' }}
run: 'echo "Draft-only mode: ${{ steps.create_release.outputs.release_url }}"'
- name: Trigger Docker image publish
if: ${{ github.event.inputs.draft_only != 'true' }}
continue-on-error: true
env:
GH_TOKEN: ${{ secrets.WORKFLOW_PAT }}
REQUIRED_BRANCH: ${{ needs.prepare.outputs.required_branch }}
run: |
gh workflow run publish-docker.yml --ref "${REQUIRED_BRANCH}" -f tag="${{ needs.prepare.outputs.tag }}"
echo "[OK] Docker publish workflow dispatched from ${REQUIRED_BRANCH}"
- name: Trigger demo server update
if: ${{ github.event.inputs.draft_only != 'true' }}
continue-on-error: true
env:
GH_TOKEN: ${{ secrets.WORKFLOW_PAT }}
REQUIRED_BRANCH: ${{ needs.prepare.outputs.required_branch }}
run: |
if [ "${{ needs.prepare.outputs.is_prerelease }}" = "true" ]; then
echo "[OK] Prerelease public demo update skipped; post-GA demo target is stable only."
exit 0
fi
gh workflow run update-demo-server.yml --ref "${REQUIRED_BRANCH}" -f tag="${{ needs.prepare.outputs.tag }}" -f target="stable"
echo "[OK] Demo server update dispatched for stable from ${REQUIRED_BRANCH}"
- name: Summary
run: |
echo "[SUCCESS] Release published!"
@ -1127,11 +1137,27 @@ jobs:
echo "[SUCCESS] Historical release assets repaired"
echo "Release: ${{ needs.prepare.outputs.tag }}"
publish_docker:
needs:
- prepare
- 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' }}
permissions:
contents: read
packages: write
id-token: write
attestations: write
uses: ./.github/workflows/publish-docker.yml
secrets: inherit
with:
tag: ${{ needs.prepare.outputs.tag }}
validate_release_assets:
needs:
- prepare
- create_release
if: ${{ always() && needs.prepare.result == 'success' && needs.create_release.result == 'success' && needs.prepare.outputs.historical_asset_backfill_only != 'true' }}
- 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') }}
permissions:
contents: write
issues: write
@ -1174,6 +1200,20 @@ jobs:
version: ${{ needs.prepare.outputs.version }}
repository: ${{ github.repository }}
update_stable_demo:
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' && needs.prepare.outputs.is_prerelease != 'true' && startsWith(needs.prepare.outputs.version, '6.') }}
permissions:
contents: read
uses: ./.github/workflows/update-demo-server.yml
secrets: inherit
with:
tag: ${{ needs.prepare.outputs.tag }}
target: stable
verify_only: false
# Publish the Helm chart for this release. publish-helm-chart.yml also
# listens for `release: published` events directly, but the create_release
# publish step PATCHes a draft release to draft=false rather than creating
@ -1337,3 +1377,65 @@ jobs:
-f r2_prefix="${r2_prefix}" \
-f allow_ga_prefix="${allow_ga_publish}"
wait_for_workflow rcourtman/pulse-pro "Promote Paid Runtime Release" main "${promote_started_at}" "private Pro live promotion" 3600
release_verdict:
name: Definitive Release Verdict
needs:
- prepare
- create_release
- publish_docker
- validate_release_assets
- install_sh_smoke
- update_stable_demo
- publish_helm_chart
- promote_floating_tags
- publish_private_pro_runtime
if: ${{ always() && needs.prepare.result == 'success' && needs.prepare.outputs.historical_asset_backfill_only != 'true' }}
runs-on: ubuntu-24.04
steps:
- name: Enforce terminal release outcomes
env:
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 }}
INSTALL_RESULT: ${{ needs.install_sh_smoke.result }}
DEMO_RESULT: ${{ needs.update_stable_demo.result }}
HELM_RESULT: ${{ needs.publish_helm_chart.result }}
FLOATING_RESULT: ${{ needs.promote_floating_tags.result }}
PRIVATE_PRO_RESULT: ${{ needs.publish_private_pro_runtime.result }}
run: |
set -euo pipefail
require_result() {
local name="$1"
local actual="$2"
local expected="$3"
if [ "$actual" != "$expected" ]; then
echo "::error::${name} ended as ${actual}; expected ${expected}."
return 1
fi
}
require_result "release assembly" "$CREATE_RESULT" success
require_result "release asset validation" "$VALIDATE_RESULT" success
if [ "${DRAFT_ONLY:-false}" != "true" ]; then
require_result "Docker publication" "$DOCKER_RESULT" success
require_result "install.sh smoke" "$INSTALL_RESULT" success
require_result "Helm publication" "$HELM_RESULT" success
require_result "floating-tag promotion" "$FLOATING_RESULT" success
if [[ "$VERSION" == 6.* ]]; then
require_result "private Pro publication" "$PRIVATE_PRO_RESULT" success
if [ "$IS_PRERELEASE" != "true" ]; then
require_result "stable demo deployment and verification" "$DEMO_RESULT" success
fi
fi
fi
echo "Release verdict passed for v${VERSION}."
if [ -n "${PREFLIGHT_RUN_URL:-}" ]; then
echo "Stable patch preflight: ${PREFLIGHT_RUN_URL}"
fi

View file

@ -133,11 +133,25 @@ jobs:
-o pulse ./cmd/pulse/
- name: Tailscale
uses: tailscale/github-action@4e4c49acaa9818630ce0bd7a564372c17e33fb4d # v2
id: tailscale
uses: tailscale/github-action@306e68a486fd2350f2bfc3b19fcd143891a4a2d8 # v4
with:
oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }}
oauth-secret: ${{ secrets.TS_OAUTH_SECRET }}
tags: tag:infra
version: '1.94.2'
ping: ${{ secrets.DEMO_SERVER_HOST }}
- name: Diagnose Tailscale setup failure
if: failure() && steps.tailscale.outcome == 'failure'
env:
DEMO_SERVER_HOST: ${{ secrets.DEMO_SERVER_HOST }}
run: bash .github/scripts/check-demo-reachability.sh diagnose
- name: Verify demo network path
env:
DEMO_SERVER_HOST: ${{ secrets.DEMO_SERVER_HOST }}
run: bash .github/scripts/check-demo-reachability.sh
- name: Setup SSH
env:

View file

@ -4,6 +4,12 @@ run-name: Publish Docker Images ${{ inputs.tag }}
# Triggered by create-release.yml after staging images pass tests.
# Builds multi-arch images (amd64+arm64) from source and publishes to Docker Hub and GHCR.
on:
workflow_call:
inputs:
tag:
description: 'Release tag (e.g., v4.34.0)'
required: true
type: string
workflow_dispatch:
inputs:
tag:

View file

@ -1,4 +1,5 @@
name: Release Dry Run
run-name: Release Dry Run v${{ inputs.version || 'scheduled' }}
on:
# Weekly drift watchdog: fires every Tuesday 07:00 UTC against the governed
@ -384,3 +385,15 @@ jobs:
with:
name: rc-to-ga-rehearsal-summary
path: release-dry-run/rc-to-ga-rehearsal-summary.md
demo_path_preflight:
name: Verify Current Stable Demo Path (No Mutation)
needs: dry-run
permissions:
contents: read
uses: ./.github/workflows/update-demo-server.yml
secrets: inherit
with:
tag: latest
target: stable
verify_only: true

View file

@ -1,8 +1,22 @@
name: Update Demo Server
on:
release:
types: [published]
workflow_call:
inputs:
tag:
description: 'Stable release tag to deploy, or latest for verification-only checks'
required: true
type: string
target:
description: 'Demo target to deploy'
required: false
default: stable
type: string
verify_only:
description: 'Verify connectivity and the current stable demo without changing the host'
required: false
default: false
type: boolean
workflow_dispatch:
inputs:
tag:
@ -17,6 +31,11 @@ on:
options:
- auto
- stable
verify_only:
description: 'Verify connectivity and the current stable demo without changing the host'
required: false
default: false
type: boolean
permissions:
contents: read
@ -28,25 +47,32 @@ jobs:
tag: ${{ steps.target.outputs.tag }}
target: ${{ steps.target.outputs.target }}
environment_name: ${{ steps.target.outputs.environment_name }}
skip: ${{ steps.target.outputs.skip || steps.latest.outputs.skip || 'false' }}
skip: ${{ steps.target.outputs.skip || 'false' }}
steps:
- name: Resolve target tag and demo environment
id: target
env:
EVENT_NAME: ${{ github.event_name }}
INPUT_TAG: ${{ inputs.tag }}
INPUT_TARGET: ${{ inputs.target }}
RELEASE_TAG: ${{ github.event.release.tag_name }}
VERIFY_ONLY: ${{ inputs.verify_only }}
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
if [ "$EVENT_NAME" = "workflow_dispatch" ]; then
TAG="$INPUT_TAG"
REQUESTED_TARGET="$INPUT_TARGET"
else
TAG="$RELEASE_TAG"
REQUESTED_TARGET="auto"
TAG="$INPUT_TAG"
REQUESTED_TARGET="$INPUT_TARGET"
if [ -z "$TAG" ]; then
echo "::error::A stable release tag is required."
exit 1
fi
if [ "$TAG" = "latest" ]; then
if [ "${VERIFY_ONLY:-false}" != "true" ]; then
echo "::error::The latest alias is allowed only for verification-only checks."
exit 1
fi
TAG="$(gh api "repos/${{ github.repository }}/releases/latest" --jq '.tag_name')"
echo "Resolved verification-only target to latest stable release ${TAG}."
fi
VERSION="${TAG#v}"
@ -118,32 +144,6 @@ jobs:
echo "[OK] ${TAG} validated for governed demo deployment on ${REQUIRED_BRANCH}"
- name: Skip if not latest published release for target
id: latest
if: github.event_name == 'release' && steps.target.outputs.skip != 'true'
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
TAG="${{ steps.target.outputs.tag }}"
LATEST=$(gh api "repos/${{ github.repository }}/releases/latest" --jq '.tag_name')
echo "Target tag: $TAG"
echo "Latest published stable release: $LATEST"
if [ -z "$LATEST" ] || [ "$LATEST" = "null" ]; then
echo "::error::Could not determine the latest published stable release for demo deployment."
exit 1
fi
if [ "$TAG" != "$LATEST" ]; then
echo "skip=true" >> "$GITHUB_OUTPUT"
echo "Release is not the latest published stable tag; skipping demo update."
else
echo "skip=false" >> "$GITHUB_OUTPUT"
fi
update-demo:
needs: resolve
if: needs.resolve.outputs.skip != 'true'
@ -163,6 +163,7 @@ jobs:
echo "Tag: ${{ needs.resolve.outputs.tag }}"
echo "Target: ${{ needs.resolve.outputs.target }}"
echo "Environment: ${{ needs.resolve.outputs.environment_name }}"
echo "Verification only: ${{ inputs.verify_only }}"
- name: Validate demo environment configuration
env:
@ -185,11 +186,13 @@ jobs:
fetch-tags: true
- name: Set up Go
if: inputs.verify_only != true
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version-file: go.mod
- name: Wait for release assets
if: inputs.verify_only != true
run: |
set -euo pipefail
TAG="${{ needs.resolve.outputs.tag }}"
@ -224,6 +227,7 @@ jobs:
exit 1
- name: Materialize tagged installer
if: inputs.verify_only != true
env:
PULSE_UPDATE_SIGNING_PUBLIC_KEY: ${{ vars.PULSE_UPDATE_SIGNING_PUBLIC_KEY }}
run: |
@ -240,11 +244,25 @@ jobs:
chmod +x /tmp/pulse-install.sh
- name: Tailscale
uses: tailscale/github-action@4e4c49acaa9818630ce0bd7a564372c17e33fb4d # v2
id: tailscale
uses: tailscale/github-action@306e68a486fd2350f2bfc3b19fcd143891a4a2d8 # v4
with:
oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }}
oauth-secret: ${{ secrets.TS_OAUTH_SECRET }}
tags: tag:infra
version: '1.94.2'
ping: ${{ secrets.DEMO_SERVER_HOST }}
- name: Diagnose Tailscale setup failure
if: failure() && steps.tailscale.outcome == 'failure'
env:
DEMO_SERVER_HOST: ${{ secrets.DEMO_SERVER_HOST }}
run: bash .github/scripts/check-demo-reachability.sh diagnose
- name: Verify demo network path
env:
DEMO_SERVER_HOST: ${{ secrets.DEMO_SERVER_HOST }}
run: bash .github/scripts/check-demo-reachability.sh
- name: Setup SSH
env:
@ -305,8 +323,14 @@ jobs:
echo "skip_current=false" >> "$GITHUB_OUTPUT"
fi
- name: Refuse mutation during verification-only checks
if: inputs.verify_only == true && steps.current.outputs.skip_current != 'true'
run: |
echo "::error::Verification-only check found a demo version mismatch; refusing to update the host."
exit 1
- name: Prepare demo host storage
if: steps.current.outputs.skip_current != 'true'
if: inputs.verify_only != true && steps.current.outputs.skip_current != 'true'
env:
DEMO_SERVER_HOST: ${{ secrets.DEMO_SERVER_HOST }}
DEMO_SERVER_USER: ${{ secrets.DEMO_SERVER_USER }}
@ -400,7 +424,7 @@ jobs:
ssh -i ~/.ssh/id_ed25519 "$DEMO_SERVER_USER@$DEMO_SERVER_HOST" "bash -s -- $(printf '%q ' "$SERVICE_NAME")" <<<"$REMOTE_SCRIPT"
- name: Upload tagged installer
if: steps.current.outputs.skip_current != 'true'
if: inputs.verify_only != true && steps.current.outputs.skip_current != 'true'
env:
DEMO_SERVER_HOST: ${{ secrets.DEMO_SERVER_HOST }}
DEMO_SERVER_USER: ${{ secrets.DEMO_SERVER_USER }}
@ -409,7 +433,7 @@ jobs:
scp -i ~/.ssh/id_ed25519 /tmp/pulse-install.sh "$DEMO_SERVER_USER@$DEMO_SERVER_HOST:/tmp/pulse-install.sh"
- name: Update demo server
if: steps.current.outputs.skip_current != 'true'
if: inputs.verify_only != true && steps.current.outputs.skip_current != 'true'
env:
DEMO_SERVER_HOST: ${{ secrets.DEMO_SERVER_HOST }}
DEMO_SERVER_USER: ${{ secrets.DEMO_SERVER_USER }}
@ -432,6 +456,7 @@ jobs:
ssh -i ~/.ssh/id_ed25519 "$DEMO_SERVER_USER@$DEMO_SERVER_HOST" "bash -s -- $(printf '%q ' "$TAG" "$SERVICE_NAME")" <<<"$REMOTE_SCRIPT"
- name: Restore demo runtime configuration
if: inputs.verify_only != true
env:
DEMO_SERVER_HOST: ${{ secrets.DEMO_SERVER_HOST }}
DEMO_SERVER_USER: ${{ secrets.DEMO_SERVER_USER }}