@heroiclands/package-build 25.6.2 → 25.7.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.
Files changed (55) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/README.md +19 -1
  3. package/bin/content-commands.mjs +64 -79
  4. package/bin/package-build.mjs +25 -10
  5. package/ci/wip-workflows.test.mjs +252 -0
  6. package/content-config.mjs +118 -8
  7. package/docs/api.md +237 -47
  8. package/docs/commands.md +99 -35
  9. package/docs/configuration.md +93 -44
  10. package/docs/guides/release-and-deploy.md +26 -14
  11. package/docs/guides/site.md +53 -0
  12. package/engine/cdn-publish.mjs +164 -6
  13. package/engine/content-index.mjs +71 -19
  14. package/engine/content-spans.mjs +18 -1
  15. package/engine/content-styles.mjs +85 -0
  16. package/engine/derived-sections.mjs +4 -3
  17. package/engine/event-views.mjs +6 -4
  18. package/engine/helpers.mjs +33 -9
  19. package/engine/index.mjs +1 -0
  20. package/engine/note-cache.mjs +1107 -0
  21. package/engine/note-page.mjs +424 -0
  22. package/engine/pack-config.mjs +47 -4
  23. package/engine/pathnames.mjs +22 -7
  24. package/engine/prepared-only.mjs +32 -0
  25. package/engine/preview-index.mjs +55 -13
  26. package/engine/site-build.mjs +34 -448
  27. package/engine/site-config.mjs +33 -4
  28. package/engine/site-maps.mjs +16 -24
  29. package/engine/site-origins.mjs +23 -0
  30. package/engine/site-page.mjs +648 -0
  31. package/engine/site-render.mjs +142 -10
  32. package/engine/site-serve.mjs +1 -1
  33. package/engine/sql-tables.mjs +12 -3
  34. package/package.json +6 -1
  35. package/site-theme/static/css/style.css +14 -0
  36. package/types/content-config.d.mts +34 -0
  37. package/types/engine/cdn-publish.d.mts +22 -0
  38. package/types/engine/content-index.d.mts +36 -2
  39. package/types/engine/content-styles.d.mts +20 -0
  40. package/types/engine/derived-sections.d.mts +3 -3
  41. package/types/engine/event-views.d.mts +4 -3
  42. package/types/engine/helpers.d.mts +18 -1
  43. package/types/engine/index.d.mts +1 -0
  44. package/types/engine/note-cache.d.mts +149 -0
  45. package/types/engine/note-page.d.mts +92 -0
  46. package/types/engine/pack-config.d.mts +30 -0
  47. package/types/engine/prepared-only.d.mts +14 -0
  48. package/types/engine/preview-index.d.mts +35 -0
  49. package/types/engine/site-build.d.mts +5 -100
  50. package/types/engine/site-config.d.mts +3 -2
  51. package/types/engine/site-maps.d.mts +7 -11
  52. package/types/engine/site-origins.d.mts +9 -0
  53. package/types/engine/site-page.d.mts +261 -0
  54. package/types/engine/site-render.d.mts +20 -1
  55. package/types/engine/sql-tables.d.mts +8 -4
package/CHANGELOG.md CHANGED
@@ -1,5 +1,28 @@
1
1
  # @heroiclands/package-build
2
2
 
3
+ ## 25.7.0
4
+
5
+ **Rendering and sites**
6
+
7
+ - Preview a saved note with its website body, theme styles and findings, without building the whole site; unchanged inputs are cached for later previews.
8
+ - Content indexing and builds that use it are faster on large content trees.
9
+ - Root sites can generate matching menus and published navigation for the selected address, validating canonical roster data before replacing previous output.
10
+
11
+ **Builds and commands**
12
+
13
+ - An explicit root selects matching website and image addresses; without it, configured image hosts stay in use.
14
+ - CDN publication defaults to internal atlas storage; choose `--dest cloudflare` for the public bucket.
15
+
16
+ **Upgrading**
17
+
18
+ - Pass `--root heroiclands.org` to public builds; website builds now default to `internal.heroiclands.org`.
19
+
20
+ ## 25.6.3
21
+
22
+ **Rendering and sites**
23
+
24
+ - Build a preview at another address with `site build --base-url` and serve its images from another asset root with `--assets`. Pages, links, script fonts and search use the preview prefix, while the package's authored configuration stays unchanged.
25
+
3
26
  ## 25.6.2
4
27
 
5
28
  **Languages and scripts**
package/README.md CHANGED
@@ -20,6 +20,13 @@ Build indexes, calendars, staged assets and packs together with `package-build c
20
20
 
21
21
  Run selected content operations together with `package-build content run index lint links compile`. The [shared content command](docs/commands.md#package-build-content-run-targets) captures one source tree for the selected checks and outputs; standalone commands remain available.
22
22
 
23
+ Render one note to the body HTML its site page carries with
24
+ `@heroiclands/package-build/note-page`: `renderNoteFile(path, { root })` reads a
25
+ saved note of a repository against its content tree and fetched dependency
26
+ indexes, and returns `{ html, styles, findings, cache }`. What it derives is
27
+ kept in `build/cache/note-page/` and reused while its inputs are unchanged, so a
28
+ second preview is fast; see [`./note-page`](docs/api.md#note-page).
29
+
23
30
  Language annotations keep authored spelling: `[Zaron]{lang=vyl}` stays Roman.
24
31
  Add `.nativefont` with an explicit `lang=` to use native script defaults;
25
32
  see [writing in invented scripts](docs/guides/writing-in-invented-scripts.md).
@@ -87,6 +94,7 @@ The documentation lives in the Markdown files in this repository. Start with [th
87
94
  | Invented languages and scripts | [Write in invented scripts](docs/guides/writing-in-invented-scripts.md) |
88
95
  | Encoding rules and the conversion core | [Encoding core](docs/encoding-core.md) |
89
96
  | Buffer-only preview and Gitea rendering | [Source preview](docs/source-preview.md) |
97
+ | One note as its site page's body | [`./note-page`](docs/api.md#note-page) |
90
98
  | Every CLI command | [Commands](docs/commands.md) |
91
99
  | Every build setting | [Configuration](docs/configuration.md) |
92
100
  | Programmatic exports | [API](docs/api.md) |
@@ -97,7 +105,7 @@ Place notes may declare a governing affiliation with `data.government` (`Address
97
105
 
98
106
  Every icon and image is covered by a provenance record stating `attribution`, `license` and `ai`. A Foundry package ships only the pictures whose record says `ai: false` under a licence on the shipping list, staged flat at the package root as `icons/<basename>` and `images/<basename>` and listed in its asset set, `asset-sets/default.json`; a reference to a picture it leaves out draws the package's `missing` stand-in, and every compiled reference states its asset's address. See [assets](docs/authoring/assets.md#what-the-foundry-package-ships) and [what a package ships](docs/reference/format-details.md#what-a-package-ships).
99
107
 
100
- `package-build cdn publish --set <file> --name <display name>` uploads every icon and image the package has, shipped or not, to `<site.assets>/<contentPackage>/` in that flat layout, and writes the named set listing the pictures the package leaves out. It reads the bucket from `CDN_ENDPOINT`, `CDN_BUCKET`, `CDN_ACCESS_KEY_ID` and `CDN_SECRET_ACCESS_KEY`, in the environment or `.env.local`. See [publish pictures to the CDN](docs/guides/release-and-deploy.md#publish-pictures-to-the-cdn).
108
+ `package-build cdn publish [--dest internal|cloudflare] --set <file> --name <display name>` manually uploads every icon and image, shipped or left out, and writes a named set listing the left-out pictures. The default publishes to atlas at `https://cdn.internal.heroiclands.org/<contentPackage>/` using SSH and rsync. `--dest cloudflare` uses the existing R2 bucket at `site.assets` and its `CDN_ENDPOINT`, `CDN_BUCKET`, `CDN_ACCESS_KEY_ID` and `CDN_SECRET_ACCESS_KEY` environment settings. Site builds never publish pictures. See [publish pictures to the CDN](docs/guides/release-and-deploy.md#publish-pictures-to-the-cdn).
101
109
 
102
110
  Leading captions, fenced divs, inline spans, and verse quotations are described in [links and markup](docs/authoring/links-and-markup.md).
103
111
 
@@ -107,3 +115,13 @@ installed browser with `package-build e2e fast -- --browser electron` (also
107
115
  available for `run` and `open`). See [browser selection and cache settings](docs/guides/e2e.md#choose-a-browser).
108
116
 
109
117
  The package is GPL-3.0-or-later for code and CC-BY-SA-4.0 for original content. See [LICENSE.md](LICENSE.md).
118
+
119
+ ## Pull request checks
120
+
121
+ A pull request whose title starts with `WIP:` runs the light checks. An existing
122
+ required check runs a short guard and fails, blocking merges while the expensive
123
+ steps stay skipped. Remove the prefix with `earl pr ready`; it starts fresh
124
+ required checks before scheduling a merge. Changing the title by hand does not
125
+ start those checks. Main, release and manual runs keep their existing checks.
126
+
127
+ Check the workflow gating locally with `node --test ci/wip-workflows.test.mjs`.
@@ -46,7 +46,12 @@ import log from "loglevel";
46
46
  import prefix from "loglevel-plugin-prefix";
47
47
  import { readStdin } from "../engine/read-stdin.mjs";
48
48
  import { compilePacks, cleanPacks, unpackPacks } from "../engine/compendiums.mjs";
49
- import { compilesFoundryDocuments, publishesContentPages, siteBase } from "../content-config.mjs";
49
+ import {
50
+ compilesFoundryDocuments,
51
+ publishesContentPages,
52
+ siteBase,
53
+ siteBuildConfig,
54
+ } from "../content-config.mjs";
50
55
  import { loadPackConfig, packConfigPath } from "../engine/pack-config.mjs";
51
56
  import { buildPdf, listBookAssets } from "../engine/pdf-build.mjs";
52
57
  import { summaryLines } from "../engine/pdf-coverage.mjs";
@@ -115,7 +120,7 @@ import {
115
120
  } from "../engine/encoding-lexicon.mjs";
116
121
  import { checkFormatting, checkPrettierConventions, lintMarkdown } from "../engine/prose-lint.mjs";
117
122
  import { lintProse, scoreProseTree } from "../engine/readability-lint.mjs";
118
- import { loadPackageBuildConfig } from "../config.mjs";
123
+ import { checkHomepage, loadPackageBuildConfig } from "../config.mjs";
119
124
  import {
120
125
  emitContentIndex,
121
126
  prepareContentIndex,
@@ -128,6 +133,11 @@ import {
128
133
  siteBuildFailed,
129
134
  formatUnaddressableFinding as formatUnaddressable,
130
135
  } from "../engine/site-build.mjs";
136
+ import {
137
+ addressDiagnostic,
138
+ frontmatterLinkDiagnostic,
139
+ pageDiagnostics,
140
+ } from "../engine/site-page.mjs";
131
141
  import { buildPackageSite, siteDirectory } from "../engine/site-render.mjs";
132
142
  import { serveSite } from "../engine/site-serve.mjs";
133
143
  import { writeSiteRoot } from "../engine/site-root.mjs";
@@ -520,7 +530,7 @@ function withIndexPreflight(command, shouldCheck = () => true) {
520
530
  handler: async (argv) => {
521
531
  let config;
522
532
  try {
523
- config = loadPackConfig();
533
+ config = argv[CONTENT_CONFIG] ?? loadPackConfig();
524
534
  if (shouldCheck(config, argv)) cachedMetadataIndexes(config);
525
535
  } catch (err) {
526
536
  if (config) {
@@ -2167,23 +2177,8 @@ function reportSiteBuild(result) {
2167
2177
  // or two competing for it, has nothing to say about its pages
2168
2178
  // yet.
2169
2179
  for (const f of gates.homepages) emitDiagnostic(f);
2170
- for (const f of gates.frontmatterLinks) {
2171
- emitDiagnostic({
2172
- file: f.file,
2173
- severity: "error",
2174
- message:
2175
- `wikilink ${f.link} authored in frontmatter at ` +
2176
- `${f.path} — frontmatter is data, is copied to the ` +
2177
- `page verbatim, and reaches the reader as brackets`,
2178
- });
2179
- }
2180
- for (const f of gates.addressErrors) {
2181
- emitDiagnostic({
2182
- file: f.file,
2183
- severity: "error",
2184
- message: `cannot derive a URL: ${f.reason}`,
2185
- });
2186
- }
2180
+ for (const f of gates.frontmatterLinks) emitDiagnostic(frontmatterLinkDiagnostic(f));
2181
+ for (const f of gates.addressErrors) emitDiagnostic(addressDiagnostic(f));
2187
2182
  reportSiteFindings(gates.sections ?? []);
2188
2183
  for (const s of gates.staleManifests) {
2189
2184
  emitDiagnostic({
@@ -2203,58 +2198,7 @@ function reportSiteBuild(result) {
2203
2198
  // would make a single bad table hide every other problem in the tree.
2204
2199
  // Each is `file:line:column: error: message`, path first — the shape
2205
2200
  // the pack build reports the same findings in.
2206
- for (const e of result.tableErrors) {
2207
- emitDiagnostic({
2208
- file: e.source,
2209
- line: typeof e.line === "number" ? e.line + 1 : undefined,
2210
- column: e.column,
2211
- severity: "error",
2212
- message: e.reason,
2213
- });
2214
- }
2215
- for (const e of result.expressionErrors) {
2216
- emitDiagnostic(e);
2217
- }
2218
- for (const e of result.secretErrors) {
2219
- emitDiagnostic({ ...e, severity: "error" });
2220
- }
2221
- for (const e of result.captionErrors) {
2222
- emitDiagnostic({ ...e, severity: "error" });
2223
- }
2224
- for (const e of result.headingErrors) {
2225
- emitDiagnostic({ ...e, severity: "error" });
2226
- }
2227
- for (const e of result.footnoteErrors) {
2228
- emitDiagnostic({ ...e, severity: "error" });
2229
- }
2230
- for (const e of result.embedErrors) {
2231
- emitDiagnostic({ ...e, severity: "error" });
2232
- }
2233
- // Worded by the shared table the pack build words the same finding
2234
- // with, and located by searching the note for the link.
2235
- for (const e of result.wikiErrors) {
2236
- emitDiagnostic({
2237
- file: e.file,
2238
- ...positionOfLiteral(readRawNote(e.file), e.link, e.occurrence),
2239
- severity: "error",
2240
- // An embed's directive speaks the image vocabulary
2241
- // rather than the link one, and carries its own
2242
- // sentence; everything else is worded by the shared
2243
- // table.
2244
- message: e.message ?? linkFindingMessage(e),
2245
- });
2246
- }
2247
- // An image whose pathname the site cannot resolve, located the
2248
- // way a wikilink finding is: by searching the note for the
2249
- // literal the resolver was handed.
2250
- for (const e of result.imageErrors) {
2251
- emitDiagnostic({
2252
- file: e.file,
2253
- ...positionOfLiteral(readRawNote(e.file), e.src, e.occurrence),
2254
- severity: "error",
2255
- message: e.message,
2256
- });
2257
- }
2201
+ for (const d of pageDiagnostics(result, readRawNote)) emitDiagnostic(d);
2258
2202
  if (siteBuildFailed(result)) return false;
2259
2203
 
2260
2204
  // Relation warnings do not fail a site build.
@@ -2279,9 +2223,11 @@ function siteCommand() {
2279
2223
  builder: (yargs) =>
2280
2224
  yargs
2281
2225
  .command(
2282
- withIndexPreflight(
2283
- withBodyPreflight(siteBuildCommand()),
2284
- publishesContentPages,
2226
+ withSiteBuildConfig(
2227
+ withIndexPreflight(
2228
+ withBodyPreflight(siteBuildCommand()),
2229
+ publishesContentPages,
2230
+ ),
2285
2231
  ),
2286
2232
  )
2287
2233
  .command(
@@ -2294,6 +2240,29 @@ function siteCommand() {
2294
2240
  };
2295
2241
  }
2296
2242
 
2243
+ /** Apply runtime site settings before the index and body preflights. */
2244
+ function withSiteBuildConfig(command) {
2245
+ const handler = command.handler;
2246
+ return {
2247
+ ...command,
2248
+ handler: async (argv) => {
2249
+ try {
2250
+ const authored = argv[CONTENT_CONFIG] ?? loadPackConfig();
2251
+ checkHomepage(authored.homepage, authored.contentPackage, authored.site.base);
2252
+ const config = siteBuildConfig(authored, {
2253
+ root: argv.root,
2254
+ baseUrl: argv.baseUrl,
2255
+ assets: argv.assets,
2256
+ });
2257
+ return await handler({ ...argv, [CONTENT_CONFIG]: config });
2258
+ } catch (err) {
2259
+ reportFailure(err);
2260
+ process.exitCode = 1;
2261
+ }
2262
+ },
2263
+ };
2264
+ }
2265
+
2297
2266
  /**
2298
2267
  * Report what the layouts, or the build's section gate, found about a site.
2299
2268
  *
@@ -2342,10 +2311,26 @@ function siteBuildCommand() {
2342
2311
  command: "build",
2343
2312
  describe: "Render the content tree as a website and index it for search",
2344
2313
  builder: (yargs) =>
2345
- yargs.option("out", {
2346
- type: "string",
2347
- describe: `The directory that is deployed (default: ${DEPLOY_ROOT})`,
2348
- }),
2314
+ yargs
2315
+ .option("out", {
2316
+ type: "string",
2317
+ describe: `The directory that is deployed (default: ${DEPLOY_ROOT})`,
2318
+ })
2319
+ .option("root", {
2320
+ type: "string",
2321
+ requiresArg: true,
2322
+ describe: "Domain for www and CDN origins (default: internal.heroiclands.org)",
2323
+ })
2324
+ .option("base-url", {
2325
+ type: "string",
2326
+ requiresArg: true,
2327
+ describe: "Absolute HTTP(S) site URL for this build, ending in /",
2328
+ })
2329
+ .option("assets", {
2330
+ type: "string",
2331
+ requiresArg: true,
2332
+ describe: "Absolute HTTP(S) asset root for this build",
2333
+ }),
2349
2334
  handler: async (argv) => {
2350
2335
  try {
2351
2336
  const config = argv[CONTENT_CONFIG] ?? loadPackConfig();
@@ -61,7 +61,7 @@
61
61
  * npx package-build bundle check
62
62
  * npx package-build release
63
63
  * npx package-build deploy <stage>
64
- * npx package-build cdn publish --set <file> --name <display name> [--dry-run]
64
+ * npx package-build cdn publish [--dest internal|cloudflare] --set <file> --name <display name> [--dry-run]
65
65
  * npx package-build container <stage> <action>
66
66
  * npx package-build e2e <seed|run|open|fast|sweep>
67
67
  *
@@ -96,6 +96,8 @@ import { loadPackConfig, packConfigPath } from "../engine/pack-config.mjs";
96
96
  import { cleanBuildArtifacts, resetStage } from "../stage.mjs";
97
97
  import {
98
98
  CDN_ENV,
99
+ INTERNAL_CDN_HOST,
100
+ internalStore,
99
101
  cdnCredentials,
100
102
  noteArtFindings,
101
103
  noteArtReferences,
@@ -1417,11 +1419,9 @@ function deployCommand() {
1417
1419
  * `cdn publish` — upload every picture the package has to the CDN, and the art
1418
1420
  * the package leaves out as a named asset set.
1419
1421
  *
1420
- * The one writer to `<site.assets>/<package>/`. It is run by hand or by a
1421
- * dispatched workflow, never by a build or a release. The store is named by
1422
- * the `CDN_*` variables in `CDN_ENV`, read from the environment or
1423
- * `.env.local`; a dry run with none of them set compares against the public
1424
- * CDN instead.
1422
+ * Run manually, separate from builds and releases. Internal uses rsync to atlas
1423
+ * and public HTTPS reads. Cloudflare uses the existing `CDN_*` R2 credentials;
1424
+ * without those credentials its dry run compares against `site.assets`.
1425
1425
  *
1426
1426
  * @returns {object} The yargs command module.
1427
1427
  */
@@ -1431,6 +1431,12 @@ function cdnPublishCommand() {
1431
1431
  describe: "Upload every picture to the CDN and write a named asset set",
1432
1432
  builder: (y) =>
1433
1433
  y
1434
+ .option("dest", {
1435
+ type: "string",
1436
+ choices: ["internal", "cloudflare"],
1437
+ default: "internal",
1438
+ describe: "CDN destination: atlas or Cloudflare R2",
1439
+ })
1434
1440
  .option("set", {
1435
1441
  type: "string",
1436
1442
  demandOption: true,
@@ -1455,15 +1461,18 @@ function cdnPublishCommand() {
1455
1461
  if (setProblem) die(setProblem);
1456
1462
  const name = String(args.name ?? "").trim();
1457
1463
  if (!name) die("`--name` is blank; a set needs a display name");
1458
- const host = String(packConfig.site?.assets ?? "").trim();
1464
+ const host =
1465
+ args.dest === "internal" ?
1466
+ INTERNAL_CDN_HOST
1467
+ : String(packConfig.site?.assets ?? "").trim();
1459
1468
  if (!host) {
1460
1469
  die(
1461
1470
  "`site.assets` is not declared, and it is the CDN root every picture " +
1462
1471
  "is published under",
1463
1472
  );
1464
1473
  }
1465
- const credentials = cdnCredentials(process.env);
1466
- if (!credentials && !args.dryRun) {
1474
+ const credentials = args.dest === "cloudflare" ? cdnCredentials(process.env) : null;
1475
+ if (args.dest === "cloudflare" && !credentials && !args.dryRun) {
1467
1476
  die(
1468
1477
  `no CDN store is configured: set ${Object.values(CDN_ENV).join(", ")} ` +
1469
1478
  "in the environment or `.env.local`",
@@ -1501,7 +1510,13 @@ function cdnPublishCommand() {
1501
1510
  records: plan.records,
1502
1511
  });
1503
1512
 
1504
- const store = credentials ? s3Store(credentials) : publicStore(host);
1513
+ const store =
1514
+ args.dest === "internal" ?
1515
+ args.dryRun ?
1516
+ publicStore(host)
1517
+ : internalStore({ destination: process.env.CDN_INTERNAL_DESTINATION })
1518
+ : credentials ? s3Store(credentials)
1519
+ : publicStore(host);
1505
1520
  console.log(
1506
1521
  `${args.dryRun ? "Dry run: comparing" : "Publishing"} ${plan.uploads.length} ` +
1507
1522
  `pictures against ${store.describe}.`,
@@ -0,0 +1,252 @@
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
+ import assert from "node:assert/strict";
14
+ import fs from "node:fs";
15
+ import path from "node:path";
16
+ import { createRequire } from "node:module";
17
+ import { spawnSync } from "node:child_process";
18
+ import { test } from "node:test";
19
+
20
+ const yaml = createRequire(import.meta.url)("yaml");
21
+ const spec = {
22
+ file: "build.yml",
23
+ gated: ["test-build"],
24
+ light: "light",
25
+ runs: [
26
+ "npm run format:check",
27
+ "npm run lint:markdown",
28
+ "npm run lint:docs-links",
29
+ "npm run lint:content-format",
30
+ "node bin/package-build.mjs changelog check",
31
+ ],
32
+ dependency: "light",
33
+ test: "ci/wip-workflows.test.mjs",
34
+ required: [
35
+ "Build and Test / Build & Test (pull_request)",
36
+ "No Attribution / No AI attribution (pull_request)",
37
+ ],
38
+ lightActions: [
39
+ "HeroicLands/.github/actions/githooks",
40
+ "HeroicLands/.github/actions/todos",
41
+ "HeroicLands/.github/actions/no-attribution",
42
+ ],
43
+ };
44
+ const workflows = Object.fromEntries(
45
+ fs
46
+ .readdirSync(".gitea/workflows")
47
+ .filter((name) => name.endsWith(".yml"))
48
+ .map((name) => [
49
+ name,
50
+ yaml.parse(fs.readFileSync(path.join(".gitea/workflows", name), "utf8")),
51
+ ]),
52
+ );
53
+ const workflow = workflows[spec.file];
54
+ const jobs = workflow.jobs;
55
+
56
+ // Evaluate the job and step condition surface used here, including Gitea's
57
+ // implicit success() and case-insensitive startsWith().
58
+ function condition(
59
+ expression,
60
+ {
61
+ title,
62
+ event = "pull_request",
63
+ results = {},
64
+ steps = {},
65
+ success = true,
66
+ cancelled = false,
67
+ } = {},
68
+ ) {
69
+ const source = String(expression ?? "success()").replace(/^\$\{\{\s*|\s*\}\}$/g, "");
70
+ if (!/\b(?:always|cancelled|success|failure)\(/.test(source) && !success) return false;
71
+ const needs = Object.fromEntries(
72
+ Object.entries(results).map(([id, result]) => [id, { result, outputs: { run: "true" } }]),
73
+ );
74
+ return Function(
75
+ "github",
76
+ "needs",
77
+ "steps",
78
+ "startsWith",
79
+ "cancelled",
80
+ "success",
81
+ "failure",
82
+ "always",
83
+ `return (${source});`,
84
+ )(
85
+ { event_name: event, event: { pull_request: { title } } },
86
+ needs,
87
+ steps,
88
+ (value, prefix) =>
89
+ String(value ?? "")
90
+ .toLowerCase()
91
+ .startsWith(prefix.toLowerCase()),
92
+ () => cancelled,
93
+ () => success,
94
+ () => !success && !cancelled,
95
+ () => true,
96
+ );
97
+ }
98
+ function enabled(job, title, event = "pull_request", results = {}, cancelled = false) {
99
+ const dependencies = [job.needs ?? []].flat();
100
+ const all = Object.fromEntries(dependencies.map((id) => [id, results[id] ?? "success"]));
101
+ return condition(job.if, {
102
+ title,
103
+ event,
104
+ results: all,
105
+ cancelled,
106
+ success: Object.values(all).every((result) => result === "success"),
107
+ });
108
+ }
109
+ function shell(run, env = {}) {
110
+ const localEnv = { ...process.env };
111
+ delete localEnv.PR_TITLE;
112
+ delete localEnv.LIGHT_RESULT;
113
+ return spawnSync("bash", ["-e", "-o", "pipefail", "-c", run], { env: { ...localEnv, ...env } })
114
+ .status;
115
+ }
116
+ const requiredJobs = Object.entries(jobs).filter(([id, job]) =>
117
+ spec.required.includes(`${workflow.name} / ${job.name ?? id} (pull_request)`),
118
+ );
119
+
120
+ test("a required merge gate runs and fails on WIP instead of satisfying branch protection", () => {
121
+ const gates = requiredJobs.filter(([, job]) => enabled(job, "WIP: work"));
122
+ const blockers = gates.filter(
123
+ ([, job]) => job.steps?.[0]?.name === "Require a ready pull request",
124
+ );
125
+ assert.ok(
126
+ blockers.length,
127
+ "all required checks were satisfied or skipped without a WIP blocker",
128
+ );
129
+ for (const [, job] of blockers) {
130
+ assert.doesNotMatch(job.if ?? "", /startsWith/);
131
+ const guard = job.steps[0];
132
+ assert.notEqual(job["continue-on-error"], true);
133
+ assert.notEqual(guard["continue-on-error"], true);
134
+ assert.equal(condition(guard.if, { title: "WIP: work" }), true);
135
+ for (const result of ["success", "failure", "cancelled", "skipped"]) {
136
+ const results = Object.fromEntries([job.needs ?? []].flat().map((id) => [id, result]));
137
+ assert.equal(enabled(job, "WIP: work", "pull_request", results), true);
138
+ }
139
+ assert.equal(guard.env.PR_TITLE, "${{ github.event.pull_request.title }}");
140
+ assert.ok(
141
+ !guard.run.includes("${{ github.event.pull_request.title }}"),
142
+ "titles must be passed through env",
143
+ );
144
+ assert.equal(shell(guard.run), 0, "raw local replay");
145
+ for (const title of ["WIP: work", "wip: work", "WIP: $(exit 7)"])
146
+ assert.equal(shell(guard.run, { PR_TITLE: title }), 1);
147
+ assert.equal(shell(guard.run, { PR_TITLE: "Ready $(exit 7)" }), 0);
148
+ }
149
+ });
150
+
151
+ test("every expensive job or step skips WIP while ready and non-PR work remains applicable", () => {
152
+ const heavy = [
153
+ ...spec.gated.map((id) => jobs[id]),
154
+ ...(spec.e2e ? [workflows["e2e-full.yml"].jobs["full-e2e"]] : []),
155
+ ];
156
+ // Also derive the compile and reusable-e2e surface from the workflows.
157
+ for (const current of Object.values(workflows)) {
158
+ if (!Object.hasOwn(current.on ?? {}, "pull_request")) continue;
159
+ for (const job of Object.values(current.jobs ?? {}))
160
+ if (
161
+ ["build", "ubuntu-vm"].includes(job["runs-on"]) ||
162
+ /\/workflows\/e2e-full\.yml@/.test(job.uses ?? "")
163
+ )
164
+ if (!heavy.includes(job)) heavy.push(job);
165
+ }
166
+ for (const job of heavy) {
167
+ assert.ok(job);
168
+ assert.equal(enabled(job, "Ready work"), true, job.name);
169
+ for (const event of ["push", "schedule", "workflow_dispatch"])
170
+ assert.equal(enabled(job, undefined, event), true, `${job.name}: ${event}`);
171
+ if (!enabled(job, "WIP: work")) continue;
172
+ assert.equal(job.steps[0].name, "Require a ready pull request");
173
+ const outcomes = Object.fromEntries(
174
+ job.steps.filter((step) => step.id).map((step) => [step.id, { outcome: "success" }]),
175
+ );
176
+ for (const step of job.steps.slice(1)) {
177
+ assert.match(step.if ?? "", /startsWith.*WIP:/, step.name ?? step.uses);
178
+ assert.equal(
179
+ condition(step.if, { title: "WIP: work", steps: outcomes }),
180
+ false,
181
+ step.name ?? step.uses,
182
+ );
183
+ }
184
+ }
185
+ });
186
+ test("light checks, attribution and changeset review remain independent on WIP", () => {
187
+ const light = Object.values(workflows)
188
+ .flatMap((w) => Object.values(w.jobs ?? {}))
189
+ .filter((job) => !String(job.if ?? "").includes("startsWith"));
190
+ const steps = light.flatMap((job) => job.steps ?? []);
191
+ for (const run of spec.runs)
192
+ assert.ok(
193
+ steps.some((step) => step.run === run || step.run?.includes(`${run}\n`)),
194
+ `missing light command: ${run}`,
195
+ );
196
+ for (const action of spec.lightActions)
197
+ assert.ok(
198
+ steps.some((step) => step.uses?.split("@")[0] === action),
199
+ `missing light action: ${action}`,
200
+ );
201
+ assert.equal(enabled(jobs[spec.light], "WIP: work"), true);
202
+ assert.ok(![jobs[spec.light].needs ?? []].flat().some((id) => spec.gated.includes(id)));
203
+ for (const file of [spec.file, ...(spec.e2e ? ["e2e-full.yml"] : [])])
204
+ assert.ok(!workflows[file].on.pull_request?.types?.includes("edited"), file);
205
+ });
206
+
207
+ test("protected status context names retain their exact spelling", () => {
208
+ const names = Object.values(workflows).flatMap((w) =>
209
+ Object.entries(w.jobs ?? {}).map(
210
+ ([id, job]) => `${w.name} / ${job.name ?? id} (pull_request)`,
211
+ ),
212
+ );
213
+ for (const required of spec.required) assert.ok(names.includes(required), required);
214
+ });
215
+
216
+ if (spec.dependency)
217
+ test("light dependency results fail closed without breaking raw local replay", () => {
218
+ for (const id of spec.gated) {
219
+ const job = jobs[id];
220
+ assert.ok([job.needs ?? []].flat().includes(spec.dependency));
221
+ const check = job.steps.find((step) => step.name === "Require light checks");
222
+ assert.ok(check, id);
223
+ assert.equal(shell(check.run), 0);
224
+ assert.equal(check.env?.LIGHT_RESULT, `\${{ needs.${spec.dependency}.result }}`);
225
+ for (const result of ["success", "failure", "cancelled", "skipped", ""]) {
226
+ assert.equal(
227
+ enabled(job, "Ready", "pull_request", { [spec.dependency]: result }),
228
+ true,
229
+ );
230
+ assert.equal(
231
+ shell(check.run, { LIGHT_RESULT: result }),
232
+ result === "success" ? 0 : 1,
233
+ );
234
+ const outcomes = {
235
+ ready: { outcome: "success" },
236
+ light_checks: { outcome: result === "success" ? "success" : "failure" },
237
+ };
238
+ for (const step of job.steps.filter(
239
+ (entry) =>
240
+ !["Require a ready pull request", "Require light checks"].includes(
241
+ entry.name,
242
+ ),
243
+ ))
244
+ assert.equal(
245
+ condition(step.if, { title: "Ready", steps: outcomes }),
246
+ result === "success",
247
+ step.name ?? step.uses,
248
+ );
249
+ }
250
+ assert.equal(enabled(job, "Ready", "pull_request", {}, true), false);
251
+ }
252
+ });