chore: version packages #2974
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # 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" |