@gjsify/cli 0.48.0 → 0.50.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/dist/cli.gjs.mjs +165 -157
- package/lib/actions/barrels-generate.d.ts +22 -0
- package/lib/actions/barrels-generate.js +36 -1
- package/lib/actions/build.js +10 -6
- package/lib/commands/barrels.js +19 -2
- package/lib/commands/build.js +4 -0
- package/lib/commands/foreach.js +11 -19
- package/lib/commands/install.js +21 -1
- package/lib/commands/test.d.ts +16 -0
- package/lib/commands/test.js +30 -29
- package/lib/commands/upgrade.js +78 -30
- package/lib/commands/workspace.js +47 -44
- package/lib/config.js +13 -0
- package/lib/index.js +10 -0
- package/lib/types/cli-build-options.d.ts +6 -0
- package/lib/types/config-data.d.ts +14 -0
- package/lib/utils/build-cache.d.ts +15 -4
- package/lib/utils/build-cache.js +62 -67
- package/lib/utils/cli-exit.d.ts +23 -0
- package/lib/utils/cli-exit.js +78 -0
- package/lib/utils/detect-native-packages.d.ts +31 -0
- package/lib/utils/detect-native-packages.js +37 -1
- package/lib/utils/package-inputs.d.ts +81 -0
- package/lib/utils/package-inputs.js +233 -0
- package/lib/utils/ship/changelog.d.ts +15 -14
- package/lib/utils/ship/changelog.js +14 -13
- package/lib/utils/ship/formats.js +23 -2
- package/lib/utils/ship/gzip.d.ts +31 -2
- package/lib/utils/ship/gzip.js +55 -3
- package/lib/utils/ship/plan.d.ts +32 -4
- package/lib/utils/ship/plan.js +61 -10
- package/lib/utils/simple-command.d.ts +17 -2
- package/lib/utils/simple-command.js +9 -4
- package/package.json +19 -19
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/** One file the build may read. `link` is set for a symlink (never followed). */
|
|
2
|
+
export interface PackageInputFile {
|
|
3
|
+
/** Package-relative path, forward slashes. */
|
|
4
|
+
rel: string;
|
|
5
|
+
abs: string;
|
|
6
|
+
/** Raw symlink target, when this entry is a symlink. */
|
|
7
|
+
link?: string;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Where the toolchain writes when the package does not say otherwise.
|
|
11
|
+
*
|
|
12
|
+
* Used for a package with NO `clear` script (a consumer project we know
|
|
13
|
+
* nothing about), and unioned onto the declared set for one that has one:
|
|
14
|
+
* `gjsify test` writes `dist/test.{gjs,node}.mjs` into packages whose `clear`
|
|
15
|
+
* line predates that command and names only `lib`, and an output left in the
|
|
16
|
+
* input set makes every run see a newer file than the artifact it is judging.
|
|
17
|
+
*/
|
|
18
|
+
export declare const CONVENTIONAL_OUTPUT_DIRS: readonly ["lib", "dist", "dist-templates"];
|
|
19
|
+
/**
|
|
20
|
+
* Files whose change means the installed dependency tree may have moved.
|
|
21
|
+
*
|
|
22
|
+
* `node_modules` is deliberately not walked — too large to stat per run, and
|
|
23
|
+
* the reason the old `gjsify test` walk skipped it too — so the lockfile is
|
|
24
|
+
* the cheap proxy for it. That substitution is not new: `actions/build.ts`
|
|
25
|
+
* has made it since the plugin bundle cache existed, and this is the list it
|
|
26
|
+
* uses, kept in one place so a fifth package manager is added once.
|
|
27
|
+
*/
|
|
28
|
+
export declare const DEP_CHANGE_LOCKFILES: readonly ["gjsify-lock.json", "package-lock.json", "yarn.lock", "pnpm-lock.yaml"];
|
|
29
|
+
export interface PackageBuildInputOptions {
|
|
30
|
+
/** Parsed `package.json`, when the caller already has it. */
|
|
31
|
+
manifest?: {
|
|
32
|
+
scripts?: Record<string, string>;
|
|
33
|
+
} | null;
|
|
34
|
+
/** Extra package-relative paths to treat as output (e.g. a `--outdir`). */
|
|
35
|
+
extraOutputs?: readonly string[];
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* The paths this package PRODUCES, package-relative — DERIVED from the
|
|
39
|
+
* package's own `clear` script rather than re-declared, the same move
|
|
40
|
+
* `utils/dev-plan.ts` makes for build flags.
|
|
41
|
+
*
|
|
42
|
+
* Three cases, and the middle one is the one that matters:
|
|
43
|
+
* - `clear` is `gjsify clear a b c` → `a b c` plus the conventional dirs.
|
|
44
|
+
* 127 of 212 workspace packages, naming `tmp`, `build`, `*.tsbuildinfo`,
|
|
45
|
+
* `test.gjs.mjs`, and in two of them `src/*.generated.ts` — a generated
|
|
46
|
+
* file INSIDE `src/` that the old walk hashed as an input.
|
|
47
|
+
* - `clear` is anything else (`echo 'nothing to do'`, 74 packages) →
|
|
48
|
+
* NOTHING. The package states it has nothing to delete, so its `lib/` is
|
|
49
|
+
* source, not output. Exactly two of the 74 have a conventional output dir
|
|
50
|
+
* on disk, `resolve-npm/lib` and `manifest-conformance/lib`, and in both
|
|
51
|
+
* it is tracked source — so the script is a trustworthy oracle both ways.
|
|
52
|
+
* - no `clear` script (11 packages, and any consumer project) → the
|
|
53
|
+
* conventional dirs, the only guess available.
|
|
54
|
+
*/
|
|
55
|
+
export declare function packageOutputPaths(manifest: {
|
|
56
|
+
scripts?: Record<string, string>;
|
|
57
|
+
} | null): string[];
|
|
58
|
+
/** `package.json` at `pkgRoot`, or `null` when absent/unreadable. */
|
|
59
|
+
export declare function readPackageManifest(pkgRoot: string): {
|
|
60
|
+
scripts?: Record<string, string>;
|
|
61
|
+
} | null;
|
|
62
|
+
/**
|
|
63
|
+
* Every file the build of the package at `pkgRoot` may read, sorted by
|
|
64
|
+
* package-relative path.
|
|
65
|
+
*
|
|
66
|
+
* Enumeration only — the caller decides whether to hash contents (build cache)
|
|
67
|
+
* or take mtimes (`gjsify test` freshness). That split is deliberate: the two
|
|
68
|
+
* must agree on the SET, and they legitimately differ on what they do with it.
|
|
69
|
+
*/
|
|
70
|
+
export declare function packageBuildInputs(pkgRoot: string, options?: PackageBuildInputOptions): PackageInputFile[];
|
|
71
|
+
/** Newest mtime (ms) among `files`, or 0 for an empty set. */
|
|
72
|
+
export declare function newestInputMtimeMs(files: readonly PackageInputFile[]): number;
|
|
73
|
+
/**
|
|
74
|
+
* Newest lockfile mtime (ms) at or above `startDir`, or 0 when there is none.
|
|
75
|
+
*
|
|
76
|
+
* Walks up because in a monorepo the lockfile that governs a package's
|
|
77
|
+
* `node_modules` sits at the workspace root, several levels away — and a
|
|
78
|
+
* dependency bump that rewrites it is precisely the case #1651 was found in
|
|
79
|
+
* (postbote, gjsify 0.33.0 → 0.49.0, not one source file touched).
|
|
80
|
+
*/
|
|
81
|
+
export declare function newestDepSignalMtimeMs(startDir: string): number;
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// ONE answer to "what are this package's build inputs" — read by the build
|
|
3
|
+
// cache (`build-cache.ts`) and by `gjsify test`'s freshness check. Add a
|
|
4
|
+
// fourth READER, never a fourth answer.
|
|
5
|
+
//
|
|
6
|
+
// It was answered three times, each an ALLOW-list over a guessed layout, and
|
|
7
|
+
// each is wrong for a package that already exists here: `src/**` misses
|
|
8
|
+
// `@gjsify/adwaita-fonts` (no `src/` at all) and the tracked source in
|
|
9
|
+
// `resolve-npm/lib` + `manifest-conformance/lib` (#821); `dirname(<test
|
|
10
|
+
// entry>)` missed `src/**` entirely, so `gjsify test` reran the PREVIOUS
|
|
11
|
+
// bundle and reported on it (#1651).
|
|
12
|
+
//
|
|
13
|
+
// So: a DENY-list. An input is any file in the package that is not a
|
|
14
|
+
// dependency, not tool/VCS state, and not something the package produces —
|
|
15
|
+
// which covers a layout nobody anticipated by construction. **In doubt, a
|
|
16
|
+
// file is an input**: a false input costs one rebuild, a false non-input
|
|
17
|
+
// costs a green test run about code that is not in the artifact.
|
|
18
|
+
//
|
|
19
|
+
// The incident, the measurements behind every number below, and the copy
|
|
20
|
+
// still open (CI's `actions/cache` glob): docs/build-artifacts.md.
|
|
21
|
+
import { existsSync, lstatSync, readdirSync, readFileSync, readlinkSync, statSync } from 'node:fs';
|
|
22
|
+
import { dirname, join } from 'node:path';
|
|
23
|
+
import { hasWildcard, segmentToRegExp } from './clear-targets.js';
|
|
24
|
+
import { gjsifyCommandArgv } from './simple-command.js';
|
|
25
|
+
/**
|
|
26
|
+
* Where the toolchain writes when the package does not say otherwise.
|
|
27
|
+
*
|
|
28
|
+
* Used for a package with NO `clear` script (a consumer project we know
|
|
29
|
+
* nothing about), and unioned onto the declared set for one that has one:
|
|
30
|
+
* `gjsify test` writes `dist/test.{gjs,node}.mjs` into packages whose `clear`
|
|
31
|
+
* line predates that command and names only `lib`, and an output left in the
|
|
32
|
+
* input set makes every run see a newer file than the artifact it is judging.
|
|
33
|
+
*/
|
|
34
|
+
export const CONVENTIONAL_OUTPUT_DIRS = ['lib', 'dist', 'dist-templates'];
|
|
35
|
+
/**
|
|
36
|
+
* Files whose change means the installed dependency tree may have moved.
|
|
37
|
+
*
|
|
38
|
+
* `node_modules` is deliberately not walked — too large to stat per run, and
|
|
39
|
+
* the reason the old `gjsify test` walk skipped it too — so the lockfile is
|
|
40
|
+
* the cheap proxy for it. That substitution is not new: `actions/build.ts`
|
|
41
|
+
* has made it since the plugin bundle cache existed, and this is the list it
|
|
42
|
+
* uses, kept in one place so a fifth package manager is added once.
|
|
43
|
+
*/
|
|
44
|
+
export const DEP_CHANGE_LOCKFILES = ['gjsify-lock.json', 'package-lock.json', 'yarn.lock', 'pnpm-lock.yaml'];
|
|
45
|
+
/**
|
|
46
|
+
* The paths this package PRODUCES, package-relative — DERIVED from the
|
|
47
|
+
* package's own `clear` script rather than re-declared, the same move
|
|
48
|
+
* `utils/dev-plan.ts` makes for build flags.
|
|
49
|
+
*
|
|
50
|
+
* Three cases, and the middle one is the one that matters:
|
|
51
|
+
* - `clear` is `gjsify clear a b c` → `a b c` plus the conventional dirs.
|
|
52
|
+
* 127 of 212 workspace packages, naming `tmp`, `build`, `*.tsbuildinfo`,
|
|
53
|
+
* `test.gjs.mjs`, and in two of them `src/*.generated.ts` — a generated
|
|
54
|
+
* file INSIDE `src/` that the old walk hashed as an input.
|
|
55
|
+
* - `clear` is anything else (`echo 'nothing to do'`, 74 packages) →
|
|
56
|
+
* NOTHING. The package states it has nothing to delete, so its `lib/` is
|
|
57
|
+
* source, not output. Exactly two of the 74 have a conventional output dir
|
|
58
|
+
* on disk, `resolve-npm/lib` and `manifest-conformance/lib`, and in both
|
|
59
|
+
* it is tracked source — so the script is a trustworthy oracle both ways.
|
|
60
|
+
* - no `clear` script (11 packages, and any consumer project) → the
|
|
61
|
+
* conventional dirs, the only guess available.
|
|
62
|
+
*/
|
|
63
|
+
export function packageOutputPaths(manifest) {
|
|
64
|
+
const clear = manifest?.scripts?.clear;
|
|
65
|
+
if (clear === undefined)
|
|
66
|
+
return [...CONVENTIONAL_OUTPUT_DIRS];
|
|
67
|
+
// `allowGlobs`: five packages here clear `*.tsbuildinfo`, and the pattern
|
|
68
|
+
// is what we want — this reads the targets, it does not run them.
|
|
69
|
+
const argv = gjsifyCommandArgv(clear, { allowGlobs: true });
|
|
70
|
+
if (argv === null || argv[0] !== 'clear')
|
|
71
|
+
return [];
|
|
72
|
+
const targets = argv.slice(1).filter((token) => !token.startsWith('-'));
|
|
73
|
+
return [...targets, ...CONVENTIONAL_OUTPUT_DIRS];
|
|
74
|
+
}
|
|
75
|
+
/** `package.json` at `pkgRoot`, or `null` when absent/unreadable. */
|
|
76
|
+
export function readPackageManifest(pkgRoot) {
|
|
77
|
+
try {
|
|
78
|
+
return JSON.parse(readFileSync(join(pkgRoot, 'package.json'), 'utf-8'));
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Match a package-relative path against output targets — the target itself and
|
|
86
|
+
* everything under it.
|
|
87
|
+
*
|
|
88
|
+
* Wildcards are honoured in the LAST segment only, which is `gjsify clear`'s
|
|
89
|
+
* own rule (`utils/clear-targets.ts`), so the set matched here is the set that
|
|
90
|
+
* command would delete.
|
|
91
|
+
*/
|
|
92
|
+
function makeOutputMatcher(targets) {
|
|
93
|
+
const compiled = targets
|
|
94
|
+
.map((target) => target
|
|
95
|
+
.split(/[\\/]+/)
|
|
96
|
+
.filter((segment) => segment !== '' && segment !== '.')
|
|
97
|
+
.map((segment) => (hasWildcard(segment) ? segmentToRegExp(segment) : segment)))
|
|
98
|
+
.filter((segments) => segments.length > 0);
|
|
99
|
+
return (rel) => {
|
|
100
|
+
const parts = rel.split('/');
|
|
101
|
+
for (const target of compiled) {
|
|
102
|
+
if (target.length > parts.length)
|
|
103
|
+
continue;
|
|
104
|
+
let matched = true;
|
|
105
|
+
for (let i = 0; i < target.length; i++) {
|
|
106
|
+
const segment = target[i];
|
|
107
|
+
const got = parts[i];
|
|
108
|
+
if (typeof segment === 'string' ? segment !== got : !segment.test(got)) {
|
|
109
|
+
matched = false;
|
|
110
|
+
break;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
if (matched)
|
|
114
|
+
return true;
|
|
115
|
+
}
|
|
116
|
+
return false;
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
function walk(dir, prefix, isOutput, out) {
|
|
120
|
+
let entries;
|
|
121
|
+
try {
|
|
122
|
+
entries = readdirSync(dir);
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
entries.sort();
|
|
128
|
+
for (const entry of entries) {
|
|
129
|
+
// `node_modules` is the dependency tree, not this package's source, and
|
|
130
|
+
// a dot-entry is VCS/editor/cache state — `.git` alone would make the
|
|
131
|
+
// walk unbounded. Measured across every package here: no file under any
|
|
132
|
+
// `src/` is dot-prefixed, so nothing the old hash covered is lost.
|
|
133
|
+
if (entry === 'node_modules' || entry.startsWith('.'))
|
|
134
|
+
continue;
|
|
135
|
+
const rel = prefix === '' ? entry : `${prefix}/${entry}`;
|
|
136
|
+
if (isOutput(rel))
|
|
137
|
+
continue;
|
|
138
|
+
const abs = join(dir, entry);
|
|
139
|
+
let st;
|
|
140
|
+
try {
|
|
141
|
+
st = lstatSync(abs);
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
if (st.isSymbolicLink()) {
|
|
147
|
+
// Not followed: a directory symlink can loop, and a workspace
|
|
148
|
+
// `node_modules` link would drag the whole tree in. The target
|
|
149
|
+
// string participates instead; file links are read by content where
|
|
150
|
+
// the consumer asks for content.
|
|
151
|
+
out.push({ rel, abs, link: readLinkTarget(abs) });
|
|
152
|
+
}
|
|
153
|
+
else if (st.isDirectory()) {
|
|
154
|
+
walk(abs, rel, isOutput, out);
|
|
155
|
+
}
|
|
156
|
+
else if (st.isFile()) {
|
|
157
|
+
out.push({ rel, abs });
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
function readLinkTarget(abs) {
|
|
162
|
+
try {
|
|
163
|
+
return readlinkSync(abs);
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
// Dropped between lstat and readlink. An empty target still differs
|
|
167
|
+
// from the file being absent, which is all the hash needs.
|
|
168
|
+
return '';
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Every file the build of the package at `pkgRoot` may read, sorted by
|
|
173
|
+
* package-relative path.
|
|
174
|
+
*
|
|
175
|
+
* Enumeration only — the caller decides whether to hash contents (build cache)
|
|
176
|
+
* or take mtimes (`gjsify test` freshness). That split is deliberate: the two
|
|
177
|
+
* must agree on the SET, and they legitimately differ on what they do with it.
|
|
178
|
+
*/
|
|
179
|
+
export function packageBuildInputs(pkgRoot, options = {}) {
|
|
180
|
+
const manifest = options.manifest !== undefined ? options.manifest : readPackageManifest(pkgRoot);
|
|
181
|
+
const outputs = [...packageOutputPaths(manifest), ...(options.extraOutputs ?? [])];
|
|
182
|
+
const files = [];
|
|
183
|
+
walk(pkgRoot, '', makeOutputMatcher(outputs), files);
|
|
184
|
+
return files;
|
|
185
|
+
}
|
|
186
|
+
/** Newest mtime (ms) among `files`, or 0 for an empty set. */
|
|
187
|
+
export function newestInputMtimeMs(files) {
|
|
188
|
+
let newest = 0;
|
|
189
|
+
for (const file of files) {
|
|
190
|
+
try {
|
|
191
|
+
const { mtimeMs } = statSync(file.abs);
|
|
192
|
+
if (mtimeMs > newest)
|
|
193
|
+
newest = mtimeMs;
|
|
194
|
+
}
|
|
195
|
+
catch {
|
|
196
|
+
// Vanished mid-walk. Treat as a change: an input we cannot read is
|
|
197
|
+
// not evidence that the artifact is current.
|
|
198
|
+
return Number.POSITIVE_INFINITY;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
return newest;
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Newest lockfile mtime (ms) at or above `startDir`, or 0 when there is none.
|
|
205
|
+
*
|
|
206
|
+
* Walks up because in a monorepo the lockfile that governs a package's
|
|
207
|
+
* `node_modules` sits at the workspace root, several levels away — and a
|
|
208
|
+
* dependency bump that rewrites it is precisely the case #1651 was found in
|
|
209
|
+
* (postbote, gjsify 0.33.0 → 0.49.0, not one source file touched).
|
|
210
|
+
*/
|
|
211
|
+
export function newestDepSignalMtimeMs(startDir) {
|
|
212
|
+
let newest = 0;
|
|
213
|
+
let dir = startDir;
|
|
214
|
+
for (;;) {
|
|
215
|
+
for (const name of DEP_CHANGE_LOCKFILES) {
|
|
216
|
+
const candidate = join(dir, name);
|
|
217
|
+
if (!existsSync(candidate))
|
|
218
|
+
continue;
|
|
219
|
+
try {
|
|
220
|
+
const { mtimeMs } = statSync(candidate);
|
|
221
|
+
if (mtimeMs > newest)
|
|
222
|
+
newest = mtimeMs;
|
|
223
|
+
}
|
|
224
|
+
catch {
|
|
225
|
+
/* raced away — the other lockfiles still answer */
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
const parent = dirname(dir);
|
|
229
|
+
if (parent === dir)
|
|
230
|
+
return newest;
|
|
231
|
+
dir = parent;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
@@ -38,19 +38,20 @@ export declare function changelogEntriesFor(version: string, source: string): st
|
|
|
38
38
|
* wrong: exactly one leading space, then `--`, one space, the maintainer, and TWO
|
|
39
39
|
* spaces before the date.
|
|
40
40
|
*
|
|
41
|
-
*
|
|
42
|
-
* gjsify's own `.deb` and
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
41
|
+
* ONE lintian tag this file still raises, measured on lintian 2.117 against
|
|
42
|
+
* gjsify's own `.deb` and left alone deliberately: `initial-upload-closes-no-bugs`,
|
|
43
|
+
* because the Debian revision is `-1` and the entry closes no bug. It is a
|
|
44
|
+
* statement about the Debian ARCHIVE's ITP workflow — the first upload of a new
|
|
45
|
+
* source package should close its ITP bug — and a package built by `gjsify ship`
|
|
46
|
+
* is never uploaded there. There is no bug number that would be true to write, so
|
|
47
|
+
* the tag is inapplicable rather than unmet.
|
|
48
|
+
*
|
|
49
|
+
* `changelog-not-compressed-with-max-compression` was a second one, and closing it
|
|
50
|
+
* was a capability gap in the CORE rather than anything wrong in this file:
|
|
51
|
+
* neither `CompressionStream` nor `@gjsify/zlib` would take a compression level,
|
|
52
|
+
* so `gzipDeterministic` had none to pass on. `@gjsify/zlib` honours
|
|
53
|
+
* `options.level` now and `plan.ts` compresses this file at
|
|
54
|
+
* `POLICY_MAX_COMPRESSION`. `verify-deb.sh` gates the tag by name, so it cannot
|
|
55
|
+
* come back quietly.
|
|
55
56
|
*/
|
|
56
57
|
export declare function renderDebianChangelog(settings: PackSettings, source: string | undefined, mtime: number): string;
|
|
@@ -134,20 +134,21 @@ const WIDTH = 80;
|
|
|
134
134
|
* wrong: exactly one leading space, then `--`, one space, the maintainer, and TWO
|
|
135
135
|
* spaces before the date.
|
|
136
136
|
*
|
|
137
|
-
*
|
|
138
|
-
* gjsify's own `.deb` and
|
|
137
|
+
* ONE lintian tag this file still raises, measured on lintian 2.117 against
|
|
138
|
+
* gjsify's own `.deb` and left alone deliberately: `initial-upload-closes-no-bugs`,
|
|
139
|
+
* because the Debian revision is `-1` and the entry closes no bug. It is a
|
|
140
|
+
* statement about the Debian ARCHIVE's ITP workflow — the first upload of a new
|
|
141
|
+
* source package should close its ITP bug — and a package built by `gjsify ship`
|
|
142
|
+
* is never uploaded there. There is no bug number that would be true to write, so
|
|
143
|
+
* the tag is inapplicable rather than unmet.
|
|
139
144
|
*
|
|
140
|
-
*
|
|
141
|
-
*
|
|
142
|
-
*
|
|
143
|
-
*
|
|
144
|
-
*
|
|
145
|
-
*
|
|
146
|
-
*
|
|
147
|
-
* and neither `CompressionStream` nor `@gjsify/zlib` takes a level, so
|
|
148
|
-
* `gzipDeterministic` cannot ask for one. Ledgered in `status/open-todos.md`:
|
|
149
|
-
* the fix is a capability in the core, and stamping the header's XFL byte to
|
|
150
|
-
* claim max compression we did not use is not one.
|
|
145
|
+
* `changelog-not-compressed-with-max-compression` was a second one, and closing it
|
|
146
|
+
* was a capability gap in the CORE rather than anything wrong in this file:
|
|
147
|
+
* neither `CompressionStream` nor `@gjsify/zlib` would take a compression level,
|
|
148
|
+
* so `gzipDeterministic` had none to pass on. `@gjsify/zlib` honours
|
|
149
|
+
* `options.level` now and `plan.ts` compresses this file at
|
|
150
|
+
* `POLICY_MAX_COMPRESSION`. `verify-deb.sh` gates the tag by name, so it cannot
|
|
151
|
+
* come back quietly.
|
|
151
152
|
*/
|
|
152
153
|
export function renderDebianChangelog(settings, source, mtime) {
|
|
153
154
|
const entries = source === undefined ? [] : changelogEntriesFor(settings.version, source);
|
|
@@ -450,7 +450,21 @@ export const FORMATS = {
|
|
|
450
450
|
// is a download: it lands in a browser's downloads folder beside other
|
|
451
451
|
// files, so it carries the version and the arch, and it avoids the spaces
|
|
452
452
|
// a display name may contain.
|
|
453
|
-
|
|
453
|
+
//
|
|
454
|
+
// AND THE OPERATING SYSTEM, which the extension does not name. Every other
|
|
455
|
+
// row's suffix names exactly one platform — `.deb`, `.rpm`, `.flatpak`,
|
|
456
|
+
// `.dmg`, `.msi` — and `.zip` names none, so this row and `windows-dir-zip`
|
|
457
|
+
// are the only two artifacts in the table a user cannot tell apart by
|
|
458
|
+
// looking at them. Before this token they were separated by nothing but a
|
|
459
|
+
// coincidence between two unrelated arch tables (`MACOS_ARCH` maps x64 to
|
|
460
|
+
// `x86_64`, `WINDOWS_ARCH` to `x64`), which put a bare `…-1.arm64.zip` on a
|
|
461
|
+
// release page beside a Windows zip with no way to choose — and would have
|
|
462
|
+
// had the two formats silently overwrite each other in `ship/out/` the day
|
|
463
|
+
// a Windows/arm64 row became possible, an upstream blocker (#1117) being
|
|
464
|
+
// all that stood in the way. `macos`, not `darwin`, for the same reason
|
|
465
|
+
// `MACOS_ARCH` writes `x86_64` rather than `x64`: this label is read by
|
|
466
|
+
// whoever downloads the file, never by `process.platform`.
|
|
467
|
+
fileName: (s, archLabel) => `${s.binaryName}-${s.version}-${s.release}.macos.${archLabel}.zip`,
|
|
454
468
|
artifactKind: 'file',
|
|
455
469
|
},
|
|
456
470
|
// ── macOS (#1354 M4) ─────────────────────────────────────────────────
|
|
@@ -661,7 +675,14 @@ export const FORMATS = {
|
|
|
661
675
|
// pair splits them: this one is a download that lands in a browser's
|
|
662
676
|
// folder beside other files, so it carries the version and the arch and
|
|
663
677
|
// avoids the spaces a display name may contain.
|
|
664
|
-
|
|
678
|
+
//
|
|
679
|
+
// …and the OS, for the reason spelled out on the `macos-app-zip` row it was
|
|
680
|
+
// copied from: `.zip` is the one container this table puts on more than one
|
|
681
|
+
// operating system, so it is the one name that has to say which. `windows`
|
|
682
|
+
// rather than `win32` — the spelling is the user's, like the `x64` this row
|
|
683
|
+
// keeps because Node's own archives, gvsbuild's assets and the runner label
|
|
684
|
+
// all use it.
|
|
685
|
+
fileName: (s, archLabel) => `${s.binaryName}-${s.version}-${s.release}.windows.${archLabel}.zip`,
|
|
665
686
|
artifactKind: 'file',
|
|
666
687
|
},
|
|
667
688
|
// ── The Windows installer (#1354 M5) ─────────────────────────────────
|
package/lib/utils/ship/gzip.d.ts
CHANGED
|
@@ -1,2 +1,31 @@
|
|
|
1
|
-
/**
|
|
2
|
-
|
|
1
|
+
/**
|
|
2
|
+
* gzip with the header's timestamp zeroed and the OS byte pinned to Unix.
|
|
3
|
+
*
|
|
4
|
+
* @param level 0-9, or omitted for zlib's default. THE ONLY HONEST WAY TO SET XFL:
|
|
5
|
+
* byte 8 of the header records how hard the compressor tried (2 = maximum,
|
|
6
|
+
* 4 = fastest, 0 = neither), `lintian` reads it to raise
|
|
7
|
+
* `changelog-not-compressed-with-max-compression`, and `file` prints it as
|
|
8
|
+
* "max compression". Stamping that byte here beside the mtime and OS bytes would
|
|
9
|
+
* be a different thing entirely and was rejected for it: mtime and OS are facts
|
|
10
|
+
* about the build ENVIRONMENT, which a reproducible build is entitled to
|
|
11
|
+
* normalise, while XFL is a statement about the compression actually performed.
|
|
12
|
+
* Writing a 2 we had not earned would make the artifact lie to the tool reading
|
|
13
|
+
* it. So the level travels down to the compressor instead.
|
|
14
|
+
*
|
|
15
|
+
* AND THE TEMPTATION IS REAL, which is why this is worth writing down: measured
|
|
16
|
+
* on gjsify's own `.deb`, the changelog member compressed at the default level
|
|
17
|
+
* and at 9 differs in EXACTLY ONE BYTE — position 9, XFL, 0 against 2 — and is
|
|
18
|
+
* 1152 bytes either way. For this input, stamping the byte would have produced
|
|
19
|
+
* the identical artifact. It is identical by coincidence of a small input: over
|
|
20
|
+
* this repo's full `CHANGELOG.md` the same two levels differ by thousands of
|
|
21
|
+
* bytes. A rule that happens to hold on today's input is not the rule.
|
|
22
|
+
*/
|
|
23
|
+
export declare function gzipDeterministic(input: Uint8Array, level?: number): Promise<Uint8Array>;
|
|
24
|
+
/**
|
|
25
|
+
* `gzip -9`, the level Debian Policy § 4.4 asks of a packaging changelog.
|
|
26
|
+
*
|
|
27
|
+
* Named rather than spelled `9` at the call site so the reason travels with the
|
|
28
|
+
* number: the `-n` half of Policy's `gzip -9 -n` is what {@link gzipDeterministic}
|
|
29
|
+
* already does to bytes 4-7.
|
|
30
|
+
*/
|
|
31
|
+
export declare const POLICY_MAX_COMPRESSION = 9;
|
package/lib/utils/ship/gzip.js
CHANGED
|
@@ -5,10 +5,54 @@
|
|
|
5
5
|
// input produce different bytes — which no test notices, because every test
|
|
6
6
|
// compares the DECOMPRESSED content. It is the single most-missed
|
|
7
7
|
// reproducibility bug in package writers, and it costs nothing to close.
|
|
8
|
+
//
|
|
9
|
+
// WHAT "DETERMINISTIC" DOES NOT COVER, measured 2026-09-11 and named here because
|
|
10
|
+
// the function's own name overstates it: the bytes are reproducible for a given
|
|
11
|
+
// HOST, not for a given artifact. `@gjsify/tar`'s gzip runs on the platform's
|
|
12
|
+
// zlib, and the platforms do not ship the same one — Fedora's libz is
|
|
13
|
+
// `zlib-ng-compat` (2.3.3) while Node bundles its own zlib (1.3.2.1-motley). Over
|
|
14
|
+
// this repo's `CHANGELOG.md` (876 192 bytes) at the DEFAULT level they produce
|
|
15
|
+
// 272 003 and 272 000 bytes respectively, so a `.deb` packed under GJS and one
|
|
16
|
+
// packed under Node already differ, today, before any level argument exists.
|
|
17
|
+
// Levels 1-4 differ too, in the other direction (zlib-ng is smaller). Two
|
|
18
|
+
// consequences worth keeping:
|
|
19
|
+
//
|
|
20
|
+
// * `tests/e2e/ship-from-stage` asserts byte-equality between a direct pack and
|
|
21
|
+
// a `--from-stage` pack, and it holds because both run on ONE host. It is
|
|
22
|
+
// structurally blind to this, and a cross-host pack is exactly what
|
|
23
|
+
// `--from-stage` exists to enable.
|
|
24
|
+
// * zlib-ng's level 9 is WORSE than its level 8 on large inputs — 277 974 vs
|
|
25
|
+
// 270 255 bytes on the file above, ~2.9 % bigger — which is why `level: 9` is
|
|
26
|
+
// asked for only where a reader demands it (see below) and not blanket-applied
|
|
27
|
+
// to the payload tarballs.
|
|
28
|
+
//
|
|
29
|
+
// Ledgered in `status/open-todos.md`; closing it means pinning ONE deflate
|
|
30
|
+
// implementation, which is a larger decision than this file.
|
|
8
31
|
import { gzip } from '@gjsify/tar';
|
|
9
|
-
/**
|
|
10
|
-
|
|
11
|
-
|
|
32
|
+
/**
|
|
33
|
+
* gzip with the header's timestamp zeroed and the OS byte pinned to Unix.
|
|
34
|
+
*
|
|
35
|
+
* @param level 0-9, or omitted for zlib's default. THE ONLY HONEST WAY TO SET XFL:
|
|
36
|
+
* byte 8 of the header records how hard the compressor tried (2 = maximum,
|
|
37
|
+
* 4 = fastest, 0 = neither), `lintian` reads it to raise
|
|
38
|
+
* `changelog-not-compressed-with-max-compression`, and `file` prints it as
|
|
39
|
+
* "max compression". Stamping that byte here beside the mtime and OS bytes would
|
|
40
|
+
* be a different thing entirely and was rejected for it: mtime and OS are facts
|
|
41
|
+
* about the build ENVIRONMENT, which a reproducible build is entitled to
|
|
42
|
+
* normalise, while XFL is a statement about the compression actually performed.
|
|
43
|
+
* Writing a 2 we had not earned would make the artifact lie to the tool reading
|
|
44
|
+
* it. So the level travels down to the compressor instead.
|
|
45
|
+
*
|
|
46
|
+
* AND THE TEMPTATION IS REAL, which is why this is worth writing down: measured
|
|
47
|
+
* on gjsify's own `.deb`, the changelog member compressed at the default level
|
|
48
|
+
* and at 9 differs in EXACTLY ONE BYTE — position 9, XFL, 0 against 2 — and is
|
|
49
|
+
* 1152 bytes either way. For this input, stamping the byte would have produced
|
|
50
|
+
* the identical artifact. It is identical by coincidence of a small input: over
|
|
51
|
+
* this repo's full `CHANGELOG.md` the same two levels differ by thousands of
|
|
52
|
+
* bytes. A rule that happens to hold on today's input is not the rule.
|
|
53
|
+
*/
|
|
54
|
+
export async function gzipDeterministic(input, level) {
|
|
55
|
+
const out = await gzip(input, level === undefined ? undefined : { level });
|
|
12
56
|
if (out.byteLength < 10 || out[0] !== 0x1f || out[1] !== 0x8b) {
|
|
13
57
|
throw new Error('gjsify ship: internal error — gzip did not produce a gzip stream.');
|
|
14
58
|
}
|
|
@@ -19,3 +63,11 @@ export async function gzipDeterministic(input) {
|
|
|
19
63
|
out[9] = 3;
|
|
20
64
|
return out;
|
|
21
65
|
}
|
|
66
|
+
/**
|
|
67
|
+
* `gzip -9`, the level Debian Policy § 4.4 asks of a packaging changelog.
|
|
68
|
+
*
|
|
69
|
+
* Named rather than spelled `9` at the call site so the reason travels with the
|
|
70
|
+
* number: the `-n` half of Policy's `gzip -9 -n` is what {@link gzipDeterministic}
|
|
71
|
+
* already does to bytes 4-7.
|
|
72
|
+
*/
|
|
73
|
+
export const POLICY_MAX_COMPRESSION = 9;
|
package/lib/utils/ship/plan.d.ts
CHANGED
|
@@ -67,11 +67,39 @@ export declare function planOverlay(settings: ShipSettings, format: FormatDescri
|
|
|
67
67
|
*/
|
|
68
68
|
export declare function assertOverlayIsLicensed(formatId: FormatId, binaryName: string, overlay: readonly StagedFile[]): void;
|
|
69
69
|
/**
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
70
|
+
* Whether an icon belongs in the theme's SYMBOLIC context rather than at a size.
|
|
71
|
+
*
|
|
72
|
+
* `symbolic` is a directory of its own in a hicolor theme and NOT a size value.
|
|
73
|
+
* Measured in `refs/adwaita-icon-theme/index.theme`, which lists `symbolic/apps`
|
|
74
|
+
* in `Directories=` beside `scalable/apps` and `16x16/apps` and gives it its own
|
|
75
|
+
* section — `Context=Applications`, `Size=16`, `MinSize=8`, `MaxSize=512`,
|
|
76
|
+
* `Type=Scalable` — against the scalable row's `Size=128`. The freedesktop icon
|
|
77
|
+
* theme specification has no notion of `symbolic` at all: a theme expresses it by
|
|
78
|
+
* giving the directory a section, which is exactly why reading the EXTENSION
|
|
79
|
+
* cannot see it, and why this question has to be asked before the size one.
|
|
80
|
+
*
|
|
81
|
+
* TWO SIGNALS, the same pair `iconThemeDir` already reads for a size: the
|
|
82
|
+
* directory an author put the file in, and the name they gave it. GTK resolves a
|
|
83
|
+
* symbolic icon by the `-symbolic` name suffix, so an author who wrote the name
|
|
84
|
+
* has said as much as one who wrote the path.
|
|
85
|
+
*
|
|
86
|
+
* SVG ONLY, because the symbolic directory is declared `Type=Scalable`: the
|
|
87
|
+
* raster form GTK's own `gtk-encode-symbolic-svg` produces
|
|
88
|
+
* (`<name>-symbolic.symbolic.png`) is installed at a SIZE instead. So a PNG keeps
|
|
89
|
+
* answering the size question, and one that cannot answer it stays refused rather
|
|
90
|
+
* than quietly becoming a scalable icon that does not scale.
|
|
91
|
+
*/
|
|
92
|
+
export declare function isSymbolicIcon(iconPath: string): boolean;
|
|
93
|
+
/**
|
|
94
|
+
* The hicolor subdirectory for an icon: the `symbolic` CONTEXT, else `scalable`
|
|
95
|
+
* for an SVG, else the pixel size read from a `<n>x<n>` path component or a
|
|
96
|
+
* trailing number in the filename.
|
|
97
|
+
*
|
|
98
|
+
* Named for the theme DIRECTORY rather than a size from the moment the first of
|
|
99
|
+
* those answers stopped being one: `iconSizeDir` returning `symbolic` would be a
|
|
100
|
+
* name every caller has to read past.
|
|
73
101
|
*/
|
|
74
|
-
export declare function
|
|
102
|
+
export declare function iconThemeDir(iconPath: string): string;
|
|
75
103
|
/**
|
|
76
104
|
* A native module inside the payload has to keep its executable bit.
|
|
77
105
|
*
|