From 84823901ba409e83cacf79bc8117a7b27b772c36 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 11:17:15 +0000 Subject: [PATCH] ci(create-objectstack): scaffold-E2E gate + registry canary + release-time template dep sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Close the remaining template-staleness gaps behind #2907 (fixes #2908): - .github/workflows/scaffold-e2e.yml - scaffold-local (PRs touching the scaffolder / examples/docker): scaffold with the repo-built dist, install from the registry (with a latest fallback for the unpublished-version window right after a bump), validate + build the generated project, boot it from the artifact and probe /api/v1/health + /ready, then docker build/run the examples/docker packaging against the same probes — the docker files finally get CI coverage. - registry-canary (nightly + manual): npx create-objectstack@latest across every template in the registry (blank + 5 remote), gate each generated project, full boot probe for blank — catches registry/publish breakage the way a real first-run user would. - scripts/sync-template-versions.mjs, chained into the root `version` script after sync-protocol-version.mjs: rewrites the blank template's @objectstack/* ranges to the scaffolder's own version at release time, so a bump can never ship a stale template. Release PRs from changesets/action skip CI (default-token anti-recursion), so version time is the one spot that cannot be bypassed — same rationale as sync-protocol-version.mjs. Every step of both jobs that can run outside Actions was executed locally: scaffold → npm install → validate → build → boot + health/ready probes, the validate-script detection, the latest-fallback rewrite, and both sync-script paths (idempotent + rewrite). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Em3ky9uu6zw2cYzVhqCij2 --- .github/workflows/scaffold-e2e.yml | 182 +++++++++++++++++++++++++++++ package.json | 2 +- scripts/sync-template-versions.mjs | 51 ++++++++ 3 files changed, 234 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/scaffold-e2e.yml create mode 100644 scripts/sync-template-versions.mjs diff --git a/.github/workflows/scaffold-e2e.yml b/.github/workflows/scaffold-e2e.yml new file mode 100644 index 0000000000..743af025f5 --- /dev/null +++ b/.github/workflows/scaffold-e2e.yml @@ -0,0 +1,182 @@ +# 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. +# Job 2 (nightly) does the same via the *published* `create-objectstack@latest` +# across every template in the registry — the new-user canary. + +name: Scaffold E2E + +on: + pull_request: + paths: + - 'packages/create-objectstack/**' + - 'examples/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@v6 + with: + node-version: '22' + + - name: Enable Corepack + run: corepack enable + + - 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 ^. 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 + fi + + - name: Validate and build the generated project + run: | + cd "$RUNNER_TEMP/e2e-app" + npm run validate + npm run build + + - name: Boot from the artifact and probe health + run: | + cd "$RUNNER_TEMP/e2e-app" + npx os start --artifact ./dist/objectstack.json --port 8080 > server.log 2>&1 & + SERVER_PID=$! + ok="" + for i in $(seq 1 30); do + 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: Docker build and run (examples/docker) + run: | + cd "$RUNNER_TEMP/e2e-app" + cp "$GITHUB_WORKSPACE"/examples/docker/Dockerfile \ + "$GITHUB_WORKSPACE"/examples/docker/docker-compose.yml \ + "$GITHUB_WORKSPACE"/examples/docker/.dockerignore . + docker build -t e2e-app . + 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 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: + template: [blank, todo, compliance, content, contracts, procurement] + steps: + - name: Setup Node.js + uses: actions/setup-node@v6 + 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 + + # Remote templates ship `build` (same gates) but not always `validate`. + - 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 + + - name: Boot and probe health (blank only) + if: matrix.template == 'blank' + run: | + cd "$RUNNER_TEMP/canary-app" + npx os start --artifact ./dist/objectstack.json --port 8080 > server.log 2>&1 & + SERVER_PID=$! + ok="" + for i in $(seq 1 30); do + 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" diff --git a/package.json b/package.json index e1c018df86..57f943090f 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "test:e2e": "turbo run test:e2e", "clean": "turbo run clean && rm -rf dist", "setup": "pnpm install && pnpm --filter @objectstack/spec build", - "version": "changeset version && node scripts/sync-protocol-version.mjs", + "version": "changeset version && node scripts/sync-protocol-version.mjs && node scripts/sync-template-versions.mjs", "release": "pnpm run build && bash scripts/build-console.sh && bash scripts/release-publish.sh", "docs:dev": "pnpm --filter @objectstack/docs dev", "docs:build": "pnpm --filter @objectstack/docs build", diff --git a/scripts/sync-template-versions.mjs b/scripts/sync-template-versions.mjs new file mode 100644 index 0000000000..38548b94de --- /dev/null +++ b/scripts/sync-template-versions.mjs @@ -0,0 +1,51 @@ +// Copyright (c) 2026 ObjectStack contributors. Apache-2.0 license. +// +// Re-sync the create-objectstack blank template's @objectstack/* dependency +// ranges with the scaffolder's own package version. Runs as part of the root +// `version` script (changesets/action calls `pnpm run version` when preparing +// the release PR), so a version bump can never ship with the template pinning +// a stale range — the drift class behind #2907: the template froze at ^6.0.0 +// while the registry published 14.x, and every fresh `npm create objectstack` +// project landed eight majors behind the docs. The scaffold-time dep rewrite +// (pkg-utils.ts) and the ratchet test (template-consistency.test.ts) both +// guard this too, but release PRs opened by changesets/action with the default +// GITHUB_TOKEN do not trigger CI, so fixing the file at version time is the +// only spot that cannot be skipped. + +import { readFileSync, writeFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; + +const root = dirname(dirname(fileURLToPath(import.meta.url))); +const scaffolderPkgPath = join(root, 'packages/create-objectstack/package.json'); +const templatePkgPath = join( + root, + 'packages/create-objectstack/src/templates/blank/package.json', +); + +const version = JSON.parse(readFileSync(scaffolderPkgPath, 'utf8')).version; +if (!/^\d+\.\d+\.\d+/.test(String(version))) { + console.error(`✗ sync-template-versions: cannot parse create-objectstack version '${version}'`); + process.exit(1); +} +const range = `^${String(version).split('.')[0]}.0.0`; + +const templatePkg = JSON.parse(readFileSync(templatePkgPath, 'utf8')); +let changed = 0; +for (const deps of [templatePkg.dependencies, templatePkg.devDependencies]) { + if (!deps) continue; + for (const dep of Object.keys(deps)) { + if (dep.startsWith('@objectstack/') && deps[dep] !== range) { + console.log(` ${dep}: ${deps[dep]} → ${range}`); + deps[dep] = range; + changed++; + } + } +} + +if (changed === 0) { + console.log(`✓ blank template already pins ${range} — in lockstep with create-objectstack@${version}`); +} else { + writeFileSync(templatePkgPath, JSON.stringify(templatePkg, null, 2) + '\n'); + console.log(`✓ blank template: ${changed} @objectstack/* range(s) → ${range} (lockstep with create-objectstack@${version})`); +}