Repository navigation
436 lines (409 loc) · 20.3 KB
/
Copy pathframework-build-base.yml
File metadata and controls
436 lines (409 loc) · 20.3 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
name: Framework Build Base Image
# Builds and pushes ghcr.io/objectstack-ai/framework-build-base:<sha> from
# docker/framework-build-base/Dockerfile: ONE framework commit on `main`, its
# workspace dependencies installed, every package built, and the Console built
# into packages/console/dist. It is a builder stage for downstream images that
# link the framework by path and pin it by commit, so they can start FROM it
# instead of installing and building the whole framework on every cold cache.
# It is NOT the runtime image; that one is docker-publish.yml.
#
# On demand only, never per commit:
# - workflow_dispatch, from main, with `sha` = a full commit on main;
# - repository_dispatch, event type `framework-build-base`, with
# client_payload {"sha": "<40 hex>"}: the hook a downstream pin move can call;
# - pull_request touching this file or docker/framework-build-base/: the dry
# run of a change to the image definition. It builds the PR's base commit
# with the PR's Dockerfile and pushes nothing; its job has no packages grant.
#
# A tag is written once. A request for a commit whose tag already exists reports
# the existing digest and builds nothing, so a downstream can ask on every pin
# move at no cost. Replacing a published tag takes a dispatch with BOTH
# `rebuild: true` and `replace_published: true`: `rebuild: true` alone over a
# published tag is refused, naming the digest it would move. A
# repository_dispatch carries neither input, so it never replaces anything.
#
# Every digest this workflow publishes also carries a tag of its own,
# `<sha>-<the first 12 hex of the digest>`, and that tag is never deleted or
# re-pointed. A downstream pins this image by digest (cloud's committed
# .objectstack-base-image), and a registry serves a digest only while some tag
# holds it: a cleanup of untagged versions, or a replace that moves the `<sha>`
# tag, would otherwise take the pinned digest away. A request whose `<sha>` tag
# already exists writes its missing digest tag too, so a digest published
# before this rule becomes durable the next time it is requested.
#
# The image definition always comes from THIS run's checkout of main; only the
# build context is the requested commit. So a commit that predates this file can
# be packaged, and a commit cannot bring its own Dockerfile into a published image.
#
# Visibility: GitHub creates a new container package private on its first push,
# and no API changes that. The maintainer sets it Public once, in the package's
# settings (Danger Zone, Change visibility; it cannot be made private again).
# Until then the anonymous-pull step below is red and names that fix.
# dispatch-gates: no-check-families -- the steps are an image build and push, registry reads and digest-tag writes, and an in-image check script (docker/framework-build-base/check-image.sh, which needs the built image), not named local checks
on:
workflow_dispatch:
inputs:
sha:
description: 'Full 40-hex framework commit on main to package (e.g. a downstream .objectstack-sha)'
required: true
type: string
rebuild:
description: 'Rebuild and re-push even when the tag already exists (the digest changes); over a published tag it also needs replace_published'
required: false
type: boolean
default: false
replace_published:
description: 'With rebuild, move a published tag to the new digest (the old digest stays served under its own sha-digest tag; downstream pins must then be rewritten)'
required: false
type: boolean
default: false
repository_dispatch:
types: [framework-build-base]
pull_request:
paths:
- '.github/workflows/framework-build-base.yml'
- 'docker/framework-build-base/**'
permissions:
contents: read
# One build per commit at a time; a second request for the same commit waits,
# then finds the tag and builds nothing. A PR's dry run is superseded by its
# next push.
concurrency:
group: framework-build-base-${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || github.event.client_payload.sha || inputs.sha }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
env:
IMAGE: ghcr.io/objectstack-ai/framework-build-base
jobs:
verify:
name: Build framework build base (dry run, no push)
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
# The same build as `publish` below; see its budget note.
timeout-minutes: 60
permissions:
contents: read
steps:
- name: Free runner disk
run: |
set -euxo pipefail
sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc /opt/hostedtoolcache/CodeQL
sudo docker image prune -af || true
df -h /
- name: Check out the image definition (this pull request)
uses: actions/checkout@v7
with:
ref: ${{ github.event.pull_request.head.sha }}
path: builder
persist-credentials: false
sparse-checkout: docker/framework-build-base
- name: Check out the framework commit (the pull request's base)
uses: actions/checkout@v7
with:
ref: ${{ github.event.pull_request.base.sha }}
path: objectstack
persist-credentials: false
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
- name: Note the build start
id: clock
run: echo "start=$(date +%s)" >> "$GITHUB_OUTPUT"
- name: Build (load into the runner, never push)
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
with:
context: objectstack
file: builder/docker/framework-build-base/Dockerfile
platforms: linux/amd64
push: false
load: true
provenance: false
sbom: false
build-args: |
FRAMEWORK_SHA=${{ github.event.pull_request.base.sha }}
tags: framework-build-base:dry-run
- name: Look inside the image
env:
SHA: ${{ github.event.pull_request.base.sha }}
START: ${{ steps.clock.outputs.start }}
run: |
set -euo pipefail
BUILD_S=$(( $(date +%s) - START ))
READINGS="$(bash builder/docker/framework-build-base/check-image.sh framework-build-base:dry-run "$SHA")"
echo "$READINGS"
{
echo "### Framework build base, dry run (nothing pushed)"
echo
echo "- framework commit (PR base): \`$SHA\`"
echo "- build wall: ${BUILD_S} s"
echo "$READINGS" | sed 's/^/- /'
} >> "$GITHUB_STEP_SUMMARY"
publish:
name: Build & push ghcr.io/objectstack-ai/framework-build-base
# Trusted runs only: a dispatch from main, or a repository_dispatch (which
# always runs main's copy of this file). This `if` is the policy, not a
# security boundary: anyone who can push a branch can also edit it there.
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
# One cold reading on record, the `verify` job of run 42464408854: job wall
# 18m45s, of which the build 15m25s (install 33 s, 82 framework tasks
# 1m58s, 74 Console tasks 10m47s, load into the runner 94 s). This job
# skips the load and adds a push and an anonymous pull of about 0.8 GB.
# 60 minutes is about 3x, room for a slow npm or a slow registry.
timeout-minutes: 60
permissions:
contents: read
packages: write
steps:
- name: Resolve the framework commit
id: sha
env:
REQUESTED: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.sha || inputs.sha }}
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
run: |
set -euo pipefail
SHA="$(printf '%s' "$REQUESTED" | tr -d '[:space:]')"
if ! printf '%s' "$SHA" | grep -Eq '^[0-9a-f]{40}$'; then
echo "::error::the requested commit is not a full 40-hex lowercase sha"
exit 1
fi
# Only commits on main are packaged: main must be ahead of the commit,
# or be it. Anything else (a branch head, a fork's pull ref) is refused.
if ! STATUS="$(gh api "repos/$REPO/compare/$SHA...main" --jq .status)"; then
echo "::error::cannot compare $SHA with main (is it a commit of $REPO?)"
exit 1
fi
case "$STATUS" in
ahead|identical) ;;
*) echo "::error::$SHA is not on main (compare status: $STATUS); only commits on main are packaged"; exit 1 ;;
esac
echo "sha=$SHA" >> "$GITHUB_OUTPUT"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
- name: Log in to ghcr.io
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Is this commit already packaged
id: existing
env:
REF: ${{ env.IMAGE }}:${{ steps.sha.outputs.sha }}
REBUILD: ${{ inputs.rebuild && 'true' || 'false' }}
REPLACE: ${{ inputs.replace_published && 'true' || 'false' }}
run: |
set -euo pipefail
# Only the registry's own "no such tag" answer means unpublished. Any
# other failed read (a registry, token or network error) stops the job:
# read as unpublished it would rebuild and move the tag past both
# guards below. buildx prints that answer as `ERROR: REF: not found`:
# containerd's resolver (core/remotes/docker/resolver.go) wraps a 404
# for the tag as "REF: " plus errdefs.ErrNotFound, whose text is
# "not found", and buildx's main.go prefixes "ERROR: ".
ERR="$(mktemp)"
if READ="$(docker buildx imagetools inspect "$REF" --format '{{json .Manifest}}' 2>"$ERR")"; then
DIGEST="$(printf '%s' "$READ" | jq -r '.digest // empty')"
if [ -z "$DIGEST" ]; then
echo "::error::$REF was read, but the answer names no digest: $READ"
exit 1
fi
elif grep -Fq "$REF: not found" "$ERR"; then
DIGEST=""
else
echo "::error::cannot tell whether $REF is published, so nothing is built or moved. The registry read failed with: $(tr '\n' ' ' < "$ERR")"
exit 1
fi
# A published digest is handed on whatever happens next: the digest-tag
# step below keeps it served under a tag of its own.
if [ -n "$DIGEST" ]; then echo "digest=$DIGEST" >> "$GITHUB_OUTPUT"; fi
if [ -z "$DIGEST" ]; then
echo "skip=false" >> "$GITHUB_OUTPUT"
elif [ "$REBUILD" != true ]; then
echo "$REF already exists at $DIGEST; nothing to build."
echo "skip=true" >> "$GITHUB_OUTPUT"
elif [ "$REPLACE" != true ]; then
echo "::error::$REF is published at $DIGEST, and rebuild: true would move it to a new digest. To replace it on purpose, dispatch again with rebuild: true and replace_published: true; then rewrite every downstream pin (in the cloud repository: run framework-base-image.sh write from its scripts directory, then commit .objectstack-base-image)."
exit 1
else
echo "$REF is published at $DIGEST; replace_published: true, so it is rebuilt and the tag moves. The old digest stays served under its digest tag."
echo "skip=false" >> "$GITHUB_OUTPUT"
fi
# Every published digest keeps a tag of its own, `<sha>-<12 hex>`, that is
# never deleted or re-pointed (see the header). The two steps below run the
# same script: this one for the digest already published under `<sha>`
# (the backfill, before a replace can move the `<sha>` tag), the next one
# for the digest this run pushed.
- name: Keep the published digest served under its own tag
if: steps.existing.outputs.digest != ''
id: kept
env:
SHA: ${{ steps.sha.outputs.sha }}
DIGEST: ${{ steps.existing.outputs.digest }}
run: |
set -euo pipefail
if ! printf '%s' "$DIGEST" | grep -Eq '^sha256:[0-9a-f]{64}$'; then
echo "::error::refusing to tag '$DIGEST': a digest is sha256: followed by 64 lowercase hex"
exit 1
fi
TAG="$IMAGE:$SHA-${DIGEST:7:12}"
held() { docker buildx imagetools inspect "$TAG" --format '{{json .Manifest}}' 2>/dev/null | jq -r '.digest // empty' || true; }
HELD="$(held)"
if [ -z "$HELD" ]; then
# --prefer-index=false copies the manifest as it is. The default
# wraps a lone image manifest in a new index, whose digest differs,
# so the tag would not hold DIGEST at all.
docker buildx imagetools create --prefer-index=false --tag "$TAG" "$IMAGE@$DIGEST"
HELD="$(held)"
if [ "$HELD" != "$DIGEST" ]; then
echo "::error::wrote $TAG, but it reads back as ${HELD:-nothing}, not $DIGEST, so $DIGEST is not yet held by a tag of its own."
exit 1
fi
elif [ "$HELD" != "$DIGEST" ]; then
echo "::error::$TAG already holds $HELD, not $DIGEST. A digest tag is never re-pointed, so nothing was changed; read the tag's history in the package's settings before touching it."
exit 1
fi
echo "$TAG holds $DIGEST"
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
- name: Free runner disk
if: steps.existing.outputs.skip != 'true'
run: |
set -euxo pipefail
sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc /opt/hostedtoolcache/CodeQL
sudo docker image prune -af || true
df -h /
- name: Check out the image definition (this run's main)
if: steps.existing.outputs.skip != 'true'
uses: actions/checkout@v7
with:
path: builder
persist-credentials: false
sparse-checkout: docker/framework-build-base
- name: Check out the framework commit (the build context)
if: steps.existing.outputs.skip != 'true'
uses: actions/checkout@v7
with:
ref: ${{ steps.sha.outputs.sha }}
path: objectstack
persist-credentials: false
- name: Note the build start
if: steps.existing.outputs.skip != 'true'
id: clock
env:
SHA: ${{ steps.sha.outputs.sha }}
run: |
set -euo pipefail
[ "$(git -C objectstack rev-parse HEAD)" = "$SHA" ] || { echo "::error::the context checkout is not $SHA"; exit 1; }
echo "start=$(date +%s)" >> "$GITHUB_OUTPUT"
- name: Build and push
if: steps.existing.outputs.skip != 'true'
id: build
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
with:
context: objectstack
file: builder/docker/framework-build-base/Dockerfile
platforms: linux/amd64
push: true
provenance: false
sbom: false
build-args: |
FRAMEWORK_SHA=${{ steps.sha.outputs.sha }}
tags: ${{ env.IMAGE }}:${{ steps.sha.outputs.sha }}
- name: Note the build wall
if: steps.existing.outputs.skip != 'true'
id: wall
env:
START: ${{ steps.clock.outputs.start }}
run: echo "seconds=$(( $(date +%s) - START ))" >> "$GITHUB_OUTPUT"
- name: Tag the pushed digest under its own name
if: steps.existing.outputs.skip != 'true'
id: digest_tag
env:
SHA: ${{ steps.sha.outputs.sha }}
DIGEST: ${{ steps.build.outputs.digest }}
run: |
set -euo pipefail
if ! printf '%s' "$DIGEST" | grep -Eq '^sha256:[0-9a-f]{64}$'; then
echo "::error::refusing to tag '$DIGEST': a digest is sha256: followed by 64 lowercase hex"
exit 1
fi
TAG="$IMAGE:$SHA-${DIGEST:7:12}"
held() { docker buildx imagetools inspect "$TAG" --format '{{json .Manifest}}' 2>/dev/null | jq -r '.digest // empty' || true; }
HELD="$(held)"
if [ -z "$HELD" ]; then
# --prefer-index=false copies the manifest as it is. The default
# wraps a lone image manifest in a new index, whose digest differs,
# so the tag would not hold DIGEST at all.
docker buildx imagetools create --prefer-index=false --tag "$TAG" "$IMAGE@$DIGEST"
HELD="$(held)"
if [ "$HELD" != "$DIGEST" ]; then
echo "::error::wrote $TAG, but it reads back as ${HELD:-nothing}, not $DIGEST, so $DIGEST is not yet held by a tag of its own."
exit 1
fi
elif [ "$HELD" != "$DIGEST" ]; then
echo "::error::$TAG already holds $HELD, not $DIGEST. A digest tag is never re-pointed, so nothing was changed; read the tag's history in the package's settings before touching it."
exit 1
fi
echo "$TAG holds $DIGEST"
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
# The image is for anyone to pull, so it is read back the way a stranger
# would: logged out. A fresh build is pulled whole and looked inside; an
# existing tag only has its manifest read, anonymously.
- name: Pull the image anonymously
id: pull
env:
SHA: ${{ steps.sha.outputs.sha }}
DIGEST: ${{ steps.build.outputs.digest || steps.existing.outputs.digest }}
FRESH: ${{ steps.existing.outputs.skip != 'true' }}
run: |
set -euo pipefail
docker logout ghcr.io
REF="$IMAGE@$DIGEST"
PRIVATE="::error::$IMAGE cannot be read anonymously. A new container package is private: the maintainer sets it Public once, in the package's settings (Danger Zone, Change visibility)."
if [ "$FRESH" = true ]; then
docker pull "$REF" || { echo "$PRIVATE"; exit 1; }
bash builder/docker/framework-build-base/check-image.sh "$REF" "$SHA" | tee "$RUNNER_TEMP/readings.txt"
else
TOKEN="$(curl -fsS "https://ghcr.io/token?scope=repository:objectstack-ai/framework-build-base:pull&service=ghcr.io" | jq -r .token)" \
&& curl -fsS -o /dev/null -H "Authorization: Bearer $TOKEN" \
-H 'Accept: application/vnd.oci.image.manifest.v1+json' \
-H 'Accept: application/vnd.docker.distribution.manifest.v2+json' \
"https://ghcr.io/v2/objectstack-ai/framework-build-base/manifests/$DIGEST" \
|| { echo "$PRIVATE"; exit 1; }
echo "manifest readable anonymously" > "$RUNNER_TEMP/readings.txt"
fi
- name: Summary
if: always() && steps.sha.outputs.sha != ''
env:
SHA: ${{ steps.sha.outputs.sha }}
DIGEST: ${{ steps.build.outputs.digest || steps.existing.outputs.digest }}
SKIPPED: ${{ steps.existing.outputs.skip }}
WALL: ${{ steps.wall.outputs.seconds }}
PULL: ${{ steps.pull.outcome }}
BUILT: ${{ steps.build.outputs.digest }}
PUSHED_TAG: ${{ steps.digest_tag.outputs.tag }}
KEPT_TAG: ${{ steps.kept.outputs.tag }}
run: |
{
echo "### $IMAGE"
echo
echo "- framework commit: \`$SHA\`"
echo "- tag: \`$IMAGE:$SHA\`"
echo "- digest: \`${DIGEST:-none}\`"
if [ -n "$BUILT" ]; then
echo "- digest tag (never deleted or re-pointed): \`${PUSHED_TAG:-not written}\`"
if [ -n "$KEPT_TAG" ]; then echo "- the replaced digest stays served under: \`$KEPT_TAG\`"; fi
else
echo "- digest tag (never deleted or re-pointed): \`${KEPT_TAG:-not written}\`"
fi
if [ "$SKIPPED" = true ]; then
echo "- build: skipped, the tag already existed"
else
echo "- build wall: ${WALL:-not reached} s"
fi
echo "- anonymous read: ${PULL:-not reached}"
if [ -f "$RUNNER_TEMP/readings.txt" ]; then sed 's/^/- /' "$RUNNER_TEMP/readings.txt"; fi
if [ -n "$DIGEST" ]; then
echo
echo "Pin it by digest: \`FROM $IMAGE:$SHA@$DIGEST\`"
fi
} >> "$GITHUB_STEP_SUMMARY"