@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 +153 -0
- package/CONTENT.md +2 -2
- package/ci/ci-docker.mjs +236 -0
- package/ci/ci-steps.mjs +249 -0
- package/content-config.mjs +16 -23
- package/docs/content-format.md +14 -13
- package/engine/base-compiler.mjs +2 -0
- package/engine/folder-notes.mjs +64 -0
- package/engine/foundry-entries.mjs +2 -0
- package/engine/frontmatter-lint.mjs +29 -1
- package/engine/frontmatter.mjs +1 -1
- package/engine/generate.mjs +11 -36
- package/engine/helpers.mjs +1 -119
- package/engine/item-compiler.mjs +7 -9
- package/engine/journals.mjs +6 -12
- package/engine/scenes.mjs +7 -9
- package/githooks/commit-msg +40 -0
- package/githooks/hook-enabled.sh +25 -0
- package/githooks/pre-commit +11 -0
- package/githooks/pre-merge-commit +12 -0
- package/githooks/pre-push +109 -0
- package/githooks/protected-branch.sh +62 -0
- package/hm3/actors.mjs +7 -10
- package/package.json +3 -1
- package/types/content-config.d.mts +0 -7
- package/types/engine/folder-notes.d.mts +39 -0
- package/types/engine/helpers.d.mts +0 -29
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
|
|
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 `
|
|
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.
|
package/ci/ci-docker.mjs
ADDED
|
@@ -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());
|
package/ci/ci-steps.mjs
ADDED
|
@@ -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());
|