@heroiclands/package-build 18.1.1 → 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 +85 -0
- package/ci/ci-docker.mjs +215 -0
- package/ci/ci-steps.mjs +249 -0
- 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/package.json +3 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,90 @@
|
|
|
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
|
+
|
|
3
88
|
## 18.1.1
|
|
4
89
|
|
|
5
90
|
### Patch Changes
|
package/ci/ci-docker.mjs
ADDED
|
@@ -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());
|
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());
|
|
@@ -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.
|
|
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",
|