Repository navigation
230 lines (217 loc) · 10.9 KB
/
Copy pathdocker-publish.yml
File metadata and controls
230 lines (217 loc) · 10.9 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
name: Docker Publish
# Builds and pushes the official runtime image
# ghcr.io/objectstack-ai/objectstack from docker/Dockerfile.
#
# Two entry points:
# - workflow_call: invoked by release.yml right after a successful
# `changeset publish`, with the just-published @objectstack/cli version.
# (A plain `on: push: tags:` trigger would never fire — the release
# workflow pushes its tags with GITHUB_TOKEN, and GitHub suppresses
# workflow triggers from GITHUB_TOKEN-pushed refs.)
# - workflow_dispatch: manual backfill / rebuild of an already-published
# version, e.g. to pick up node:22-slim base-image CVE patches between
# framework releases.
#
# The image tag always equals the @objectstack/cli version baked inside.
on:
workflow_call:
inputs:
version:
description: 'Published @objectstack/cli version to package (e.g. 14.8.0)'
required: true
type: string
workflow_dispatch:
inputs:
version:
description: '@objectstack/cli version to package (e.g. 14.8.0) — must already be on npm'
required: true
type: string
permissions:
contents: read
jobs:
publish:
name: Build & push ghcr.io/objectstack-ai/objectstack
runs-on: ubuntu-latest
# Job wall, all four readings on record: 3.5 and 3.5 min (the two dispatch runs), 4.3
# min (18.1.0, Release run 40078805767, attempt 2) and 1.4 min (attempt 1, red). Four
# readings make p99 the max, 4.3; the bound is wide because the image is built for two
# platforms, arm64 under QEMU, and a cold layer cache is unmeasured.
timeout-minutes: 30
permissions:
contents: read
packages: write
env:
IMAGE: ghcr.io/objectstack-ai/objectstack
steps:
- name: Checkout repository
uses: actions/checkout@v7
- name: Validate version input
id: version
env:
VERSION: ${{ inputs.version }}
run: |
if ! echo "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+([-.][0-9A-Za-z.-]+)?$'; then
echo "::error::'$VERSION' is not a valid semver version"
exit 1
fi
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
- name: Wait until npm serves the CLI's install tree
# On a release, this job starts about a minute after `changeset
# publish` returns. npm can still answer 404 for some of the group's
# tarballs at that point, and the image build's `npm install -g` then
# fails (18.1.0: spec-18.1.0.tgz, run 40078805767, #25613). So poll the
# registry before building: walk @objectstack/cli@VERSION's
# @objectstack/* dependency tree, and wait until every manifest and
# tarball answers 200. The wait is bounded at 10 minutes, and on a
# timeout the error names each package still missing. A rebuild of a
# version that is already out passes at once.
env:
VERSION: ${{ steps.version.outputs.version }}
run: |
node --input-type=module - <<'NODE'
const version = process.env.VERSION;
const registry = 'https://registry.npmjs.org';
const deadline = Date.now() + 10 * 60 * 1000;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// A lockstep group: an @objectstack/* range names the version it was
// published with (`^18.1.0` or `18.1.0`), so strip the operator.
const pinOf = (range) => range.replace(/^[\^~=v]+/, '');
// A network error counts as a miss and is retried like a 404.
async function ready(name, ver) {
try {
const res = await fetch(`${registry}/${name.replace('/', '%2f')}/${ver}`);
if (!res.ok) return null;
const manifest = await res.json();
const tarball = await fetch(manifest.dist.tarball, { method: 'HEAD' });
return tarball.ok ? manifest : null;
} catch {
return null;
}
}
const seen = new Map();
let pending = [['@objectstack/cli', version]];
let delay = 5000;
while (pending.length > 0) {
const missing = [];
const found = [];
for (const [name, ver] of pending) {
const manifest = await ready(name, ver);
if (!manifest) { missing.push([name, ver]); continue; }
seen.set(name, ver);
for (const [dep, range] of Object.entries(manifest.dependencies ?? {})) {
if (dep.startsWith('@objectstack/') && !seen.has(dep)
&& ![...pending, ...found].some(([n]) => n === dep)) {
found.push([dep, pinOf(range)]);
}
}
}
pending = [...missing, ...found];
// Newly found dependencies are read at once; only a miss waits.
if (missing.length === 0) continue;
if (Date.now() + delay > deadline) {
for (const [name, ver] of missing) {
console.log(`::error::npm does not serve ${name}@${ver} (manifest or tarball) after 10 minutes`);
}
process.exit(1);
}
console.log(`waiting ${delay / 1000}s for ${missing.length} package(s): ${missing.map(([n, v]) => `${n}@${v}`).join(', ')}`);
await sleep(delay);
delay = Math.min(delay * 2, 60000);
}
console.log(`npm serves @objectstack/cli@${version} and ${seen.size - 1} @objectstack/* dependencies.`);
NODE
- name: Decide which rolling tags this version may move
# A rebuild of an older version must not move `latest`, `X.Y` or `X`
# back onto it. Each rolling tag moves only when no stable
# @objectstack/cli version on npm in that tag's range is newer, so a
# release, or a rebuild of the current version, moves all three as
# before. When npm cannot be read, a dispatch pushes only the exact tag,
# and the release lane moves every rolling tag as before. The rules,
# and both fail directions, are in the script's header (#26961).
#
# LANE cannot come from `github.event_name`. In a workflow_call run the
# `github` context is the CALLER's, and release.yml's repair lane is a
# workflow_dispatch too. `github.workflow_ref` names this file only
# when it was dispatched directly.
#
# The self-test runs first. A pull request never runs this step, so
# this is the last point where the decision can be checked before it
# takes effect.
id: rolling
env:
VERSION: ${{ steps.version.outputs.version }}
LANE: ${{ contains(github.workflow_ref, '/.github/workflows/docker-publish.yml@') && 'dispatch' || 'release' }}
run: |
node scripts/ci/docker-rolling-tags.mjs --self-test
node scripts/ci/docker-rolling-tags.mjs --version "$VERSION" --lane "$LANE"
- name: Set up QEMU (arm64 emulation)
uses: docker/setup-qemu-action@99012661954931238ded8c8b007157a8430204e1 # v4.4.0
- 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: Compute image tags
id: meta
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
with:
images: ${{ env.IMAGE }}
# Tag = CLI version. The rolling minor/major/latest tags move only as
# far as the step above allows; prereleases (x.y.z-rc.1) get only
# their exact tag.
#
# `latest` has TWO sources in this action: the raw line below, and
# `flavor: latest=auto` (the default), which adds `latest` by itself
# for any stable `type=semver` value. Both read the same output, so
# gating only one of them still moves `latest`. For the newest
# version the tag list is exactly what it was before this change.
flavor: |
latest=${{ steps.rolling.outputs.latest }}
tags: |
type=semver,pattern={{version}},value=${{ steps.version.outputs.version }}
type=semver,pattern={{major}}.{{minor}},value=${{ steps.version.outputs.version }},enable=${{ steps.rolling.outputs.minor }}
type=semver,pattern={{major}},value=${{ steps.version.outputs.version }},enable=${{ steps.rolling.outputs.major }}
type=raw,value=latest,enable=${{ steps.rolling.outputs.latest }}
- name: Build and push
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
with:
context: docker
platforms: linux/amd64,linux/arm64
push: true
build-args: |
OS_CLI_VERSION=${{ steps.version.outputs.version }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
- name: Smoke-test the pushed image (amd64)
# `os --version` proves the CLI resolved, installed, and runs on the
# pushed image; a boot test needs an artifact + DB and belongs to the
# examples/e2e suites, not here.
#
# The `require()` line proves the SQL drivers are actually IN the built
# image. `check:docs-image-tag` cannot see that: it compares the
# Dockerfile install line against README.md's published table, and both
# would still agree if the drivers vanished from the image. The class
# has already cost a boot once -- docker/Dockerfile records that a tree
# without `pg` "died at boot on `Cannot find module 'pg'`", which is why
# the install line exists. `require()` needs no artifact and no
# database, so it stays on the right side of the same line drawn above.
#
# `-w` is load-bearing, not incidental. `npm install -g` puts the
# drivers in /usr/local/lib/node_modules, which is NOT on `require()`'s
# search path from this image's WORKDIR (/srv/app): node's global
# folders are $PREFIX/lib/node, and the node:22-slim base sets no
# NODE_PATH. Run from /srv/app the probe fails on a CORRECT image, so
# it would be a constant red rather than a check. Resolving from inside
# the global tree is also how the real boot path resolves them --
# driver-sql lives there too. Do not "simplify" this to a bare
# `docker run`; verified in both directions before it landed.
env:
VERSION: ${{ steps.version.outputs.version }}
run: |
docker pull "$IMAGE:$VERSION"
docker run --rm "$IMAGE:$VERSION" os --version
docker run --rm -w /usr/local/lib/node_modules "$IMAGE:$VERSION" \
node -e "require('pg'); require('mysql2')"