1212# from live media is the other half and needs `kuma install`, which needs
1313# this to exist first: there is nothing to install until something is
1414# published.
15+ #
16+ # The shape is compose, stage, prove, promote. The compose builds the
17+ # image; the audit checks it; the push puts it on a staging tag nobody
18+ # tracks; the validation laps install and boot THOSE bytes from the
19+ # registry; and the promote job moves the two real tags only if every lap
20+ # came back green. The order is not style: two of the first three
21+ # publishes failed their validations (44.3.0's signatures the fleet's own
22+ # policy could not read, 44.6.0's greeter segfault), and under the old
23+ # order the tags were already live when the laps said so — the laps could
24+ # tell somebody the same day, but what they said was that the broken
25+ # thing was public. Now a red lap means the tags still name the last
26+ # image that booted. The staging tag is disposable by design: nobody
27+ # installs from it, and the next publish overwrites it.
28+ #
29+ # Composes of one commit are not byte-stable — the layered packages
30+ # deliberately resolve live (src/lock.rs: pinning rpm NVRAs is worse than
31+ # useless on a live Fedora), so the base digest is pinned and everything
32+ # else moves with the repo. That is exactly why the promotion shape is
33+ # mandatory rather than nice: the bytes that go live must be the bytes
34+ # that were validated, because there is no such thing as "the same image
35+ # built again".
1536name : publish
1637
1738on :
@@ -35,10 +56,19 @@ permissions:
3556 packages : write
3657
3758jobs :
59+ # Compose and stage. The real tags (the moving `:niri` and the pinned
60+ # `:niri-$version`) are computed here but pushed nowhere; only the
61+ # staging tag leaves this job.
3862 publish :
3963 runs-on : ubuntu-latest
4064 outputs :
65+ image_repo : ${{ steps.ref.outputs.image_repo }}
66+ example : ${{ steps.guard.outputs.example }}
67+ version : ${{ steps.ref.outputs.version }}
68+ staging : ${{ steps.ref.outputs.staging }}
4169 remote : ${{ steps.ref.outputs.remote }}
70+ pinned : ${{ steps.ref.outputs.pinned }}
71+ digest : ${{ steps.push.outputs.digest }}
4272 steps :
4373 - uses : actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
4474
@@ -81,11 +111,14 @@ jobs:
81111
82112 # Lowercased because ghcr rejects an uppercase path, and the owner
83113 # here is not.
84- # Two tags for one image: a moving one to track, and a pinned one
85- # to go back to. `:niri` means "the current niri image", which is
86- # what somebody rebasing wants and is also useless for saying "the
87- # one that worked last week": every publish overwrites what it
88- # means. kuma.lock exists because kuma cares about pinning what a
114+ # Three tags, two jobs. The staging tag is where this job's image
115+ # lives while the validation laps ask it questions. The moving tag
116+ # (`:niri`) means "the current niri image", which is what somebody
117+ # rebasing wants and is also useless for saying "the one that
118+ # worked last week": every publish overwrites what it means. The
119+ # pinned tag (`:niri-$version`) is the one to go back to. Both real
120+ # tags are moved by the promote job, on the digest this job's push
121+ # records. kuma.lock exists because kuma cares about pinning what a
89122 # build came from; the images it produces had no equivalent.
90123 #
91124 # The version comes from Cargo.toml, so a publish cannot label
@@ -96,10 +129,13 @@ jobs:
96129 set -euo pipefail
97130 owner=$(echo "${{ github.repository_owner }}" | tr '[:upper:]' '[:lower:]')
98131 version=$(grep -m1 '^version = ' Cargo.toml | cut -d'"' -f2)
99- repo ="ghcr.io/$owner/kuma"
132+ image_repo ="ghcr.io/$owner/kuma"
100133 {
101- echo "remote=$repo:${{ steps.guard.outputs.example }}"
102- echo "pinned=$repo:${{ steps.guard.outputs.example }}-$version"
134+ echo "image_repo=$image_repo"
135+ echo "version=$version"
136+ echo "staging=$image_repo:${{ steps.guard.outputs.example }}-staging"
137+ echo "remote=$image_repo:${{ steps.guard.outputs.example }}"
138+ echo "pinned=$image_repo:${{ steps.guard.outputs.example }}-$version"
103139 echo "local=localhost/kuma-publish:${{ steps.guard.outputs.example }}"
104140 } >> "$GITHUB_OUTPUT"
105141
@@ -173,21 +209,19 @@ jobs:
173209 exit 1
174210 fi
175211
176- # --digestfile, because the registry decides the digest and podman
177- # only finds out by being told. `podman inspect .RepoDigests` reports
178- # the LOCAL manifest, which is not what the registry stored once
179- # podman recompressed layers on the way up, and signing that gives a
180- # signature for a manifest nobody can fetch.
181- - name : push
212+ # The staging tag is the only thing this job puts on the registry:
213+ # the validation laps install and boot these exact bytes before the
214+ # tags people pull have moved. `--digestfile`, because the registry
215+ # decides the digest and podman only finds out by being told.
216+ # `podman inspect .RepoDigests` reports the LOCAL manifest, which is
217+ # not what the registry stored once podman recompressed layers on
218+ # the way up, and signing that gives a signature for a manifest
219+ # nobody can fetch.
220+ - name : push the staging tag
182221 id : push
183222 run : |
184223 set -euo pipefail
185- podman tag "${{ steps.ref.outputs.local }}" "${{ steps.ref.outputs.remote }}"
186- podman tag "${{ steps.ref.outputs.local }}" "${{ steps.ref.outputs.pinned }}"
187- podman push --digestfile digest.txt "${{ steps.ref.outputs.remote }}"
188- # Same manifest, so the same digest and the one signature below
189- # covers both tags.
190- podman push "${{ steps.ref.outputs.pinned }}"
224+ podman push --digestfile digest.txt "${{ steps.ref.outputs.staging }}"
191225 echo "digest=$(cat digest.txt)" >> "$GITHUB_OUTPUT"
192226
193227 - name : sign
@@ -201,18 +235,11 @@ jobs:
201235 # signature, and a signature on a tag says nothing about which
202236 # bytes were signed. The digest comes from the push itself.
203237 cosign sign --key env://COSIGN_KEY --yes \
204- "${{ steps.ref.outputs.remote }}@${{ steps.push.outputs.digest }}"
205- # A key pair rather than keyless, decided against the alternative:
206- # a policy.json sigstoreSigned requirement takes one of keyPath,
207- # fulcio or pki, and the fulcio block needs both oidcIssuer and
208- # subjectEmail. A GitHub Actions certificate carries no email, only
209- # a URI SAN naming the workflow, so no policy can express "signed
210- # by kuma's release workflow". The release binary stays keyless
211- # because `cosign verify-blob` has no such constraint.
238+ "${{ steps.ref.outputs.staging }}@${{ steps.push.outputs.digest }}"
212239
213240 # By TAG, with the committed public key: exactly the command
214- # SECURITY.md gives a stranger, run against the registry rather than
215- # against anything this job computed.
241+ # SECURITY.md gives a stranger, run against the registry rather
242+ # than against anything this job computed.
216243 #
217244 # That is the whole point, and the earlier version got it wrong. It
218245 # verified the same digest string the sign step had produced, so when
@@ -221,18 +248,98 @@ jobs:
221248 # The step meant to catch a silent signing failure shared the
222249 # failure's input and confirmed it. Verifying the tag resolves
223250 # through the registry, so it can only pass if what people pull is
224- # what was signed.
251+ # what was signed. The staging tag is the tag that exists here.
252+ - name : verify the way a stranger would
253+ if : ${{ !inputs.allow_unsigned }}
254+ run : |
255+ set -euo pipefail
256+ cosign verify --key cosign.pub \
257+ "${{ steps.ref.outputs.staging }}" >/dev/null
258+ echo "verified: ${{ steps.ref.outputs.staging }}"
259+
260+ # The registry question can only change when the registry changes, so
261+ # the staging push triggers the validation rather than a person
262+ # remembering to. Every lap installs and boots the staged digest —
263+ # pinned by digest, so a concurrent publish re-staging the tag cannot
264+ # put different bytes under this run's verdict. The REAL tags have not
265+ # moved yet: a red lap here is a red run and no published change.
266+ validate :
267+ needs : publish
268+ uses : ./.github/workflows/published.yml
269+ with :
270+ image : ${{ needs.publish.outputs.staging }}@${{ needs.publish.outputs.digest }}
271+
272+ # The tags move here, and only here, and only by digest: `crane tag`
273+ # re-points a registry tag at a manifest that is already stored, which
274+ # moves no bytes and cannot change the digest the signature names —
275+ # `podman pull` + `podman push` instead would risk recompressing layers
276+ # on the way back up and signing a manifest nobody can fetch, the
277+ # exact class the digestfile comment above is about. The signature
278+ # already covers these bytes (it was made against this digest in the
279+ # publish job), so the verified thing and the promoted thing are the
280+ # same thing by construction, and the verify below is the stranger's
281+ # own command run against the tags the stranger uses.
282+ promote :
283+ needs : [publish, validate]
284+ runs-on : ubuntu-latest
285+ steps :
286+ - uses : actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
287+
288+ - uses : sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
289+ if : ${{ !inputs.allow_unsigned }}
290+ with :
291+ cosign-release : v2.6.5
292+
293+ # crane reads Docker's keychain, so the docker login is what it
294+ # finds (the publish job's podman login wrote a different file in
295+ # a different job's storage).
296+ - name : log in to ghcr
297+ run : |
298+ set -euo pipefail
299+ echo "${{ secrets.GITHUB_TOKEN }}" \
300+ | docker login ghcr.io -u "${{ github.actor }}" --password-stdin
301+
302+ # Downloaded and verified by hash rather than trusted by tag: a
303+ # moving download is one more silent thing deciding what this gate
304+ # means, and the hash is what makes "verified" mean the same bytes
305+ # somebody read once. v0.22.1's Linux x86_64 tarball, hashed
306+ # against the release's own checksums.txt the day it was pinned.
307+ - name : install crane
308+ run : |
309+ set -euo pipefail
310+ curl --proto '=https' --tlsv1.2 --silent --show-error --fail --location \
311+ -o crane.tgz \
312+ https://github.com/google/go-containerregistry/releases/download/v0.22.1/go-containerregistry_Linux_x86_64.tar.gz
313+ echo "0ab7a1d6932a213aed964ce97666c3077fe691c8606413674a8b3e0b9ec4cda0 crane.tgz" | sha256sum -c -
314+ tar -xzf crane.tgz crane
315+
316+ - name : move the tags
317+ env :
318+ IMAGE_REPO : ${{ needs.publish.outputs.image_repo }}
319+ DIGEST : ${{ needs.publish.outputs.digest }}
320+ EXAMPLE : ${{ needs.publish.outputs.example }}
321+ VERSION : ${{ needs.publish.outputs.version }}
322+ run : |
323+ set -euo pipefail
324+ ./crane tag "$IMAGE_REPO@$DIGEST" "$EXAMPLE"
325+ ./crane tag "$IMAGE_REPO@$DIGEST" "$EXAMPLE-$VERSION"
326+
225327 - name : verify the way a stranger would
226328 if : ${{ !inputs.allow_unsigned }}
329+ env :
330+ REMOTE : ${{ needs.publish.outputs.remote }}
331+ PINNED : ${{ needs.publish.outputs.pinned }}
227332 run : |
228333 set -euo pipefail
229- for ref in "${{ steps.ref.outputs.remote }} " "${{ steps.ref.outputs.pinned }} "; do
334+ for ref in "$REMOTE " "$PINNED "; do
230335 cosign verify --key cosign.pub "$ref" >/dev/null
231336 echo "verified: $ref"
232337 done
233338
234339 - name : summary
235340 env :
341+ REMOTE : ${{ needs.publish.outputs.remote }}
342+ PINNED : ${{ needs.publish.outputs.pinned }}
236343 ALLOW_UNSIGNED : ${{ inputs.allow_unsigned }}
237344 run : |
238345 # shellcheck disable=SC2016 # Actions substitutes its own
@@ -241,30 +348,18 @@ jobs:
241348 {
242349 echo "### Published"
243350 echo
244- echo '`${{ steps.ref.outputs.remote }}` (moves)'
245- echo '`${{ steps.ref.outputs.pinned }}` (pinned)'
351+ echo 'The validation laps booted these exact bytes before either'
352+ echo 'tag moved.'
353+ echo
354+ echo '`'"$REMOTE"'` (moves)'
355+ echo '`'"$PINNED"'` (pinned)'
246356 echo
247357 if [ "$ALLOW_UNSIGNED" = "true" ]; then
248358 echo "**Unsigned.** Rebasing onto it verifies nothing."
249359 fi
250360 echo "Rebase from an atomic Fedora with:"
251361 echo
252362 echo '```'
253- echo "sudo bootc switch ${{ steps.ref.outputs.remote }} "
363+ echo "sudo bootc switch $REMOTE "
254364 echo '```'
255365 } >> "$GITHUB_STEP_SUMMARY"
256-
257- # The registry question can only change when the registry changes, so
258- # the publish that changes it triggers the validation rather than a
259- # person remembering to. Runs after, deliberately: this is the check
260- # that the published image installs and boots, and nothing it finds can
261- # unpublish anything. What it can do is tell somebody the same day.
262- #
263- # This is published.yml's only automatic trigger. It used to have a
264- # weekly schedule as well, which re-tested an unchanged artifact every
265- # Monday; the dispatch is still there for asking on purpose.
266- validate :
267- needs : publish
268- uses : ./.github/workflows/published.yml
269- with :
270- image : ${{ needs.publish.outputs.remote }}
0 commit comments