1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
name: Static registry
# Publishes a small artifact to the project's own static OCI registry and then pulls it
# back with a real client. The registry is an S3 bucket behind CloudFront, laid out so that
# the distribution spec's read path resolves against plain objects — see
# tools/publish-static-registry.sh for the shape and why there is no write path.
#
# Many public registries, including GHCR, Docker Hub and ECR Public, do not expose the
# CORS headers the proxyless page needs. OCIR is an exception with an explicit anonymous
# token endpoint (see web/README.md); this registry provides a controlled CORS fixture.
# The CORS assertions at the end are therefore the point of this workflow, not a detail.
on:
# On pull_request as well as main, deliberately. The publish path is credentials, IAM
# policy and cache headers; none of that can be reviewed by reading a diff, and a workflow
# that only runs after merge finds the break on main. Fork PRs simply skip: GitHub refuses
# `id-token: write` to a fork's workflow, so the role cannot be assumed and the repository
# variables are unset anyway.
pull_request:
paths:
- tools/publish-static-registry.sh
- tools/build-firmware-artifact.sh
- .github/workflows/registry.yml
push:
branches:
paths:
- tools/publish-static-registry.sh
- tools/build-firmware-artifact.sh
- .github/workflows/registry.yml
workflow_dispatch:
permissions:
contents: read
# Never cancel a run that has already uploaded: a cancelled publish can leave a tag pointing
# at a manifest whose blobs were deleted by the next run's `sync --delete`.
concurrency:
group: registry-${{ github.ref }}
cancel-in-progress: false
env:
# Same pin and checksum as firmware.yml — one tool, one version, verified both places.
CRANE_VERSION: "0.21.7"
CRANE_SHA256: "1a57bc98207fa1c0d04bf760699099e26f8383499bfd55b99c1b919a928a7230"
jobs:
publish:
name: Publish and pull back
runs-on: ubuntu-latest
# Unset on forks and on anyone else's clone, where the infrastructure does not exist.
# Skipping is right here: a red X on a fork PR would be reporting our account's absence
# as the contributor's failure.
if: vars.OCI_REGISTRY_BUCKET != ''
permissions:
contents: read
id-token: write # OIDC, so there is no AWS key to store or rotate
env:
HOST: ${{ vars.OCI_REGISTRY_HOST }}
BUCKET: ${{ vars.OCI_REGISTRY_BUCKET }}
DISTRIBUTION: ${{ vars.OCI_REGISTRY_DISTRIBUTION_ID }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install crane
run: |
set -eu
curl -fsSLo /tmp/crane.tar.gz \
"https://github.com/google/go-containerregistry/releases/download/v${CRANE_VERSION}/go-containerregistry_Linux_x86_64.tar.gz"
echo "${CRANE_SHA256} /tmp/crane.tar.gz" | sha256sum --check
tar -xzf /tmp/crane.tar.gz -C /tmp crane
sudo install -m 0755 /tmp/crane /usr/local/bin/crane
crane version
# A PR gets its own repository name rather than its own tag, because the publisher
# deletes whatever is under the name it is given (`sync --delete`, the only garbage
# collection a registry bucket can safely have). Sharing a name across concurrent PRs
# would mean each run deleting the others' blobs.
- name: Choose the target repository
id: target
run: |
set -eu
if [ -n "${{ github.event.pull_request.number }}" ]; then
echo "name=oci-zero/ci/pr-${{ github.event.pull_request.number }}" >> "$GITHUB_OUTPUT"
echo "ephemeral=true" >> "$GITHUB_OUTPUT"
else
echo "name=oci-zero/ci/main" >> "$GITHUB_OUTPUT"
echo "ephemeral=false" >> "$GITHUB_OUTPUT"
fi
# Synthetic, not the real ESP32 firmware: this workflow is testing the publish path,
# and a cross-compile would add ten minutes to a job that proves nothing more. It goes
# through build-firmware-artifact.sh rather than hand-writing JSON so the bytes have
# the same shape as a real artifact — vendor media types, an index, a gzip layer.
- name: Build a small artifact
id: layout
run: |
set -eu
printf 'oci-zero static registry smoke test %s\n' "$GITHUB_SHA" > payload.bin
./tools/build-firmware-artifact.sh \
--image payload.bin:0x10000 \
--version "0.0.0-ci+sha.$(git rev-parse --short=7 HEAD)" \
--revision "$GITHUB_SHA" \
--output layout \
--summary layout.env
cat layout.env >> "$GITHUB_OUTPUT"
- name: Lay it out as a registry tree
id: publish
run: |
set -eu
./tools/publish-static-registry.sh \
--layout layout \
--name '${{ steps.target.outputs.name }}' \
--tag latest \
--output publish >> "$GITHUB_OUTPUT"
- uses: aws-actions/configure-aws-credentials@866cb167f1d1a75ae9c75cdb39377cfd9c0f794e # v5.0.0
with:
role-to-assume: ${{ vars.OCI_REGISTRY_ROLE_ARN }}
aws-region: ${{ vars.OCI_REGISTRY_REGION }}
# Two passes, because `aws s3 sync` sets one content type for the whole run and a
# manifest served as octet-stream is not a manifest as far as a client is concerned.
# The sync is what deletes keys this build no longer produces; the copies then fix the
# handful of objects whose type and freshness matter. Doing it in this order means a
# manifest is briefly served with the wrong type, which the invalidation below clears
# before anything reads it.
- name: Upload
run: |
set -eu
aws s3 sync "publish/v2/${NAME}/" "s3://${BUCKET}/v2/${NAME}/" \
--delete --no-progress \
--content-type application/octet-stream \
--cache-control 'public, max-age=31536000, immutable'
while IFS=$'\t' read -r path ctype cachecontrol; do
[ -n "$path" ] || continue
aws s3 cp "publish/${path}" "s3://${BUCKET}/${path}" --no-progress \
--content-type "$ctype" --cache-control "$cachecontrol"
done < publish/uploads.tsv
env:
NAME: ${{ steps.target.outputs.name }}
# Two wildcard paths rather than one per object: invalidation is billed per path after
# the first 1,000 a month, and a digest path contains a colon, which CloudFront's path
# parser is not worth arguing with.
- name: Invalidate what changed
run: |
set -eu
id=$(aws cloudfront create-invalidation \
--distribution-id "$DISTRIBUTION" \
--paths "/v2/${NAME}/manifests/*" "/v2/${NAME}/tags/*" \
--query 'Invalidation.Id' --output text)
aws cloudfront wait invalidation-completed \
--distribution-id "$DISTRIBUTION" --id "$id"
env:
NAME: ${{ steps.target.outputs.name }}
- name: The registry answers the OCI version check
run: |
set -eu
code=$(curl -fsS -o /tmp/v2.json -w '%{http_code}' "https://${HOST}/v2/")
echo "GET /v2/ -> $code $(cat /tmp/v2.json)"
test "$code" = "200"
- name: crane resolves the tag to the digest we published
run: |
set -eu
remote=$(crane digest "${HOST}/${NAME}:latest")
echo "remote: $remote"
echo "expected: ${{ steps.publish.outputs.index_digest }}"
test "$remote" = '${{ steps.publish.outputs.index_digest }}'
env:
NAME: ${{ steps.target.outputs.name }}
# The strongest assertion available: fetch every object back through the registry API
# and compare it with the bytes that went in. A wrong content type, a truncated object
# or a CloudFront transformation all surface here as a byte difference rather than as
# a subtly broken artifact discovered much later.
#
# `crane manifest` and `crane blob` rather than `crane pull`: these are the two GET
# shapes the spec defines, and they carry no opinion about what the content is. `crane
# pull` resolves through crane's image model, which a vendor artifactType and a config
# that is not an image config do not fit — it would be testing crane's tolerance for
# this artifact, not the registry's correctness.
- name: crane fetches every object back byte for byte
run: |
set -eu
# uploads.tsv lists exactly the objects published as manifests; everything else in
# the layout is a blob. That is the same split the registry serves them under.
# Space-separated, because the skip test below is a glob against " $manifests ".
# Left as newlines it silently never matches, and the loop then asks for a
# manifest through the blobs endpoint — which any registry refuses, because
# manifests and blobs are separate namespaces.
manifests=$(cut -f1 publish/uploads.tsv \
| sed -n 's|.*/manifests/\(sha256:.*\)$|\1|p' | tr '\n' ' ')
for digest in $manifests; do
crane manifest "${HOST}/${NAME}@${digest}" > /tmp/fetched
# The index is the layout's root document and lives at index.json, not under
# blobs/ — an OCI layout stores everything it *references* as a blob but not the
# entry point itself. Every other manifest is reached through it and so is a blob.
if [ "$digest" = "$INDEX_DIGEST" ]; then
source=layout/index.json
else
source="layout/blobs/sha256/${digest#sha256:}"
fi
cmp "$source" /tmp/fetched
echo "manifest identical: $digest"
done
for blob in layout/blobs/sha256/*; do
hex=$(basename "$blob")
case " $manifests " in
*" sha256:$hex "*) continue ;;
esac
crane blob "${HOST}/${NAME}@sha256:${hex}" > /tmp/fetched
cmp "$blob" /tmp/fetched
echo "blob identical: sha256:$hex"
done
env:
NAME: ${{ steps.target.outputs.name }}
INDEX_DIGEST: ${{ steps.publish.outputs.index_digest }}
# Why this registry exists. If these fail, the proxyless page is back to needing a
# proxy and there is no reason to be paying for a bucket.
- name: A browser can read it
run: |
set -eu
manifest="https://${HOST}/v2/${NAME}/manifests/latest"
echo "--- preflight ---"
curl -fsS -X OPTIONS -D /tmp/preflight -o /dev/null \
-H 'Origin: https://example.invalid' \
-H 'Access-Control-Request-Method: GET' \
-H 'Access-Control-Request-Headers: accept' \
"$manifest"
cat /tmp/preflight
grep -qi '^access-control-allow-origin: \*' /tmp/preflight
echo "--- actual request ---"
curl -fsS -D /tmp/headers -o /tmp/manifest.json \
-H 'Origin: https://example.invalid' \
-H 'Accept: application/vnd.oci.image.index.v1+json' \
"$manifest"
cat /tmp/headers
grep -qi '^access-control-allow-origin: \*' /tmp/headers
grep -qi '^content-type: application/vnd.oci.image.index.v1+json' /tmp/headers
# Compression must stay off on the distribution. A gzipped manifest makes
# Content-Length describe the compressed body while the client holds the
# decompressed one, so a client that checks a descriptor's size against the header
# rejects a manifest that is perfectly valid. Assert the absence, because the
# setting is one checkbox away and the symptom would be baffling.
if grep -qi '^content-encoding:' /tmp/headers; then
echo "the distribution is compressing responses; see cloudfront.tf in my-infra" >&2
exit 1
fi
declared=$(grep -i '^content-length:' /tmp/headers | tr -d '\r' | awk '{print $2}')
actual=$(wc -c < /tmp/manifest.json)
echo "content-length: $declared, body: $actual"
test "$declared" = "$actual"
env:
NAME: ${{ steps.target.outputs.name }}
# A PR's namespace is scaffolding. Removing it also exercises the s3:DeleteObject half
# of the publish policy, which nothing else in this workflow touches.
- name: Remove the pull request's namespace
if: always() && steps.target.outputs.ephemeral == 'true'
# Never the reason a run is red. If the failure above was the credentials step this
# cannot work anyway, and reporting a cleanup error would bury the real one.
continue-on-error: true
run: aws s3 rm "s3://${BUCKET}/v2/${NAME}/" --recursive --only-show-errors
env:
NAME: ${{ steps.target.outputs.name }}