Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .changeset/scaffold-blank-template-ci-workflow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
"create-objectstack": minor
---

Scaffolded projects now ship a CI workflow, and a `lint` script for it to run.
The blank template carries `.github/workflows/ci.yml` — one job, on `push` and
`pull_request`: checkout, pnpm, Node 22, `pnpm install --frozen-lockfile`, then
`pnpm validate`, `pnpm lint` and `pnpm typecheck`.

`lint` is new to the template (`objectstack lint`). It is not a second spelling
of `validate`: the two share the authoring-rule engine but only `lint` runs the
hook-body lowering check, which catches handlers that have silently stopped
lowering to metadata-only bodies — a change of deployment shape produced by a
refactor that looks like tidying.

The scaffolder already created `.github/` at runtime for a single file
(`copilot-instructions.md`) while the template's gates shipped as npm scripts
nothing ever ran, so a fresh project started with no CI at all — and ObjectStack
metadata mistakes fail silently at runtime, which makes `objectstack validate`
the only place they surface early. That gate is now unskippable for a human and
for an AI agent authoring metadata in the project, instead of advisory.

Existing projects are unaffected; copy the file from a fresh scaffold to adopt
it.
3 changes: 2 additions & 1 deletion packages/create-objectstack/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,8 @@
"@types/node": "^26.2.0",
"tsup": "^8.5.1",
"typescript": "^6.0.3",
"vitest": "^4.1.10"
"vitest": "^4.1.10",
"yaml": "^2.9.0"
},
"repository": {
"type": "git",
Expand Down
221 changes: 221 additions & 0 deletions packages/create-objectstack/src/template-ci-workflow.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
// Copyright (c) 2026 ObjectStack contributors. Apache-2.0 license.
//
// The bundled template's own CI workflow (#16330).
//
// The scaffolder already creates a `.github/` directory at runtime — for one
// file, `copilot-instructions.md` — while the template's gates (`validate`,
// `typecheck`) shipped as npm scripts nothing ever ran. A scaffolded project
// therefore started with zero CI, and the product claim that metadata mistakes
// surface at authoring time rested entirely on a human remembering to type the
// command. `.github/workflows/ci.yml` is the fix; this file is what keeps it
// honest.
//
// Three properties, each of which failed silently before it was pinned:
//
// 1. The file is real YAML. A workflow GitHub cannot parse is not reported as
// a broken workflow to the user who just scaffolded — it is reported as no
// CI at all, which is indistinguishable from the defect being fixed here.
// 2. Every `pnpm <script>` step names a script the template's own
// package.json declares. This is not hypothetical: the template shipped no
// `lint` script while the card asked for a `pnpm lint` step, and that step
// would have failed on the first push of every scaffolded project with
// `Command "lint" not found`. The template now declares `lint` — measured
// green against a real scaffold before the step was added — and this pin
// is what keeps the step list and the script list from drifting apart
// again, in either direction.
// 3. `.github/` survives the copy. It is the first dot-DIRECTORY the template
// has ever carried, and dotfiles have been a packaging problem here before
// (`_gitignore`; see TEMPLATE_FILE_ALIASES). The tarball half of that
// question is answered by the packing ratchet in
// `template-consistency.test.ts`, which packs for real; this file covers
// the scaffold-copy half.
//
// On the YAML dependency: the sibling `scaffold-e2e-boot-probe.test.ts`
// deliberately hand-parses a workflow instead of importing a parser, because 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 — and `yaml` is a devDependency, which never reaches the published
// tarball (`files` ships `dist` alone).

import { describe, it, expect } from 'vitest';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { parse as parseYaml } from 'yaml';
import { copyDir } from './template-copy.js';

const HERE = path.dirname(fileURLToPath(import.meta.url));
const pkgRoot = path.resolve(HERE, '..');
const blankDir = path.join(pkgRoot, 'src', 'templates', 'blank');

/** Where the workflow lives in the template, and where it must land in a scaffold. */
const WORKFLOW_REL = '.github/workflows/ci.yml';
const workflowPath = path.join(blankDir, ...WORKFLOW_REL.split('/'));

interface WorkflowStep {
name?: string;
uses?: string;
run?: string;
with?: Record<string, unknown>;
}

const readWorkflow = (): Record<string, any> =>
parseYaml(fs.readFileSync(workflowPath, 'utf8')) as Record<string, any>;

/**
* The `on:` block.
*
* Read through a fallback because `on` is a YAML **1.1** boolean literal: a
* parser on that schema returns the trigger block under the key `true`, not
* `"on"`. This package parses with `yaml`, which defaults to the 1.2 core
* schema and keeps the string — the fallback is here so a schema change
* downgrades to a still-correct read instead of an assertion about `undefined`.
*/
const triggersOf = (doc: Record<string, any>): unknown =>
doc.on ?? doc[true as unknown as string];

const stepsOf = (doc: Record<string, any>): WorkflowStep[] =>
Object.values(doc.jobs as Record<string, { steps?: WorkflowStep[] }>).flatMap(
(job) => job.steps ?? [],
);

describe('bundled template CI workflow', () => {
it('ships a workflow at .github/workflows/ci.yml', () => {
expect(
fs.existsSync(workflowPath),
`the blank template must carry ${WORKFLOW_REL} — without it every scaffolded ` +
'project starts with no CI and its validate/typecheck scripts are advisory',
).toBe(true);
});

it('parses as YAML and declares at least one job with steps', () => {
const doc = readWorkflow();
expect(typeof doc, 'the workflow did not parse to a mapping').toBe('object');
expect(doc.name).toBeTruthy();

const jobs = doc.jobs as Record<string, { steps?: unknown[] }>;
expect(Object.keys(jobs).length, 'the workflow declares no jobs').toBeGreaterThan(0);
for (const [id, job] of Object.entries(jobs)) {
expect(Array.isArray(job.steps), `job "${id}" declares no steps`).toBe(true);
expect(job.steps!.length, `job "${id}" has an empty step list`).toBeGreaterThan(0);
}
});

it('runs on push and on pull_request', () => {
const triggers = triggersOf(readWorkflow());
const names = Array.isArray(triggers)
? triggers.map(String)
: Object.keys(triggers as Record<string, unknown>);
expect(names).toContain('push');
expect(names).toContain('pull_request');
});

// The load-bearing one. A workflow step naming a script the project does not
// declare fails with `Command "<script>" not found` on the first push — a
// scaffold whose CI is red out of the box teaches the user to ignore CI,
// which is worse than shipping none. Derived from the template's real
// package.json rather than restated, so adding a step for a script that does
// not exist (or deleting a script a step runs) reds here.
it('runs only package.json scripts the template actually declares', () => {
const templatePkg = JSON.parse(
fs.readFileSync(path.join(blankDir, 'package.json'), 'utf8'),
) as { scripts: Record<string, string> };

const invoked: string[] = [];
for (const step of stepsOf(readWorkflow())) {
if (!step.run) continue;
for (const line of step.run.split('\n')) {
// `pnpm <word>` where <word> is not a pnpm builtin is a script run.
const m = /^\s*pnpm(?:\s+run)?\s+([a-z][a-z0-9:_-]*)/i.exec(line);
if (!m) continue;
const word = m[1];
if (word === 'install' || word === 'exec' || word === 'dlx') continue;
invoked.push(word);
}
}

expect(invoked.length, 'the workflow runs no project script at all').toBeGreaterThan(0);
for (const script of invoked) {
expect(
Object.keys(templatePkg.scripts),
`${WORKFLOW_REL} runs \`pnpm ${script}\`, but the blank template's package.json ` +
'declares no such script — the step would fail on the first push of every ' +
'scaffolded project. Add the script to the template (and to the other ' +
'scaffolder, packages/cli/src/commands/init.ts) or drop the step.',
).toContain(script);
}

// The three gates this workflow exists to run. `lint` is here on a
// MEASUREMENT, not on the card's wording: scaffolded for real from the
// repo-built scaffolder, `npm install` against the registry, then
// `npm run lint` -> exit 0, "All checks passed". It is not a second
// spelling of `validate` either — `validate.ts` and `lint.ts` share the
// authoring-rule engine but only `lint.ts` imports `checkHookBodyLowering`,
// so dropping this step drops that rule from every scaffolded project.
expect(invoked).toContain('validate');
expect(invoked).toContain('lint');
expect(invoked).toContain('typecheck');
});

// Derived from the Dockerfile's build stage rather than restated: the
// template states its Node floor there (its `engines` block carries only a
// pnpm floor), so these are the same declaration and must not drift.
it('pins the same Node major the template Dockerfile builds on', () => {
const dockerfile = fs.readFileSync(path.join(blankDir, 'Dockerfile'), 'utf8');
const fromNode = /^FROM\s+node:(\d+)[-\s]/m.exec(dockerfile);
expect(fromNode, 'the template Dockerfile no longer builds on a node: base image').toBeTruthy();

const setupNode = stepsOf(readWorkflow()).find((s) => s.uses?.startsWith('actions/setup-node@'));
expect(setupNode, 'the workflow has no actions/setup-node step').toBeTruthy();
expect(
String(setupNode!.with!['node-version']),
"the workflow's Node pin and the Dockerfile's build image are one declaration",
).toBe(fromNode![1]);
});

// pnpm must be on PATH before setup-node runs, because `cache: pnpm` makes
// setup-node shell out to pnpm to locate the store. Getting the order wrong
// does not degrade — it kills the job in the setup step.
it('acquires pnpm before the setup-node step that caches through it', () => {
const steps = stepsOf(readWorkflow());
const pnpmAt = steps.findIndex((s) => s.uses?.startsWith('pnpm/action-setup@'));
const nodeAt = steps.findIndex((s) => s.uses?.startsWith('actions/setup-node@'));
expect(pnpmAt, 'the workflow never acquires pnpm').toBeGreaterThanOrEqual(0);
expect(nodeAt).toBeGreaterThanOrEqual(0);
if (String(steps[nodeAt].with?.cache ?? '') === 'pnpm') {
expect(
pnpmAt,
'setup-node with `cache: pnpm` shells out to pnpm; acquiring pnpm after it ' +
'fails the job with "Unable to locate executable file: pnpm"',
).toBeLessThan(nodeAt);
}
});

it('pins every action to a version tag', () => {
for (const step of stepsOf(readWorkflow())) {
if (!step.uses) continue;
expect(step.uses, `unpinned action reference: ${step.uses}`).toMatch(/@v\d+/);
}
});

// The dot-DIRECTORY half of the packaging question. `.github/` is the first
// one this template has carried; `copyDir` is what materialises a scaffold,
// so this is the real copy, not a re-implementation of it. The tarball half
// — whether `npm pack` strips the directory — is answered by the packing
// ratchet in template-consistency.test.ts, which packs for real.
it('lands in a scaffold under its real dot-directory name', () => {
const out = fs.mkdtempSync(path.join(os.tmpdir(), 'create-objectstack-ci-'));
try {
const collected: string[] = [];
copyDir(blankDir, out, collected);

const landed = path.join(out, ...WORKFLOW_REL.split('/'));
expect(fs.existsSync(landed), `${WORKFLOW_REL} did not survive the scaffold copy`).toBe(true);
expect(collected).toContain(WORKFLOW_REL);
expect(fs.readFileSync(landed, 'utf8')).toBe(fs.readFileSync(workflowPath, 'utf8'));
} finally {
fs.rmSync(out, { recursive: true, force: true });
}
});
});
11 changes: 11 additions & 0 deletions packages/create-objectstack/src/template-consistency.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -365,6 +365,17 @@ describe('templates survive npm packing', () => {
expect(rules).toContain('.env');
});

// The first dot-DIRECTORY the template has ever carried (#16330). The set
// comparison above already covers it, but it names nothing: a strip of
// `.github` would read there as "some file went missing". Naming the path
// literally, the way the .dockerignore case below does, is what makes the
// answer to "do nested dot-directories survive `npm pack`?" readable.
it('carries the .github workflow directory through the tarball and the scaffold', () => {
expect(packed).toContain('blank/.github/workflows/ci.yml');
expect(scaffolded).toContain('.github/workflows/ci.yml');
expect(TEMPLATE_FILE_ALIASES.has('.github')).toBe(false);
});

it('leaves a literal template dotfile that packs fine alone', () => {
// .dockerignore is NOT stripped — verified against the published 15.1.1
// tarball, which ships it while .gitignore is absent. It stays literal, so
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# Continuous integration for this ObjectStack app.
#
# ObjectStack metadata fails SILENTLY at runtime, not at edit time: a mistyped
# permission grant or a bare field name in an action predicate raises nothing,
# it just behaves wrongly for whoever hits it first. `objectstack validate` is
# where that class surfaces — and running it is something a human, or an AI
# agent authoring metadata in this project, has to remember. This workflow is
# what makes it unskippable. AGENTS.md carries the authoring conventions it
# enforces.
#
# One job, one file: a starting point, not a CI framework. Grow it with the
# project — add a step when you add a script, a second job when you deploy.

name: CI

on: [push, pull_request]

# Read-only. Nothing here writes to the repository or publishes anything.
permissions:
contents: read

jobs:
verify:
name: Validate
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v7

# pnpm BEFORE setup-node, deliberately. `cache: pnpm` below makes
# setup-node shell out to pnpm to locate the store, so pnpm has to be on
# PATH by then. Reversed, this does not degrade — the job dies in the
# setup step with "Unable to locate executable file: pnpm".
#
# `version` is explicit because this project declares no `packageManager`
# field — pinning one would make the project pnpm-only, and a
# corepack-driven npm or yarn then refuses to run in it — so
# pnpm/action-setup has nothing to resolve from. Keep this in step with
# the `engines.pnpm` floor in package.json.
- uses: pnpm/action-setup@v6
with:
version: 10

- uses: actions/setup-node@v7
with:
node-version: '22'
cache: pnpm

# `--frozen-lockfile` installs exactly what the lockfile records and fails
# when the two disagree, so `pnpm-lock.yaml` has to be committed.
# Scaffolding wrote it for you unless you passed `--skip-install`; in that
# case run `pnpm install` once and commit the result.
- run: pnpm install --frozen-lockfile

# The gate this file exists for: schema, CEL predicates and widget
# bindings. The same checks `pnpm build` runs, without producing an
# artifact.
- run: pnpm validate

# Not a second spelling of `validate`. The two share the authoring-rule
# engine but not the rule set — the hook-body lowering check, which
# catches handlers that silently stop lowering to metadata-only bodies
# (a change of deployment shape, from a refactor that looks like
# tidying), runs here and nowhere else.
- run: pnpm lint

- run: pnpm typecheck
2 changes: 2 additions & 0 deletions packages/create-objectstack/src/templates/blank/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,8 @@ After editing any metadata, run:

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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
"start": "objectstack start",
"build": "objectstack build",
"validate": "objectstack validate",
"lint": "objectstack lint",
"typecheck": "tsc --noEmit"
},
"dependencies": {
Expand Down
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading