Skip to content

Commit e98b948

Browse files
fix(ci): make promotion and review decisions fail safely (#1843)
1 parent 55c2802 commit e98b948

24 files changed

Lines changed: 742 additions & 604 deletions

.github/actions/setup-caches/action.yml

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,7 @@ runs:
3636
fi
3737
echo "dependencies=$DEPENDENCY_HASH" >> "$GITHUB_OUTPUT"
3838
echo "generated-clients=$GENERATED_CLIENTS_HASH" >> "$GITHUB_OUTPUT"
39-
# Only default-branch runs write caches: a cache saved on any other ref is invisible to every
40-
# other ref, so a pull-request write would never be reused.
39+
# Publish default-branch caches for reuse across PRs, rather than PR-scoped entries.
4140
if [[ "$GITHUB_REF" == "refs/heads/$DEFAULT_BRANCH" && "$GITHUB_EVENT_NAME" =~ ^(push|schedule|workflow_dispatch)$ ]]; then
4241
echo "save=true" >> "$GITHUB_OUTPUT"
4342
else

.github/actions/setup-toolchain/action.yml

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,9 @@ runs:
1212
shell: bash
1313
run: |
1414
echo "version=$(jq -r '.devEngines.packageManager.version' package.json)" >> "$GITHUB_OUTPUT"
15-
# On Windows this shell is Git Bash, whose $HOME is an MSYS path that the Windows PATH
16-
# cannot resolve. os.homedir() is what both pnpm/setup and actions/cache's `~` expand to, so
17-
# asking Node keeps the destination, the cache path and PATH the same directory everywhere.
15+
# Use the native home directory, not Git Bash's MSYS path, on Windows.
1816
echo "dest=$(node -p 'require("node:path").join(require("node:os").homedir(), "setup-pnpm")')" >> "$GITHUB_OUTPUT"
19-
# The path is pnpm/setup's own default destination, so the action does not restate it as `dest`;
20-
# the version check below fails the job if that default ever moves.
17+
# Cache pnpm/setup's default installation directory.
2118
- id: pnpm-cache
2219
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
2320
with:
@@ -78,14 +75,12 @@ runs:
7875
cache-dependency-path: pnpm-lock.yaml
7976
install: false
8077
require-lockfile: true
81-
# A cache key cannot be overwritten, so one truncated save would poison every job until the pin
82-
# moves. Answering here proves the restored tree and the PATH it was reached through, and holds
83-
# the freshly installed tree to the pin before it is offered to anyone else.
78+
# Cache entries are immutable: validate the executable before publishing a new entry.
8479
- shell: bash
8580
env:
8681
PNPM_VERSION: ${{ steps.pnpm.outputs.version }}
8782
run: test "$(pnpm --version)" = "$PNPM_VERSION"
88-
# A cache saved on any ref other than the default branch is invisible to every other ref.
83+
# Default-branch caches are reusable across PRs; merge-ref caches are scoped to that PR.
8984
- if: steps.pnpm-cache.outputs.cache-hit != 'true' && github.ref == format('refs/heads/{0}', github.event.repository.default_branch)
9085
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
9186
with:
Lines changed: 6 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,4 @@
1-
# Moves the environment that follows the default branch to every commit CI has built.
2-
#
3-
# Releases are how production is promoted: they carry a version, a provenance manifest and a signed
4-
# lock, and a person decides when one is cut. Staging exists to be the branch as it stands, so it
5-
# follows commits instead — the promotion workflow is still the only thing that writes a channel, so
6-
# the identity a host pins does not change.
7-
#
8-
# There is no switch for this. An environment that sometimes follows the branch and sometimes runs a
9-
# release is two environments wearing one name, and the channel already has the only hold that
10-
# matters: `freeze`, which is signed and travels with the channel rather than sitting beside it.
1+
# Promotes successful main builds through the environment-protected promotion workflow.
112
name: Continuous staging
123

134
on:
@@ -19,49 +10,28 @@ on:
1910
permissions: {}
2011

2112
concurrency:
22-
# Not cancel-in-progress: a superseded promotion has already written its channel, and the next one
23-
# supersedes it on the host anyway.
13+
# Let the current run finish observing the promotion it dispatched.
2414
group: continuous-staging
2515
cancel-in-progress: false
2616

2717
jobs:
2818
follow-main:
29-
# A red build is not something to deploy, and a run that was cancelled decided nothing.
3019
if: >-
3120
${{ github.event.workflow_run.conclusion == 'success'
3221
&& github.event.workflow_run.event == 'push' }}
3322
runs-on: ubuntu-latest
3423
timeout-minutes: 30
3524
permissions:
3625
actions: write # dispatch the promotion workflow
37-
contents: read # read the channel to honour a freeze
3826
steps:
3927
- name: Follow this commit on staging
4028
env:
4129
GH_TOKEN: ${{ github.token }}
4230
COMMIT: ${{ github.event.workflow_run.head_sha }}
4331
run: |
4432
set -euo pipefail
45-
# A hold has to survive the next green build, or it is advice rather than a hold. The
46-
# channel is the record of it, so it is what gets asked.
47-
frozen=$(gh api "repos/$GITHUB_REPOSITORY/contents/channels/staging.json?ref=deploy-state" \
48-
--jq '.content' 2>/dev/null | base64 -d 2>/dev/null | jq -r '.freeze // false' || echo false)
49-
if [ "$frozen" = true ]; then
50-
echo "::notice::staging is frozen; leaving it where it is"
51-
exit 0
52-
fi
53-
54-
since=$(date -u +%Y-%m-%dT%H:%M:%SZ)
55-
gh workflow run promote.yml --repo "$GITHUB_REPOSITORY" \
56-
-f environment=Staging -f commit="$COMMIT"
57-
# The dispatch returns before the run exists, and taking the newest run blindly could
58-
# watch someone else's promotion, so the run is matched on being newer than the request.
59-
for _ in $(seq 1 30); do
60-
run=$(gh run list --repo "$GITHUB_REPOSITORY" --workflow promote.yml --limit 10 \
61-
--json databaseId,createdAt \
62-
--jq "[.[] | select(.createdAt > \"$since\")] | sort_by(.createdAt) | .[0].databaseId // empty")
63-
[ -n "$run" ] && break
64-
sleep 5
65-
done
66-
[ -n "$run" ] || { echo "::error::promote.yml did not start"; exit 1; }
33+
run=$(gh api --method POST "repos/$GITHUB_REPOSITORY/actions/workflows/promote.yml/dispatches" \
34+
-f ref=main -f 'inputs[environment]=Staging' -f "inputs[commit]=$COMMIT" \
35+
-f 'inputs[automatic]=true' -F return_run_details=true --jq '.workflow_run_id')
36+
[[ "$run" =~ ^[1-9][0-9]*$ ]] || { echo "::error::Dispatch returned no run ID"; exit 1; }
6737
gh run watch "$run" --repo "$GITHUB_REPOSITORY" --exit-status

.github/workflows/promote.yml

Lines changed: 46 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,7 @@
1-
# Moves an environment to a release by writing its channel on `deploy-state`, which the hosts poll.
2-
#
3-
# The hosts pin this workflow's identity, and a GitHub environment gates it, so a signature carrying
4-
# that identity is proof its reviewers approved. That holds only while this workflow stays
5-
# non-reusable: a `workflow_call` trigger would let any caller mint the same identity. The release
6-
# reaches staging by dispatching this workflow, not by triggering on its completion, so no run here
7-
# inherits a context it did not ask for.
1+
# Hosts trust this workflow's signing identity. Keep signing behind the environment gate;
2+
# making this workflow reusable would let callers share that identity.
83
name: Promote
9-
run-name: Promote ${{ inputs.release }} to ${{ inputs.environment }}
4+
run-name: Promote ${{ inputs.release || inputs.commit }} to ${{ inputs.environment }}
105

116
on:
127
workflow_dispatch:
@@ -24,6 +19,10 @@ on:
2419
description: Commit to run, as a full SHA. Only for an environment that follows main.
2520
type: string
2621
required: false
22+
automatic:
23+
description: Follow main only if the channel is not frozen or ahead of this build
24+
type: boolean
25+
default: false
2726
allow-rollback:
2827
description: Permit moving this environment backwards
2928
type: boolean
@@ -39,22 +38,49 @@ concurrency:
3938
cancel-in-progress: false
4039

4140
jobs:
41+
automatic:
42+
if: ${{ inputs.automatic }}
43+
runs-on: ubuntu-latest
44+
timeout-minutes: 5
45+
permissions:
46+
contents: read
47+
outputs:
48+
apply: ${{ steps.policy.outputs.apply }}
49+
steps:
50+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
51+
with:
52+
persist-credentials: false
53+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
54+
with:
55+
ref: deploy-state
56+
path: deploy-state
57+
persist-credentials: false
58+
- uses: ./.github/actions/setup-toolchain
59+
with:
60+
install: "none"
61+
# The workflow lock covers this read through publication, including manually requested holds.
62+
- name: Check automatic promotion
63+
id: policy
64+
env:
65+
GH_TOKEN: ${{ github.token }}
66+
COMMIT: ${{ inputs.commit }}
67+
CHANNEL: ${{ inputs.environment }}
68+
run: node scripts/automatic-promotion.ts
69+
4270
promote:
71+
needs: automatic
72+
if: ${{ !cancelled() && !failure() && (!inputs.automatic || needs.automatic.outputs.apply == 'true') }}
4373
runs-on: ubuntu-latest
4474
timeout-minutes: 20
4575
environment:
4676
name: ${{ inputs.environment }}
47-
# GitHub records this as the deployment's environment_url, on the status it writes when the
48-
# job ends. A status posted from inside the job would be superseded by that one, so the URL
49-
# has to come from here rather than from a step. It is a step output because APP_HOSTNAME is
50-
# scoped to the environment being resolved.
77+
# APP_HOSTNAME is environment-scoped, so resolve it after the job starts.
5178
url: ${{ steps.release.outputs.environment_url }}
5279
permissions:
5380
contents: write
5481
id-token: write
5582
steps:
56-
# `deploy-state` carries channels and no code, so the tooling that writes it is checked out
57-
# from the ref this workflow was dispatched on and the branch itself sits beside it.
83+
# Tooling comes from the dispatched workflow ref, not from the deployment target.
5884
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
5985
with:
6086
persist-credentials: false
@@ -71,17 +97,7 @@ jobs:
7197

7298
- uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
7399

74-
# The resolver is tooling: it comes from the default branch so that a commit older than it can
75-
# still be promoted, which is exactly what rolling an environment back means.
76-
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
77-
with:
78-
path: .promotion-scripts
79-
sparse-checkout: scripts
80-
persist-credentials: false
81-
82-
# The pins are data about the commit being promoted, so they come from that commit. Reading
83-
# them from the default branch instead would deploy one commit's code against another's
84-
# upstream images.
100+
# Unlike tooling, upstream image pins must come from the deployment target.
85101
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
86102
if: ${{ inputs.commit != '' }}
87103
with:
@@ -106,8 +122,7 @@ jobs:
106122
echo "::error::Name a release or a commit, not both"; exit 1; }
107123
[[ "$COMMIT" =~ ^[0-9a-f]{40}$ ]] || {
108124
echo "::error::Follow a full commit SHA, not '$COMMIT'"; exit 1; }
109-
# Only what is already on the default branch may be followed: a commit that is not an
110-
# ancestor of main has not been through review, whoever is able to dispatch this.
125+
# Dispatch permission must not authorize commits outside main.
111126
gh api "repos/$GITHUB_REPOSITORY/compare/$COMMIT...main" --jq .status |
112127
grep -qE '^(identical|ahead)$' || {
113128
echo "::error::$COMMIT is not on the default branch"; exit 1; }
@@ -120,9 +135,7 @@ jobs:
120135
release="$REQUESTED"
121136
[[ "$release" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]] || {
122137
echo "::error::Promote an immutable vX.Y.Z release, not '$release'"; exit 1; }
123-
# GitHub tag immutability is not a gate here: the host takes the source commit and every
124-
# image digest from the cosign-signed release lock, so a moved tag changes nothing that is
125-
# deployed. Requiring it would refuse the older releases a rollback exists to reach.
138+
# The host binds the tag's source tree to the signed release lock before applying it.
126139
[ "$(gh release view "$release" --repo "$GITHUB_REPOSITORY" --json isDraft --jq .isDraft)" = false ] || {
127140
echo "::error::$release is still a draft"; exit 1; }
128141
echo "release=$release" >> "$GITHUB_OUTPUT"
@@ -144,10 +157,8 @@ jobs:
144157
channel=$(echo "$CHANNEL" | tr '[:upper:]' '[:lower:]')
145158
mkdir -p channels
146159
if [ "$FOLLOWS" = commit ]; then
147-
# A commit has no release to fetch a lock from, so the channel carries the digests
148-
# itself. They are covered by the signature below, which is the same authority that
149-
# stands behind a release lock.
150-
node "$GITHUB_WORKSPACE/.promotion-scripts/scripts/commit-image-lock.ts" \
160+
# Commit deployments carry verified image digests in the signed channel.
161+
node "$GITHUB_WORKSPACE/scripts/commit-image-lock.ts" \
151162
"$RELEASE" "$OWNER" "$GITHUB_WORKSPACE/.promotion-inventory/security/release-images.json" \
152163
> images.json
153164
jq -n --arg commit "$RELEASE" --slurpfile images images.json \
@@ -172,8 +183,6 @@ jobs:
172183
GH_TOKEN: ${{ github.token }}
173184
RELEASE: ${{ steps.release.outputs.release }}
174185
run: |
175-
# An empty commit would read as a move forward to hosts that compare ancestry, so a channel
176-
# already at this release produces none.
177186
node ../scripts/commit-via-api.ts \
178187
--branch deploy-state \
179188
--message "chore(deploy): ${CHANNEL_FILE} -> ${RELEASE}" \
@@ -186,8 +195,7 @@ jobs:
186195
HOSTNAME: ${{ vars.APP_HOSTNAME }}
187196
run: |
188197
set -euo pipefail
189-
# Nothing here can push the hosts, so the deploy is observed: the webapp publishes the
190-
# version it runs, which makes convergence visible without any access to the host.
198+
# Observe the public webapp version; host metrics report stack readiness separately.
191199
deadline=$(( SECONDS + 900 ))
192200
while [ "$SECONDS" -lt "$deadline" ]; do
193201
config=$(curl -fsS --max-time 20 "https://${HOSTNAME}/" |

.github/workflows/review-policy.yml

Lines changed: 6 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,19 @@
11
name: Review policy
22

33
on:
4-
pull_request_target: # zizmor: ignore[dangerous-triggers] API reads only; no pull-request code is executed.
4+
pull_request_target: # zizmor: ignore[dangerous-triggers] Trusted policy only; no pull-request code is executed.
55
types: [opened, reopened, synchronize, ready_for_review, edited]
66

77
permissions: {}
88

99
concurrency:
10-
# Never cancel: runs in a group are serialised, so the newest event's decision is the one that
11-
# lands, and a cancelled run leaves an approval unsubmitted rather than half-written.
10+
# Serialize writes without interrupting an approval; each run re-reads the current head.
1211
group: review-policy-${{ github.event.pull_request.number }}
1312
cancel-in-progress: false
1413

1514
jobs:
16-
# This job is *not* a required status check, and must never become one. The `main` ruleset requires
17-
# one native approving review; all this job does is supply that approval for the authors listed in
18-
# `REVIEW_POLICY_MAINTAINERS`. A pull request by anyone else gets no approval here and sits in
19-
# GitHub's own "Review required" state, which blocks merging, merge-queue entry and auto-merge.
20-
#
21-
# `synchronize` is load-bearing: the ruleset dismisses stale approvals on push, so every push has
22-
# to earn a fresh one. `edited` is load-bearing for stacked pull requests: merging a lower layer
23-
# retargets the upper one onto `main`, which fires no other event here — without it the layer
24-
# arrives at the queue unapproved and cannot enter. For the same reason there is no `branches:`
25-
# filter: a layer must already hold its approval when it is retargeted, and an approval on a pull
26-
# request targeting anything but `main` satisfies no rule and is inert.
15+
# Not a required check: GitHub's native review requirement owns merge eligibility.
16+
# No base filter: stacked layers need approval before retargeting. Pushes dismiss stale reviews.
2717
approve:
2818
name: Approve a maintainer's pull request
2919
runs-on: ubuntu-latest
@@ -32,18 +22,15 @@ jobs:
3222
contents: read
3323
pull-requests: write
3424
steps:
35-
# The default branch, never `github.sha` or the pull request's head: the base branch's copy of
36-
# the policy is the authoritative one, and nothing from the pull request is fetched or run.
25+
# Execute only the default branch's policy, never the pull request's code.
3726
- name: Load the trusted policy evaluator
3827
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
3928
with:
4029
ref: ${{ github.event.repository.default_branch }}
4130
persist-credentials: false
4231
sparse-checkout: scripts
4332

44-
# The review is attributed to `github-actions[bot]`, which counts toward the ruleset's
45-
# `required_approving_review_count`. That needs the organisation's "Allow GitHub Actions to
46-
# create and approve pull requests" setting left on.
33+
# Requires the organisation's "Allow GitHub Actions to create and approve pull requests" setting.
4734
- name: Approve when the author is a listed maintainer
4835
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
4936
env:

0 commit comments

Comments
 (0)