@heroiclands/package-build 23.1.0 → 23.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # @heroiclands/package-build
2
2
 
3
+ ## 23.1.1
4
+
5
+ ### Patch Changes
6
+
7
+ `package-build yaml` accepts an empty trigger such as `workflow_dispatch:` in a Gitea workflow under `.gitea/workflows/`, as it already does under `.github/workflows/`.
8
+
9
+ Rebuilding a release of the same commit produces an identical `module.zip` or `system.zip`, so a rebuilt archive can be compared with the one already published. Setting `SOURCE_DATE_EPOCH` stamps the archive's files with that time.
10
+
3
11
  ## 23.1.0
4
12
 
5
13
  ### Minor Changes
@@ -105,12 +105,28 @@ change is minor; an optional command, flag, or key is patch. A major bump
105
105
  requires the maintainer's direct decision. `package-build changelog check`
106
106
  checks the quality of any pending changeset.
107
107
 
108
- Merging a changeset to `main` makes the **Version Packages** pull request. Its
109
- generated changelog section is reviewed as the release note. Merging that
110
- pull request runs the **Publish to npm** workflow: npm Trusted Publishing
111
- publishes the version, and the workflow creates its version tag and GitHub
112
- Release. The workflow's manual dispatch can retry publishing a version already
113
- merged to `main`. The [npm package page](https://www.npmjs.com/package/@heroiclands/package-build)
108
+ The repository's primary forge is Gitea, and GitHub is its push mirror. The
109
+ release runs in two halves, one on each:
110
+
111
+ 1. **Version, on Gitea.** Merging a changeset to `main` runs
112
+ `.gitea/workflows/release.yml`, which opens or updates the **Version
113
+ Packages** pull request. Its generated changelog section is reviewed as the
114
+ release note. Merging that pull request runs the same workflow again, which
115
+ pushes the tag `v<version>` at the merged version commit. Releasing from
116
+ Gitea is switched on by the repository variable `HL_RELEASE_ENABLED`.
117
+ 2. **Publish, on GitHub.** The push mirror carries the tag to GitHub, where it
118
+ starts the **Publish to npm** workflow, `.github/workflows/release.yml`. It
119
+ refuses a tag that names a commit outside `main` or that is not
120
+ `v<version>` of the tagged `package.json`. When npm does not have the
121
+ version yet, it runs the tests and publishes through npm Trusted Publishing
122
+ with provenance. It then creates the GitHub Release from the version's
123
+ changelog section, unless one exists.
124
+
125
+ Nothing on Gitea publishes to npm, and nothing on GitHub runs for a branch
126
+ push. Every step of the GitHub half is safe to repeat: re-running it, or
127
+ dispatching it with the tag as its ref, finishes a release whose publish or
128
+ Release step failed and changes nothing for one that completed. The
129
+ [npm package page](https://www.npmjs.com/package/@heroiclands/package-build)
114
130
  shows the published version and README.
115
131
 
116
132
  Two scripts in this repository's own `package.json` run the same steps at a
@@ -104,10 +104,11 @@ export const frontmatterProcessor = {
104
104
  * - `no-empty-key`, `no-empty-document` — a fence or a file that parses to
105
105
  * nothing at all.
106
106
  *
107
- * **GitHub workflows are exempt from `no-empty-mapping-value`**, because an
108
- * empty value is the language there: `on:` `push:` and `workflow_dispatch:`
109
- * carry their meaning by being present, and writing `push: null` to satisfy a
110
- * linter would be worse YAML, not better.
107
+ * **Workflows are exempt from `no-empty-mapping-value`**, under
108
+ * `.github/workflows/` and Gitea's `.gitea/workflows/` alike, because an empty
109
+ * value is the language there: `on:` `push:` and `workflow_dispatch:` carry
110
+ * their meaning by being present, and writing `push: null` to satisfy a linter
111
+ * would be worse YAML, not better.
111
112
  *
112
113
  * @param {object} plugin - The `eslint-plugin-yml` module.
113
114
  * @returns {object[]} A complete flat config, for `overrideConfig`.
@@ -128,6 +129,8 @@ export function yamlLintConfig(plugin) {
128
129
  files: [
129
130
  "**/.github/workflows/*.yml",
130
131
  "**/.github/workflows/*.yaml",
132
+ "**/.gitea/workflows/*.yml",
133
+ "**/.gitea/workflows/*.yaml",
131
134
  "**/action.yml",
132
135
  "**/action.yaml",
133
136
  ],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heroiclands/package-build",
3
- "version": "23.1.0",
3
+ "version": "23.1.1",
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",
package/release.mjs CHANGED
@@ -45,6 +45,60 @@ import { ZipArchive } from "archiver";
45
45
 
46
46
  import { SCHEMA_ARTIFACT_FILE } from "./engine/foreign-catalog.mjs";
47
47
 
48
+ /** The earliest instant a zip entry's date field can hold. */
49
+ const ZIP_EPOCH_MS = Date.UTC(1980, 0, 1);
50
+
51
+ /**
52
+ * The timestamp stamped on every archive entry: `SOURCE_DATE_EPOCH` (seconds)
53
+ * when set to a number, otherwise 1980-01-01T00:00:00Z. A value earlier than
54
+ * 1980 is raised to it, which is as early as a zip can record.
55
+ *
56
+ * @returns {Date}
57
+ */
58
+ function archiveDate() {
59
+ const seconds = Number(process.env.SOURCE_DATE_EPOCH);
60
+ const ms =
61
+ process.env.SOURCE_DATE_EPOCH && Number.isFinite(seconds) ? seconds * 1000 : ZIP_EPOCH_MS;
62
+ return new Date(Math.max(ms, ZIP_EPOCH_MS));
63
+ }
64
+
65
+ /**
66
+ * Append a directory tree to an archive in path order, with a fixed date and
67
+ * fixed permissions (0755 directories, 0644 files) so the bytes depend only on
68
+ * the tree's names and contents.
69
+ *
70
+ * Entries are appended one at a time from buffers: archiver's own directory
71
+ * walk stats files concurrently, so its entry order and recorded mtimes follow
72
+ * the filesystem.
73
+ *
74
+ * @param {import("archiver").ZipArchive} archive
75
+ * @param {string} root - The directory whose contents become the archive.
76
+ * @param {Date} date - The date recorded on every entry.
77
+ * @returns {Promise<void>}
78
+ */
79
+ async function appendTree(archive, root, date) {
80
+ /** @param {string} rel - Path relative to `root`, `/`-separated, or "". */
81
+ async function walk(rel) {
82
+ const entries = await fsp.readdir(path.join(root, rel), { withFileTypes: true });
83
+ entries.sort((a, b) =>
84
+ a.name < b.name ? -1
85
+ : a.name > b.name ? 1
86
+ : 0,
87
+ );
88
+ for (const entry of entries) {
89
+ const name = rel ? `${rel}/${entry.name}` : entry.name;
90
+ if (entry.isDirectory()) {
91
+ archive.append(Buffer.alloc(0), { name: `${name}/`, date, mode: 0o755 });
92
+ await walk(name);
93
+ } else {
94
+ const body = await fsp.readFile(path.join(root, name));
95
+ archive.append(body, { name, date, mode: 0o644 });
96
+ }
97
+ }
98
+ }
99
+ await walk("");
100
+ }
101
+
48
102
  /**
49
103
  * Zip the staged tree and place the manifest beside the archive.
50
104
  *
@@ -55,6 +109,10 @@ import { SCHEMA_ARTIFACT_FILE } from "./engine/foreign-catalog.mjs";
55
109
  * failure is timing-dependent, so it survives every run that happens to be
56
110
  * fast enough, which is what makes it worth being explicit about.
57
111
  *
112
+ * **The archive is reproducible.** Entries are written in path order with one
113
+ * date (`SOURCE_DATE_EPOCH` when set, otherwise 1980-01-01) and fixed
114
+ * permissions, so a rebuild of one staged tree is byte-identical.
115
+ *
58
116
  * @param {object} [opts]
59
117
  * @param {string} [opts.stageDir] - The staged package tree.
60
118
  * @param {string} [opts.outDir] - Where the release assets are written.
@@ -114,10 +172,10 @@ export async function packRelease({
114
172
  });
115
173
 
116
174
  archive.pipe(output);
117
- // `false` — no top-level directory inside the zip. Foundry unpacks the
118
- // archive *into* the package directory, so an extra level would nest the
119
- // manifest one deeper than it looks for it.
120
- archive.directory(stage, false);
175
+ // No top-level directory inside the zip: Foundry unpacks the archive
176
+ // *into* the package directory, so an extra level would nest the manifest
177
+ // one deeper than it looks for it.
178
+ await appendTree(archive, stage, archiveDate());
121
179
  await archive.finalize();
122
180
  await closed;
123
181
 
@@ -22,10 +22,11 @@
22
22
  * - `no-empty-key`, `no-empty-document` — a fence or a file that parses to
23
23
  * nothing at all.
24
24
  *
25
- * **GitHub workflows are exempt from `no-empty-mapping-value`**, because an
26
- * empty value is the language there: `on:` `push:` and `workflow_dispatch:`
27
- * carry their meaning by being present, and writing `push: null` to satisfy a
28
- * linter would be worse YAML, not better.
25
+ * **Workflows are exempt from `no-empty-mapping-value`**, under
26
+ * `.github/workflows/` and Gitea's `.gitea/workflows/` alike, because an empty
27
+ * value is the language there: `on:` `push:` and `workflow_dispatch:` carry
28
+ * their meaning by being present, and writing `push: null` to satisfy a linter
29
+ * would be worse YAML, not better.
29
30
  *
30
31
  * @param {object} plugin - The `eslint-plugin-yml` module.
31
32
  * @returns {object[]} A complete flat config, for `overrideConfig`.
@@ -8,6 +8,10 @@
8
8
  * failure is timing-dependent, so it survives every run that happens to be
9
9
  * fast enough, which is what makes it worth being explicit about.
10
10
  *
11
+ * **The archive is reproducible.** Entries are written in path order with one
12
+ * date (`SOURCE_DATE_EPOCH` when set, otherwise 1980-01-01) and fixed
13
+ * permissions, so a rebuild of one staged tree is byte-identical.
14
+ *
11
15
  * @param {object} [opts]
12
16
  * @param {string} [opts.stageDir] - The staged package tree.
13
17
  * @param {string} [opts.outDir] - Where the release assets are written.