Skip to content

Commit 8d4660d

Browse files
committed
publish: the tags move only when the laps come back green
1 parent 73f62c4 commit 8d4660d

2 files changed

Lines changed: 151 additions & 55 deletions

File tree

‎.github/workflows/publish.yml‎

Lines changed: 145 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,27 @@
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".
1536
name: publish
1637

1738
on:
@@ -35,10 +56,19 @@ permissions:
3556
packages: write
3657

3758
jobs:
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 }}

‎.github/workflows/published.yml‎

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,12 @@ name: published
1212
# writes one, and until this existed nothing ever booted a disk it had
1313
# written.
1414
on:
15-
# publish.yml calls this when it has moved the tag, which is the only
16-
# moment the answer can change. Every job here reads the mutable
17-
# `:niri` tag, so dispatching them before a publish just re-tests the
18-
# previous release, and remembering the order was the whole mechanism
19-
# until now.
15+
# publish.yml calls this once its staging tag holds the digest, and
16+
# pins the input by digest so the laps judge exact bytes. The real
17+
# tags (the moving and the pinned one) move only when every lap here
18+
# is green — the promote job. Until then the tags people pull still
19+
# name the last image that booted, which is the promotion gate's
20+
# whole point: a red lap is a red run and no published change.
2021
#
2122
# It reads "every job" rather than "three of four" because the fourth
2223
# never belonged: `fix-verification` installed an image built from the

0 commit comments

Comments
 (0)