Skip to content

Commit b2a4d6d

Browse files
committed
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
1 parent 3e270d4 commit b2a4d6d

5 files changed

Lines changed: 287 additions & 1 deletion

File tree

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

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: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
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+
- run: pnpm typecheck

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)