Skip to content

Commit 7ae3f43

Browse files
ci: the image that is published is the image that was tested
The workflow built twice: once in the job that ran the container tests, and again in the job that pushed. Nothing connected them, and nothing had to - the Dockerfile pins a minor Alpine tag and installs unpinned apk packages, so two builds minutes apart are not required to agree. What shipped was a build nothing had ever run. Each architecture is now built once, pushed by digest with no tag on it, then pulled back out of the registry and tested through it. A final job assembles the manifest list from the digests that passed and builds nothing itself. A digest with no tag is not published in any useful sense - nothing can resolve to it without already knowing it - so a failed run leaves an unreferenced digest and no tag naming bytes that nothing ran. Pull requests still build locally and push nothing: there is nothing to publish, and a fork's token could not push it anyway. The summary now prints the whole pin rather than the manifest list digest. FluxOS records a tag AND a per-architecture image id, and an id is the digest of that image's own config - which cannot be derived from the list digest, so the old summary could not fill in the file it existed to fill in. Verified against the published v1.0.0: the same commands reproduce the two ids currently pinned. Actions are pinned to commits. A major tag is mutable and every job here holds packages: write, so a moved tag is a compromised release of the program that runs as root over an application's volume. A concurrency group per ref, because two pushes to main would otherwise race for the same tag and the later-finishing one wins rather than the newer one. In-progress runs are cancelled for pull requests only: killing a publishing run between its pushes and its manifest list leaves the digests unreferenced. gofmt now covers test/ as well as cmd/, and vet runs a second time with -tags docker - the container tests are behind that tag, so nothing compiled them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 03f9225 commit 7ae3f43

2 files changed

Lines changed: 152 additions & 33 deletions

File tree

.github/workflows/build.yml

Lines changed: 131 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -16,33 +16,53 @@ permissions:
1616
contents: read
1717
packages: write
1818

19+
# One run per ref. Two pushes to main in quick succession would otherwise race
20+
# each other to the same tag, and the winner is whichever finishes last rather
21+
# than whichever is newer.
22+
#
23+
# Cancelled only for pull requests. A run that is publishing has already pushed
24+
# per-architecture images, and killing it between that and the manifest list
25+
# leaves them in the registry with nothing pointing at them.
26+
concurrency:
27+
group: ${{ github.workflow }}-${{ github.ref }}
28+
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
29+
1930
env:
2031
IMAGE: ghcr.io/runonflux/flux-volume-tools
2132

33+
# Actions are pinned to commits rather than to major tags. A major tag is
34+
# mutable, and every job here runs with packages: write - so a compromised tag
35+
# would be a compromised release of the program that runs as root over an
36+
# application's volume. The comment after each is the tag it was resolved from.
2237
jobs:
2338
# flux-op's own logic, reachable without a container in the way. The shell
2439
# implementation this replaced could only be exercised through one, which is
2540
# how it shipped handing every command it ran /dev/null as its standard input.
2641
unit:
2742
runs-on: ubuntu-latest
2843
steps:
29-
- uses: actions/checkout@v4
44+
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
3045

31-
- uses: actions/setup-go@v5
46+
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
3247
with:
3348
go-version-file: go.mod
3449

3550
- name: Assert the source is formatted
3651
run: |
37-
unformatted="$(gofmt -l cmd)"
52+
unformatted="$(gofmt -l cmd test)"
3853
if [ -n "$unformatted" ]; then
3954
echo "not gofmt'd: $unformatted"
4055
exit 1
4156
fi
4257
4358
- run: go vet ./...
4459

45-
- run: go test ./... -race
60+
# The container tests are behind a build tag, so the vet above never
61+
# compiles them. They are the half most likely to rot precisely because
62+
# nothing compiles them by default.
63+
- run: go vet -tags docker ./...
64+
65+
- run: go test ./cmd/... -race
4666

4767
# What the image DOES, exercised through a container configured exactly as the
4868
# FluxOS volume executor configures it - read-only rootfs, no network, all
@@ -52,85 +72,165 @@ jobs:
5272
#
5373
# Both architectures. arm64 is published and, before this, was never executed
5474
# here at all.
75+
#
76+
# Where this run publishes, the image is pushed BEFORE it is tested and is
77+
# tested through the registry. That ordering is the point: what gets tagged is
78+
# then the same bytes that passed, rather than a second build of the same
79+
# source. The Dockerfile pins a minor Alpine tag and installs unpinned apk
80+
# packages, so two builds minutes apart are not required to agree - and the one
81+
# that shipped used to be the one nothing had run.
82+
#
83+
# A digest with no tag on it is not published in any useful sense: nothing can
84+
# resolve to it without already knowing it. If the tests fail, no tag is ever
85+
# created and the digest is left unreferenced.
5586
image:
5687
runs-on: ubuntu-latest
5788
strategy:
5889
fail-fast: false
5990
matrix:
60-
platform: [linux/amd64, linux/arm64]
91+
include:
92+
- platform: linux/amd64
93+
arch: amd64
94+
- platform: linux/arm64
95+
arch: arm64
6196
steps:
62-
- uses: actions/checkout@v4
97+
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
6398

64-
- uses: actions/setup-go@v5
99+
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
65100
with:
66101
go-version-file: go.mod
67102

68103
# Only the non-native architecture needs emulation, and only to RUN the
69104
# image: flux-op is cross-compiled in the build stage either way.
70-
- uses: docker/setup-qemu-action@v3
105+
- uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3
71106
if: matrix.platform != 'linux/amd64'
72107

73-
- uses: docker/setup-buildx-action@v3
108+
- uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
109+
110+
- uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
111+
if: github.event_name != 'pull_request'
112+
with:
113+
registry: ghcr.io
114+
username: ${{ github.actor }}
115+
password: ${{ secrets.GITHUB_TOKEN }}
116+
117+
- name: Build this architecture and push it by digest
118+
id: pushed
119+
if: github.event_name != 'pull_request'
120+
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
121+
with:
122+
context: .
123+
platforms: ${{ matrix.platform }}
124+
outputs: type=image,name=${{ env.IMAGE }},push-by-digest=true,name-canonical=true,push=true
74125

126+
- name: Take the image under test back out of the registry
127+
if: github.event_name != 'pull_request'
128+
run: |
129+
reference="${IMAGE}@${{ steps.pushed.outputs.digest }}"
130+
docker pull --platform '${{ matrix.platform }}' "$reference"
131+
echo "IMAGE_UNDER_TEST=$reference" >> "$GITHUB_ENV"
132+
133+
# A pull request publishes nothing, so there is nothing to push by digest
134+
# and no token to do it with on a fork. Built and kept locally instead.
75135
- name: Build the image
76-
uses: docker/build-push-action@v6
136+
if: github.event_name == 'pull_request'
137+
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
77138
with:
78139
context: .
79140
platforms: ${{ matrix.platform }}
80141
push: false
81142
load: true
82143
tags: flux-volume-tools:test
83144

145+
- name: Name the image under test
146+
if: github.event_name == 'pull_request'
147+
run: echo "IMAGE_UNDER_TEST=flux-volume-tools:test" >> "$GITHUB_ENV"
148+
84149
# -count=1 because the image is an input the test cache cannot see: with a
85150
# warm cache the arm64 run would report the amd64 result and pass without
86151
# ever starting a container.
87152
- name: Assert what the image does
153+
env:
154+
FLUX_VOLUME_TOOLS_IMAGE: ${{ env.IMAGE_UNDER_TEST }}
88155
run: go test -tags docker -count=1 ./test/container/ -v
89156

157+
- name: Keep the digest that passed
158+
if: github.event_name != 'pull_request'
159+
run: |
160+
mkdir -p /tmp/digests
161+
digest='${{ steps.pushed.outputs.digest }}'
162+
touch "/tmp/digests/${digest#sha256:}"
163+
164+
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
165+
if: github.event_name != 'pull_request'
166+
with:
167+
name: digest-${{ matrix.arch }}
168+
path: /tmp/digests/*
169+
if-no-files-found: error
170+
retention-days: 1
171+
172+
# Assembles the manifest list from the digests that passed. This job builds
173+
# nothing: it only names images that already exist and have already been
174+
# tested, which is what makes the published image the tested one.
90175
publish:
91176
needs: [unit, image]
92177
if: github.event_name != 'pull_request'
93178
runs-on: ubuntu-latest
94179
steps:
95-
- uses: actions/checkout@v4
96-
97-
- uses: docker/setup-qemu-action@v3
180+
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
181+
with:
182+
path: /tmp/digests
183+
pattern: digest-*
184+
merge-multiple: true
98185

99-
- uses: docker/setup-buildx-action@v3
186+
- uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
100187

101-
- uses: docker/login-action@v3
188+
- uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
102189
with:
103190
registry: ghcr.io
104191
username: ${{ github.actor }}
105192
password: ${{ secrets.GITHUB_TOKEN }}
106193

107194
- id: meta
108-
uses: docker/metadata-action@v5
195+
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5
109196
with:
110197
images: ${{ env.IMAGE }}
111198
tags: |
112199
type=ref,event=tag
113200
type=raw,value=latest,enable={{is_default_branch}}
114201
115-
- id: build
116-
uses: docker/build-push-action@v6
117-
with:
118-
context: .
119-
platforms: linux/amd64,linux/arm64
120-
push: true
121-
tags: ${{ steps.meta.outputs.tags }}
122-
labels: ${{ steps.meta.outputs.labels }}
123-
124-
# This digest is the value FluxOS pins. It is the manifest LIST digest, so it
125-
# resolves to the right architecture on both x86 and arm nodes.
126-
- name: Publish digest to run summary
202+
- name: Assemble the manifest list from the digests that passed
203+
working-directory: /tmp/digests
204+
run: |
205+
docker buildx imagetools create \
206+
$(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
207+
$(printf "${IMAGE}@sha256:%s " *)
208+
209+
# Everything FluxOS has to write down, in one place. The pin is a tag AND
210+
# a per-architecture image id, and the id is the digest of the image's own
211+
# CONFIG rather than of its manifest - so the manifest list digest alone,
212+
# which is what this job used to print, is not enough to fill it in.
213+
- name: Write the pin to the run summary
127214
run: |
215+
reference="${IMAGE}:${{ steps.meta.outputs.version }}"
128216
{
129217
echo '## Published'
130218
echo
131-
echo 'Pin this in FluxOS:'
219+
echo 'ZelBack/config/volumeToolsImage.json:'
132220
echo
133-
echo '```'
134-
echo "${{ env.IMAGE }}@${{ steps.build.outputs.digest }}"
221+
echo '```json'
222+
echo '{'
223+
echo " \"image\": \"${reference}\","
224+
echo ' "imageIds": {'
225+
for arch in amd64 arm64; do
226+
manifest="$(docker buildx imagetools inspect "$reference" --raw |
227+
jq -r --arg a "$arch" '.manifests[] | select(.platform.architecture == $a and .platform.os == "linux") | .digest')"
228+
config="$(docker buildx imagetools inspect "${IMAGE}@${manifest}" --raw | jq -r '.config.digest')"
229+
comma=','
230+
[ "$arch" = arm64 ] && comma=''
231+
echo " \"${arch}\": \"${config}\"${comma}"
232+
done
233+
echo ' }'
234+
echo '}'
135235
echo '```'
136236
} >> "$GITHUB_STEP_SUMMARY"

README.md

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -255,5 +255,24 @@ It requires no secrets — GHCR publishing uses the automatic `GITHUB_TOKEN` wit
255255

256256
Every build runs a smoke test first that asserts each binary above is the expected
257257
implementation, so a change in Alpine's packaging fails the build rather than
258-
shipping a busybox applet into production. The published manifest digest is written
259-
to the workflow run summary; that is the value to pin in FluxOS.
258+
shipping a busybox applet into production.
259+
260+
**The image that is published is the image that was tested.** Each architecture is
261+
built once, pushed by digest with no tag on it, and then pulled back out of the
262+
registry and tested through it. Only once both have passed does a final job
263+
assemble the manifest list from those digests — so a tag never names bytes that
264+
nothing ran. It used to build a second time to publish, and since the Dockerfile
265+
pins a minor Alpine tag and installs unpinned packages, two builds minutes apart
266+
were not required to agree.
267+
268+
A digest carrying no tag is not published in any useful sense — nothing can
269+
resolve to it without already knowing it — so a failed run leaves an unreferenced
270+
digest and no tag.
271+
272+
The run summary prints the whole pin, ready to paste: the tag and both
273+
per-architecture image ids. The ids are the digests of each image's own *config*,
274+
which is what FluxOS verifies against, and they cannot be derived from the
275+
manifest list digest alone.
276+
277+
Actions are pinned to commit SHAs rather than to major tags. A major tag is
278+
mutable and every job holds `packages: write`.

0 commit comments

Comments
 (0)