|
| 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 | +}); |
0 commit comments