@heroiclands/package-build 18.1.1 → 19.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,158 @@
1
1
  # @heroiclands/package-build
2
2
 
3
+ ## 19.0.0
4
+
5
+ ### Major Changes
6
+
7
+ - e46a791: **`folder:` and the `*-folders.yaml` schema are retired** (#260) — the last step
8
+ of #254, once every tree is green on the new spelling.
9
+
10
+ They go together. The `folder:` Foundry-id spelling had nothing left to resolve
11
+ against once the YAML was gone, and the YAML had no reader once the spelling was
12
+ refused. What replaces both landed in #255–#258: a folder is a note
13
+ (`type: folder`), `packFolder:` names one by **address**, the packs a folder
14
+ materialises in are derived from what references it, and its Foundry id is
15
+ hashed from its canonical address.
16
+
17
+ **What is gone**
18
+
19
+ | Removed | Replaced by |
20
+ | ------------------------------------------------------------ | ---------------------------------------------------------------------------- |
21
+ | `*-folders.yaml`, five files per tree | `type: folder` notes, resolved through the address index |
22
+ | `loadFolders`, `buildFolderResolver` and its five invariants | `collectFolderNotes` / `buildFolderNoteIndex` |
23
+ | `writeFolderDocs` | `writeFolderNoteDocs`, written after the pass that learns what it references |
24
+ | The `folders:` pack-configuration key | nothing — a pack materialises what its documents reference |
25
+ | `folder:` on a note | `packFolder:` |
26
+
27
+ **Both are refused, not ignored.** A retired field left ignored reads to its
28
+ author as though it still works: the note says one thing and the build does
29
+ another, and nothing says so. A note that writes `folder:` fails the build
30
+ naming `packFolder` and the line to rewrite, and the frontmatter lint reports
31
+ every one of them in the tree at once — which is what a tree still to sweep
32
+ needs. A pack configuration that still names a `folders:` file is refused the
33
+ same way, saying where the folders went rather than merely "no such key".
34
+
35
+ **Presence is the whole test**, in both positions a note wrote it — top-level
36
+ and inside the `sohl:` block. An empty `folder:` parses as `null` and is still
37
+ the field, and a value that happens to match a folder note's id is still the
38
+ retired spelling. There is no value that makes writing it correct, so each
39
+ message says what to write instead rather than which value to change.
40
+
41
+ **Why this is a major.** A tree that has not swept goes red on adoption, by
42
+ design — that is the signal, and it is the reason the removal waited until the
43
+ support and the sweeps had landed. Removing either half earlier would have
44
+ broken a repository mid-migration, which is the failure the `image:` → `img:`
45
+ migration (#142, #149) was staged to avoid: read both, sweep, then remove.
46
+
47
+ _The adventure-configuration path is untouched here — see #259, deferred._
48
+
49
+ ### Patch Changes
50
+
51
+ - 829363c: **The container's export is a git repository**, so a workflow step that shells
52
+ out to git behaves as it does on the runner.
53
+
54
+ `git archive HEAD` yields a bare directory with no `.git`. A step that asks git
55
+ something — `git ls-files` in a tracked-artifact check, for instance — then
56
+ fails for want of a repository, which reads as _the check failing_ rather than
57
+ as this harness lacking something the runner has. GitHub's own first step is a
58
+ **checkout**, so the faithful export is one too.
59
+
60
+ Found by `HarnMaster-3-FoundryVTT`, whose `check-no-compiled-packs.mjs` lists
61
+ tracked files that way: it passed on the host and failed in the container, and
62
+ the pre-push hook duly refused a push over a defect that was entirely this
63
+ harness's — exactly the false positive that teaches people to reach for
64
+ `--no-verify`.
65
+
66
+ Initialised and staged rather than committed: `git ls-files` reads the index, so
67
+ staging every extracted file reproduces exactly the set the runner would see,
68
+ and nothing needs a configured identity to commit with. Where `git init` cannot
69
+ run, it says so and carries on rather than pretending.
70
+
71
+ ## 18.2.0
72
+
73
+ ### Minor Changes
74
+
75
+ - e55c1c0: **The git hooks ship here now**, and with them a pre-push check that runs a
76
+ repository's own Build & Test workflow in a container before the push leaves.
77
+
78
+ **Why here.** Five repositories carried `.githooks/` with `commit-msg`,
79
+ `pre-commit`, `pre-merge-commit` and `protected-branch.sh` — **twenty copies of
80
+ four byte-identical files**, each free to drift. The `.github` repository cannot
81
+ help: its `actions/*` are fetched by the GitHub _runner_ through `uses:`, and
82
+ nothing on a developer's machine fetches from it. This package is what every
83
+ repository already installs, so it is the only thing that reaches every
84
+ checkout.
85
+
86
+ A consumer points git at the packaged directory once:
87
+
88
+ ```json
89
+ "prepare": "git config core.hooksPath node_modules/@heroiclands/package-build/githooks"
90
+ ```
91
+
92
+ and then carries no hook files at all.
93
+
94
+ **Every hook has its own switch.** They read `hooks.<key>` through one shared
95
+ `hook_enabled` helper, with git's normal precedence — a plain `git config` sets
96
+ one clone, `--global` sets a machine — so they are turned on and off
97
+ individually rather than as a set:
98
+
99
+ | hook | key | default |
100
+ | -------------------------------- | ----------------------- | ------- |
101
+ | `pre-commit`, `pre-merge-commit` | `hooks.protectedBranch` | on |
102
+ | `commit-msg` | `hooks.noAttribution` | on |
103
+ | `pre-push` | `hooks.prePushCi` | **off** |
104
+
105
+ The defaults differ deliberately: a guard that costs nothing is on unless
106
+ refused, while one that runs a container for minutes is off unless asked for.
107
+ `hooks.allowCommitOnMain` is still honoured — it is the name that has always
108
+ meant this, and an existing opt-out must not quietly stop working. The two
109
+ branch hooks share a key because they are one rule: git runs `pre-merge-commit`
110
+ _instead of_ `pre-commit` for a merge, so separate keys would let a `git pull`
111
+ on `main` through a half-disabled guard.
112
+
113
+ **The new hook, and it is off unless you ask for it.** `pre-push` runs the
114
+ steps of the repository's own `.github/workflows/build.yml` and refuses the push
115
+ if they fail — but only where someone has opted in:
116
+
117
+ ```bash
118
+ git config hooks.prePushCi true # this clone, and every worktree of it
119
+ git config --global hooks.prePushCi true # every repository on this machine
120
+ ```
121
+
122
+ Off, it is silent and instant. That is deliberate: the check costs a couple of
123
+ minutes and ships to every repository installing this package, so it must not
124
+ be something a contributor discovers by having their push get slow.
125
+
126
+ Four decisions worth knowing:
127
+
128
+ - **The steps are read, not restated.** `ci/ci-steps.mjs` parses them from the
129
+ workflow, so there is no second copy of the command list to go stale — which
130
+ matters because a stale copy fails _silently_: running four of five steps
131
+ still exits 0. It refuses loudly when it recognises no steps or finds no
132
+ workflow, and names the `uses:` steps it cannot run. It parses rather than
133
+ importing a YAML library because the first step it must run is `npm ci`, so
134
+ anything from `node_modules` is missing exactly when it is needed.
135
+ - **In a container, over `git archive HEAD`.** Timing decides this: the workflow
136
+ begins with `npm ci`, so a host run costs about what the container costs
137
+ (134s cold, measured) and pays it by deleting the working tree's
138
+ `node_modules` each time. The container touches nothing of yours and tests
139
+ only committed content, as GitHub does — closing a dirty environment and a
140
+ case-sensitive filesystem, neither of which a Mac can catch.
141
+ - **`linux/amd64` by default**, matching the runner. Measured on Apple silicon
142
+ with Docker Desktop's Rosetta translation: JS compute 1104/1071/1078 ms native
143
+ against 1098/1062/1093 ms emulated, and `npm ci` 23s either way —
144
+ indistinguishable, so matching the runner is free. `--native` opts out, for a
145
+ machine where that translation is unavailable.
146
+
147
+ - **Docker is not required, even when enabled.** Without it the check reports
148
+ that it could not run, says so loudly, and **lets the push through**. The
149
+ workflow is what enforces this; the hook only saves a round trip, so someone
150
+ without Docker — or without this package installed — must still be able to
151
+ open a pull request. Only a genuine check _failure_ refuses a push.
152
+
153
+ `git push --no-verify` skips it once. A branch delete pushes no commits, so the
154
+ hook stands aside. First enabled run pulls the image (~400MB), once.
155
+
3
156
  ## 18.1.1
4
157
 
5
158
  ### Patch Changes
package/CONTENT.md CHANGED
@@ -85,7 +85,7 @@ paths:
85
85
  # order is worked out separately, from what each pass reads (see "Declaration
86
86
  # order is presentation" below).
87
87
  packs:
88
- - { name: items, type: Item, label: Items, folders: item-folders.yaml }
88
+ - { name: items, type: Item, label: Items }
89
89
  - { name: journals, type: JournalEntry, label: Journals }
90
90
  # A companion is written by its parent's pass rather than one of its own.
91
91
  - name: scenes
@@ -96,7 +96,7 @@ packs:
96
96
  # A pack whose per-document JSON is already built — checked in rather than
97
97
  # generated. `prebuilt` names where it lives, generation is skipped for it,
98
98
  # and `cleanPackEntry` and the Scene/Level integrity check still run. It may
99
- # not carry `folders`, `companions` or `default`, and may not be a companion:
99
+ # not carry `companions` or `default`, and may not be a companion:
100
100
  # each of those describes a generation pass a prebuilt pack does not have.
101
101
  # When every configured pack is prebuilt the content walk is skipped
102
102
  # entirely, so a package with no `assets/content` builds.
@@ -0,0 +1,236 @@
1
+ /*
2
+ * This file is part of the Song of Heroic Lands (SoHL) system for Foundry VTT.
3
+ * Copyright (c) 2024-2026 Tom Rodriguez ("Toasty") — <toasty@heroiclands.org>
4
+ *
5
+ * This work is licensed under the GNU General Public License v3.0 (GPLv3).
6
+ * You may copy, modify, and distribute it under the terms of that license.
7
+ *
8
+ * For full terms, see the LICENSE.md file in the project root or visit:
9
+ * https://www.gnu.org/licenses/gpl-3.0.html
10
+ *
11
+ * SPDX-License-Identifier: GPL-3.0-or-later
12
+ */
13
+
14
+ /**
15
+ * Run the Build & Test steps in a container, from a **clean export of HEAD**.
16
+ *
17
+ * The pre-push hook runs the same steps on this machine, which catches nearly
18
+ * everything. Two things it structurally cannot catch, and this does:
19
+ *
20
+ * 1. **A dirty environment.** The hook runs against your working tree with its
21
+ * `node_modules`, its `build/`, its `.env.local` and whatever a previous run
22
+ * left behind. GitHub starts from a checkout of the commit and nothing else.
23
+ * So this exports `HEAD` with `git archive` — committed content only, no
24
+ * ignored files, no stale artifacts — and runs there.
25
+ * 2. **macOS is case-insensitive; the runner is not.** An import whose case does
26
+ * not match its file resolves here and fails on Linux, and no amount of
27
+ * running the right commands locally will show it.
28
+ *
29
+ * **It runs `linux/amd64`, matching the runner, and that costs nothing.** The
30
+ * expected objection is that this machine is arm64 and emulation is slow. It is
31
+ * not, because Docker Desktop translates `linux/amd64` with **Rosetta** rather
32
+ * than QEMU. Measured here, three runs each:
33
+ *
34
+ * | | arm64 native | amd64 |
35
+ * | --- | --- | --- |
36
+ * | JS compute | 1104 / 1071 / 1078 ms | 1098 / 1062 / 1093 ms |
37
+ * | `npm ci`, 1,134 packages | 23s | 23s |
38
+ *
39
+ * Indistinguishable — so matching the runner is free, and the architecture gap
40
+ * this otherwise carried is simply closed.
41
+ *
42
+ * ⚠️ **That speed is a setting, not a property of the machine.** It depends on
43
+ * Docker Desktop's `UseVirtualizationFrameworkRosetta`; with it off the
44
+ * translation falls back to QEMU and the picture reverses sharply. `--native`
45
+ * runs on the host architecture if that ever becomes the better trade — it is
46
+ * the faster-but-less-faithful option, which is why it is the one you ask for.
47
+ *
48
+ * @module
49
+ */
50
+
51
+ import fs from "node:fs";
52
+ import os from "node:os";
53
+ import path from "node:path";
54
+ import { spawnSync } from "node:child_process";
55
+ import { fileURLToPath } from "node:url";
56
+
57
+ /** This package's `ci/` directory, mounted into the container. */
58
+ const CI_DIR = path.dirname(fileURLToPath(import.meta.url));
59
+
60
+ /**
61
+ * The repository under test — the git work tree the hook fired in, never this
62
+ * package's own.
63
+ *
64
+ * Asked of git rather than assumed from the working directory, so the command
65
+ * behaves the same run from a subdirectory as from the root.
66
+ */
67
+ const ROOT = (() => {
68
+ const top = spawnSync("git", ["rev-parse", "--show-toplevel"], { encoding: "utf8" });
69
+ return (top.stdout ?? "").trim() || process.cwd();
70
+ })();
71
+
72
+ /** The image. Node's own, matching the `node-version` the workflow sets up. */
73
+ const DEFAULT_IMAGE = "node:24-bookworm";
74
+
75
+ /**
76
+ * Exit status meaning *the check could not run*, as distinct from *it failed*.
77
+ *
78
+ * The caller has to tell those apart: one is a reason to refuse a push, the
79
+ * other is a reason to say so and let it through.
80
+ */
81
+ export const UNAVAILABLE = 2;
82
+
83
+ /**
84
+ * Run a command, inheriting stdio, and return whether it succeeded.
85
+ *
86
+ * @param {string} command - The executable.
87
+ * @param {string[]} args - Its arguments.
88
+ * @param {object} [opts] - Passed to `spawnSync`.
89
+ * @returns {number} The exit status.
90
+ */
91
+ function run(command, args, opts = {}) {
92
+ const result = spawnSync(command, args, { stdio: "inherit", ...opts });
93
+ return result.status ?? 1;
94
+ }
95
+
96
+ /**
97
+ * Export `HEAD` to a temporary directory — committed content, nothing else.
98
+ *
99
+ * @returns {string} The directory.
100
+ * @throws {Error} When the export fails.
101
+ */
102
+ function exportHead() {
103
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "sohl-ci-"));
104
+ const archive = spawnSync("git", ["archive", "--format=tar", "HEAD"], {
105
+ cwd: ROOT,
106
+ maxBuffer: 1024 * 1024 * 512,
107
+ });
108
+ if (archive.status !== 0) {
109
+ throw new Error(`git archive HEAD failed: ${String(archive.stderr)}`);
110
+ }
111
+ const untar = spawnSync("tar", ["-x", "-C", dir], { input: archive.stdout });
112
+ if (untar.status !== 0) throw new Error("could not unpack the export");
113
+
114
+ // Make the export a repository. `git archive` yields a bare directory, and
115
+ // a workflow step that shells out to git — `git ls-files` in a
116
+ // tracked-artifact check, say — then fails for want of a `.git`, which
117
+ // reads as the check failing rather than as this harness lacking something
118
+ // the runner has. GitHub's own step is a *checkout*, so the faithful export
119
+ // is one too.
120
+ //
121
+ // Initialised and staged rather than committed: `git ls-files` lists the
122
+ // index, so staging every extracted file reproduces exactly the set the
123
+ // runner would see, without needing an identity configured to commit with.
124
+ const init = spawnSync("git", ["init", "-q"], { cwd: dir });
125
+ if (init.status === 0) {
126
+ spawnSync("git", ["add", "-A"], { cwd: dir });
127
+ } else {
128
+ console.error(
129
+ "ci-docker: NOTE — could not make the export a git repository, so a " +
130
+ "workflow step that shells out to git will fail here in a way it " +
131
+ "would not on the runner.",
132
+ );
133
+ }
134
+ return dir;
135
+ }
136
+
137
+ function main() {
138
+ const argv = process.argv.slice(2);
139
+ // Matching the runner is the default; `--native` opts out. See the module
140
+ // note for why the usual speed objection does not apply here.
141
+ const native = argv.includes("--native");
142
+ const image =
143
+ argv.find((a) => a.startsWith("--image="))?.slice("--image=".length) ?? DEFAULT_IMAGE;
144
+
145
+ // Docker absent, or installed but not running. **Not a failure**: this
146
+ // check is a convenience that saves a round trip, and the thing that
147
+ // actually enforces the workflow is the workflow. A contributor without
148
+ // Docker must still be able to push — blocking them would turn a courtesy
149
+ // into a barrier, for exactly the people who are not its audience.
150
+ //
151
+ // `UNAVAILABLE` rather than success, so the hook can say so loudly instead
152
+ // of passing in silence; a skipped check that looks like a green one is how
153
+ // a guard stops being trusted.
154
+ if (run("docker", ["info"], { stdio: "ignore" }) !== 0) {
155
+ console.error(
156
+ "ci-docker: Docker is not available, so the workflow was NOT checked here.\n" +
157
+ " This is not a failure — GitHub will run it. Install or start Docker\n" +
158
+ " to catch these before pushing.",
159
+ );
160
+ return UNAVAILABLE;
161
+ }
162
+
163
+ // Uncommitted work is invisible to this run *and* to GitHub, which is the
164
+ // point — but say so, because a green run over a stale HEAD proves nothing
165
+ // about what is on your disk.
166
+ const dirty = spawnSync("git", ["status", "--porcelain"], { cwd: ROOT, encoding: "utf8" });
167
+ if (dirty.stdout?.trim()) {
168
+ console.log(
169
+ "ci-docker: NOTE — your working tree has uncommitted changes.\n" +
170
+ " This runs HEAD, exactly as GitHub would; those changes are not tested.",
171
+ );
172
+ }
173
+
174
+ let dir;
175
+ try {
176
+ dir = exportHead();
177
+ } catch (err) {
178
+ console.error(`ci-docker: ${err.message}`);
179
+ return 1;
180
+ }
181
+
182
+ const head = spawnSync("git", ["rev-parse", "--short", "HEAD"], {
183
+ cwd: ROOT,
184
+ encoding: "utf8",
185
+ });
186
+ console.log(
187
+ `ci-docker: running the workflow's steps in ${image} over a clean export of ` +
188
+ `${head.stdout?.trim() ?? "HEAD"} ` +
189
+ `(${native ? "linux/arm64, this machine's own" : "linux/amd64, as the runner"}).`,
190
+ );
191
+
192
+ const status = run("docker", [
193
+ "run",
194
+ "--rm",
195
+ ...(native ? [] : ["--platform", "linux/amd64"]),
196
+ "-v",
197
+ `${dir}:/work`,
198
+ // The step runner comes from this package, not from the export: the
199
+ // repository under test does not carry it, and should not have to.
200
+ "-v",
201
+ `${CI_DIR}:/ci:ro`,
202
+ "-w",
203
+ "/work",
204
+ image,
205
+ "node",
206
+ "/ci/ci-steps.mjs",
207
+ ]);
208
+
209
+ fs.rmSync(dir, { recursive: true, force: true });
210
+ // The runner says 2 when the repository has nothing for it to check; that
211
+ // travels back out unchanged so the hook can let the push through.
212
+ if (status === UNAVAILABLE) {
213
+ console.error("\nci-docker: nothing to check in this repository.");
214
+ return UNAVAILABLE;
215
+ }
216
+ if (status !== 0) {
217
+ console.error("\nci-docker: FAILED — GitHub would report the same.");
218
+ return status;
219
+ }
220
+ console.log("\nci-docker: clean, from a fresh checkout on Linux.");
221
+ return 0;
222
+ }
223
+
224
+ // Compared as *real* paths: a consumer reaches this through
225
+ // `node_modules/@heroiclands/package-build`, which npm may make a symlink, and
226
+ // `import.meta.url` is symlink-resolved while `process.argv[1]` is not. Comparing
227
+ // them raw makes the module exit 0 having done nothing — silently, which is the
228
+ // worst way for a check to fail.
229
+ const invokedDirectly = (() => {
230
+ try {
231
+ return fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url));
232
+ } catch {
233
+ return false;
234
+ }
235
+ })();
236
+ if (invokedDirectly) process.exit(main());
@@ -0,0 +1,249 @@
1
+ /*
2
+ * This file is part of the Song of Heroic Lands (SoHL) system for Foundry VTT.
3
+ * Copyright (c) 2024-2026 Tom Rodriguez ("Toasty") — <toasty@heroiclands.org>
4
+ *
5
+ * This work is licensed under the GNU General Public License v3.0 (GPLv3).
6
+ * You may copy, modify, and distribute it under the terms of that license.
7
+ *
8
+ * For full terms, see the LICENSE.md file in the project root or visit:
9
+ * https://www.gnu.org/licenses/gpl-3.0.html
10
+ *
11
+ * SPDX-License-Identifier: GPL-3.0-or-later
12
+ */
13
+
14
+ /**
15
+ * Run, locally, exactly what the Build & Test workflow runs.
16
+ *
17
+ * **The workflow is the statement; this reads it.** A hook holding its own copy
18
+ * of the command list is a second statement of one thing, and the copy is the
19
+ * one that goes stale — silently, because a pre-push check that runs four of
20
+ * five steps still exits 0. So the steps are parsed out of
21
+ * `.github/workflows/build.yml` at run time and there is nothing to keep in
22
+ * step.
23
+ *
24
+ * **It refuses rather than assumes.** If the workflow's shape changes so that
25
+ * no `run:` steps are found, this fails loudly instead of passing having done
26
+ * nothing. A guard that quietly covers nothing is worse than no guard, because
27
+ * it is trusted.
28
+ *
29
+ * **What it cannot run**, and says so: a `uses:` step is a published action, not
30
+ * a command — the forbidden-marker check, the coverage upload. Those stay
31
+ * GitHub's to run, and the summary names them so the gap is visible rather than
32
+ * assumed away.
33
+ *
34
+ * **It parses the workflow itself rather than importing a YAML library**, for a
35
+ * reason that is easy to miss: the *first* step it has to run is `npm ci`, so
36
+ * anything this script imports from `node_modules` is unavailable exactly when
37
+ * it is needed — on a fresh clone, or in a worktree that has never been
38
+ * installed. The parser is therefore deliberately small, and strict: it reads
39
+ * `run:` scalars and `run: |` blocks by indentation and refuses when it
40
+ * recognises nothing.
41
+ *
42
+ * @module
43
+ */
44
+
45
+ import fs from "node:fs";
46
+ import path from "node:path";
47
+ import { spawnSync } from "node:child_process";
48
+ import { fileURLToPath } from "node:url";
49
+
50
+ /**
51
+ * The repository whose workflow is being run — the working directory, not this
52
+ * file's package.
53
+ *
54
+ * This ships in `@heroiclands/package-build` and runs against whichever
55
+ * repository invoked it: from a hook, git sets the working directory to the
56
+ * repository root; in the container, it is the mounted export. Resolving
57
+ * relative to `import.meta.url` would find the *package's* own tree, which is
58
+ * never the one under test.
59
+ */
60
+ const ROOT = process.cwd();
61
+
62
+ /**
63
+ * Exit status meaning *there was nothing to check*, as distinct from *the check
64
+ * failed*. Mirrors the same constant in `ci-docker.mjs`.
65
+ */
66
+ export const UNAVAILABLE = 2;
67
+ const WORKFLOW_DIR = path.join(ROOT, ".github/workflows");
68
+
69
+ /**
70
+ * The workflow's steps, split into what can be run here and what cannot.
71
+ *
72
+ * @returns {{run: Array<{name: string, run: string}>, skipped: string[]}} The
73
+ * `run:` steps in workflow order, and the names of the `uses:` steps.
74
+ * @throws {Error} When the workflow cannot be read or declares no `run:` step.
75
+ */
76
+ export function ciSteps() {
77
+ const files = prTriggeredWorkflows();
78
+ if (!files.length) {
79
+ throw new NoWorkflow(
80
+ `no workflow under .github/workflows is triggered by \`pull_request\`, ` +
81
+ `so there is nothing here that a pull request would run.`,
82
+ );
83
+ }
84
+ const run = [];
85
+ const skipped = [];
86
+ for (const file of files) {
87
+ const found = stepsIn(file);
88
+ run.push(...found.run);
89
+ skipped.push(...found.skipped);
90
+ }
91
+ if (!run.length) {
92
+ throw new Error(
93
+ `${files.map((f) => path.relative(ROOT, f)).join(", ")} declare no ` +
94
+ `\`run:\` steps — either their shape changed or they are entirely ` +
95
+ `composed of actions. Refusing to report success having run nothing.`,
96
+ );
97
+ }
98
+ return { run, skipped, files };
99
+ }
100
+
101
+ /**
102
+ * Raised when the repository has no workflow a pull request would run.
103
+ *
104
+ * Distinguished from a parse failure because the two deserve opposite answers:
105
+ * a repository that simply has no such workflow is not broken and its pushes
106
+ * must not be refused, while one whose workflow stopped parsing is a defect in
107
+ * this parser that should be loud.
108
+ */
109
+ export class NoWorkflow extends Error {}
110
+
111
+ /**
112
+ * Every workflow a pull request would run.
113
+ *
114
+ * `pull_request` appearing before the first `jobs:` key is the test — it is the
115
+ * trigger block, and these files put it there. Deliberately not a YAML parse:
116
+ * see the module note on why this cannot import a library.
117
+ *
118
+ * @returns {string[]} Absolute paths, in directory order.
119
+ */
120
+ function prTriggeredWorkflows() {
121
+ let names;
122
+ try {
123
+ names = fs.readdirSync(WORKFLOW_DIR).filter((n) => /\.ya?ml$/i.test(n));
124
+ } catch {
125
+ return [];
126
+ }
127
+ return names
128
+ .map((name) => path.join(WORKFLOW_DIR, name))
129
+ .filter((file) => {
130
+ const text = fs.readFileSync(file, "utf8");
131
+ const head = text.split(/^jobs:/m)[0];
132
+ return /^\s*pull_request:?\s*$/m.test(head) || /^on:.*pull_request/m.test(head);
133
+ });
134
+ }
135
+
136
+ /**
137
+ * The `run:` and `uses:` steps of one workflow.
138
+ *
139
+ * @param {string} file - The workflow.
140
+ * @returns {{run: Array<{name: string, run: string}>, skipped: string[]}} Its steps.
141
+ */
142
+ function stepsIn(file) {
143
+ const lines = fs.readFileSync(file, "utf8").split("\n");
144
+ const run = [];
145
+ const skipped = [];
146
+ /** The most recent `- name:` seen, which labels whatever step follows. */
147
+ let name = null;
148
+
149
+ const indentOf = (line) => line.length - line.trimStart().length;
150
+
151
+ for (let i = 0; i < lines.length; i += 1) {
152
+ const line = lines[i];
153
+ const text = line.trim();
154
+ if (text.startsWith("#") || text === "") continue;
155
+
156
+ const named = /^-?\s*name:\s*(.+)$/.exec(text);
157
+ if (named) {
158
+ name = named[1].replace(/^["']|["']$/g, "");
159
+ continue;
160
+ }
161
+ if (/^-?\s*uses:\s*\S/.test(text)) {
162
+ skipped.push(name ?? text.replace(/^-?\s*uses:\s*/, ""));
163
+ name = null;
164
+ continue;
165
+ }
166
+
167
+ const inline = /^-?\s*run:\s*(?!\|)(.+)$/.exec(text);
168
+ if (inline) {
169
+ run.push({ name: name ?? inline[1], run: inline[1].trim() });
170
+ name = null;
171
+ continue;
172
+ }
173
+ // A `run: |` block scalar: every following line indented deeper.
174
+ if (/^-?\s*run:\s*\|\s*$/.test(text)) {
175
+ const base = indentOf(line);
176
+ const body = [];
177
+ while (i + 1 < lines.length) {
178
+ const next = lines[i + 1];
179
+ if (next.trim() !== "" && indentOf(next) <= base) break;
180
+ body.push(next.trim());
181
+ i += 1;
182
+ }
183
+ const command = body.filter(Boolean).join(" && ");
184
+ if (command) run.push({ name: name ?? command, run: command });
185
+ name = null;
186
+ }
187
+ }
188
+ return { run, skipped };
189
+ }
190
+
191
+ /**
192
+ * Run every step, stopping at the first failure.
193
+ *
194
+ * Stopping is deliberate and mirrors the runner: a later step routinely depends
195
+ * on an earlier one having produced something.
196
+ *
197
+ * @returns {number} The exit code to leave with.
198
+ */
199
+ function main() {
200
+ let steps;
201
+ try {
202
+ steps = ciSteps();
203
+ } catch (err) {
204
+ // A repository with no pull-request workflow is not broken, and its
205
+ // pushes must not be refused — it simply has nothing for this to check.
206
+ // Distinguished from a parse failure, which is a defect here and stays
207
+ // loud.
208
+ if (err instanceof NoWorkflow) {
209
+ console.error(`ci-steps: ${err.message}`);
210
+ return UNAVAILABLE;
211
+ }
212
+ console.error(`ci-steps: ${err.message}`);
213
+ return 1;
214
+ }
215
+
216
+ const { run, skipped } = steps;
217
+ console.log(`ci-steps: running ${run.length} step(s) from .github/workflows/build.yml`);
218
+ if (skipped.length) {
219
+ console.log(`ci-steps: not runnable here (published actions): ${skipped.join(", ")}`);
220
+ }
221
+
222
+ for (const [i, step] of run.entries()) {
223
+ console.log(`\nci-steps: [${i + 1}/${run.length}] ${step.name}\n $ ${step.run}`);
224
+ const result = spawnSync(step.run, { cwd: ROOT, shell: true, stdio: "inherit" });
225
+ if (result.status !== 0) {
226
+ console.error(
227
+ `\nci-steps: FAILED at "${step.name}" — this is what GitHub would report.\n` +
228
+ ` Fix it, or push with --no-verify if you mean to.`,
229
+ );
230
+ return result.status ?? 1;
231
+ }
232
+ }
233
+ console.log(`\nci-steps: all ${run.length} step(s) passed.`);
234
+ return 0;
235
+ }
236
+
237
+ // Compared as *real* paths: a consumer reaches this through
238
+ // `node_modules/@heroiclands/package-build`, which npm may make a symlink, and
239
+ // `import.meta.url` is symlink-resolved while `process.argv[1]` is not. Comparing
240
+ // them raw makes the module exit 0 having done nothing — silently, which is the
241
+ // worst way for a check to fail.
242
+ const invokedDirectly = (() => {
243
+ try {
244
+ return fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url));
245
+ } catch {
246
+ return false;
247
+ }
248
+ })();
249
+ if (invokedDirectly) process.exit(main());