Skip to content

Commit 4998efa

Browse files
claude[bot]claude
andauthored
feat(create-objectstack): ship a CI workflow in the blank template so a scaffolded app has gates from its first push (#16364)
* feat(create-objectstack): ship a CI workflow in the blank template 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 * fix(docs-audit): scope the dot-directory prune premise to the files the 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 * feat(create-objectstack): the blank template declares `lint`, and CI 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 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 0374bcb commit 4998efa

9 files changed

Lines changed: 357 additions & 15 deletions

File tree

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
---
2+
"create-objectstack": minor
3+
---
4+
5+
Scaffolded projects now ship a CI workflow, and a `lint` script for it to run.
6+
The blank template carries `.github/workflows/ci.yml` — one job, on `push` and
7+
`pull_request`: checkout, pnpm, Node 22, `pnpm install --frozen-lockfile`, then
8+
`pnpm validate`, `pnpm lint` and `pnpm typecheck`.
9+
10+
`lint` is new to the template (`objectstack lint`). It is not a second spelling
11+
of `validate`: the two share the authoring-rule engine but only `lint` runs the
12+
hook-body lowering check, which catches handlers that have silently stopped
13+
lowering to metadata-only bodies — a change of deployment shape produced by a
14+
refactor that looks like tidying.
15+
16+
The scaffolder already created `.github/` at runtime for a single file
17+
(`copilot-instructions.md`) while the template's gates shipped as npm scripts
18+
nothing ever ran, so a fresh project started with no CI at all — and ObjectStack
19+
metadata mistakes fail silently at runtime, which makes `objectstack validate`
20+
the only place they surface early. That gate is now unskippable for a human and
21+
for an AI agent authoring metadata in the project, instead of advisory.
22+
23+
Existing projects are unaffected; copy the file from a fresh scaffold to adopt
24+
it.

packages/create-objectstack/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,8 @@
3434
"@types/node": "^26.2.0",
3535
"tsup": "^8.5.1",
3636
"typescript": "^6.0.3",
37-
"vitest": "^4.1.10"
37+
"vitest": "^4.1.10",
38+
"yaml": "^2.9.0"
3839
},
3940
"repository": {
4041
"type": "git",
Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
// Copyright (c) 2026 ObjectStack contributors. Apache-2.0 license.
2+
//
3+
// The bundled template's own CI workflow (#16330).
4+
//
5+
// The scaffolder already creates a `.github/` directory at runtime — for one
6+
// file, `copilot-instructions.md` — while the template's gates (`validate`,
7+
// `typecheck`) shipped as npm scripts nothing ever ran. A scaffolded project
8+
// therefore started with zero CI, and the product claim that metadata mistakes
9+
// surface at authoring time rested entirely on a human remembering to type the
10+
// command. `.github/workflows/ci.yml` is the fix; this file is what keeps it
11+
// honest.
12+
//
13+
// Three properties, each of which failed silently before it was pinned:
14+
//
15+
// 1. The file is real YAML. A workflow GitHub cannot parse is not reported as
16+
// a broken workflow to the user who just scaffolded — it is reported as no
17+
// CI at all, which is indistinguishable from the defect being fixed here.
18+
// 2. Every `pnpm <script>` step names a script the template's own
19+
// package.json declares. This is not hypothetical: the template shipped no
20+
// `lint` script while the card asked for a `pnpm lint` step, and that step
21+
// would have failed on the first push of every scaffolded project with
22+
// `Command "lint" not found`. The template now declares `lint` — measured
23+
// green against a real scaffold before the step was added — and this pin
24+
// is what keeps the step list and the script list from drifting apart
25+
// again, in either direction.
26+
// 3. `.github/` survives the copy. It is the first dot-DIRECTORY the template
27+
// has ever carried, and dotfiles have been a packaging problem here before
28+
// (`_gitignore`; see TEMPLATE_FILE_ALIASES). The tarball half of that
29+
// question is answered by the packing ratchet in
30+
// `template-consistency.test.ts`, which packs for real; this file covers
31+
// the scaffold-copy half.
32+
//
33+
// On the YAML dependency: the sibling `scaffold-e2e-boot-probe.test.ts`
34+
// deliberately hand-parses a workflow instead of importing a parser, because it
35+
// needs a `run:` block's bytes verbatim and a parser would normalise a
36+
// malformed file away. Here the parse IS the assertion, so that reasoning
37+
// inverts — and `yaml` is a devDependency, which never reaches the published
38+
// tarball (`files` ships `dist` alone).
39+
40+
import { describe, it, expect } from 'vitest';
41+
import fs from 'node:fs';
42+
import os from 'node:os';
43+
import path from 'node:path';
44+
import { fileURLToPath } from 'node:url';
45+
import { parse as parseYaml } from 'yaml';
46+
import { copyDir } from './template-copy.js';
47+
48+
const HERE = path.dirname(fileURLToPath(import.meta.url));
49+
const pkgRoot = path.resolve(HERE, '..');
50+
const blankDir = path.join(pkgRoot, 'src', 'templates', 'blank');
51+
52+
/** Where the workflow lives in the template, and where it must land in a scaffold. */
53+
const WORKFLOW_REL = '.github/workflows/ci.yml';
54+
const workflowPath = path.join(blankDir, ...WORKFLOW_REL.split('/'));
55+
56+
interface WorkflowStep {
57+
name?: string;
58+
uses?: string;
59+
run?: string;
60+
with?: Record<string, unknown>;
61+
}
62+
63+
const readWorkflow = (): Record<string, any> =>
64+
parseYaml(fs.readFileSync(workflowPath, 'utf8')) as Record<string, any>;
65+
66+
/**
67+
* The `on:` block.
68+
*
69+
* Read through a fallback because `on` is a YAML **1.1** boolean literal: a
70+
* parser on that schema returns the trigger block under the key `true`, not
71+
* `"on"`. This package parses with `yaml`, which defaults to the 1.2 core
72+
* schema and keeps the string — the fallback is here so a schema change
73+
* downgrades to a still-correct read instead of an assertion about `undefined`.
74+
*/
75+
const triggersOf = (doc: Record<string, any>): unknown =>
76+
doc.on ?? doc[true as unknown as string];
77+
78+
const stepsOf = (doc: Record<string, any>): WorkflowStep[] =>
79+
Object.values(doc.jobs as Record<string, { steps?: WorkflowStep[] }>).flatMap(
80+
(job) => job.steps ?? [],
81+
);
82+
83+
describe('bundled template CI workflow', () => {
84+
it('ships a workflow at .github/workflows/ci.yml', () => {
85+
expect(
86+
fs.existsSync(workflowPath),
87+
`the blank template must carry ${WORKFLOW_REL} — without it every scaffolded ` +
88+
'project starts with no CI and its validate/typecheck scripts are advisory',
89+
).toBe(true);
90+
});
91+
92+
it('parses as YAML and declares at least one job with steps', () => {
93+
const doc = readWorkflow();
94+
expect(typeof doc, 'the workflow did not parse to a mapping').toBe('object');
95+
expect(doc.name).toBeTruthy();
96+
97+
const jobs = doc.jobs as Record<string, { steps?: unknown[] }>;
98+
expect(Object.keys(jobs).length, 'the workflow declares no jobs').toBeGreaterThan(0);
99+
for (const [id, job] of Object.entries(jobs)) {
100+
expect(Array.isArray(job.steps), `job "${id}" declares no steps`).toBe(true);
101+
expect(job.steps!.length, `job "${id}" has an empty step list`).toBeGreaterThan(0);
102+
}
103+
});
104+
105+
it('runs on push and on pull_request', () => {
106+
const triggers = triggersOf(readWorkflow());
107+
const names = Array.isArray(triggers)
108+
? triggers.map(String)
109+
: Object.keys(triggers as Record<string, unknown>);
110+
expect(names).toContain('push');
111+
expect(names).toContain('pull_request');
112+
});
113+
114+
// The load-bearing one. A workflow step naming a script the project does not
115+
// declare fails with `Command "<script>" not found` on the first push — a
116+
// scaffold whose CI is red out of the box teaches the user to ignore CI,
117+
// which is worse than shipping none. Derived from the template's real
118+
// package.json rather than restated, so adding a step for a script that does
119+
// not exist (or deleting a script a step runs) reds here.
120+
it('runs only package.json scripts the template actually declares', () => {
121+
const templatePkg = JSON.parse(
122+
fs.readFileSync(path.join(blankDir, 'package.json'), 'utf8'),
123+
) as { scripts: Record<string, string> };
124+
125+
const invoked: string[] = [];
126+
for (const step of stepsOf(readWorkflow())) {
127+
if (!step.run) continue;
128+
for (const line of step.run.split('\n')) {
129+
// `pnpm <word>` where <word> is not a pnpm builtin is a script run.
130+
const m = /^\s*pnpm(?:\s+run)?\s+([a-z][a-z0-9:_-]*)/i.exec(line);
131+
if (!m) continue;
132+
const word = m[1];
133+
if (word === 'install' || word === 'exec' || word === 'dlx') continue;
134+
invoked.push(word);
135+
}
136+
}
137+
138+
expect(invoked.length, 'the workflow runs no project script at all').toBeGreaterThan(0);
139+
for (const script of invoked) {
140+
expect(
141+
Object.keys(templatePkg.scripts),
142+
`${WORKFLOW_REL} runs \`pnpm ${script}\`, but the blank template's package.json ` +
143+
'declares no such script — the step would fail on the first push of every ' +
144+
'scaffolded project. Add the script to the template (and to the other ' +
145+
'scaffolder, packages/cli/src/commands/init.ts) or drop the step.',
146+
).toContain(script);
147+
}
148+
149+
// The three gates this workflow exists to run. `lint` is here on a
150+
// MEASUREMENT, not on the card's wording: scaffolded for real from the
151+
// repo-built scaffolder, `npm install` against the registry, then
152+
// `npm run lint` -> exit 0, "All checks passed". It is not a second
153+
// spelling of `validate` either — `validate.ts` and `lint.ts` share the
154+
// authoring-rule engine but only `lint.ts` imports `checkHookBodyLowering`,
155+
// so dropping this step drops that rule from every scaffolded project.
156+
expect(invoked).toContain('validate');
157+
expect(invoked).toContain('lint');
158+
expect(invoked).toContain('typecheck');
159+
});
160+
161+
// Derived from the Dockerfile's build stage rather than restated: the
162+
// template states its Node floor there (its `engines` block carries only a
163+
// pnpm floor), so these are the same declaration and must not drift.
164+
it('pins the same Node major the template Dockerfile builds on', () => {
165+
const dockerfile = fs.readFileSync(path.join(blankDir, 'Dockerfile'), 'utf8');
166+
const fromNode = /^FROM\s+node:(\d+)[-\s]/m.exec(dockerfile);
167+
expect(fromNode, 'the template Dockerfile no longer builds on a node: base image').toBeTruthy();
168+
169+
const setupNode = stepsOf(readWorkflow()).find((s) => s.uses?.startsWith('actions/setup-node@'));
170+
expect(setupNode, 'the workflow has no actions/setup-node step').toBeTruthy();
171+
expect(
172+
String(setupNode!.with!['node-version']),
173+
"the workflow's Node pin and the Dockerfile's build image are one declaration",
174+
).toBe(fromNode![1]);
175+
});
176+
177+
// pnpm must be on PATH before setup-node runs, because `cache: pnpm` makes
178+
// setup-node shell out to pnpm to locate the store. Getting the order wrong
179+
// does not degrade — it kills the job in the setup step.
180+
it('acquires pnpm before the setup-node step that caches through it', () => {
181+
const steps = stepsOf(readWorkflow());
182+
const pnpmAt = steps.findIndex((s) => s.uses?.startsWith('pnpm/action-setup@'));
183+
const nodeAt = steps.findIndex((s) => s.uses?.startsWith('actions/setup-node@'));
184+
expect(pnpmAt, 'the workflow never acquires pnpm').toBeGreaterThanOrEqual(0);
185+
expect(nodeAt).toBeGreaterThanOrEqual(0);
186+
if (String(steps[nodeAt].with?.cache ?? '') === 'pnpm') {
187+
expect(
188+
pnpmAt,
189+
'setup-node with `cache: pnpm` shells out to pnpm; acquiring pnpm after it ' +
190+
'fails the job with "Unable to locate executable file: pnpm"',
191+
).toBeLessThan(nodeAt);
192+
}
193+
});
194+
195+
it('pins every action to a version tag', () => {
196+
for (const step of stepsOf(readWorkflow())) {
197+
if (!step.uses) continue;
198+
expect(step.uses, `unpinned action reference: ${step.uses}`).toMatch(/@v\d+/);
199+
}
200+
});
201+
202+
// The dot-DIRECTORY half of the packaging question. `.github/` is the first
203+
// one this template has carried; `copyDir` is what materialises a scaffold,
204+
// so this is the real copy, not a re-implementation of it. The tarball half
205+
// — whether `npm pack` strips the directory — is answered by the packing
206+
// ratchet in template-consistency.test.ts, which packs for real.
207+
it('lands in a scaffold under its real dot-directory name', () => {
208+
const out = fs.mkdtempSync(path.join(os.tmpdir(), 'create-objectstack-ci-'));
209+
try {
210+
const collected: string[] = [];
211+
copyDir(blankDir, out, collected);
212+
213+
const landed = path.join(out, ...WORKFLOW_REL.split('/'));
214+
expect(fs.existsSync(landed), `${WORKFLOW_REL} did not survive the scaffold copy`).toBe(true);
215+
expect(collected).toContain(WORKFLOW_REL);
216+
expect(fs.readFileSync(landed, 'utf8')).toBe(fs.readFileSync(workflowPath, 'utf8'));
217+
} finally {
218+
fs.rmSync(out, { recursive: true, force: true });
219+
}
220+
});
221+
});

packages/create-objectstack/src/template-consistency.test.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -365,6 +365,17 @@ describe('templates survive npm packing', () => {
365365
expect(rules).toContain('.env');
366366
});
367367

368+
// The first dot-DIRECTORY the template has ever carried (#16330). The set
369+
// comparison above already covers it, but it names nothing: a strip of
370+
// `.github` would read there as "some file went missing". Naming the path
371+
// literally, the way the .dockerignore case below does, is what makes the
372+
// answer to "do nested dot-directories survive `npm pack`?" readable.
373+
it('carries the .github workflow directory through the tarball and the scaffold', () => {
374+
expect(packed).toContain('blank/.github/workflows/ci.yml');
375+
expect(scaffolded).toContain('.github/workflows/ci.yml');
376+
expect(TEMPLATE_FILE_ALIASES.has('.github')).toBe(false);
377+
});
378+
368379
it('leaves a literal template dotfile that packs fine alone', () => {
369380
// .dockerignore is NOT stripped — verified against the published 15.1.1
370381
// tarball, which ships it while .gitignore is absent. It stays literal, so
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
# Continuous integration for this ObjectStack app.
2+
#
3+
# ObjectStack metadata fails SILENTLY at runtime, not at edit time: a mistyped
4+
# permission grant or a bare field name in an action predicate raises nothing,
5+
# it just behaves wrongly for whoever hits it first. `objectstack validate` is
6+
# where that class surfaces — and running it is something a human, or an AI
7+
# agent authoring metadata in this project, has to remember. This workflow is
8+
# what makes it unskippable. AGENTS.md carries the authoring conventions it
9+
# enforces.
10+
#
11+
# One job, one file: a starting point, not a CI framework. Grow it with the
12+
# project — add a step when you add a script, a second job when you deploy.
13+
14+
name: CI
15+
16+
on: [push, pull_request]
17+
18+
# Read-only. Nothing here writes to the repository or publishes anything.
19+
permissions:
20+
contents: read
21+
22+
jobs:
23+
verify:
24+
name: Validate
25+
runs-on: ubuntu-latest
26+
timeout-minutes: 15
27+
steps:
28+
- uses: actions/checkout@v7
29+
30+
# pnpm BEFORE setup-node, deliberately. `cache: pnpm` below makes
31+
# setup-node shell out to pnpm to locate the store, so pnpm has to be on
32+
# PATH by then. Reversed, this does not degrade — the job dies in the
33+
# setup step with "Unable to locate executable file: pnpm".
34+
#
35+
# `version` is explicit because this project declares no `packageManager`
36+
# field — pinning one would make the project pnpm-only, and a
37+
# corepack-driven npm or yarn then refuses to run in it — so
38+
# pnpm/action-setup has nothing to resolve from. Keep this in step with
39+
# the `engines.pnpm` floor in package.json.
40+
- uses: pnpm/action-setup@v6
41+
with:
42+
version: 10
43+
44+
- uses: actions/setup-node@v7
45+
with:
46+
node-version: '22'
47+
cache: pnpm
48+
49+
# `--frozen-lockfile` installs exactly what the lockfile records and fails
50+
# when the two disagree, so `pnpm-lock.yaml` has to be committed.
51+
# Scaffolding wrote it for you unless you passed `--skip-install`; in that
52+
# case run `pnpm install` once and commit the result.
53+
- run: pnpm install --frozen-lockfile
54+
55+
# The gate this file exists for: schema, CEL predicates and widget
56+
# bindings. The same checks `pnpm build` runs, without producing an
57+
# artifact.
58+
- run: pnpm validate
59+
60+
# Not a second spelling of `validate`. The two share the authoring-rule
61+
# engine but not the rule set — the hook-body lowering check, which
62+
# catches handlers that silently stop lowering to metadata-only bodies
63+
# (a change of deployment shape, from a refactor that looks like
64+
# tidying), runs here and nowhere else.
65+
- run: pnpm lint
66+
67+
- run: pnpm typecheck

packages/create-objectstack/src/templates/blank/README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,8 @@ After editing any metadata, run:
100100

101101
```bash
102102
pnpm validate # schema + CEL predicates + widget bindings (no artifact)
103+
pnpm lint # authoring rules validate does not run — e.g. a handler that
104+
# stopped lowering to a metadata-only body
103105
pnpm typecheck # TypeScript types against @objectstack/spec
104106
```
105107

packages/create-objectstack/src/templates/blank/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
"start": "objectstack start",
1212
"build": "objectstack build",
1313
"validate": "objectstack validate",
14+
"lint": "objectstack lint",
1415
"typecheck": "tsc --noEmit"
1516
},
1617
"dependencies": {

pnpm-lock.yaml

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)