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.
83name : Promote
9- run-name : Promote ${{ inputs.release }} to ${{ inputs.environment }}
4+ run-name : Promote ${{ inputs.release || inputs.commit }} to ${{ inputs.environment }}
105
116on :
127 workflow_dispatch :
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
4140jobs :
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
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}/" |
0 commit comments