-
Notifications
You must be signed in to change notification settings - Fork 10
525 lines (496 loc) · 29.5 KB
/
Copy pathscaffold-e2e.yml
File metadata and controls
525 lines (496 loc) · 29.5 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
# Scaffold E2E — the first-run experience as a regression gate (#2908).
#
# Two failure classes broke `npm create objectstack` in the past and neither
# was visible to unit tests:
# 1. The bundled template drifted from the published framework (pinned
# ^6.0.0 while the registry was at 14.x; used APIs removed in 14.x).
# 2. Nothing ever exercised the published package end-to-end, so registry
# breakage would only be found by a real new user.
#
# Job 1 (PRs touching the scaffolder / docker example) scaffolds with the
# repo-built scaffolder and runs the generated project against the registry:
# install → validate → build → boot → health probes → docker build/run. That
# run scaffolds with `--skip-install` and installs the project itself, so it
# exercises the scaffolder's `--skip-install` shape; a second, throwaway
# project is scaffolded WITHOUT it purely to assert the shape a real user
# ships (the pinned Dockerfile — see that step for why the split exists).
# Job 2 (nightly) does the same via the *published* `create-objectstack@latest`
# across every template in the registry — the new-user canary.
#
# Every step below is part of an install/build/boot/docker pipeline, not a named local
# verification script a dev pre-runs with `pnpm check:x` — there is no check family here
# to discover, deliberately (#9187):
# dispatch-gates: no-check-families -- steps are an e2e pipeline, not named local checks
name: Scaffold E2E
on:
pull_request:
paths:
- 'packages/create-objectstack/**'
- 'docker/**'
- '.github/workflows/scaffold-e2e.yml'
schedule:
- cron: '23 3 * * *'
workflow_dispatch:
permissions:
contents: read
jobs:
scaffold-local:
name: Scaffold with repo dist
if: github.event_name != 'schedule'
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Checkout repository
uses: actions/checkout@v7
- name: Setup Node.js
uses: actions/setup-node@v7
with:
node-version: '22'
- name: Setup pnpm
uses: ./.github/actions/setup-pnpm
- name: Get pnpm store directory
shell: bash
run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Setup pnpm cache
uses: actions/cache@v6
with:
path: ${{ env.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-v3-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-v3-
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build the scaffolder
run: pnpm --filter create-objectstack build
- name: Scaffold a project
run: |
cd "$RUNNER_TEMP"
node "$GITHUB_WORKSPACE/packages/create-objectstack/bin/create-objectstack.js" e2e-app --skip-install --skip-skills
- name: Install generated project (registry deps)
run: |
cd "$RUNNER_TEMP/e2e-app"
# The scaffolder pins ^<repo version>. Right after a version bump
# lands but before the release publishes, that version is not on the
# registry yet — fall back to the latest published release so the
# template is still exercised against the real registry.
if ! npm install --no-fund --no-audit; then
echo "::warning::repo version not published yet — falling back to latest"
node -e '
const fs = require("fs");
const pkg = JSON.parse(fs.readFileSync("package.json", "utf8"));
for (const deps of [pkg.dependencies, pkg.devDependencies]) {
if (!deps) continue;
for (const d of Object.keys(deps)) {
if (d.startsWith("@objectstack/")) deps[d] = "latest";
}
}
fs.writeFileSync("package.json", JSON.stringify(pkg, null, 2) + "\n");
'
npm install --no-fund --no-audit
# The fallback just swapped the repo's unpublished version for
# whatever `latest` points at — which during an RC window is the
# PREVIOUS major (17.0.0-rc.N unpublished → latest is 16.x). The
# template's manifest still stamps the repo's protocol major
# (`engines: { protocol: '^17' }`, written by
# scripts/sync-template-versions.mjs), so the ADR-0087 D1 handshake
# in `os start` correctly refuses to boot the artifact:
# ✗ package 'e2e-app' targets protocol ^17 (engines.protocol)
# but this runtime is protocol 16.0.0
# That is the gate working, on a skew this step introduced (#4894).
# Re-stamp the manifest to the protocol major actually installed so
# the rest of the job exercises the template against a coherent
# runtime. Same alignment the Docker step below already does by
# reading the resolved CLI version; done here BEFORE `npm run
# build`, so the artifact carries the corrected range too.
#
# The major is read off the installed @objectstack/spec package:
# PROTOCOL_VERSION is kept in lockstep with that package's own major
# (packages/spec/src/kernel/protocol-version.ts, asserted by
# protocol-version.test.ts), so the two cannot drift.
#
# Deliberately confined to the fallback branch: on the normal path
# the project installs the repo's own version and the majors agree
# by construction — a template that stamped the wrong major would
# still fail, which is what template-consistency.test.ts is for.
node -e '
const fs = require("fs");
const major = JSON.parse(
fs.readFileSync("node_modules/@objectstack/spec/package.json", "utf8"),
).version.split(".")[0];
const path = "objectstack.config.ts";
const src = fs.readFileSync(path, "utf8");
const stamp = /engines:\s*\{\s*protocol:\s*[\x27"][^\x27"]*[\x27"]\s*\}/;
if (!stamp.test(src)) {
console.log("::error::fallback cannot re-stamp engines.protocol — no stamp found in " + path);
process.exit(1);
}
const out = src.replace(stamp, "engines: { protocol: \x27^" + major + "\x27 }");
fs.writeFileSync(path, out);
console.log("::notice::fallback re-stamped engines.protocol to ^" + major + " (installed @objectstack/spec major) — this run exercises the template against protocol " + major + ", not the repo\x27s");
'
fi
- name: Validate and build the generated project
run: |
cd "$RUNNER_TEMP/e2e-app"
npm run validate
npm run build
# Two questions this step must answer about its own server, and used to
# answer neither (#9779). Both belong here rather than in the CLI: `os
# start` is already correct — the shell around it was not.
#
# 1. IS ANYTHING ALREADY SERVING 8080, BEFORE WE BOOT ANYTHING?
# `os start` is a PRODUCTION boot. packages/cli/src/commands/serve.ts
# gates its port auto-shift on `flags.dev || NODE_ENV === 'development'`
# and `start` spawns `serve` with neither (start.ts forces
# NODE_ENV=production when the caller has not set it). Measured on this
# checkout, a neighbouring server holding the requested port:
#
# $ os start --port 38200 # neighbour already on 38200
# ✗ Port 38200 is already in use.
# ObjectStack does not auto-select a different port in production
# $ echo $?
# 1
# $ os start --port 38500 # nothing on 38500
# ✓ Server is ready → http://localhost:38500/ (stays up, 200s)
#
# So this step's server NEVER drifts to another port — it binds 8080 or
# it dies. That is exactly what makes the WAIT LOOP the whole defect,
# and it is why neither sibling fix filed alongside this one transfers:
# there is no shifted port to read back (#9647) and no `--strictPort` to
# ask for (#9578), because the CLI already behaves as if it had one.
# The neighbour simply keeps answering 8080. Measured with the previous
# block verbatim: the loop took its 200 on iteration 1 after 0s, then
# asserted /api/v1/ready against the NEIGHBOUR's app, and `kill
# "$SERVER_PID"` succeeded against our own `os start` — still booting,
# not yet dead. Exit 0, on an app this job never started.
#
# A liveness check ALONE does not catch that: through the window that
# decides the run, our process is genuinely alive. The one question that
# separates the two worlds is whether something was already answering
# the exact URL the loop accepts as proof — so that is asked first.
#
# 2. IS OUR OWN SERVER STILL ALIVE?
# `os start` exits 1 on a busy port, an unreadable artifact, or any boot
# failure. Unasked, the loop burns its full 60s and then reports only
# "never became healthy" — the slowest, vaguest form of a fact the
# process table had at second 2.
#
# The PORTS STAY FIXED, on purpose. Every `runs-on:` in this repo is
# `ubuntu-latest` — one fresh VM per job — so no two CI jobs of this
# workflow, or of any other, can share 8080. Per-run ports would trade a
# readable literal for machinery that buys nothing on the runner this
# actually runs on. The two guards are for the shared-namespace replay:
# a self-hosted runner, or a developer running this block by hand in an
# agent dispatch container. Deliberately still open at that grade: a
# neighbour arriving AFTER the pre-flight and before our own bind. Closing
# it needs an affirmative "our server bound" signal — the runtime state
# file serve.ts publishes under OS_HOME — which is what to reach for if
# this workflow ever moves onto a runner it shares.
- name: Boot from the artifact and probe health
run: |
cd "$RUNNER_TEMP/e2e-app"
if curl -fsS http://localhost:8080/api/v1/health > /dev/null 2>&1; then
echo "::error::something is already serving http://localhost:8080/api/v1/health before this step booted anything — every probe below would assert against it, not against the app this job scaffolded"
exit 1
fi
npx os start --artifact ./dist/objectstack.json --port 8080 > server.log 2>&1 &
SERVER_PID=$!
ok=""
for i in $(seq 1 30); do
if ! kill -0 "$SERVER_PID" 2>/dev/null; then
echo "::error::the server this step started exited before becoming healthy"; cat server.log; exit 1
fi
if curl -fsS http://localhost:8080/api/v1/health > /dev/null 2>&1; then ok=1; break; fi
sleep 2
done
if [ -z "$ok" ]; then
echo "::error::server never became healthy"; cat server.log; exit 1
fi
curl -fsS http://localhost:8080/api/v1/ready
kill "$SERVER_PID"
- name: 'Skills boundary: only the curated skills/ catalog installs'
# 15.1 third-party eval: the repo-internal dogfood-verification skill
# (.claude/skills/) leaked into scaffolded projects. Two leak paths,
# two assertions against THIS checkout:
# 1. The scaffolder/docs command is `skills add …/objectstack/skills
# --all` — the /skills subpath is the hard boundary, because the
# skills CLI's --all implies --skill '*' which INCLUDES
# metadata.internal skills. Install from the subpath and assert
# set-equality with the curated catalog.
# 2. Interactive `skills add …/framework` (no --all) relies on
# metadata.internal hiding — assert repo-root discovery surfaces
# exactly the curated set and nothing internal.
run: |
cd "$RUNNER_TEMP"
node "$GITHUB_WORKSPACE/packages/create-objectstack/bin/create-objectstack.js" skills-probe --skip-install --skip-skills
cd skills-probe
expected=$(cd "$GITHUB_WORKSPACE/skills" && ls -d objectstack-*/ | tr -d '/' | sort)
expected_count=$(echo "$expected" | wc -l | tr -d ' ')
npx -y skills add "$GITHUB_WORKSPACE/skills" --all --copy
installed=$(find . -name SKILL.md -not -path '*/node_modules/*' -exec dirname {} \; | xargs -rn1 basename | sort -u)
echo "installed skills:"; echo "$installed"
if [ "$installed" != "$expected" ]; then
echo "::error::scaffolded skill set != curated skills/ catalog (internal-skill leak or missing skill)"
printf 'expected:\n%s\n' "$expected"
exit 1
fi
# Strip ANSI color codes — the skills CLI colors the count even
# when piped ("Found \e[32m9\e[39m skills"), which broke the greps.
listing=$(npx -y skills add "$GITHUB_WORKSPACE" --list 2>&1 | sed 's/\x1b\[[0-9;]*m//g')
echo "$listing"
if echo "$listing" | grep -q 'dogfood-verification'; then
echo "::error::repo-root discovery surfaced an internal skill (metadata.internal marker missing?)"
exit 1
fi
if ! echo "$listing" | grep -q "Found $expected_count skill"; then
echo "::error::repo-root discovery does not match the curated catalog (expected $expected_count skills) — a SKILL.md outside skills/ is missing metadata.internal"
exit 1
fi
- name: 'Pinned shape: scaffold WITHOUT --skip-install, assert the pin ran'
# Everything above scaffolds with `--skip-install` and installs the
# project in a separate step, so the scaffolder's OWN post-install work
# never runs — and the Dockerfile runtime-image pin (#9017) lives
# exactly there. The shape those steps build and boot is therefore the
# UNPINNED one (`FROM ghcr.io/objectstack-ai/objectstack:latest`), while
# every real `npx create-objectstack` user ships the PINNED one, so the
# pin silently ceasing to happen was invisible to this job (#9117).
#
# A second, throwaway project closes that gap the cheap way: scaffold it
# the way a real user does — no `--skip-install` — and assert one thing,
# that the emitted Dockerfile names the @objectstack/cli version this
# project actually resolved. No docker build: the difference between the
# two shapes is exactly one FROM tag, and the leg above already proves
# the image builds and boots. runtime-image.test.ts covers the REWRITE
# on fabricated input; what only an end-to-end run can catch is the pin
# never RUNNING — a scaffolder that stops calling it, or an install path
# that no longer leaves a resolvable CLI behind.
#
# `--skip-skills` is kept: skills install after the pin, so dropping it
# would only add a network fetch, never coverage.
#
# The unpublished-version window is the one degradation, and it is
# evidenced rather than blanket. The install step above carries the
# fallback that rewrites unresolvable `@objectstack/*` ranges to
# `latest`; that fallback lives in THIS WORKFLOW rather than in the
# shipped scaffolder, so this step — whose whole point is to run the
# scaffolder's OWN install — cannot borrow it.
#
# WHY IT STILL LIVES HERE, RECORDED RATHER THAN TRIBAL (#9149).
# Relocating the fallback into the scaffolder would remove this
# degradation outright. It was considered and DECLINED at triage on
# 2026-08-17: `create-objectstack` is a PUBLISHED package, so a fallback
# that rewrites a real user's dependency ranges to `latest` is a
# user-visible public-surface expansion, not CI scaffolding — and a user
# scaffolding during a release window arguably wants a resolvable pin,
# not a floating tag. Reopening it needs a decision card answering
# #9149's three questions: where the rewrite belongs, whether `latest`
# is right for a real user at all, and what happens to the release-time
# stamping that rides the same step (`engines.protocol`, and since #9264
# `specVersion` too — see scripts/sync-template-versions.mjs). Until
# such a card is ruled, the degradation stays ON PURPOSE, and this
# pointer is why a reader finds that out here instead of rediscovering
# it.
#
# WHAT THE DEGRADATION IS NOT ALLOWED TO SWALLOW. The registry answer is
# classified THREE ways, not two. "npm says no version matches" and "npm
# could not be reached" both surface as an empty string, and both used
# to take the skip — so a registry outage could silently present itself
# as the known window and carry the leg green. Now:
# * unreachable registry ⇒ ::error:: — an unclassifiable
# answer is never evidence of the window, so it is never skipped.
# * reachable, range unsatisfiable ⇒ the known window ⇒ warn, and
# assert the DEGRADED shape rather than nothing at all: with no
# resolvable CLI the scaffolder pins nothing, so the template's own
# `latest` tag must survive untouched. A tag that is neither
# `latest` nor a resolved version means the scaffolder invented one
# with nothing to resolve — a real defect this window must not hide.
# * reachable, range satisfiable ⇒ the scaffolder's install path is
# itself broken ⇒ ::error::. Unchanged: this is the branch that
# still catches a genuinely broken scaffolder install.
run: |
cd "$RUNNER_TEMP"
node "$GITHUB_WORKSPACE/packages/create-objectstack/bin/create-objectstack.js" pinned-probe --skip-skills
cd pinned-probe
if [ ! -f node_modules/@objectstack/cli/package.json ]; then
RANGE=$(node -p "require('./package.json').devDependencies['@objectstack/cli']")
# Asked WITHOUT the range first, so an unreachable registry cannot
# masquerade as "that version is not published yet" and take the
# skip below. Both failures read as an empty string; only this
# rangeless probe tells them apart.
if [ -z "$(npm view "@objectstack/cli" version 2>/dev/null)" ]; then
echo "::error::cannot reach @objectstack/cli on the registry at all, so the emitted range '$RANGE' cannot be classified. An unreachable registry is NOT evidence of the unpublished-version window; refusing to skip the pinned-shape assertion on an answer this job cannot interpret."
exit 1
fi
if [ -z "$(npm view "@objectstack/cli@$RANGE" version 2>/dev/null)" ]; then
echo "::warning::no @objectstack/cli matching '$RANGE' is on the registry — the unpublished-version window the install step's fallback exists for (that fallback lives in this workflow, not the scaffolder; #9149 records why). Pin-EQUALITY is SKIPPED for this run; the degraded shape is still asserted below."
TAG=$(sed -n 's|^FROM ghcr\.io/objectstack-ai/objectstack:||p' Dockerfile)
if [ "$TAG" != "latest" ]; then
echo "::error::the scaffolder resolved no @objectstack/cli (nothing on the registry satisfies '$RANGE'), so it must leave the template's runtime tag at 'latest' — but the emitted Dockerfile FROM tag is '${TAG:-<none>}'. A tag invented with nothing to resolve is a real defect, not the unpublished-version window."
exit 1
fi
echo "degraded shape as expected: no resolvable CLI, FROM tag left at 'latest'"
exit 0
fi
echo "::error::the scaffolder ran its own install but left no node_modules/@objectstack/cli behind, though the registry does satisfy '$RANGE' — its install path is broken, so the Dockerfile pin can never run for a real user"
exit 1
fi
RESOLVED=$(node -p "require('./node_modules/@objectstack/cli/package.json').version")
TAG=$(sed -n 's|^FROM ghcr\.io/objectstack-ai/objectstack:||p' Dockerfile)
echo "resolved @objectstack/cli : $RESOLVED"
echo "emitted Dockerfile FROM tag : ${TAG:-<none>}"
if [ "$TAG" != "$RESOLVED" ]; then
echo "::error::the scaffolder did NOT pin the Dockerfile runtime image: the emitted FROM tag is '${TAG:-<none>}' but this project resolved @objectstack/cli@$RESOLVED. This is the file every real npx create-objectstack user ships (#9017); the --skip-install legs in this job cannot see it."
exit 1
fi
echo "pinned as expected: FROM tag == resolved CLI ($RESOLVED)"
- name: Build official runtime image from this checkout
# The scaffolded app's Dockerfile builds FROM
# ghcr.io/objectstack-ai/objectstack. Build that base HERE from
# docker/Dockerfile instead of pulling, so
# (a) PRs exercise the official runtime Dockerfile itself, and
# (b) the e2e stays hermetic — no dependency on a prior release
# having published the tag (chicken-and-egg on the very first one).
#
# Pin the runtime's CLI to the SAME version the generated project
# actually resolved to — NOT a hardcoded `latest`. The scaffolded
# artifact is built by that project's `@objectstack/cli` and carries
# its `engines.protocol` major; `os start` in the runtime image rejects
# any artifact from a different protocol major. During an RC window the
# project pins `^<repo rc>` (e.g. 16.0.0-rc.1, protocol 16) while the
# `latest` dist-tag still points at the last STABLE cli (protocol 15),
# so a hardcoded `latest` skews the two and boot fails. Reading the
# resolved version keeps the image in lockstep whether the project got
# the repo RC, the `latest` fallback (see the install step), or a
# published stable.
#
# The TAG this image is built under is READ OUT OF the generated
# Dockerfile rather than hardcoded (#9017). The two used to be
# hand-matched literals — `:latest` here and `:latest` in the template —
# and the scaffolder now pins that tag to the CLI a project resolved, so
# a hardcoded tag here would stop naming the image the scaffolded
# `FROM` asks for. Docker would then silently pull the last PUBLISHED
# image instead of the one built from this checkout, and (b) above would
# be quietly false while the job stayed green. Deriving both from one
# source is what makes that skew impossible rather than merely fixed.
# (This job scaffolds with --skip-install, so today that tag reads
# `latest`; it follows the file if that ever changes.)
run: |
CLI_VERSION=$(node -p "require('$RUNNER_TEMP/e2e-app/node_modules/@objectstack/cli/package.json').version")
RUNTIME_TAG=$(sed -n 's|^FROM ghcr\.io/objectstack-ai/objectstack:||p' \
"$RUNNER_TEMP/e2e-app/Dockerfile")
if [ -z "$RUNTIME_TAG" ]; then
echo "::error::no FROM ghcr.io/objectstack-ai/objectstack:<tag> line in the scaffolded Dockerfile — the base image the next step builds against cannot be named, so this job would silently test a pulled image instead of this checkout"
exit 1
fi
echo "Runtime image will bundle @objectstack/cli@$CLI_VERSION (matches the scaffolded artifact's protocol)"
echo "Tagging it ghcr.io/objectstack-ai/objectstack:$RUNTIME_TAG (read from the scaffolded Dockerfile)"
docker build -t "ghcr.io/objectstack-ai/objectstack:$RUNTIME_TAG" \
--build-arg OS_CLI_VERSION="$CLI_VERSION" \
"$GITHUB_WORKSPACE/docker"
- name: Docker build and run (scaffolded Dockerfile)
# The blank template ships its own Dockerfile / docker-compose.yml /
# .dockerignore, so build the generated project as-is rather than a
# hand-kept copy (the standalone examples/docker copy was removed in
# 6c28bac).
#
# Scope, precisely: this is the `--skip-install` shape of that path, so
# the Dockerfile built here still carries the floating tag. A real
# `npm create objectstack` user ships the PINNED shape, which differs by
# exactly one FROM tag — asserted by the pinned-shape step above rather
# than built a second time here, since the layers below the tag are the
# ones this build already proves (#9117).
run: |
cd "$RUNNER_TEMP/e2e-app"
docker build --pull=false -t e2e-app .
# The fixed container NAME and the fixed HOST PORT below both stay,
# and they are a different animal from the in-process port shift the
# two `os start` blocks guard against (#9779). Docker declines both
# collisions at `docker run` time and never relocates: a duplicate
# `--name e2e` is refused by name, and a taken `-p 18080:8080` fails
# to bind rather than publishing somewhere else. Under this step's
# `bash -e` either aborts the step before the loop is reached. So a
# `docker run` that SUCCEEDED is proof that 18080 is ours — this leg
# has no wrong-answer mode for a per-run port to remove. (Read from
# docker's published behaviour, not measured: the dispatch container
# this was written in ships the docker CLI with no daemon behind it.)
#
# What it did share with the other two is the missing question. The
# loop asked only whether 18080 answered, so a container that started
# and then died at second 3 cost the full 60s and reported "never
# became healthy" instead of "it is not running, here is why".
docker run -d --name e2e -p 18080:8080 \
-e OS_SECRET_KEY="$(openssl rand -hex 32)" \
-e OS_AUTH_SECRET="$(openssl rand -hex 32)" \
e2e-app
ok=""
for i in $(seq 1 30); do
if [ "$(docker inspect -f '{{.State.Running}}' e2e 2>/dev/null)" != "true" ]; then
echo "::error::the container this step started is no longer running"; docker logs e2e; exit 1
fi
if curl -fsS http://localhost:18080/api/v1/health > /dev/null 2>&1; then ok=1; break; fi
sleep 2
done
if [ -z "$ok" ]; then
echo "::error::container never became healthy"; docker logs e2e; exit 1
fi
docker rm -f e2e
registry-canary:
name: 'Registry canary: ${{ matrix.template }}'
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
# `blank` is the whole catalog: the five remote content templates
# (todo, compliance, content, contracts, procurement) were delisted
# from the marketplace and retired from the scaffolder, so canarying
# them would gate the release on unmaintained content.
template: [blank]
steps:
- name: Setup Node.js
uses: actions/setup-node@v7
with:
node-version: '22'
- name: Scaffold with published create-objectstack@latest
run: |
cd "$RUNNER_TEMP"
npx -y create-objectstack@latest canary-app -t ${{ matrix.template }} --skip-skills
# `build` is the gate every template must pass; `validate` is run only
# when the scaffolded project defines it (the now-retired remote
# templates did not always ship it).
- name: Gate the generated project
run: |
cd "$RUNNER_TEMP/canary-app"
if node -e "process.exit(JSON.parse(require('fs').readFileSync('package.json','utf8')).scripts?.validate ? 0 : 1)"; then
npm run validate
fi
npm run build
# Same two guards, same reasoning, as `Boot from the artifact and probe
# health` in scaffold-local — see the measurement written out there. Kept
# as a copy rather than factored into a composite action: these two blocks
# boot DIFFERENT artifacts (repo dist vs. the published scaffolder) and the
# thing under test is the block a reader of this file can see whole.
- name: Boot and probe health (blank only)
if: matrix.template == 'blank'
run: |
cd "$RUNNER_TEMP/canary-app"
if curl -fsS http://localhost:8080/api/v1/health > /dev/null 2>&1; then
echo "::error::something is already serving http://localhost:8080/api/v1/health before this step booted anything — every probe below would assert against it, not against the app this job scaffolded"
exit 1
fi
npx os start --artifact ./dist/objectstack.json --port 8080 > server.log 2>&1 &
SERVER_PID=$!
ok=""
for i in $(seq 1 30); do
if ! kill -0 "$SERVER_PID" 2>/dev/null; then
echo "::error::the server this step started exited before becoming healthy"; cat server.log; exit 1
fi
if curl -fsS http://localhost:8080/api/v1/health > /dev/null 2>&1; then ok=1; break; fi
sleep 2
done
if [ -z "$ok" ]; then
echo "::error::server never became healthy"; cat server.log; exit 1
fi
curl -fsS http://localhost:8080/api/v1/ready
kill "$SERVER_PID"