@heroiclands/package-build 18.1.0 → 18.2.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,116 @@
1
1
  # @heroiclands/package-build
2
2
 
3
+ ## 18.2.0
4
+
5
+ ### Minor Changes
6
+
7
+ - e55c1c0: **The git hooks ship here now**, and with them a pre-push check that runs a
8
+ repository's own Build & Test workflow in a container before the push leaves.
9
+
10
+ **Why here.** Five repositories carried `.githooks/` with `commit-msg`,
11
+ `pre-commit`, `pre-merge-commit` and `protected-branch.sh` — **twenty copies of
12
+ four byte-identical files**, each free to drift. The `.github` repository cannot
13
+ help: its `actions/*` are fetched by the GitHub _runner_ through `uses:`, and
14
+ nothing on a developer's machine fetches from it. This package is what every
15
+ repository already installs, so it is the only thing that reaches every
16
+ checkout.
17
+
18
+ A consumer points git at the packaged directory once:
19
+
20
+ ```json
21
+ "prepare": "git config core.hooksPath node_modules/@heroiclands/package-build/githooks"
22
+ ```
23
+
24
+ and then carries no hook files at all.
25
+
26
+ **Every hook has its own switch.** They read `hooks.<key>` through one shared
27
+ `hook_enabled` helper, with git's normal precedence — a plain `git config` sets
28
+ one clone, `--global` sets a machine — so they are turned on and off
29
+ individually rather than as a set:
30
+
31
+ | hook | key | default |
32
+ | -------------------------------- | ----------------------- | ------- |
33
+ | `pre-commit`, `pre-merge-commit` | `hooks.protectedBranch` | on |
34
+ | `commit-msg` | `hooks.noAttribution` | on |
35
+ | `pre-push` | `hooks.prePushCi` | **off** |
36
+
37
+ The defaults differ deliberately: a guard that costs nothing is on unless
38
+ refused, while one that runs a container for minutes is off unless asked for.
39
+ `hooks.allowCommitOnMain` is still honoured — it is the name that has always
40
+ meant this, and an existing opt-out must not quietly stop working. The two
41
+ branch hooks share a key because they are one rule: git runs `pre-merge-commit`
42
+ _instead of_ `pre-commit` for a merge, so separate keys would let a `git pull`
43
+ on `main` through a half-disabled guard.
44
+
45
+ **The new hook, and it is off unless you ask for it.** `pre-push` runs the
46
+ steps of the repository's own `.github/workflows/build.yml` and refuses the push
47
+ if they fail — but only where someone has opted in:
48
+
49
+ ```bash
50
+ git config hooks.prePushCi true # this clone, and every worktree of it
51
+ git config --global hooks.prePushCi true # every repository on this machine
52
+ ```
53
+
54
+ Off, it is silent and instant. That is deliberate: the check costs a couple of
55
+ minutes and ships to every repository installing this package, so it must not
56
+ be something a contributor discovers by having their push get slow.
57
+
58
+ Four decisions worth knowing:
59
+
60
+ - **The steps are read, not restated.** `ci/ci-steps.mjs` parses them from the
61
+ workflow, so there is no second copy of the command list to go stale — which
62
+ matters because a stale copy fails _silently_: running four of five steps
63
+ still exits 0. It refuses loudly when it recognises no steps or finds no
64
+ workflow, and names the `uses:` steps it cannot run. It parses rather than
65
+ importing a YAML library because the first step it must run is `npm ci`, so
66
+ anything from `node_modules` is missing exactly when it is needed.
67
+ - **In a container, over `git archive HEAD`.** Timing decides this: the workflow
68
+ begins with `npm ci`, so a host run costs about what the container costs
69
+ (134s cold, measured) and pays it by deleting the working tree's
70
+ `node_modules` each time. The container touches nothing of yours and tests
71
+ only committed content, as GitHub does — closing a dirty environment and a
72
+ case-sensitive filesystem, neither of which a Mac can catch.
73
+ - **`linux/amd64` by default**, matching the runner. Measured on Apple silicon
74
+ with Docker Desktop's Rosetta translation: JS compute 1104/1071/1078 ms native
75
+ against 1098/1062/1093 ms emulated, and `npm ci` 23s either way —
76
+ indistinguishable, so matching the runner is free. `--native` opts out, for a
77
+ machine where that translation is unavailable.
78
+
79
+ - **Docker is not required, even when enabled.** Without it the check reports
80
+ that it could not run, says so loudly, and **lets the push through**. The
81
+ workflow is what enforces this; the hook only saves a round trip, so someone
82
+ without Docker — or without this package installed — must still be able to
83
+ open a pull request. Only a genuine check _failure_ refuses a push.
84
+
85
+ `git push --no-verify` skips it once. A branch delete pushes no commits, so the
86
+ hook stands aside. First enabled run pulls the image (~400MB), once.
87
+
88
+ ## 18.1.1
89
+
90
+ ### Patch Changes
91
+
92
+ - 029cf03: **A homepage note is no longer refused for an `id` it did not author** (#319).
93
+
94
+ `resolveNoteId` fills `fm.id` **in place**, so every downstream reader sees one
95
+ derived value — deliberate, and documented as such. The homepage refusal
96
+ iterated that same object, so a note that authors no `id` was reported with a
97
+ message telling the author to delete a field that is not in the file.
98
+
99
+ It was an **error**, so it failed `lint:addresses`, and `lint` heads the build
100
+ chain — which meant it failed every pull request opened against a repository
101
+ carrying a homepage note, whatever that pull request changed.
102
+
103
+ A refused field must now be one the note actually wrote. The caller already owns
104
+ the raw note text and already positions these findings with it, so it answers
105
+ which keys are declared at the note's own top level; `positionInFrontmatter`'s
106
+ `topLevel` option is the existing helper for exactly that question, so a nested
107
+ `id:` under some other key is not mistaken for the note's own. With no answer
108
+ supplied, every key in `fm` still counts — the previous behaviour, and the right
109
+ one for a caller holding authored frontmatter only.
110
+
111
+ Measured on `sohl`: `lint:addresses` goes from one error to **clean across all
112
+ 1,685 notes**.
113
+
3
114
  ## 18.1.0
4
115
 
5
116
  ### Minor Changes
@@ -0,0 +1,215 @@
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
+ return dir;
114
+ }
115
+
116
+ function main() {
117
+ const argv = process.argv.slice(2);
118
+ // Matching the runner is the default; `--native` opts out. See the module
119
+ // note for why the usual speed objection does not apply here.
120
+ const native = argv.includes("--native");
121
+ const image =
122
+ argv.find((a) => a.startsWith("--image="))?.slice("--image=".length) ?? DEFAULT_IMAGE;
123
+
124
+ // Docker absent, or installed but not running. **Not a failure**: this
125
+ // check is a convenience that saves a round trip, and the thing that
126
+ // actually enforces the workflow is the workflow. A contributor without
127
+ // Docker must still be able to push — blocking them would turn a courtesy
128
+ // into a barrier, for exactly the people who are not its audience.
129
+ //
130
+ // `UNAVAILABLE` rather than success, so the hook can say so loudly instead
131
+ // of passing in silence; a skipped check that looks like a green one is how
132
+ // a guard stops being trusted.
133
+ if (run("docker", ["info"], { stdio: "ignore" }) !== 0) {
134
+ console.error(
135
+ "ci-docker: Docker is not available, so the workflow was NOT checked here.\n" +
136
+ " This is not a failure — GitHub will run it. Install or start Docker\n" +
137
+ " to catch these before pushing.",
138
+ );
139
+ return UNAVAILABLE;
140
+ }
141
+
142
+ // Uncommitted work is invisible to this run *and* to GitHub, which is the
143
+ // point — but say so, because a green run over a stale HEAD proves nothing
144
+ // about what is on your disk.
145
+ const dirty = spawnSync("git", ["status", "--porcelain"], { cwd: ROOT, encoding: "utf8" });
146
+ if (dirty.stdout?.trim()) {
147
+ console.log(
148
+ "ci-docker: NOTE — your working tree has uncommitted changes.\n" +
149
+ " This runs HEAD, exactly as GitHub would; those changes are not tested.",
150
+ );
151
+ }
152
+
153
+ let dir;
154
+ try {
155
+ dir = exportHead();
156
+ } catch (err) {
157
+ console.error(`ci-docker: ${err.message}`);
158
+ return 1;
159
+ }
160
+
161
+ const head = spawnSync("git", ["rev-parse", "--short", "HEAD"], {
162
+ cwd: ROOT,
163
+ encoding: "utf8",
164
+ });
165
+ console.log(
166
+ `ci-docker: running the workflow's steps in ${image} over a clean export of ` +
167
+ `${head.stdout?.trim() ?? "HEAD"} ` +
168
+ `(${native ? "linux/arm64, this machine's own" : "linux/amd64, as the runner"}).`,
169
+ );
170
+
171
+ const status = run("docker", [
172
+ "run",
173
+ "--rm",
174
+ ...(native ? [] : ["--platform", "linux/amd64"]),
175
+ "-v",
176
+ `${dir}:/work`,
177
+ // The step runner comes from this package, not from the export: the
178
+ // repository under test does not carry it, and should not have to.
179
+ "-v",
180
+ `${CI_DIR}:/ci:ro`,
181
+ "-w",
182
+ "/work",
183
+ image,
184
+ "node",
185
+ "/ci/ci-steps.mjs",
186
+ ]);
187
+
188
+ fs.rmSync(dir, { recursive: true, force: true });
189
+ // The runner says 2 when the repository has nothing for it to check; that
190
+ // travels back out unchanged so the hook can let the push through.
191
+ if (status === UNAVAILABLE) {
192
+ console.error("\nci-docker: nothing to check in this repository.");
193
+ return UNAVAILABLE;
194
+ }
195
+ if (status !== 0) {
196
+ console.error("\nci-docker: FAILED — GitHub would report the same.");
197
+ return status;
198
+ }
199
+ console.log("\nci-docker: clean, from a fresh checkout on Linux.");
200
+ return 0;
201
+ }
202
+
203
+ // Compared as *real* paths: a consumer reaches this through
204
+ // `node_modules/@heroiclands/package-build`, which npm may make a symlink, and
205
+ // `import.meta.url` is symlink-resolved while `process.argv[1]` is not. Comparing
206
+ // them raw makes the module exit 0 having done nothing — silently, which is the
207
+ // worst way for a check to fail.
208
+ const invokedDirectly = (() => {
209
+ try {
210
+ return fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url));
211
+ } catch {
212
+ return false;
213
+ }
214
+ })();
215
+ 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());
@@ -890,7 +890,18 @@ export function lintNote(
890
890
  // so the finding must survive the early returns below.
891
891
  findings.push(...checkTags(note, { type }));
892
892
 
893
- for (const { locator, message } of checkHomepageAddressFields(fm)) {
893
+ // A refused field must be one the note *wrote*: `resolveNoteId` fills
894
+ // `fm.id` in place, so the parsed frontmatter carries a derived id the
895
+ // author never typed (#319). The raw text is the only place that
896
+ // distinguishes them, and `positionInFrontmatter` already answers it —
897
+ // `topLevel` so a nested `id:` under some other key is not mistaken for the
898
+ // note's own.
899
+ const authoredAtTopLevel = (key) =>
900
+ positionInFrontmatter(note.raw ?? "", key, undefined, { topLevel: true }).line !==
901
+ undefined;
902
+ for (const { locator, message } of checkHomepageAddressFields(fm, {
903
+ isAuthored: authoredAtTopLevel,
904
+ })) {
894
905
  findings.push({
895
906
  file: note.file,
896
907
  ...at(locator.key, locator.literal),
@@ -192,12 +192,24 @@ export const HOMEPAGE_REFUSED_FIELDS = Object.freeze(
192
192
  * belong to the caller. This mirrors {@link module:engine/retired-fields},
193
193
  * whose retired-field messages are likewise positioned by whoever reports them.
194
194
  *
195
+ * **A refused field must be one the note *wrote*.** `resolveNoteId` fills
196
+ * `fm.id` **in place** so every downstream reader sees one derived value —
197
+ * deliberately, and documented as such — and this ran over the same object, so
198
+ * a homepage that authors no `id` was told to delete one that is not there
199
+ * (#319). Since the caller already owns the raw note text, it also answers
200
+ * which keys the note actually declared; without an answer every key in `fm`
201
+ * counts, which is the old behaviour and right for a caller holding authored
202
+ * frontmatter only.
203
+ *
195
204
  * @param {object|null|undefined} fm - Parsed frontmatter.
205
+ * @param {object} [options] - Options.
206
+ * @param {(key: string) => boolean} [options.isAuthored] - Whether the note
207
+ * declares this key at its own top level. Defaults to "every key in `fm`".
196
208
  * @returns {Array<{field: string, locator: {key: string, literal?: string},
197
209
  * message: string}>} One entry per finding, empty for any note that is not a
198
210
  * homepage and declares nothing wrong.
199
211
  */
200
- export function checkHomepageAddressFields(fm) {
212
+ export function checkHomepageAddressFields(fm, { isAuthored } = {}) {
201
213
  if (!isHomepage(fm)) return [];
202
214
  const out = [];
203
215
 
@@ -218,7 +230,9 @@ export function checkHomepageAddressFields(fm) {
218
230
 
219
231
  for (const key of Object.keys(fm)) {
220
232
  const message = HOMEPAGE_REFUSED_FIELDS.get(key);
221
- if (message) out.push({ field: key, locator: { key }, message });
233
+ if (!message) continue;
234
+ if (isAuthored && !isAuthored(key)) continue;
235
+ out.push({ field: key, locator: { key }, message });
222
236
  }
223
237
  return out;
224
238
  }
@@ -0,0 +1,40 @@
1
+ #!/usr/bin/env sh
2
+ #
3
+ # Reject commit messages that carry AI/assistant attribution.
4
+ #
5
+ # This project does not want Co-Authored-By trailers naming an AI assistant, nor
6
+ # "Generated with Claude Code"-style signatures, in its history. The matching CI
7
+ # guard is the shared HeroicLands/.github/actions/no-attribution action, which
8
+ # this repository's .github/workflows/no-attribution.yml calls. That action holds
9
+ # the pattern for every repository; this hook is the only other copy, because a
10
+ # git hook runs from your checkout and cannot live in an action. Keep them in sync.
11
+ #
12
+ # Installed for everyone via the package.json "prepare" script, which points
13
+ # git at this directory (`git config core.hooksPath .githooks`) on `npm install`.
14
+
15
+ . "$(dirname "$0")/hook-enabled.sh"
16
+
17
+ # On unless refused: it costs nothing, and the No Attribution workflow enforces
18
+ # the same rule on GitHub, so opting out locally only moves the failure later.
19
+ hook_enabled noAttribution true || exit 0
20
+
21
+ msg_file="$1"
22
+
23
+ # Scan only the real message: drop the diff appended by `commit --verbose` (from
24
+ # the scissors comment onward) and strip remaining comment lines.
25
+ content="$(sed '/^#.*>8/,$d' "$msg_file" | grep -v '^#')"
26
+
27
+ # Anchored to the start of a line: real attribution is a trailer / signature at
28
+ # column 0, so prose that merely *mentions* these phrases mid-sentence (e.g. this
29
+ # hook's own docs) does not trip it. The generated-with branch tolerates a
30
+ # leading emoji/space.
31
+ pattern='^[[:space:]]*co-authored-by:.*(claude|anthropic)|^[^[:alpha:]]*generated with .*claude code'
32
+
33
+ if printf '%s\n' "$content" | grep -iEq "$pattern"; then
34
+ echo "commit-msg: AI/assistant attribution is not allowed in commit messages." >&2
35
+ printf '%s\n' "$content" | grep -iEn "$pattern" | sed 's/^/ /' >&2
36
+ echo "Remove the 'Co-Authored-By: …' / 'Generated with Claude Code' line and commit again." >&2
37
+ exit 1
38
+ fi
39
+
40
+ exit 0
@@ -0,0 +1,25 @@
1
+ #!/usr/bin/env sh
2
+ #
3
+ # Whether one hook is switched on, so every hook asks the same way.
4
+ #
5
+ # `hook_enabled <key> <default>` reads `hooks.<key>` with git's normal
6
+ # precedence — a plain `git config` sets it for one clone, `--global` for a
7
+ # machine — and falls back to the hook's own default when unset. Each hook has
8
+ # its own key, so they are turned on and off individually rather than as a set:
9
+ # a repository may well want the branch guard and not the workflow check, or
10
+ # the reverse.
11
+ #
12
+ # Defaults differ by hook, and deliberately: a guard that costs nothing is on
13
+ # unless refused, while one that runs a container for minutes is off unless
14
+ # asked for.
15
+
16
+ hook_enabled() {
17
+ _key="$1"
18
+ _default="$2"
19
+ _value="$(git config --bool "hooks.$_key" 2>/dev/null)"
20
+ if [ -z "$_value" ]; then
21
+ [ "$_default" = "true" ]
22
+ return $?
23
+ fi
24
+ [ "$_value" = "true" ]
25
+ }
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env sh
2
+ #
3
+ # Refuse to commit on a protected branch. See protected-branch.sh for the rule
4
+ # and its opt-outs; pre-merge-commit is this hook's counterpart for merges.
5
+ #
6
+ # Installed for everyone via the package.json "prepare" script, which points git
7
+ # at this directory (`git config core.hooksPath .githooks`) on `npm install`.
8
+
9
+ . "$(dirname "$0")/protected-branch.sh"
10
+
11
+ guard_protected_branch
@@ -0,0 +1,12 @@
1
+ #!/usr/bin/env sh
2
+ #
3
+ # Refuse to create a *merge* commit on a protected branch — the `git pull` on
4
+ # main case, which pre-commit never sees, because git runs this hook instead of
5
+ # that one for a merge. See protected-branch.sh for the rule and its opt-outs.
6
+ #
7
+ # Installed for everyone via the package.json "prepare" script, which points git
8
+ # at this directory (`git config core.hooksPath .githooks`) on `npm install`.
9
+
10
+ . "$(dirname "$0")/protected-branch.sh"
11
+
12
+ guard_protected_branch
@@ -0,0 +1,109 @@
1
+ #!/usr/bin/env sh
2
+ #
3
+ # Before pushing, run exactly what the Build & Test workflow runs — in a
4
+ # container, over a clean export of HEAD — so a red GitHub check is something
5
+ # you chose rather than something you discover.
6
+ #
7
+ # The steps are not listed here. `utils/ci-steps.mjs` reads them out of
8
+ # `.github/workflows/build.yml`, because a hook holding its own copy of the
9
+ # command list is a second statement of one thing, and the copy is the one that
10
+ # goes stale — silently, since running four of five steps still exits 0.
11
+ #
12
+ # **On push rather than at `gh pr create`**, because a push is where this
13
+ # repository's work reaches GitHub: normally one, just before the pull request.
14
+ # Nothing to remember, no wrapper command, and no entry in package.json — the
15
+ # hook is the whole mechanism.
16
+ #
17
+ # **In a container rather than on this machine**, which is not the obvious
18
+ # choice until you time it. The workflow's first step is `npm ci`, so a local
19
+ # run costs very nearly what the container costs (134s measured, cold) — and it
20
+ # pays that by deleting and reinstalling your working tree's `node_modules`
21
+ # every time. The container runs a `git archive` of HEAD instead: same price,
22
+ # nothing of yours touched, plus the three classes this machine cannot catch —
23
+ # a dirty environment, a case-sensitive filesystem, and the runner's own
24
+ # architecture. It runs `linux/amd64` to match the runner, which costs nothing
25
+ # measurable because Docker Desktop translates it with Rosetta rather than
26
+ # QEMU; `ci/ci-docker.mjs` carries the timings and the `--native` opt-out.
27
+ #
28
+ # This hook ships in `@heroiclands/package-build`. A consuming repository points
29
+ # git at it once, in its package.json "prepare" script:
30
+ #
31
+ # git config core.hooksPath node_modules/@heroiclands/package-build/githooks
32
+ #
33
+ # so the hooks are the package's, not a copy per repository — there were twenty
34
+ # copies of four identical files before this moved.
35
+ #
36
+ # **Off by default.** Turn it on where you want it:
37
+ #
38
+ # git config hooks.prePushCi true this clone
39
+ # git config --global hooks.prePushCi true every repository on this machine
40
+ #
41
+ # **Docker is not required even then.** Without it the check reports that it
42
+ # could not run, says so loudly, and lets the push through — the workflow itself
43
+ # is what enforces this, and a contributor without Docker is still entitled to
44
+ # open a pull request. First run pulls the image (~400MB), once.
45
+ #
46
+ # Skipping it once, when enabled: git push --no-verify
47
+ #
48
+ # Deleting a remote branch pushes no commits, so there is nothing to check and
49
+ # the hook stands aside.
50
+
51
+ # **Off unless asked for.** This runs a container for a couple of minutes, and
52
+ # it ships to every repository that installs this package — so it cannot be
53
+ # something a contributor discovers by having their push get slow. Opting in is
54
+ # a deliberate act by whoever wants the check.
55
+ #
56
+ # Read with git's normal precedence, so `--global` opts a machine in and a plain
57
+ # `git config` opts in one clone (and every worktree of it).
58
+ #
59
+ # Silent when off: a message on every push, to everyone who never asked for
60
+ # this, is noise.
61
+ . "$(dirname "$0")/hook-enabled.sh"
62
+
63
+ hook_enabled prePushCi false || exit 0
64
+
65
+ # Read the ref list on stdin; a delete has an all-zero local sha.
66
+ has_commits=""
67
+ while read -r _local_ref local_sha _remote_ref _remote_sha; do
68
+ case "$local_sha" in
69
+ *[!0]*) has_commits="yes" ;;
70
+ esac
71
+ done
72
+ if [ -z "$has_commits" ]; then
73
+ exit 0
74
+ fi
75
+
76
+ echo "pre-push: running the Build & Test workflow in a container over HEAD."
77
+ echo "pre-push: to skip once, push with --no-verify."
78
+
79
+ # The runner lives beside this hook inside the package. If it is not there,
80
+ # this hook is being used from somewhere else — a global `core.hooksPath`, a
81
+ # copy — in a repository that does not install the package. Nothing to run, and
82
+ # certainly nothing to refuse a push over.
83
+ runner="$(dirname "$0")/../ci/ci-docker.mjs"
84
+ if [ ! -f "$runner" ]; then
85
+ echo "pre-push: no workflow runner alongside this hook; skipping the check."
86
+ exit 0
87
+ fi
88
+
89
+ node "$runner"
90
+ status=$?
91
+
92
+ # 2 means the check could not run — no Docker — which is not the same as a
93
+ # failure and must not stop the push. Whoever is pushing may not be the
94
+ # maintainer, may be on a machine without Docker, and is entitled to open a
95
+ # pull request; GitHub is what enforces the workflow. Said loudly, though: a
96
+ # skipped check that reads like a passed one is how a guard loses its meaning.
97
+ # 127 is "could not execute it at all" — no node on PATH, package not
98
+ # installed. Infrastructure, not a verdict, so it is handled the same way.
99
+ if [ "$status" -eq 2 ] || [ "$status" -eq 127 ]; then
100
+ echo ""
101
+ echo "pre-push: pushing WITHOUT having checked the workflow locally."
102
+ exit 0
103
+ fi
104
+
105
+ if [ "$status" -ne 0 ]; then
106
+ echo ""
107
+ echo "pre-push: refusing to push — GitHub would report the same failure."
108
+ exit 1
109
+ fi
@@ -0,0 +1,62 @@
1
+ #!/usr/bin/env sh
2
+ #
3
+ # Shared guard: refuse to create a commit while HEAD is on a protected branch.
4
+ #
5
+ # Sourced by the pre-commit and pre-merge-commit hooks, which are two hooks for
6
+ # one rule: git runs pre-merge-commit *instead of* pre-commit when the commit is
7
+ # a merge, so a repository that guards only pre-commit still lets a stray
8
+ # `git pull` on main write a merge commit.
9
+ #
10
+ # Why guard at commit time at all: `main` is protected on GitHub in every
11
+ # HeroicLands repository — changes land by pull request, squash-merged, never by
12
+ # a direct push. That protection fires at *push* time, by which point the commit
13
+ # already exists on the local branch and has to be moved off it. This moves the
14
+ # refusal forward to the point where the fix is still just "branch first".
15
+ #
16
+ # It is an accident guard, not a security control. `git commit --no-verify`
17
+ # bypasses it, and a repository that genuinely wants commits on its default
18
+ # branch opts out with `git config hooks.allowCommitOnMain true`.
19
+ #
20
+ # Known gap: `git cherry-pick` and `git revert` run neither hook, and a rebase
21
+ # replays commits with HEAD detached (deliberately allowed below).
22
+ #
23
+ # This file is copied verbatim into every HeroicLands repository, for the same
24
+ # reason the commit-msg hook is: a hook runs from your checkout, so it cannot be
25
+ # shared through a GitHub Action. Keep the copies identical.
26
+
27
+ guard_protected_branch() {
28
+ . "$(dirname "$0")/hook-enabled.sh"
29
+
30
+ # Off where this repository says so. Two spellings: `hooks.protectedBranch`
31
+ # is this hook's key in the per-hook scheme, and `hooks.allowCommitOnMain`
32
+ # is the name that has always meant this — kept, and still honoured, so an
33
+ # existing opt-out does not quietly stop working.
34
+ if ! hook_enabled protectedBranch true; then
35
+ return 0
36
+ fi
37
+ if [ "$(git config --bool hooks.allowCommitOnMain 2>/dev/null)" = "true" ]; then
38
+ return 0
39
+ fi
40
+
41
+ # Detached HEAD — a rebase, a bisect, or an explicit checkout of a commit.
42
+ # There is no branch to protect, and refusing here would break `git rebase`.
43
+ branch="$(git symbolic-ref --quiet --short HEAD)" || return 0
44
+
45
+ case "$branch" in
46
+ main | master) ;;
47
+ *) return 0 ;;
48
+ esac
49
+
50
+ cat >&2 <<EOF
51
+ $(basename "$0"): refusing to commit on the protected branch '$branch'.
52
+
53
+ '$branch' is protected on GitHub, so this commit could never be pushed from
54
+ here. Move it onto a branch first — this keeps everything you have staged:
55
+
56
+ git switch -c <type>/<issue_#>_<slug>
57
+
58
+ To commit here anyway just this once, use 'git commit --no-verify'. To opt this
59
+ repository out permanently, 'git config hooks.allowCommitOnMain true'.
60
+ EOF
61
+ return 1
62
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heroiclands/package-build",
3
- "version": "18.1.0",
3
+ "version": "18.2.0",
4
4
  "description": "Shared toolchain for building and shipping a HeroicLands Foundry VTT package — content compilation, manifest, localization, staging, bundle, release and deployment.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "type": "module",
@@ -105,6 +105,7 @@
105
105
  "README.md",
106
106
  "bin",
107
107
  "bundle.mjs",
108
+ "ci",
108
109
  "config.mjs",
109
110
  "container.mjs",
110
111
  "content-config.mjs",
@@ -113,6 +114,7 @@
113
114
  "deploy.mjs",
114
115
  "e2e.mjs",
115
116
  "engine",
117
+ "githooks",
116
118
  "hm3",
117
119
  "index.mjs",
118
120
  "lang.mjs",
@@ -42,12 +42,26 @@ export function isHomepage(fm: object | null | undefined): boolean;
42
42
  * belong to the caller. This mirrors {@link module:engine/retired-fields},
43
43
  * whose retired-field messages are likewise positioned by whoever reports them.
44
44
  *
45
+ * **A refused field must be one the note *wrote*.** `resolveNoteId` fills
46
+ * `fm.id` **in place** so every downstream reader sees one derived value —
47
+ * deliberately, and documented as such — and this ran over the same object, so
48
+ * a homepage that authors no `id` was told to delete one that is not there
49
+ * (#319). Since the caller already owns the raw note text, it also answers
50
+ * which keys the note actually declared; without an answer every key in `fm`
51
+ * counts, which is the old behaviour and right for a caller holding authored
52
+ * frontmatter only.
53
+ *
45
54
  * @param {object|null|undefined} fm - Parsed frontmatter.
55
+ * @param {object} [options] - Options.
56
+ * @param {(key: string) => boolean} [options.isAuthored] - Whether the note
57
+ * declares this key at its own top level. Defaults to "every key in `fm`".
46
58
  * @returns {Array<{field: string, locator: {key: string, literal?: string},
47
59
  * message: string}>} One entry per finding, empty for any note that is not a
48
60
  * homepage and declares nothing wrong.
49
61
  */
50
- export function checkHomepageAddressFields(fm: object | null | undefined): Array<{
62
+ export function checkHomepageAddressFields(fm: object | null | undefined, { isAuthored }?: {
63
+ isAuthored?: ((key: string) => boolean) | undefined;
64
+ }): Array<{
51
65
  field: string;
52
66
  locator: {
53
67
  key: string;