Repository navigation
667 lines (619 loc) · 29 KB
/
Copy pathrelease-image.yml
File metadata and controls
667 lines (619 loc) · 29 KB
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
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
name: Release server image
run-name: Release server image ${{ github.event.release.tag_name || inputs.tag }}
on:
release:
types: [published]
workflow_dispatch:
inputs:
tag:
description: Existing published immutable release tag (v0.3.9)
required: true
type: string
# Container publication is deliberately separate from release-build.yml. The
# candidate workflow remains contents:read only and cannot publish packages.
permissions:
contents: read
concurrency:
group: release-server-image-${{ github.event.release.tag_name || inputs.tag }}
cancel-in-progress: false
env:
REGISTRY: ghcr.io
jobs:
resolve:
# Preview releases only distribute GitHub binaries; no automatic GHCR image.
# An operator can still explicitly dispatch this workflow from main.
if: github.event_name == 'workflow_dispatch' || github.event.release.prerelease == false
runs-on: ubuntu-latest
timeout-minutes: 10
outputs:
tag: ${{ steps.release.outputs.tag }}
version: ${{ steps.release.outputs.version }}
image_version: ${{ steps.release.outputs.image_version }}
source_sha: ${{ steps.release.outputs.source_sha }}
source_short: ${{ steps.release.outputs.source_short }}
built_at: ${{ steps.release.outputs.built_at }}
created_at: ${{ steps.release.outputs.created_at }}
image: ${{ steps.release.outputs.image }}
prerelease: ${{ steps.release.outputs.prerelease }}
publish_latest: ${{ steps.release.outputs.publish_latest }}
steps:
- name: Require reviewed workflow source
if: github.event_name == 'workflow_dispatch'
shell: bash
run: |
set -euo pipefail
if [ "$GITHUB_REF" != "refs/heads/main" ]; then
echo "Manual server-image publication must be dispatched from main." >&2
exit 1
fi
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
fetch-depth: 0
fetch-tags: true
persist-credentials: false
ref: refs/tags/${{ github.event.release.tag_name || inputs.tag }}
- name: Resolve immutable published release
id: release
shell: bash
env:
GH_TOKEN: ${{ github.token }}
EVENT_NAME: ${{ github.event_name }}
EVENT_TAG: ${{ github.event.release.tag_name }}
INPUT_TAG: ${{ inputs.tag }}
run: |
set -euo pipefail
if [ "$EVENT_NAME" = release ]; then
tag="$EVENT_TAG"
else
tag="$INPUT_TAG"
fi
if [[ ! "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$ ]]; then
echo "Invalid release tag: $tag" >&2
exit 1
fi
release_json="$RUNNER_TEMP/release.json"
gh api "repos/$GITHUB_REPOSITORY/releases/tags/$tag" > "$release_json"
python3 - "$release_json" "$tag" <<'PY'
import json
import sys
release = json.load(open(sys.argv[1], encoding="utf-8"))
tag = sys.argv[2]
if release.get("tag_name") != tag:
raise SystemExit("GitHub Release tag does not match requested tag")
if release.get("draft") is not False or not release.get("published_at"):
raise SystemExit("server images may be published only for a public GitHub Release")
PY
if [ "$(git cat-file -t "$tag")" != tag ]; then
echo "$tag must be an annotated tag." >&2
exit 1
fi
source_sha="$(git rev-list -n 1 "$tag")"
if [ "$(git rev-parse HEAD)" != "$source_sha" ]; then
echo "Checked-out source does not match $tag." >&2
exit 1
fi
if [ -n "$(git status --porcelain --untracked-files=all)" ]; then
echo "Release image checkout is not clean." >&2
exit 1
fi
version="${tag#v}"
# Docker tag names do not allow SemVer '+'. Underscore cannot occur in
# SemVer itself, so this mapping remains collision-free for valid releases.
image_version="${version//+/_}"
package_version="$(python3 -c 'import json; print(json.load(open("npm/webcodex/package.json", encoding="utf-8"))["version"])')"
if [ "$package_version" != "$version" ]; then
echo "Tag/package version mismatch: tag=$version package=$package_version" >&2
exit 1
fi
built_at="$(git for-each-ref --format='%(taggerdate:unix)' "refs/tags/$tag")"
created_at="$(git for-each-ref --format='%(taggerdate:iso-strict)' "refs/tags/$tag")"
if [[ ! "$built_at" =~ ^[0-9]+$ ]] || [ -z "$created_at" ]; then
echo "Could not resolve immutable tagger time for $tag." >&2
exit 1
fi
prerelease="$(python3 - "$release_json" <<'PY'
import json
import sys
print(str(bool(json.load(open(sys.argv[1], encoding="utf-8")).get("prerelease"))).lower())
PY
)"
publish_latest=false
if [ "$prerelease" = false ]; then
latest_tag="$(gh api "repos/$GITHUB_REPOSITORY/releases/latest" --jq .tag_name 2>/dev/null || true)"
if [ "$latest_tag" = "$tag" ]; then
publish_latest=true
fi
fi
owner="${GITHUB_REPOSITORY_OWNER,,}"
image="$REGISTRY/$owner/webcodex-server"
{
echo "tag=$tag"
echo "version=$version"
echo "image_version=$image_version"
echo "source_sha=$source_sha"
echo "source_short=${source_sha:0:12}"
echo "built_at=$built_at"
echo "created_at=$created_at"
echo "image=$image"
echo "prerelease=$prerelease"
echo "publish_latest=$publish_latest"
} >> "$GITHUB_OUTPUT"
build:
needs: resolve
strategy:
fail-fast: false
matrix:
include:
- runner: ubuntu-latest
platform: linux/amd64
architecture: amd64
slug: amd64
- runner: ubuntu-24.04-arm
platform: linux/arm64
architecture: arm64
slug: arm64
runs-on: ${{ matrix.runner }}
timeout-minutes: 75
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
fetch-depth: 1
persist-credentials: false
ref: refs/tags/${{ needs.resolve.outputs.tag }}
- uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
- name: Log in to GHCR
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push native image by digest
id: build
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
with:
context: .
file: ./Dockerfile
platforms: ${{ matrix.platform }}
push: true
outputs: type=image,name=${{ needs.resolve.outputs.image }},push-by-digest=true,name-canonical=true,push=true
build-args: |
WEBCODEX_GIT_COMMIT=${{ needs.resolve.outputs.source_short }}
WEBCODEX_GIT_DIRTY=false
WEBCODEX_BUILT_AT=${{ needs.resolve.outputs.built_at }}
labels: |
org.opencontainers.image.title=WebCodex Server
org.opencontainers.image.source=https://github.com/${{ github.repository }}
org.opencontainers.image.revision=${{ needs.resolve.outputs.source_sha }}
org.opencontainers.image.version=${{ needs.resolve.outputs.version }}
org.opencontainers.image.created=${{ needs.resolve.outputs.created_at }}
cache-from: type=gha,scope=server-image-${{ matrix.slug }}
cache-to: type=gha,mode=max,scope=server-image-${{ matrix.slug }}
provenance: false
sbom: false
- name: Verify native runtime image
shell: bash
env:
IMAGE: ${{ needs.resolve.outputs.image }}
DIGEST: ${{ steps.build.outputs.digest }}
EXPECTED_ARCH: ${{ matrix.architecture }}
VERSION: ${{ needs.resolve.outputs.version }}
SOURCE_SHA: ${{ needs.resolve.outputs.source_sha }}
BUILT_AT: ${{ needs.resolve.outputs.built_at }}
run: |
set -euo pipefail
if [[ ! "$DIGEST" =~ ^sha256:[0-9a-f]{64}$ ]]; then
echo "Build did not return a canonical image digest." >&2
exit 1
fi
ref="$IMAGE@$DIGEST"
docker pull "$ref" >/dev/null
actual_arch="$(docker image inspect --format '{{.Architecture}}' "$ref")"
test "$actual_arch" = "$EXPECTED_ARCH"
test "$(docker image inspect --format '{{.Config.User}}' "$ref")" = "webcodex:webcodex"
docker run --rm --entrypoint sh "$ref" -ec '
test "$(id -u)" = 10001
test "$(id -g)" = 10001
test ! -e /usr/local/bin/webcodex-runner
'
server_info="$RUNNER_TEMP/webcodex-server-build-info.json"
cli_info="$RUNNER_TEMP/webcodex-cli-build-info.json"
docker run --rm "$ref" --build-info-json > "$server_info"
docker run --rm --entrypoint /usr/local/bin/webcodex "$ref" --build-info-json > "$cli_info"
python3 - "$server_info" "$cli_info" "$VERSION" "$SOURCE_SHA" "$BUILT_AT" <<'PY'
import json
import sys
server_path, cli_path, version, source_sha, built_at = sys.argv[1:]
for path, binary in ((server_path, "webcodex-server"), (cli_path, "webcodex")):
info = json.load(open(path, encoding="utf-8"))
if info.get("schema_version") != 1 or info.get("binary") != binary:
raise SystemExit(f"unexpected image build-info schema: {binary}")
commit = info.get("git_commit")
if (
info.get("version") != version
or not isinstance(commit, str)
or not source_sha.startswith(commit)
or info.get("git_dirty") is not False
or info.get("built_at") != built_at
):
raise SystemExit(f"image build identity mismatch: {binary}")
PY
name="webcodex-image-smoke-${GITHUB_RUN_ID}-${{ matrix.slug }}"
cleanup() { docker rm -f "$name" >/dev/null 2>&1 || true; }
trap cleanup EXIT
docker run -d --name "$name" \
-e WEBCODEX_TOKEN=0000000000000000000000000000000000000000000000000000000000000000 \
-e WEBCODEX_PUBLIC_URL=https://webcodex.invalid \
"$ref" >/dev/null
for _ in $(seq 1 40); do
health="$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}missing{{end}}' "$name")"
if [ "$health" = healthy ]; then
break
fi
if [ "$health" = unhealthy ] || [ "$health" = missing ]; then
echo "Container health check failed: $health" >&2
docker logs --tail 80 "$name" >&2 || true
exit 1
fi
sleep 2
done
health="$(docker inspect --format '{{.State.Health.Status}}' "$name")"
if [ "$health" != healthy ]; then
echo "Container did not become healthy before the smoke deadline." >&2
docker logs --tail 80 "$name" >&2 || true
exit 1
fi
mkdir -p "$RUNNER_TEMP/server-image-digests"
touch "$RUNNER_TEMP/server-image-digests/${DIGEST#sha256:}"
- name: Upload native digest marker
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: server-image-digest-${{ matrix.slug }}
path: ${{ runner.temp }}/server-image-digests/*
if-no-files-found: error
retention-days: 1
merge:
needs: [resolve, build]
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: write
packages: write
outputs:
digest: ${{ steps.publish.outputs.digest }}
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
fetch-depth: 1
persist-credentials: false
# Deployment assets are part of the reviewed publication adapter rather
# than the immutable application source. Using workflow_sha preserves
# guarded backfill for older Releases whose tags predate this tooling.
ref: ${{ github.workflow_sha }}
- uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
- name: Log in to GHCR
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
pattern: server-image-digest-*
path: ${{ runner.temp }}/server-image-digests
merge-multiple: true
- name: Publish immutable multi-arch manifest
id: publish
shell: bash
env:
GH_TOKEN: ${{ github.token }}
IMAGE: ${{ needs.resolve.outputs.image }}
TAG: ${{ needs.resolve.outputs.tag }}
VERSION: ${{ needs.resolve.outputs.version }}
IMAGE_VERSION: ${{ needs.resolve.outputs.image_version }}
SOURCE_SHA: ${{ needs.resolve.outputs.source_sha }}
CREATED_AT: ${{ needs.resolve.outputs.created_at }}
PUBLISH_LATEST: ${{ needs.resolve.outputs.publish_latest }}
WORKFLOW_SHA: ${{ github.workflow_sha }}
run: |
set -euo pipefail
version_ref="$IMAGE:v$IMAGE_VERSION"
numeric_ref="$IMAGE:$IMAGE_VERSION"
metadata_name=webcodex-server-image.json
metadata="$RUNNER_TEMP/$metadata_name"
manifest_json="$RUNNER_TEMP/manifest.json"
release_record_dir="$RUNNER_TEMP/existing-release-record"
mkdir -p "$release_record_dir"
refs=()
shopt -s nullglob
markers=("$RUNNER_TEMP"/server-image-digests/*)
if [ "${#markers[@]}" -ne 2 ]; then
echo "Expected exactly two native image digests, found ${#markers[@]}." >&2
exit 1
fi
for marker in "${markers[@]}"; do
child="$(basename "$marker")"
if [[ ! "$child" =~ ^[0-9a-f]{64}$ ]]; then
echo "Invalid native image digest marker: $child" >&2
exit 1
fi
refs+=("$IMAGE@sha256:$child")
done
if docker buildx imagetools inspect "$version_ref" --format '{{json .Manifest}}' > "$manifest_json" 2>/dev/null; then
echo "$version_ref already exists; validating the immutable published image before reconciliation."
else
if docker buildx imagetools inspect "$numeric_ref" >/dev/null 2>&1; then
echo "$numeric_ref exists while canonical $version_ref is missing; refusing to create a conflicting immutable tag." >&2
exit 1
fi
docker buildx imagetools create -t "$version_ref" "${refs[@]}"
docker buildx imagetools inspect "$version_ref" --format '{{json .Manifest}}' > "$manifest_json"
fi
python3 - "$manifest_json" "$metadata" "$IMAGE" "$TAG" "$VERSION" "$IMAGE_VERSION" "$SOURCE_SHA" "$CREATED_AT" <<'PY'
import json
import re
import sys
manifest_path, output_path, image, tag, version, image_version, source_sha, created_at = sys.argv[1:]
manifest = json.load(open(manifest_path, encoding="utf-8"))
digest = manifest.get("digest")
if not isinstance(digest, str) or not re.fullmatch(r"sha256:[0-9a-f]{64}", digest):
raise SystemExit("multi-arch manifest is missing a canonical digest")
entries = manifest.get("manifests")
if not isinstance(entries, list):
raise SystemExit("published image is not a multi-arch manifest")
platforms = {}
for entry in entries:
platform = entry.get("platform") if isinstance(entry, dict) else None
child = entry.get("digest") if isinstance(entry, dict) else None
if not isinstance(platform, dict) or not isinstance(child, str):
raise SystemExit("invalid child manifest entry")
key = f"{platform.get('os')}/{platform.get('architecture')}"
if key in platforms or not re.fullmatch(r"sha256:[0-9a-f]{64}", child):
raise SystemExit("invalid or duplicate child image digest")
platforms[key] = child
if set(platforms) != {"linux/amd64", "linux/arm64"}:
raise SystemExit(f"unexpected server-image platforms: {sorted(platforms)}")
value = {
"schema_version": 1,
"image": image,
"tag": tag,
"version": version,
"image_tag": f"v{image_version}",
"source_sha": source_sha,
"created_at": created_at,
"digest": digest,
"platforms": {key: platforms[key] for key in sorted(platforms)},
}
with open(output_path, "w", encoding="utf-8", newline="\n") as handle:
json.dump(value, handle, indent=2, sort_keys=True)
handle.write("\n")
PY
digest="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1], encoding="utf-8"))["digest"])' "$metadata")"
# A rerun may encounter an already-published canonical tag after a
# transient later-step failure. Reconcile it only when every child
# image still proves the expected source/version/platform contract.
while read -r platform child; do
image_json="$RUNNER_TEMP/child-${platform//\//-}.json"
docker buildx imagetools inspect "$IMAGE@$child" --format '{{json .Image}}' > "$image_json"
python3 - "$image_json" "$platform" "$SOURCE_SHA" "$VERSION" "$GITHUB_REPOSITORY" <<'PY'
import json
import sys
path, platform, source_sha, version, repository = sys.argv[1:]
image = json.load(open(path, encoding="utf-8"))
expected_os, expected_arch = platform.split("/", 1)
if image.get("os") != expected_os or image.get("architecture") != expected_arch:
raise SystemExit(f"child image platform mismatch for {platform}")
config = image.get("config")
if not isinstance(config, dict):
raise SystemExit(f"child image config is missing for {platform}")
if config.get("User") != "webcodex:webcodex":
raise SystemExit(f"child image user is not the non-root WebCodex user for {platform}")
if config.get("Entrypoint") != ["/usr/local/bin/webcodex-server"]:
raise SystemExit(f"child image entrypoint is unexpected for {platform}")
labels = config.get("Labels")
expected = {
"org.opencontainers.image.source": f"https://github.com/{repository}",
"org.opencontainers.image.revision": source_sha,
"org.opencontainers.image.version": version,
}
if not isinstance(labels, dict) or any(labels.get(key) != value for key, value in expected.items()):
raise SystemExit(f"child image provenance labels do not match the release for {platform}")
PY
done < <(python3 - "$metadata" <<'PY'
import json
import sys
value = json.load(open(sys.argv[1], encoding="utf-8"))
for platform, child in value["platforms"].items():
print(platform, child)
PY
)
release_json="$RUNNER_TEMP/release-assets.json"
gh api "repos/$GITHUB_REPOSITORY/releases/tags/$TAG" > "$release_json"
metadata_asset_id="$(python3 - "$release_json" "$metadata_name" <<'PY'
import json
import sys
release = json.load(open(sys.argv[1], encoding="utf-8"))
name = sys.argv[2]
print(next((str(asset["id"]) for asset in release.get("assets", []) if asset.get("name") == name), ""))
PY
)"
bootstrap_name=webcodex-server-bootstrap.sh
bootstrap_asset_id="$(python3 - "$release_json" "$bootstrap_name" <<'PY'
import json
import sys
release = json.load(open(sys.argv[1], encoding="utf-8"))
name = sys.argv[2]
print(next((str(asset["id"]) for asset in release.get("assets", []) if asset.get("name") == name), ""))
PY
)"
durable_record_exists=false
if [ -n "$metadata_asset_id" ]; then
if [ -z "$bootstrap_asset_id" ]; then
echo "$metadata_name exists without $bootstrap_name; refusing partial durable record reconciliation." >&2
exit 1
fi
for name in "$metadata_name" "$bootstrap_name"; do
gh release download "$TAG" --repo "$GITHUB_REPOSITORY" --pattern "$name" --dir "$release_record_dir"
done
python3 - "$metadata" "$release_record_dir/$metadata_name" "$release_record_dir/$bootstrap_name" <<'PY'
import json
import sys
from scripts.verify_public_release import validate_server_image_release_record
expected_path, existing_path, bootstrap_path = sys.argv[1:]
expected = json.load(open(expected_path, encoding="utf-8"))
existing = json.load(open(existing_path, encoding="utf-8"))
validate_server_image_release_record(
existing,
expected["version"],
open(bootstrap_path, "rb").read(),
expected_base=expected,
)
PY
cp "$release_record_dir/$metadata_name" "$metadata"
durable_record_exists=true
echo "Existing immutable GitHub Release deployment record reconciled without regeneration."
else
deployment_dir="$RUNNER_TEMP/server-deployment-assets"
deployment_summary="$RUNNER_TEMP/server-deployment-assets.json"
python3 scripts/prepare_server_deployment_assets.py \
--compose compose.yaml \
--bootstrap deploy/docker/bootstrap.sh \
--output-dir "$deployment_dir" \
--digest "$digest" \
--summary "$deployment_summary" >/dev/null
python3 - "$metadata" "$deployment_summary" "$WORKFLOW_SHA" <<'PY'
import json
import os
import re
import sys
metadata_path, deployment_path, workflow_sha = sys.argv[1:]
value = json.load(open(metadata_path, encoding="utf-8"))
deployment = json.load(open(deployment_path, encoding="utf-8"))
assets = deployment.get("assets")
if not isinstance(assets, dict) or set(assets) != {"webcodex-server-bootstrap.sh"}:
raise SystemExit("server deployment asset summary is incomplete")
if not re.fullmatch(r"[0-9a-f]{40}", workflow_sha):
raise SystemExit("publication workflow source SHA is invalid")
value["deployment_source_sha"] = workflow_sha
value["deployment_assets"] = assets
tmp = metadata_path + ".tmp"
with open(tmp, "w", encoding="utf-8", newline="\n") as handle:
json.dump(value, handle, indent=2, sort_keys=True)
handle.write("\n")
os.replace(tmp, metadata_path)
PY
fi
# The numeric version is an immutable alias of vVERSION. Repair a
# missing alias, but never retarget one that already points elsewhere.
if numeric_json="$(docker buildx imagetools inspect "$numeric_ref" --format '{{json .Manifest}}' 2>/dev/null)"; then
numeric_digest="$(printf '%s' "$numeric_json" | python3 -c 'import json,sys; print(json.load(sys.stdin)["digest"])')"
if [ "$numeric_digest" != "$digest" ]; then
echo "$numeric_ref already points at a different digest; refusing to move it." >&2
exit 1
fi
else
docker buildx imagetools create -t "$numeric_ref" "$version_ref"
fi
# latest is intentionally mutable, but only the current latest stable
# GitHub Release may update it. Backfilling an older release never rolls
# latest backwards.
if [ "$PUBLISH_LATEST" = true ]; then
docker buildx imagetools create -t "$IMAGE:latest" "$version_ref"
fi
# Once metadata exists, it is the immutable durable deployment record.
# A rerun from a newer reviewed workflow validates and preserves those
# exact bytes rather than trying to rewrite deployment_source_sha.
if [ "$durable_record_exists" = false ]; then
# Without metadata, a previous bootstrap upload is only a partial
# publication. It must still match the current reviewed bytes before
# metadata can commit to it.
for asset in \
"$deployment_dir/$bootstrap_name" \
"$metadata"; do
asset_name="$(basename "$asset")"
asset_id="$(python3 - "$release_json" "$asset_name" <<'PY'
import json
import sys
release = json.load(open(sys.argv[1], encoding="utf-8"))
name = sys.argv[2]
print(next((str(item["id"]) for item in release.get("assets", []) if item.get("name") == name), ""))
PY
)"
if [ -n "$asset_id" ]; then
compare_dir="$RUNNER_TEMP/compare-$asset_name"
mkdir -p "$compare_dir"
gh release download "$TAG" --repo "$GITHUB_REPOSITORY" --pattern "$asset_name" --dir "$compare_dir"
if ! cmp -s "$asset" "$compare_dir/$asset_name"; then
echo "$asset_name differs from the immutable GitHub Release record." >&2
exit 1
fi
else
gh release upload "$TAG" "$asset"
fi
done
fi
echo "digest=$digest" >> "$GITHUB_OUTPUT"
{
echo "### Published WebCodex Server image"
echo "- image: \`$version_ref\`"
echo "- digest: \`$digest\`"
echo "- platforms: \`linux/amd64\`, \`linux/arm64\`"
echo "- release metadata asset: \`$metadata_name\`"
echo "- clone-free asset: \`webcodex-server-bootstrap.sh\` (embeds digest-pinned Compose)"
} >> "$GITHUB_STEP_SUMMARY"
verify-public:
needs: [resolve, merge]
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
steps:
- uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
- name: Require anonymous GHCR availability
shell: bash
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ needs.resolve.outputs.tag }}
run: |
set -euo pipefail
tmp="$RUNNER_TEMP/server-image-public"
mkdir -p "$tmp"
for name in webcodex-server-image.json webcodex-server-bootstrap.sh; do
gh release download "$TAG" --repo "$GITHUB_REPOSITORY" --pattern "$name" --dir "$tmp"
done
readarray -t values < <(python3 - "$tmp" <<'PY'
import hashlib
import json
import pathlib
import re
import sys
root = pathlib.Path(sys.argv[1])
value = json.loads((root / "webcodex-server-image.json").read_text(encoding="utf-8"))
assets = value.get("deployment_assets")
name = "webcodex-server-bootstrap.sh"
if not isinstance(assets, dict) or set(assets) != {name}:
raise SystemExit("server deployment asset metadata is incomplete")
data = (root / name).read_bytes()
if hashlib.sha256(data).hexdigest() != assets[name]:
raise SystemExit("server deployment asset digest mismatch")
digest = value.get("digest")
image = value.get("image")
if not isinstance(image, str) or not isinstance(digest, str):
raise SystemExit("server image metadata is incomplete")
bootstrap = data.decode("utf-8")
pinned = f"{image}@{digest}"
if bootstrap.count(pinned) != 1 or re.search(r"webcodex-server:latest", bootstrap):
raise SystemExit("clone-free bootstrap is not pinned to the immutable image digest")
if "compose_target=webcodex-server-compose.yaml" not in bootstrap:
raise SystemExit("clone-free bootstrap does not materialize the canonical compose filename")
print(image)
print(digest)
PY
)
image="${values[0]}"
digest="${values[1]}"
docker logout ghcr.io >/dev/null 2>&1 || true
docker buildx imagetools inspect "$image@$digest" >/dev/null
echo "Anonymous GHCR pull metadata is available for $image@$digest"