Files
pulse/.github/workflows/qualify-browser-update-release.yml
T
pulse-triage[bot] 2fb4bf9eee Bind release browser qualification to the narrow CI identity
Use a dedicated Auth Keys write client restricted to tag:ci-deploy, never the broader demo registration. Reject missing configuration and validate the actual running Self tag and DNS before Serve. Keep exact-source, denial-stop, no-replay and cleanup contracts, with connected offline registration and Serve controls.

Change-source: pulse-maintainer
2026-10-04 04:57:17 +01:00

178 lines
8.9 KiB
YAML

name: Qualify Published RC Browser Update
run-name: Browser update ${{ inputs.tag }} at ${{ inputs.expected_workflow_sha }}
# Control runs only from reviewed public main. No source build, production
# target, publishing credential, old stopped-target retry or arbitrary URL.
on:
workflow_dispatch:
inputs:
tag:
description: 'Exact published immutable vX.Y.Z-rc.N'
required: true
type: string
expected_workflow_sha:
description: 'Exact reviewed main control SHA'
required: true
type: string
permissions:
contents: read
concurrency:
group: browser-release-${{ inputs.tag }}
cancel-in-progress: false
jobs:
journey:
runs-on: ubuntu-24.04
timeout-minutes: 40
steps:
- name: Checkout reviewed control without credentials
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Bind control and exact published release pair
id: pair
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ inputs.tag }}
EXPECTED_CONTROL: ${{ inputs.expected_workflow_sha }}
run: |
set -euo pipefail
test "$GITHUB_REPOSITORY" = rcourtman/Pulse
test "$GITHUB_REF" = refs/heads/main
test "$GITHUB_WORKFLOW_SHA" = "$EXPECTED_CONTROL"
test "$GITHUB_SHA" = "$EXPECTED_CONTROL"
test "$(git rev-parse HEAD)" = "$EXPECTED_CONTROL"
[[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+-rc\.[1-9][0-9]*$ ]]
mkdir -m 0700 "$RUNNER_TEMP/browser-release"
gh api repos/rcourtman/Pulse/releases/latest > "$RUNNER_TEMP/browser-release/baseline.json"
gh api "repos/rcourtman/Pulse/releases/tags/$TAG" > "$RUNNER_TEMP/browser-release/candidate.json"
from=$(jq -er '.tag_name | select(test("^v[0-9]+\\.[0-9]+\\.[0-9]+$"))' "$RUNNER_TEMP/browser-release/baseline.json")
peel() {
local object type sha
object=$(gh api "repos/rcourtman/Pulse/git/ref/tags/$1")
type=$(jq -er '.object.type' <<<"$object")
sha=$(jq -er '.object.sha' <<<"$object")
if [[ "$type" == tag ]]; then
object=$(gh api "repos/rcourtman/Pulse/git/tags/$sha")
test "$(jq -er '.tag' <<<"$object")" = "$1"
type=$(jq -er '.object.type' <<<"$object")
sha=$(jq -er '.object.sha' <<<"$object")
fi
test "$type" = commit
[[ "$sha" =~ ^[0-9a-f]{40}$ ]]
printf '%s' "$sha"
}
from_commit=$(peel "$from")
to_commit=$(peel "$TAG")
python3 scripts/release_browser_packet.py \
--baseline "$RUNNER_TEMP/browser-release/baseline.json" \
--candidate "$RUNNER_TEMP/browser-release/candidate.json" \
--from-commit "$from_commit" --to-commit "$to_commit" --tag "$TAG" \
--output "$RUNNER_TEMP/browser-release/packet.json"
python3 -c 'import json,os,pathlib; keys=("GITHUB_WORKFLOW_SHA","GITHUB_SHA","GITHUB_RUN_ID","GITHUB_RUN_ATTEMPT"); pathlib.Path(os.environ["RUNNER_TEMP"]+"/browser-release/control.json").write_text(json.dumps({k:os.environ[k] for k in keys},indent=2)+"\n")'
python3 scripts/write_github_output.py from "$from"
python3 scripts/write_github_output.py from_commit "$from_commit"
python3 scripts/write_github_output.py to_commit "$to_commit"
- name: Authenticate both release archives before execution
env:
FROM: ${{ steps.pair.outputs.from }}
TAG: ${{ inputs.tag }}
FROM_COMMIT: ${{ steps.pair.outputs.from_commit }}
TO_COMMIT: ${{ steps.pair.outputs.to_commit }}
run: |
set -euo pipefail
root="$RUNNER_TEMP/browser-release"
key=$(grep -oE 'ssh-ed25519 [A-Za-z0-9+/=]+ pulse-installer' README.md | head -1)
test -n "$key"
printf 'pulse-installer %s\n' "$key" > "$root/allowed_signers"
for tag in "$FROM" "$TAG"; do
mkdir -m 0700 "$root/$tag"
name="pulse-$tag-linux-amd64.tar.gz"
for file in "$name" "$name.sshsig"; do
curl --fail --silent --show-error --location --retry 0 --max-time 120 --max-filesize 536870912 \
"https://github.com/rcourtman/Pulse/releases/download/$tag/$file" --output "$root/$tag/$file"
done
ssh-keygen -Y verify -f "$root/allowed_signers" -I pulse-installer -n pulse-install \
-s "$root/$tag/$name.sshsig" < "$root/$tag/$name"
done
python3 scripts/release_browser_packet.py --baseline "$root/baseline.json" --candidate "$root/candidate.json" \
--from-commit "$FROM_COMMIT" --to-commit "$TO_COMMIT" --tag "$TAG" \
--archives "$root" --output "$root/packet.json"
- name: Prepare pinned browser without lifecycle hooks
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '24.20.0'
package-manager-cache: false
- name: Prepare Chromium and native tools
env:
PLAYWRIGHT_BROWSERS_PATH: ${{ runner.temp }}/browser-release/browsers
run: |
set -euo pipefail
cd tests/integration
npm ci --ignore-scripts
npx --no-install playwright install --with-deps chromium
sudo apt-get update -qq
sudo apt-get install -y --no-install-recommends jq curl openssh-client
- name: Require the dedicated narrow CI registration binding
env:
CI_CLIENT_ID: ${{ secrets.TS_CI_DEPLOY_OAUTH_CLIENT_ID }}
CI_CLIENT_SECRET: ${{ secrets.TS_CI_DEPLOY_OAUTH_SECRET }}
run: |
set -euo pipefail
if [[ -z "$CI_CLIENT_ID" || -z "$CI_CLIENT_SECRET" ]]; then
echo 'Dedicated tag:ci-deploy OAuth binding is not configured; no demo credential fallback is permitted.' >&2
exit 2
fi
- name: Register a fresh ephemeral narrow CI node
id: tailnet
uses: tailscale/github-action@780049a30b6ff5c378a9e7b389d15ece7a204888 # v4.1.3
with:
oauth-client-id: ${{ secrets.TS_CI_DEPLOY_OAUTH_CLIENT_ID }}
oauth-secret: ${{ secrets.TS_CI_DEPLOY_OAUTH_SECRET }}
tags: tag:ci-deploy
version: '1.94.2'
- name: Serve only this disposable install
id: serve
run: |
set -euo pipefail
# Validate the actual Self tag before Serve. Never select a peer or
# fall back to the demo client's broader tag:infra identity.
origin=$(tailscale status --json | python3 scripts/release_browser_tailnet.py)
tailscale serve --bg --https=443 http://127.0.0.1:7655
python3 scripts/write_github_output.py origin "$origin"
- name: Exercise native browser update and documented recovery
env:
FROM: ${{ steps.pair.outputs.from }}
TAG: ${{ inputs.tag }}
ORIGIN: ${{ steps.serve.outputs.origin }}
run: |
set -euo pipefail
sudo env -i PATH=/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin LANG=C.UTF-8 \
GITHUB_ACTIONS=true RUNNER_ENVIRONMENT=github-hosted GITHUB_REPOSITORY=rcourtman/Pulse GITHUB_REF=refs/heads/main \
PULSE_REHEARSAL_WORKDIR="$RUNNER_TEMP/browser-release/journey" \
PULSE_REHEARSAL_BROWSER_ORIGIN="$ORIGIN" PULSE_REHEARSAL_NODE="$(command -v node)" \
PULSE_REHEARSAL_PACKET_MANIFEST="$RUNNER_TEMP/browser-release/packet.json" \
PLAYWRIGHT_BROWSERS_PATH="$RUNNER_TEMP/browser-release/browsers" \
bash scripts/release_lifecycle_rehearsal.sh --hosted-browser --from "$FROM" --to "$TAG"
- name: Close ephemeral Serve route
if: always() && steps.tailnet.outcome == 'success'
run: tailscale serve reset
- name: Retain bounded identity and terminal receipts, never credentials
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: browser-release-${{ github.run_id }}
path: |
${{ runner.temp }}/browser-release/control.json
${{ runner.temp }}/browser-release/packet.json
${{ runner.temp }}/browser-release/journey/state/browser-upgrade.json
${{ runner.temp }}/browser-release/journey/state/browser-recovery.json
${{ runner.temp }}/browser-release/journey/state/browser-cleanup.json
${{ runner.temp }}/browser-release/journey/state/lifecycle-result.json
${{ runner.temp }}/browser-release/journey/state/installer.json
${{ runner.temp }}/browser-release/journey/state/browser-*.png
${{ runner.temp }}/browser-release/journey/state/installed-*.json
${{ runner.temp }}/browser-release/journey/state/settings.*.json
${{ runner.temp }}/browser-release/journey/state/auto-updates.*.tsv
if-no-files-found: error
retention-days: 7