@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.
- package/CHANGELOG.md +23 -0
- package/README.md +19 -1
- package/bin/content-commands.mjs +64 -79
- package/bin/package-build.mjs +25 -10
- package/ci/wip-workflows.test.mjs +252 -0
- package/content-config.mjs +118 -8
- package/docs/api.md +237 -47
- package/docs/commands.md +99 -35
- package/docs/configuration.md +93 -44
- package/docs/guides/release-and-deploy.md +26 -14
- package/docs/guides/site.md +53 -0
- package/engine/cdn-publish.mjs +164 -6
- package/engine/content-index.mjs +71 -19
- package/engine/content-spans.mjs +18 -1
- package/engine/content-styles.mjs +85 -0
- package/engine/derived-sections.mjs +4 -3
- package/engine/event-views.mjs +6 -4
- package/engine/helpers.mjs +33 -9
- package/engine/index.mjs +1 -0
- package/engine/note-cache.mjs +1107 -0
- package/engine/note-page.mjs +424 -0
- package/engine/pack-config.mjs +47 -4
- package/engine/pathnames.mjs +22 -7
- package/engine/prepared-only.mjs +32 -0
- package/engine/preview-index.mjs +55 -13
- package/engine/site-build.mjs +34 -448
- package/engine/site-config.mjs +33 -4
- package/engine/site-maps.mjs +16 -24
- package/engine/site-origins.mjs +23 -0
- package/engine/site-page.mjs +648 -0
- package/engine/site-render.mjs +142 -10
- package/engine/site-serve.mjs +1 -1
- package/engine/sql-tables.mjs +12 -3
- package/package.json +6 -1
- package/site-theme/static/css/style.css +14 -0
- package/types/content-config.d.mts +34 -0
- package/types/engine/cdn-publish.d.mts +22 -0
- package/types/engine/content-index.d.mts +36 -2
- package/types/engine/content-styles.d.mts +20 -0
- package/types/engine/derived-sections.d.mts +3 -3
- package/types/engine/event-views.d.mts +4 -3
- package/types/engine/helpers.d.mts +18 -1
- package/types/engine/index.d.mts +1 -0
- package/types/engine/note-cache.d.mts +149 -0
- package/types/engine/note-page.d.mts +92 -0
- package/types/engine/pack-config.d.mts +30 -0
- package/types/engine/prepared-only.d.mts +14 -0
- package/types/engine/preview-index.d.mts +35 -0
- package/types/engine/site-build.d.mts +5 -100
- package/types/engine/site-config.d.mts +3 -2
- package/types/engine/site-maps.d.mts +7 -11
- package/types/engine/site-origins.d.mts +9 -0
- package/types/engine/site-page.d.mts +261 -0
- package/types/engine/site-render.d.mts +20 -1
- 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
|
|
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`.
|
package/bin/content-commands.mjs
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
2283
|
-
|
|
2284
|
-
|
|
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
|
|
2346
|
-
|
|
2347
|
-
|
|
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();
|
package/bin/package-build.mjs
CHANGED
|
@@ -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
|
-
*
|
|
1421
|
-
*
|
|
1422
|
-
*
|
|
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 =
|
|
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 =
|
|
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
|
+
});
|