Skip to content

feat(create-objectstack): ship a CI workflow in the blank template so a scaffolded app has gates from its first push - #16364

Merged
os-sam merged 3 commits into
mainfrom
claude/issue-16330-scaffold-ci-workflow
Sep 6, 2026
Merged

feat(create-objectstack): ship a CI workflow in the blank template so a scaffolded app has gates from its first push#16364
os-sam merged 3 commits into
mainfrom
claude/issue-16330-scaffold-ci-workflow

Conversation

@claude

@claude claude Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Fixes #16330

The blank template now carries .github/workflows/ci.yml, so a freshly
scaffolded project has CI from its first push instead of a set of npm scripts
nobody runs.

What landed

packages/create-objectstack/src/templates/blank/.github/workflows/ci.yml
one job, one file, on: [push, pull_request]:

actions/checkout@v7
pnpm/action-setup@v6      (version: 10)
actions/setup-node@v7     (node-version: '22', cache: pnpm)
pnpm install --frozen-lockfile
pnpm validate
pnpm lint
pnpm typecheck

Plus the one script that step needed: the blank template's package.json gains
"lint": "objectstack lint", and the README's "Verify your changes" block names
it beside the other two.

Action majors are pinned the way this repo pins its own; the pnpm-before-node
ordering is the one showcase-smoke.yml documents (setup-node's cache: pnpm
shells out to pnpm, so pnpm has to be on PATH first).

pnpm lint: inferred out, then measured back in

The first revision of this PR dropped the card's pnpm lint step, reasoning
that the template declared no lint script so the step would fail on the first
push. That reasoning was incomplete: adding the script is a one-line change to
packages/create-objectstack/src/templates/blank/package.json, which is inside
this card's file surface. The omission rested on an untested option, so it was
tested.

The measurement. A project scaffolded for real from the repo-built
scaffolder (node packages/create-objectstack/bin/create-objectstack.js accept-app --skip-install --skip-skills), dependencies installed from the
registry (npm install resolved @objectstack/cli and @objectstack/spec at
17.3.0 — the normal path, no latest fallback), then the workflow's three
steps run in order against the project as generated:

pnpm validate  -> EXIT=0
pnpm lint      -> EXIT=0     ✓ All checks passed (90ms)
pnpm typecheck -> EXIT=0

So the step goes in. That also settles the card's acceptance criterion — "that
workflow's steps pass against the scaffolded project as generated" — by
measurement rather than by inference.

Why it is not a second spelling of validate. validate.ts calls
runAuthoringRules('validate', …), so security-owd-unset — the first defect
this card cites — is already reachable from pnpm validate. But
checkHookBodyLowering, which produces the second (hook-body/not-lowerable),
is imported by packages/cli/src/commands/lint.ts and by nothing else:
git grep hook-body-lowering -- packages returns that import and the rule's own
test file. Without this step a scaffolded project has no route to that rule at
all, and the card would have delivered one of the two defects it was filed
about.

What is still out of surface. packages/cli/src/commands/init.ts — the
second scaffolder — writes the same five scripts and still has no lint, so the
two now disagree. That is a packages/cli/ change and stays out of this PR;
#16350 was narrowed to exactly that parity gap, with the measurement above
recorded on it so it is mechanical rather than open.

The three premises the card asked to verify

1. The template carried no .github. Confirmed against origin/main
3e270d4e: src/templates/blank/ held one dotfile, .dockerignore, and no dot
directory.

2. Does a nested dot-directory survive packaging and the copy? Yes, both
halves, measured rather than assumed — no packaging change is needed.

  • Tarball: the packing ratchet in template-consistency.test.ts runs a real
    npm pack with the real files allowlist and compares the extracted tree to
    src/templates/. It is green with .github/workflows/ci.yml in the set, and
    this PR adds an assertion naming that path literally, next to the existing
    .dockerignore one, so a strip reads as itself rather than as "some file went
    missing".
  • Copy: copyDir walks with readdirSync(…, { withFileTypes: true }) and
    recurses into directories, so dot entries were never at risk there. A test
    runs the real copyDir over the template into a tempdir and asserts the file
    lands at .github/workflows/ci.yml byte-identical — and the end-to-end
    scaffold above produced a project carrying both .github/copilot-instructions.md
    and .github/workflows/ci.yml.
  • Why _gitignore is the exception and this is not: npm pack strips
    .gitignore and .npmrc specifically, at every depth. That list is not "every
    dotfile" — TEMPLATE_FILE_ALIASES' own header records .dockerignore packing
    fine and staying literal, and .github/ now joins it.

3. Pin Node to the template's engines. Falsified as stated: the template's
package.json declares engines.pnpm (>=10.15) and no engines.node. The
template states its Node floor in its Dockerfile (FROM node:22-slim), so the
workflow is pinned from there, and a test holds the two equal rather than
restating 22 a third time. (The repo's own .nvmrc and
create-objectstack's engines.node >=22.0.0 agree.)

Outside the declared file surface — called out rather than buried

The card scoped this to packages/create-objectstack/ plus a changeset. Two
files fall outside it, and neither is optional:

  • pnpm-lock.yaml — three lines, the mechanical consequence of declaring the
    yaml devDependency. CI installs --frozen-lockfile, so omitting it is a red
    build, not a smaller diff.
  • scripts/docs-audit/affected-docs.mjsa judgement call, and the most
    interesting finding of the card.

walkSourceFiles prunes every dot-directory under packages/** as build
residue, and its --self-test re-measures the premise that makes that safe
instead of quoting it:

git ls-files 'packages/**' | grep -cE '(^|/)\.[^/]+/'   →   0

Adding the template's .github/ makes that zero a one, and
node scripts/docs-audit/check-affected-docs.mjs goes red. That gate is not
noise — it is a deliberate trip-wire whose comment says the remedy is "a choice
made deliberately: move the file, or go back to pruning the residue trees by
name." Both of its two named remedies are wrong here: the path IS the
deliverable, and its own header argues at length against re-listing residue
trees by name.

So the premise is narrowed to the files the walk actually collects — .ts, the
only extension it reads (if (!e.isFile() || !e.name.endsWith('.ts')) continue;)
— which is the property the prune's safety genuinely rests on. A non-.ts file
was never collectable, so nothing the prune could hide has changed. One-line
filter change plus the comments that record why.

Proven not to be a disarming, from the committed state: staging a .ts under a
dot-directory in packages/** still reds the pin by name (expected walkSourceFiles=0, got 1), and unstaging it returns the gate to exit 0 with a
clean tree.

The underscore-rename escape (_github/ mapped back at copy time, the way
_gitignore works) was considered and rejected: TEMPLATE_FILE_ALIASES' own
header states the map covers only what npm pack genuinely strips, a pinned
test asserts .dockerignore is NOT in it, and this PR measured that .github/
packs fine. Aliasing it to dodge an unrelated gate would be a workaround that
also puts a false statement into the alias map.

Tests

New: packages/create-objectstack/src/template-ci-workflow.test.ts — the file
exists, parses as YAML, triggers on push and pull_request, runs only scripts the
template declares (and runs all three of them), pins the Dockerfile's Node
major, acquires pnpm before setup-node, pins every action, and survives the real
scaffold copy into its dot directory.

pnpm --filter create-objectstack test — 15 files, 188 tests, all green.
pnpm --filter create-objectstack typecheck — clean (and tsc --listFiles
confirms the new test is in the program, so that green is about this file).

Every new assertion class was ablated from the committed implementation and
restored byte-identically (git hash-object back to the HEAD blob, plus an
empty git diff HEAD), each with an on-disk proof that the mutation landed:

ablation result
delete .github/workflows/ci.yml 9 failed / 35 passed, across both suites
insert a step for a script the template lacks 1 failed / 43 passed, naming the missing script
drop "lint" from the template, keep the step 1 failed / 7 passed — declares no such script
drop the pnpm lint step, keep the script 1 failed / 7 passed — expected [ 'validate', 'typecheck' ] to include 'lint'

The last two are the same pin measured in both directions: it catches a step
without a script and a script without a step.

Packaging note

yaml@^2.9.0 is added to devDependencies (the range already used by
@objectstack/spec and @objectstack/cli) for the YAML parse the acceptance
criterion asks for; devDependencies never reach the published tarball, whose
files ships dist, README.md and CHANGELOG.md. The sibling
scaffold-e2e-boot-probe.test.ts deliberately hand-parses a workflow instead —
it needs a run: block's bytes verbatim and a parser would normalise a
malformed file away. Here the parse is the assertion, so that reasoning
inverts; the new test says so in its header.

Changeset: create-objectstack minor.

Filed separately, not addressed in this PR

No change to this repository's own .github/workflows/.

Gates

node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack
re-derived for the final path set: 84 commands, the same 84 as before the round-2
edits (the added paths land in families already covered). Re-run in full at
4f566942: 80 green, 0 red, 4 not measured

  • pnpm check:dual-build-cjs-loads, pnpm check:type-check-debt — exit 3,
    PREREQUISITE NOT MET: they read a fully built closure this tree does not
    have. Not a pass and not a finding. (check:dts-closure DID run this round
    and is green, the two package builds having given it something to sweep.)
  • node scripts/check-plugin-teardown-shape.mjs --self-test — cannot reach its
    pinned positive-control commit in a shallow clone (git fetch --deepen=500
    did not bring it into reach). Checker-health only; the gate proper passes.
  • pnpm check:pm-dispatch-gates — its self-test battery exceeds this
    container's ~10 minute foreground cap (killed at 560s, every case green up to
    the cut). CI runs it.

node scripts/docs-audit/check-affected-docs.mjs, the one gate this diff
tripped, is green in this sweep. So are the two template-specific gates the
dispatch called out: pnpm --filter @objectstack/spec run check:template-manifests
and pnpm check:template-version-sync, plus pnpm check:published-files.

Because the diff edits a gate script, its own suites were run on top of the
derived family: pnpm check:docs-audit-scope, pnpm check:docs-transcript-drift
(after pnpm --filter '@objectstack/lint...' build satisfied its prerequisite)
and node scripts/check-self-test-wired.mjs — all green.

pnpm lint (eslint . --no-inline-config) — the full repo-wide run, not a
narrowed one — exits 0 at 4f566942, the final commit. pnpm check:nul-bytes
green, plus a manual control-character grep over every changed path: clean.

turbo ls --affected against the base names 8 packages. Seven of them
(@objectstack/cli, qa/dogfood, qa/downstream-contract, four example apps)
are reached by turbo's input hashing on scripts/**, not by any surface this
diff moves — the edited file is a CI gate's own self-test filter. Their suites
are declared to CI.


Generated by Claude Code

The scaffolder already created `.github/` at runtime for one file
(copilot-instructions.md) while the template's own gates — `validate` and
`typecheck` — shipped as npm scripts nothing ever ran, so every scaffolded
project started with zero CI and the "metadata mistakes fail silently at
runtime, the gates are where they surface" claim rested on a human
remembering to type the command.

Adds `.github/workflows/ci.yml` to the blank template: one job, one file,
checkout -> pnpm/action-setup -> setup-node (Node 22, pnpm cache) ->
`pnpm install --frozen-lockfile` -> `pnpm validate` -> `pnpm typecheck`.

No `pnpm lint` step: the blank template declares no `lint` script and
neither scaffolder writes one, so that step would fail on the first push of
every scaffolded project. The new test derives the allowed step set from the
template's package.json so the two cannot drift.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PbJ5Cy9KDAzeQHo8bsMadG
…he walk collects

`walkSourceFiles` prunes every dot-directory under `packages/**` as build
residue, and `--self-test` re-measures the premise that makes that safe rather
than quoting it. The premise was stated over ALL tracked files, but the walk
collects `.ts` and nothing else, so it was strictly stronger than the property
the prune rests on.

The blank project template now commits `.github/workflows/ci.yml` — a template
asset this audit has never had a reason to read — which reds the pin while the
prune's actual safety is untouched. A false positive about a real premise is
the shape most likely to get a good pin deleted, so the measurement is scoped to
the extension the walk collects and stays armed for the first tracked `.ts`
under a dot-directory, which is the case the comment describes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PbJ5Cy9KDAzeQHo8bsMadG
@github-actions github-actions Bot added the size/m label Sep 6, 2026
@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

⚠️ 2 changed file(s) yielded no anchor (packages/create-objectstack/src/templates/blank/.github/workflows/ci.yml, packages/create-objectstack/src/templates/blank/README.md), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files. Nothing else in this diff resolved to a documentable surface (no symbol, route or SDK anchor derived from 1 changed package(s)).

What this run could not see
  • 2 changed file(s) yielded no anchor (packages/create-objectstack/src/templates/blank/.github/workflows/ci.yml, packages/create-objectstack/src/templates/blank/README.md) — pages documenting those are invisible to this run
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 1 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json c02f70e13ca16dfcb07b61dcc14c0825c8229578packageMentionDocs.

@github-actions github-actions Bot added dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation tests tooling labels Sep 6, 2026
…runs it

The workflow shipped `validate` and `typecheck` only, because the template
declared no `lint` script and a step naming one would have failed on the first
push of every scaffolded project. Adding the script is inside this package, so
the omission rested on an untested option rather than on a measurement.

Measured instead: scaffolded a project for real from the repo-built scaffolder,
installed its dependencies from the registry (@objectstack/cli 17.3.0), wired
`"lint": "objectstack lint"` and ran it — exit 0, "All checks passed". So the
script and the step both land.

`lint` is not a second spelling of `validate`. `validate.ts` and `lint.ts` share
the authoring-rule engine, but `checkHookBodyLowering` is imported by `lint.ts`
alone — so without this step a scaffolded project has no way to reach the
hook-body lowering rule, one of the two defects this card was filed about.

The derived-step test now requires `lint` alongside `validate` and `typecheck`,
and still derives the ALLOWED set from the template's own package.json, so the
step list and the script list cannot drift apart in either direction.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PbJ5Cy9KDAzeQHo8bsMadG
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

2 participants