@gjsify/cli 0.42.0 → 0.44.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 +444 -415
- package/lib/actions/build.d.ts +6 -0
- package/lib/actions/build.js +66 -9
- package/lib/commands/affected-classify.js +7 -5
- package/lib/commands/build.js +6 -0
- package/lib/commands/flatpak/ci.js +10 -2
- package/lib/commands/flatpak/init.js +15 -16
- package/lib/commands/flatpak/utils.d.ts +0 -26
- package/lib/commands/flatpak/utils.js +4 -29
- package/lib/commands/ship.d.ts +2 -0
- package/lib/commands/ship.js +358 -104
- package/lib/config.js +25 -0
- package/lib/types/cli-build-options.d.ts +11 -0
- package/lib/types/config-data.d.ts +106 -2
- package/lib/utils/flatpak-runtime.d.ts +41 -0
- package/lib/utils/flatpak-runtime.js +54 -0
- package/lib/utils/gjs-bundle-guard.d.ts +57 -3
- package/lib/utils/gjs-bundle-guard.js +122 -13
- package/lib/utils/jsx-config.d.ts +45 -0
- package/lib/utils/jsx-config.js +98 -0
- package/lib/utils/onboard-probe.d.ts +23 -0
- package/lib/utils/onboard-probe.js +65 -0
- package/lib/utils/ship/deb.d.ts +3 -3
- package/lib/utils/ship/deb.js +6 -2
- package/lib/utils/ship/depends.d.ts +13 -13
- package/lib/utils/ship/depends.js +25 -3
- package/lib/utils/ship/discover.js +72 -6
- package/lib/utils/ship/flatpak-config.d.ts +66 -0
- package/lib/utils/ship/flatpak-config.js +132 -0
- package/lib/utils/ship/flatpak.d.ts +45 -0
- package/lib/utils/ship/flatpak.js +148 -0
- package/lib/utils/ship/formats.d.ts +35 -0
- package/lib/utils/ship/formats.js +152 -5
- package/lib/utils/ship/mime.d.ts +19 -0
- package/lib/utils/ship/mime.js +89 -0
- package/lib/utils/ship/payload.d.ts +76 -0
- package/lib/utils/ship/payload.js +91 -1
- package/lib/utils/ship/plan.d.ts +30 -1
- package/lib/utils/ship/plan.js +54 -2
- package/lib/utils/ship/rpm.d.ts +3 -3
- package/lib/utils/ship/rpm.js +20 -3
- package/lib/utils/ship/scripts.d.ts +14 -4
- package/lib/utils/ship/scripts.js +26 -8
- package/lib/utils/ship/settings.js +20 -0
- package/lib/utils/ship/stage-manifest.d.ts +163 -0
- package/lib/utils/ship/stage-manifest.js +445 -0
- package/lib/utils/ship/stage-writer.d.ts +30 -3
- package/lib/utils/ship/stage-writer.js +76 -9
- package/lib/utils/ship/types.d.ts +203 -27
- package/lib/utils/ship/types.js +5 -0
- package/package.json +19 -19
|
@@ -0,0 +1,445 @@
|
|
|
1
|
+
// `.gjsify-ship-stage.json` — what one `gjsify ship --stage` hands the
|
|
2
|
+
// `gjsify ship --from-stage` that finishes it (ADR 0024 § A2).
|
|
3
|
+
//
|
|
4
|
+
// A CLOSURE, not a settings dump. The packing host has the staged tree and
|
|
5
|
+
// nothing else: no project directory, no `package.json`, no built bundle, no
|
|
6
|
+
// `gjsify.ship` config. Everything the packers still need has to be in here,
|
|
7
|
+
// and everything that is a BUILD-HOST PATH must not be — serialised verbatim,
|
|
8
|
+
// `bundlePath` and friends name files that do not exist where they are read.
|
|
9
|
+
//
|
|
10
|
+
// The measurement that decided the contents: each of these omissions fails
|
|
11
|
+
// SILENTLY, at exit 0, with an artifact that installs.
|
|
12
|
+
//
|
|
13
|
+
// * no `staged` — modes come from the plan (see `stage-writer.ts`), so the
|
|
14
|
+
// launcher packs 0644 and the installed `bin/<name>` is not executable.
|
|
15
|
+
// * no `overlay` — the `.deb` ships no `/usr/share/doc/<pkg>/copyright`,
|
|
16
|
+
// which Debian Policy § 12.5 requires and lintian errors on, and the
|
|
17
|
+
// `.rpm` ships no `/usr/share/licenses/<pkg>/LICENSE`.
|
|
18
|
+
// * no `namespaces` — they are scanned from the BUILD TREE's bundle, so a
|
|
19
|
+
// packing host that re-derived nothing would emit `Depends:` without
|
|
20
|
+
// `gir1.2-gtk-4.0` and `gir1.2-adw-1`: the package installs on a machine
|
|
21
|
+
// with no GTK and dies at the first import. That is the exact failure
|
|
22
|
+
// ADR 0024 § 6 exists to prevent, moved one host to the left.
|
|
23
|
+
// * no `mtime` — an artifact upload does not carry mtimes, so re-stat'ing
|
|
24
|
+
// the stage stamps every header with the download time and two hosts
|
|
25
|
+
// packing the same stage produce different bytes.
|
|
26
|
+
//
|
|
27
|
+
// What is deliberately NOT carried, so the next reader does not add it back:
|
|
28
|
+
// * per-file digests. ADR 0024 § A4 measured that the darwin finish leg has
|
|
29
|
+
// to re-sign all 106 Mach-O images inside the stage, at which point a
|
|
30
|
+
// sha256 set either refuses the artifact it exists to produce or is
|
|
31
|
+
// relaxed to exempt 106 files and stops checking anything. A per-file SIZE
|
|
32
|
+
// survives that and still catches the truncated transfer, so that is what
|
|
33
|
+
// `staged[].bytes` is.
|
|
34
|
+
// * anything derivable from the payload — `hasIcons`, `hasSchemas`, the
|
|
35
|
+
// bundled typelibs. `readPayloadFacts` answers all three from the tree
|
|
36
|
+
// that is right there.
|
|
37
|
+
import { existsSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
38
|
+
import { join, sep } from 'node:path';
|
|
39
|
+
import { cliVersion } from '../publish-headers.js';
|
|
40
|
+
import { FORMAT_IDS } from './formats.js';
|
|
41
|
+
/** The one file a stage carries that is not payload. Lives at the stage ROOT. */
|
|
42
|
+
export const STAGE_MANIFEST_FILE = '.gjsify-ship-stage.json';
|
|
43
|
+
/**
|
|
44
|
+
* The schema this CLI writes and the only one it reads.
|
|
45
|
+
*
|
|
46
|
+
* A stage is a short-lived intermediate between two jobs of ONE run, not an
|
|
47
|
+
* archive, so there is no migration path in either direction and both
|
|
48
|
+
* mismatches are refused rather than guessed at — see {@link readStageManifest}.
|
|
49
|
+
* Bump it whenever a field's MEANING changes, not when one is added that an
|
|
50
|
+
* older reader would simply ignore; a reader that ignores a field it does not
|
|
51
|
+
* know is exactly how a mode plan silently loses an entry.
|
|
52
|
+
*
|
|
53
|
+
* 2 added `settings.appId` and `settings.flatpak` for the Flatpak target. That
|
|
54
|
+
* is the second case and not the first: an older reader ignoring
|
|
55
|
+
* `settings.flatpak` would not skip a field, it would pack a Flatpak against
|
|
56
|
+
* whatever runtime IT defaults to — a different `org.gnome.Platform` version
|
|
57
|
+
* than the project asked for, at exit 0.
|
|
58
|
+
*/
|
|
59
|
+
export const STAGE_SCHEMA_VERSION = 2;
|
|
60
|
+
/**
|
|
61
|
+
* The OS whose layout `planStage` produces.
|
|
62
|
+
*
|
|
63
|
+
* A constant rather than a measurement, because it is a property of the PLAN
|
|
64
|
+
* (`bin/`, `lib/<name>/`, `share/…` is the Linux/XDG layout), not of the host
|
|
65
|
+
* that ran it — assembling is cross-platform (ADR 0024 § A1). When
|
|
66
|
+
* `gjsify ship darwin --stage` lands this becomes the positional's value, and
|
|
67
|
+
* this constant is where the compiler will point.
|
|
68
|
+
*/
|
|
69
|
+
export const STAGE_LAYOUT_OS = 'linux';
|
|
70
|
+
/**
|
|
71
|
+
* The half of {@link ShipSettings} that crosses the host boundary.
|
|
72
|
+
*
|
|
73
|
+
* Written out field by field rather than spread: a spread would carry
|
|
74
|
+
* `projectDir`, `bundlePath`, `bundleDir`, `iconFiles`, `schemaFiles`,
|
|
75
|
+
* `extraFiles` and `licenseFile` — seven absolute paths from the assembling
|
|
76
|
+
* host — into a file whose whole purpose is to be read somewhere else. The
|
|
77
|
+
* explicit list is also what makes adding a field a decision: see the comment
|
|
78
|
+
* on {@link PackSettings} for what belongs on each side. `appId` and `flatpak`
|
|
79
|
+
* were that decision for the Flatpak target, and the `--from-stage` e2e is
|
|
80
|
+
* where an omission would have shown: it deletes the project, so a field the
|
|
81
|
+
* packer needs and the manifest lacks has nowhere to be read from.
|
|
82
|
+
*/
|
|
83
|
+
export function toPackSettings(settings) {
|
|
84
|
+
return {
|
|
85
|
+
binaryName: settings.binaryName,
|
|
86
|
+
appId: settings.appId,
|
|
87
|
+
version: settings.version,
|
|
88
|
+
release: settings.release,
|
|
89
|
+
maintainer: settings.maintainer,
|
|
90
|
+
summary: settings.summary,
|
|
91
|
+
description: settings.description,
|
|
92
|
+
license: settings.license,
|
|
93
|
+
...(settings.homepage === undefined ? {} : { homepage: settings.homepage }),
|
|
94
|
+
section: settings.section,
|
|
95
|
+
group: settings.group,
|
|
96
|
+
extraDepends: settings.extraDepends,
|
|
97
|
+
typelibPackages: settings.typelibPackages,
|
|
98
|
+
minGjsVersion: settings.minGjsVersion,
|
|
99
|
+
flatpak: settings.flatpak,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
/** Absolute path of a stage's manifest. */
|
|
103
|
+
export function stageManifestPath(stageDir) {
|
|
104
|
+
return join(stageDir, STAGE_MANIFEST_FILE);
|
|
105
|
+
}
|
|
106
|
+
/** `linux-x64`. One spelling repo-wide (root AGENTS.md § Runtime & platform model). */
|
|
107
|
+
export function formatTarget(target) {
|
|
108
|
+
return `${target.os}-${target.arch}`;
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Write the manifest at the stage root, AFTER the tree itself.
|
|
112
|
+
*
|
|
113
|
+
* `bytes` is stat'ed off the written tree rather than taken from the plan: the
|
|
114
|
+
* plan holds a source path or a string, and what the packing host will compare
|
|
115
|
+
* against is what actually landed on disk here.
|
|
116
|
+
*/
|
|
117
|
+
export function writeStageManifest(input) {
|
|
118
|
+
const overlay = {};
|
|
119
|
+
for (const format of input.formats) {
|
|
120
|
+
overlay[format.id] = (input.overlay.get(format.id) ?? []).map((file) => {
|
|
121
|
+
if (file.source.kind !== 'text') {
|
|
122
|
+
// Every overlay entry `planOverlay` produces today is rendered text. A file
|
|
123
|
+
// reference would be a build-host path, and putting one in here is how the
|
|
124
|
+
// manifest would start naming files the packing host cannot open.
|
|
125
|
+
throw new Error(`gjsify ship: internal error — the ${format.id} overlay entry ${file.path} is a file ` +
|
|
126
|
+
'reference, and a stage manifest may only carry rendered text. Render it in ' +
|
|
127
|
+
'`planOverlay` instead.');
|
|
128
|
+
}
|
|
129
|
+
return { path: file.path, mode: file.mode, text: file.source.text };
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
const manifest = {
|
|
133
|
+
schema: STAGE_SCHEMA_VERSION,
|
|
134
|
+
tool: { name: '@gjsify/cli', version: cliVersion() },
|
|
135
|
+
target: { os: STAGE_LAYOUT_OS, arch: input.settings.arch },
|
|
136
|
+
formats: input.formats.map((format) => format.id),
|
|
137
|
+
mtime: input.mtime,
|
|
138
|
+
namespaces: [...input.namespaces],
|
|
139
|
+
settings: toPackSettings(input.settings),
|
|
140
|
+
staged: input.staged.map((file) => ({
|
|
141
|
+
path: file.path,
|
|
142
|
+
mode: file.mode,
|
|
143
|
+
bytes: statSync(join(input.stageDir, file.path.split('/').join(sep))).size,
|
|
144
|
+
})),
|
|
145
|
+
overlay,
|
|
146
|
+
};
|
|
147
|
+
writeFileSync(stageManifestPath(input.stageDir), `${JSON.stringify(manifest, null, 2)}\n`);
|
|
148
|
+
return manifest;
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Read and validate a stage's manifest.
|
|
152
|
+
*
|
|
153
|
+
* Every field is checked, because this file is the only thing standing between
|
|
154
|
+
* "a directory someone pointed us at" and a signed-looking `.deb`. A missing
|
|
155
|
+
* `staged` entry would reach `readStage` as a mode nobody planned; a `mode` that
|
|
156
|
+
* arrived as a string would reach `chmod` as `NaN`.
|
|
157
|
+
*/
|
|
158
|
+
export function readStageManifest(stageDir) {
|
|
159
|
+
const path = stageManifestPath(stageDir);
|
|
160
|
+
if (!existsSync(path)) {
|
|
161
|
+
throw new Error(`gjsify ship: ${stageDir} has no ${STAGE_MANIFEST_FILE}, so it is not a stage this command can ` +
|
|
162
|
+
'pack. A stage is produced by `gjsify ship --stage`, which writes that file at the stage root ' +
|
|
163
|
+
"beside the payload. If you pointed --from-stage at a project's `ship/` directory, point it at " +
|
|
164
|
+
'`ship/stage/` instead.');
|
|
165
|
+
}
|
|
166
|
+
let raw;
|
|
167
|
+
try {
|
|
168
|
+
raw = JSON.parse(readFileSync(path, 'utf-8'));
|
|
169
|
+
}
|
|
170
|
+
catch (error) {
|
|
171
|
+
throw new Error(`gjsify ship: ${path} is not readable JSON (${error.message}). ` +
|
|
172
|
+
'Re-run the `--stage` phase; a truncated manifest means the stage transfer was truncated too.');
|
|
173
|
+
}
|
|
174
|
+
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
|
|
175
|
+
throw new Error(`gjsify ship: ${path} is not a JSON object. Re-run the \`--stage\` phase.`);
|
|
176
|
+
}
|
|
177
|
+
const data = raw;
|
|
178
|
+
const schema = data.schema;
|
|
179
|
+
if (typeof schema !== 'number' || !Number.isInteger(schema)) {
|
|
180
|
+
throw new Error(`gjsify ship: ${path} has no integer \`schema\` field, so it was not written by \`gjsify ship ` +
|
|
181
|
+
'--stage`. Re-run the `--stage` phase.');
|
|
182
|
+
}
|
|
183
|
+
if (schema > STAGE_SCHEMA_VERSION) {
|
|
184
|
+
throw new Error(`gjsify ship: ${path} declares schema ${schema} and this gjsify understands ` +
|
|
185
|
+
`${STAGE_SCHEMA_VERSION}. The stage was assembled by a newer gjsify: upgrade this one ` +
|
|
186
|
+
'(`gjsify self-update`), or re-run the `--stage` phase with this version. A manifest from the ' +
|
|
187
|
+
'future is refused rather than partially read, because an unknown field could be the one that ' +
|
|
188
|
+
"decides a file's mode.");
|
|
189
|
+
}
|
|
190
|
+
if (schema < STAGE_SCHEMA_VERSION) {
|
|
191
|
+
throw new Error(`gjsify ship: ${path} declares schema ${schema} and this gjsify understands ` +
|
|
192
|
+
`${STAGE_SCHEMA_VERSION}. Re-run the \`--stage\` phase with this gjsify. A stage is a ` +
|
|
193
|
+
'short-lived intermediate between two jobs of one run, not an archive, so no migration is ' +
|
|
194
|
+
'kept for it.');
|
|
195
|
+
}
|
|
196
|
+
const at = (field) => `${path} → ${field}`;
|
|
197
|
+
const tool = record(data.tool, at('tool'));
|
|
198
|
+
const target = record(data.target, at('target'));
|
|
199
|
+
const manifest = {
|
|
200
|
+
schema,
|
|
201
|
+
tool: {
|
|
202
|
+
name: expectString(tool.name, at('tool.name')),
|
|
203
|
+
version: expectString(tool.version, at('tool.version')),
|
|
204
|
+
},
|
|
205
|
+
target: {
|
|
206
|
+
os: expectString(target.os, at('target.os')),
|
|
207
|
+
arch: expectString(target.arch, at('target.arch')),
|
|
208
|
+
},
|
|
209
|
+
formats: expectArray(data.formats, at('formats')).map((entry, index) => {
|
|
210
|
+
const id = expectString(entry, at(`formats[${index}]`));
|
|
211
|
+
if (!FORMAT_IDS.includes(id)) {
|
|
212
|
+
throw new Error(`gjsify ship: ${at(`formats[${index}]`)} is "${id}", which this gjsify does not know. ` +
|
|
213
|
+
`Known formats: ${FORMAT_IDS.join(', ')}.`);
|
|
214
|
+
}
|
|
215
|
+
return id;
|
|
216
|
+
}),
|
|
217
|
+
mtime: expectInteger(data.mtime, at('mtime')),
|
|
218
|
+
namespaces: expectArray(data.namespaces, at('namespaces')).map((entry, index) => expectString(entry, at(`namespaces[${index}]`))),
|
|
219
|
+
settings: readPackSettings(record(data.settings, at('settings')), at('settings')),
|
|
220
|
+
staged: expectArray(data.staged, at('staged')).map((entry, index) => {
|
|
221
|
+
const file = record(entry, at(`staged[${index}]`));
|
|
222
|
+
return {
|
|
223
|
+
path: expectString(file.path, at(`staged[${index}].path`)),
|
|
224
|
+
mode: expectInteger(file.mode, at(`staged[${index}].mode`)),
|
|
225
|
+
bytes: expectInteger(file.bytes, at(`staged[${index}].bytes`)),
|
|
226
|
+
};
|
|
227
|
+
}),
|
|
228
|
+
overlay: {},
|
|
229
|
+
};
|
|
230
|
+
// `readStage` refuses in both directions — a staged path the plan does not name,
|
|
231
|
+
// and a planned path the stage lacks — but both sides can be empty TOGETHER and
|
|
232
|
+
// agree. Measured: a directory holding only the sidecar packed into a structurally
|
|
233
|
+
// valid `ship-demo_1.2.3-1_all.deb` that `dpkg -i` installs and that contains no
|
|
234
|
+
// `bin/` and no `lib/`; the command simply does not exist afterwards.
|
|
235
|
+
if (manifest.staged.length === 0) {
|
|
236
|
+
throw new Error(`gjsify ship: ${at('staged')} is empty, so this stage describes no payload. ` +
|
|
237
|
+
'A stage always carries at least the launcher its plan named. Re-run the ' +
|
|
238
|
+
'`--stage` phase; a stage that lost its plan cannot be packed into anything ' +
|
|
239
|
+
'but an empty package.');
|
|
240
|
+
}
|
|
241
|
+
const overlay = record(data.overlay, at('overlay'));
|
|
242
|
+
for (const id of manifest.formats) {
|
|
243
|
+
// No `?? []`: `writeStageManifest` emits a key for every staged format even
|
|
244
|
+
// when the project has no LICENCE (the value is then an empty array), so a
|
|
245
|
+
// MISSING key can only mean a mutilated manifest. Defaulting it away is the
|
|
246
|
+
// `?? 0o644` bug one field over — measured: with `"overlay": {}` the finish
|
|
247
|
+
// phase exited 0 and wrote a `.deb` carrying no
|
|
248
|
+
// `/usr/share/doc/<pkg>/copyright`, which Debian Policy § 12.5 requires and
|
|
249
|
+
// lintian errors on.
|
|
250
|
+
manifest.overlay[id] = expectArray(overlay[id], at(`overlay.${id}`)).map((entry, index) => {
|
|
251
|
+
const file = record(entry, at(`overlay.${id}[${index}]`));
|
|
252
|
+
return {
|
|
253
|
+
path: expectString(file.path, at(`overlay.${id}[${index}].path`)),
|
|
254
|
+
mode: expectInteger(file.mode, at(`overlay.${id}[${index}].mode`)),
|
|
255
|
+
text: expectString(file.text, at(`overlay.${id}[${index}].text`)),
|
|
256
|
+
};
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
return manifest;
|
|
260
|
+
}
|
|
261
|
+
/** The overlay for one format, in the shape `writeStage` takes. */
|
|
262
|
+
export function overlayFiles(manifest, format) {
|
|
263
|
+
return (manifest.overlay[format] ?? []).map((file) => ({
|
|
264
|
+
path: file.path,
|
|
265
|
+
mode: file.mode,
|
|
266
|
+
source: { kind: 'text', text: file.text },
|
|
267
|
+
}));
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* Refuse a stage that is not the one this job was told to pack.
|
|
271
|
+
*
|
|
272
|
+
* WHAT THIS IS: a matrix-leg mix-up check. It catches a finish job that
|
|
273
|
+
* downloaded the wrong artifact — the leg that forgot to route — and nothing
|
|
274
|
+
* more. It is opt-in (no flag, no check) and it compares two strings the same
|
|
275
|
+
* pipeline wrote one job apart, so treat a green run as "this is the stage I
|
|
276
|
+
* asked for", never as "this stage is correctly labelled".
|
|
277
|
+
*
|
|
278
|
+
* WHAT THIS IS NOT: the guard its precedent
|
|
279
|
+
* (`packages/node-gi/scripts/verify-bundle-manifest.mjs --expect-host-target`)
|
|
280
|
+
* provides. That script compares a bundle to `process.platform`/`process.arch`
|
|
281
|
+
* because the runner BUILT it, so the machine is the right second opinion.
|
|
282
|
+
* Here the stage arrives from another host by design, so a host comparison
|
|
283
|
+
* would refuse a legal use — measured: an `arm64`-labelled stage packs on this
|
|
284
|
+
* x64 host at exit 0 with correct bytes, because both packers are pure JS.
|
|
285
|
+
*
|
|
286
|
+
* The class that precedent guards — a label and the bytes under it disagreeing
|
|
287
|
+
* — is NOT guarded here, and `target.arch` cannot guard it: it is defaulted
|
|
288
|
+
* from `process.arch` only when `--arch` is absent, so the moment an operator
|
|
289
|
+
* says which leg they are on, `target.arch` is that operator's claim and
|
|
290
|
+
* nothing compares it to a machine or to a byte. Measured BEFORE
|
|
291
|
+
* {@link assertPayloadMatchesArch} existed: a stage carrying an x86-64 `.so`,
|
|
292
|
+
* assembled `--stage --arch arm64` and packed `--expect-target linux-arm64`,
|
|
293
|
+
* exited 0 and produced an `aarch64` rpm whose only `.so` was `e_machine=0x3e`.
|
|
294
|
+
* The same command in this tree now exits 1 and writes nothing — kept in the
|
|
295
|
+
* past tense because it is the reason the guard below it exists, not a
|
|
296
|
+
* description of what this function does.
|
|
297
|
+
*
|
|
298
|
+
* That class is guarded one layer down instead, where the oracle is free and
|
|
299
|
+
* cannot be wrong about a machine it never saw: {@link assertPayloadMatchesArch}
|
|
300
|
+
* in `payload.ts` reads ELF `e_machine` / Mach-O `cputype` out of the payload
|
|
301
|
+
* and refuses a label the bytes contradict. Payload-versus-label goes red on
|
|
302
|
+
* the mislabelled stage AND stays green on the legitimate cross-host pack;
|
|
303
|
+
* host-versus-label can only do one of the two.
|
|
304
|
+
*/
|
|
305
|
+
export function assertExpectedTarget(manifest, expected) {
|
|
306
|
+
if (expected === undefined)
|
|
307
|
+
return;
|
|
308
|
+
const actual = formatTarget(manifest.target);
|
|
309
|
+
if (expected !== actual) {
|
|
310
|
+
throw new Error(`gjsify ship: --expect-target ${expected} but this stage was assembled for ${actual}. ` +
|
|
311
|
+
'The stage records the target its `--arch` resolved to at stage time, so this is a stage from ' +
|
|
312
|
+
'another matrix leg: download the artifact belonging to ' +
|
|
313
|
+
`${expected}, or drop --expect-target if packing this one is what you meant.`);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
/** Refuse a format the stage carries no overlay closure for. */
|
|
317
|
+
export function assertFormatsStaged(manifest, formats) {
|
|
318
|
+
const missing = formats.filter((format) => !manifest.formats.includes(format.id)).map((format) => format.id);
|
|
319
|
+
if (missing.length === 0)
|
|
320
|
+
return;
|
|
321
|
+
throw new Error(`gjsify ship: this stage was assembled for ${manifest.formats.join(', ')}, and --target names ` +
|
|
322
|
+
`${missing.join(', ')}. Phase one renders each format's overlay — rpm wants the licence at ` +
|
|
323
|
+
'`share/licenses/<pkg>/LICENSE`, deb wants a machine-readable copyright at ' +
|
|
324
|
+
'`share/doc/<pkg>/copyright` — so a format the stage never saw would pack without it, and Debian ' +
|
|
325
|
+
`Policy § 12.5 makes that a broken package. Re-run \`gjsify ship --stage --target ${[
|
|
326
|
+
...manifest.formats,
|
|
327
|
+
...missing,
|
|
328
|
+
].join(',')}\` on the assembling host.`);
|
|
329
|
+
}
|
|
330
|
+
/**
|
|
331
|
+
* The build stamp the packing host uses: the stage's, always.
|
|
332
|
+
*
|
|
333
|
+
* A `SOURCE_DATE_EPOCH` set only in the finish job would silently produce a
|
|
334
|
+
* different artifact than the same stage packed on the assembling host, which
|
|
335
|
+
* is the one property the two-phase split has to keep. It is a warning rather
|
|
336
|
+
* than a refusal because the honest fix ("set it in both jobs or in neither")
|
|
337
|
+
* is the caller's, and refusing would break a runner that exports it globally.
|
|
338
|
+
*/
|
|
339
|
+
export function resolveStageMtime(manifest, env = process.env) {
|
|
340
|
+
const raw = env.SOURCE_DATE_EPOCH;
|
|
341
|
+
if (raw === undefined || Number(raw) === manifest.mtime)
|
|
342
|
+
return { mtime: manifest.mtime, warnings: [] };
|
|
343
|
+
return {
|
|
344
|
+
mtime: manifest.mtime,
|
|
345
|
+
warnings: [
|
|
346
|
+
`SOURCE_DATE_EPOCH is ${raw} here and this stage was stamped ${manifest.mtime}. The stage's stamp ` +
|
|
347
|
+
'wins, because changing it would make this artifact differ from the same stage packed on the ' +
|
|
348
|
+
'assembling host. Set the same value in both jobs, or set it in neither.',
|
|
349
|
+
],
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
/** One actionable line when the two phases ran different gjsify versions. */
|
|
353
|
+
export function warnOnToolSkew(manifest) {
|
|
354
|
+
const running = cliVersion();
|
|
355
|
+
if (manifest.tool.version === running)
|
|
356
|
+
return [];
|
|
357
|
+
return [
|
|
358
|
+
`this stage was assembled by ${manifest.tool.name} ${manifest.tool.version} and is being packed by ` +
|
|
359
|
+
`${running}. Both phases run the same packer, so a version difference can change the artifact's ` +
|
|
360
|
+
'bytes. Pin both jobs to one gjsify version.',
|
|
361
|
+
];
|
|
362
|
+
}
|
|
363
|
+
function readPackSettings(data, at) {
|
|
364
|
+
const field = (name) => `${at}.${name}`;
|
|
365
|
+
const extraDepends = record(data.extraDepends, field('extraDepends'));
|
|
366
|
+
const typelibPackages = record(data.typelibPackages, field('typelibPackages'));
|
|
367
|
+
return {
|
|
368
|
+
binaryName: expectString(data.binaryName, field('binaryName')),
|
|
369
|
+
appId: expectString(data.appId, field('appId')),
|
|
370
|
+
version: expectString(data.version, field('version')),
|
|
371
|
+
release: expectString(data.release, field('release')),
|
|
372
|
+
maintainer: expectString(data.maintainer, field('maintainer')),
|
|
373
|
+
summary: expectString(data.summary, field('summary')),
|
|
374
|
+
description: expectArray(data.description, field('description')).map((entry, index) => expectString(entry, field(`description[${index}]`))),
|
|
375
|
+
license: expectString(data.license, field('license')),
|
|
376
|
+
...(data.homepage === undefined ? {} : { homepage: expectString(data.homepage, field('homepage')) }),
|
|
377
|
+
section: expectString(data.section, field('section')),
|
|
378
|
+
group: expectString(data.group, field('group')),
|
|
379
|
+
extraDepends: {
|
|
380
|
+
deb: expectArray(extraDepends.deb ?? [], field('extraDepends.deb')).map((entry, index) => expectString(entry, field(`extraDepends.deb[${index}]`))),
|
|
381
|
+
rpm: expectArray(extraDepends.rpm ?? [], field('extraDepends.rpm')).map((entry, index) => expectString(entry, field(`extraDepends.rpm[${index}]`))),
|
|
382
|
+
},
|
|
383
|
+
typelibPackages: Object.fromEntries(Object.entries(typelibPackages).map(([namespace, value]) => {
|
|
384
|
+
const row = record(value, field(`typelibPackages.${namespace}`));
|
|
385
|
+
return [
|
|
386
|
+
namespace,
|
|
387
|
+
{
|
|
388
|
+
deb: expectString(row.deb, field(`typelibPackages.${namespace}.deb`)),
|
|
389
|
+
rpm: expectString(row.rpm, field(`typelibPackages.${namespace}.rpm`)),
|
|
390
|
+
},
|
|
391
|
+
];
|
|
392
|
+
})),
|
|
393
|
+
minGjsVersion: expectString(data.minGjsVersion, field('minGjsVersion')),
|
|
394
|
+
flatpak: readFlatpakSettings(record(data.flatpak, field('flatpak')), field('flatpak')),
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
/**
|
|
398
|
+
* The Flatpak half, validated the same way as everything else here.
|
|
399
|
+
*
|
|
400
|
+
* Every field is required and none is defaulted, deliberately: a `?? []` on
|
|
401
|
+
* `finishArgs` would turn a mutilated manifest into an app with no display
|
|
402
|
+
* socket — it installs, it starts, and it draws nothing. The defaults belong on
|
|
403
|
+
* the assembling host, where `gjsify.ship` and `kind` are in reach; by the time
|
|
404
|
+
* a manifest exists the answer has already been decided and writing it down
|
|
405
|
+
* twice is how the two hosts disagree.
|
|
406
|
+
*/
|
|
407
|
+
function readFlatpakSettings(data, at) {
|
|
408
|
+
const field = (name) => `${at}.${name}`;
|
|
409
|
+
const strings = (value, name) => expectArray(value, field(name)).map((entry, index) => expectString(entry, field(`${name}[${index}]`)));
|
|
410
|
+
return {
|
|
411
|
+
runtime: expectString(data.runtime, field('runtime')),
|
|
412
|
+
runtimeVersion: expectString(data.runtimeVersion, field('runtimeVersion')),
|
|
413
|
+
sdk: expectString(data.sdk, field('sdk')),
|
|
414
|
+
branch: expectString(data.branch, field('branch')),
|
|
415
|
+
sdkExtensions: strings(data.sdkExtensions, 'sdkExtensions'),
|
|
416
|
+
appendPath: strings(data.appendPath, 'appendPath'),
|
|
417
|
+
finishArgs: strings(data.finishArgs, 'finishArgs'),
|
|
418
|
+
cleanup: strings(data.cleanup, 'cleanup'),
|
|
419
|
+
};
|
|
420
|
+
}
|
|
421
|
+
function record(value, at) {
|
|
422
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
423
|
+
throw new Error(`gjsify ship: ${at} must be an object. Re-run the \`--stage\` phase.`);
|
|
424
|
+
}
|
|
425
|
+
return value;
|
|
426
|
+
}
|
|
427
|
+
function expectArray(value, at) {
|
|
428
|
+
if (!Array.isArray(value)) {
|
|
429
|
+
throw new Error(`gjsify ship: ${at} must be an array. Re-run the \`--stage\` phase.`);
|
|
430
|
+
}
|
|
431
|
+
return value;
|
|
432
|
+
}
|
|
433
|
+
function expectString(value, at) {
|
|
434
|
+
if (typeof value !== 'string') {
|
|
435
|
+
throw new Error(`gjsify ship: ${at} must be a string. Re-run the \`--stage\` phase.`);
|
|
436
|
+
}
|
|
437
|
+
return value;
|
|
438
|
+
}
|
|
439
|
+
function expectInteger(value, at) {
|
|
440
|
+
if (typeof value !== 'number' || !Number.isInteger(value) || value < 0) {
|
|
441
|
+
throw new Error(`gjsify ship: ${at} must be a non-negative integer. Re-run the \`--stage\` phase. ` +
|
|
442
|
+
'(Modes are decimal here — JSON has no octal literal, so 0o755 is written 493.)');
|
|
443
|
+
}
|
|
444
|
+
return value;
|
|
445
|
+
}
|
|
@@ -1,17 +1,44 @@
|
|
|
1
1
|
import type { PayloadEntry } from './payload.js';
|
|
2
|
-
import type { StagedFile } from './types.js';
|
|
2
|
+
import type { StagedFile, StagePlanEntry } from './types.js';
|
|
3
3
|
/**
|
|
4
4
|
* Materialise a planned tree under `root`, replacing whatever was there.
|
|
5
5
|
*
|
|
6
6
|
* The directory is wiped first: a stale file from a previous run is otherwise
|
|
7
7
|
* indistinguishable from a staged one, and would be packed.
|
|
8
|
+
*
|
|
9
|
+
* NEVER call this on a stage that ARRIVED — the wipe is unconditional, so
|
|
10
|
+
* pointing it at a downloaded stage deletes the payload it was about to pack.
|
|
11
|
+
* `gjsify ship --from-stage` writes only the format's own `overlay/` directory
|
|
12
|
+
* for that reason.
|
|
8
13
|
*/
|
|
9
14
|
export declare function writeStage(root: string, files: readonly StagedFile[]): void;
|
|
10
15
|
/**
|
|
11
|
-
* Read a staged tree back as a payload
|
|
16
|
+
* Read a staged tree back as a payload, and refuse a tree that is not the
|
|
17
|
+
* planned one.
|
|
12
18
|
*
|
|
13
19
|
* Modes come from the PLAN, not from `stat()`: a checked-out tree can carry
|
|
14
20
|
* whatever umask the build host had, and an executable bit that survives on
|
|
15
21
|
* one machine and not another is exactly the kind of difference that ships.
|
|
22
|
+
* A CI artifact is the sharpest case — `actions/upload-artifact` stores no
|
|
23
|
+
* POSIX mode at all, so a stage that made the trip has 0644 on its launcher
|
|
24
|
+
* and the plan is the only surviving record that it should be 0755.
|
|
25
|
+
*
|
|
26
|
+
* That same trip is why a disagreement is now an error in BOTH directions:
|
|
27
|
+
*
|
|
28
|
+
* - a path in the tree the plan does not name used to be packed at `?? 0o644`.
|
|
29
|
+
* That default is a guess about a file nobody planned; if it is a program it
|
|
30
|
+
* installs unrunnable, and the package is otherwise perfect.
|
|
31
|
+
* - a path in the plan the tree does not hold used to be silently absent from
|
|
32
|
+
* the payload. `.deb` and `.rpm` both accept that happily and the result is
|
|
33
|
+
* a package that installs and cannot start — this project's most expensive
|
|
34
|
+
* failure class.
|
|
35
|
+
*
|
|
36
|
+
* `bytes` is compared when the plan carries it (i.e. when the plan came from a
|
|
37
|
+
* stage manifest). It catches the truncated transfer that the file-set check
|
|
38
|
+
* cannot see: a half-written upload leaves every path in place. It is a SIZE
|
|
39
|
+
* and not a digest on purpose — ADR 0024 § A4 measured that the darwin finish
|
|
40
|
+
* leg has to re-sign all 106 Mach-O images inside the stage, so a per-file
|
|
41
|
+
* sha256 would have to be relaxed to exempt them and would then be checking
|
|
42
|
+
* nothing.
|
|
16
43
|
*/
|
|
17
|
-
export declare function readStage(roots: readonly string[],
|
|
44
|
+
export declare function readStage(roots: readonly string[], plan: readonly StagePlanEntry[]): PayloadEntry[];
|
|
@@ -5,17 +5,41 @@
|
|
|
5
5
|
// in-memory list, so "what the user inspected" and "what got packed" cannot
|
|
6
6
|
// diverge. It also makes the staged tree a legitimate deliverable on its own —
|
|
7
7
|
// which is what a Flatpak module's `cp -a stage/.` will consume once Flatpak
|
|
8
|
-
// moves under `ship` (ADR 0024 § 8)
|
|
8
|
+
// moves under `ship` (ADR 0024 § 8), and what `gjsify ship --from-stage` packs
|
|
9
|
+
// on a host that has the tree and nothing else (ADR 0024 § A2).
|
|
10
|
+
//
|
|
11
|
+
// That second consumer is why {@link readStage} refuses rather than defaults.
|
|
12
|
+
// A stage now arrives from another machine, so "the tree and the plan disagree"
|
|
13
|
+
// stopped being impossible and became a transfer failure — and every way of
|
|
14
|
+
// papering over it produces an artifact that installs.
|
|
9
15
|
import { chmodSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
10
16
|
import { dirname, join, sep } from 'node:path';
|
|
11
17
|
import { listFilesRecursive } from './discover.js';
|
|
18
|
+
import { STAGE_MANIFEST_FILE } from './stage-manifest.js';
|
|
12
19
|
/**
|
|
13
20
|
* Materialise a planned tree under `root`, replacing whatever was there.
|
|
14
21
|
*
|
|
15
22
|
* The directory is wiped first: a stale file from a previous run is otherwise
|
|
16
23
|
* indistinguishable from a staged one, and would be packed.
|
|
24
|
+
*
|
|
25
|
+
* NEVER call this on a stage that ARRIVED — the wipe is unconditional, so
|
|
26
|
+
* pointing it at a downloaded stage deletes the payload it was about to pack.
|
|
27
|
+
* `gjsify ship --from-stage` writes only the format's own `overlay/` directory
|
|
28
|
+
* for that reason.
|
|
17
29
|
*/
|
|
18
30
|
export function writeStage(root, files) {
|
|
31
|
+
for (const file of files) {
|
|
32
|
+
// The stage root carries exactly one file that is not payload, and this
|
|
33
|
+
// is its name. A planned file with the same path (reachable through
|
|
34
|
+
// `gjsify.ship.extraFiles`) would shadow the manifest: the tree would
|
|
35
|
+
// then either pack the manifest as payload or lose the closure that
|
|
36
|
+
// makes the tree packable somewhere else.
|
|
37
|
+
if (file.path === STAGE_MANIFEST_FILE) {
|
|
38
|
+
throw new Error(`gjsify ship: a payload file may not be called \`${STAGE_MANIFEST_FILE}\` — that name belongs ` +
|
|
39
|
+
'to the stage manifest, which sits at the stage root beside the payload and carries what ' +
|
|
40
|
+
'the packing host needs (ADR 0024 § A2). Rename the `gjsify.ship.extraFiles` destination.');
|
|
41
|
+
}
|
|
42
|
+
}
|
|
19
43
|
rmSync(root, { recursive: true, force: true, maxRetries: 5 });
|
|
20
44
|
mkdirSync(root, { recursive: true });
|
|
21
45
|
for (const file of files) {
|
|
@@ -27,23 +51,66 @@ export function writeStage(root, files) {
|
|
|
27
51
|
}
|
|
28
52
|
}
|
|
29
53
|
/**
|
|
30
|
-
* Read a staged tree back as a payload
|
|
54
|
+
* Read a staged tree back as a payload, and refuse a tree that is not the
|
|
55
|
+
* planned one.
|
|
31
56
|
*
|
|
32
57
|
* Modes come from the PLAN, not from `stat()`: a checked-out tree can carry
|
|
33
58
|
* whatever umask the build host had, and an executable bit that survives on
|
|
34
59
|
* one machine and not another is exactly the kind of difference that ships.
|
|
60
|
+
* A CI artifact is the sharpest case — `actions/upload-artifact` stores no
|
|
61
|
+
* POSIX mode at all, so a stage that made the trip has 0644 on its launcher
|
|
62
|
+
* and the plan is the only surviving record that it should be 0755.
|
|
63
|
+
*
|
|
64
|
+
* That same trip is why a disagreement is now an error in BOTH directions:
|
|
65
|
+
*
|
|
66
|
+
* - a path in the tree the plan does not name used to be packed at `?? 0o644`.
|
|
67
|
+
* That default is a guess about a file nobody planned; if it is a program it
|
|
68
|
+
* installs unrunnable, and the package is otherwise perfect.
|
|
69
|
+
* - a path in the plan the tree does not hold used to be silently absent from
|
|
70
|
+
* the payload. `.deb` and `.rpm` both accept that happily and the result is
|
|
71
|
+
* a package that installs and cannot start — this project's most expensive
|
|
72
|
+
* failure class.
|
|
73
|
+
*
|
|
74
|
+
* `bytes` is compared when the plan carries it (i.e. when the plan came from a
|
|
75
|
+
* stage manifest). It catches the truncated transfer that the file-set check
|
|
76
|
+
* cannot see: a half-written upload leaves every path in place. It is a SIZE
|
|
77
|
+
* and not a digest on purpose — ADR 0024 § A4 measured that the darwin finish
|
|
78
|
+
* leg has to re-sign all 106 Mach-O images inside the stage, so a per-file
|
|
79
|
+
* sha256 would have to be relaxed to exempt them and would then be checking
|
|
80
|
+
* nothing.
|
|
35
81
|
*/
|
|
36
|
-
export function readStage(roots,
|
|
37
|
-
const
|
|
82
|
+
export function readStage(roots, plan) {
|
|
83
|
+
const planned = new Map(plan.map((entry) => [entry.path, entry]));
|
|
38
84
|
const byPath = new Map();
|
|
39
85
|
for (const root of roots) {
|
|
40
86
|
for (const rel of listFilesRecursive(root)) {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
87
|
+
if (rel === STAGE_MANIFEST_FILE)
|
|
88
|
+
continue;
|
|
89
|
+
const entry = planned.get(rel);
|
|
90
|
+
if (entry === undefined) {
|
|
91
|
+
throw new Error(`gjsify ship: ${join(root, rel.split('/').join(sep))} is in the stage and not in its ` +
|
|
92
|
+
'manifest, so nothing knows what mode to give it. Every payload file takes its mode ' +
|
|
93
|
+
'from the plan, never from the filesystem, so a file that arrived some other way would ' +
|
|
94
|
+
'be packed 0644 — unrunnable if it is a program. Remove it, or add it through ' +
|
|
95
|
+
'`gjsify.ship.extraFiles` and re-run the `--stage` phase.');
|
|
96
|
+
}
|
|
97
|
+
const data = new Uint8Array(readFileSync(join(root, rel.split('/').join(sep))));
|
|
98
|
+
if (entry.bytes !== undefined && entry.bytes !== data.byteLength) {
|
|
99
|
+
throw new Error(`gjsify ship: ${rel} is ${data.byteLength} bytes in the stage and ${entry.bytes} in its ` +
|
|
100
|
+
'manifest. The stage arrived truncated or was edited after it was assembled. ' +
|
|
101
|
+
'Re-upload it, or re-run the `--stage` phase.');
|
|
102
|
+
}
|
|
103
|
+
byPath.set(rel, { path: rel, mode: entry.mode, data });
|
|
46
104
|
}
|
|
47
105
|
}
|
|
106
|
+
const missing = plan.filter((entry) => !byPath.has(entry.path)).map((entry) => entry.path);
|
|
107
|
+
if (missing.length > 0) {
|
|
108
|
+
throw new Error(`gjsify ship: the stage manifest lists ${missing.length} file(s) the stage does not contain: ` +
|
|
109
|
+
`${missing.slice(0, 5).join(', ')}${missing.length > 5 ? ', …' : ''}. ` +
|
|
110
|
+
'A payload has to be complete before it is packed — both formats would pack the rest without ' +
|
|
111
|
+
'complaint and install a package that cannot start. Re-run the `--stage` phase, and check that ' +
|
|
112
|
+
'whatever moved the stage between hosts copied every file (an artifact upload that drops empty ' +
|
|
113
|
+
'files or dotfiles is the usual cause).');
|
|
114
|
+
}
|
|
48
115
|
return [...byPath.values()].sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
|
|
49
116
|
}
|