@gjsify/cli 0.46.0 → 0.48.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 +87 -83
- package/lib/commands/affected-classify.js +30 -1
- package/lib/commands/dlx.js +7 -3
- package/lib/commands/foreach.js +9 -2
- package/lib/commands/onboard.js +11 -0
- package/lib/commands/publish.d.ts +41 -0
- package/lib/commands/publish.js +223 -10
- package/lib/commands/run.js +7 -2
- package/lib/commands/ship.js +32 -4
- package/lib/types/config-data.d.ts +37 -1
- package/lib/utils/base64.d.ts +34 -0
- package/lib/utils/base64.js +47 -0
- package/lib/utils/cli-runtime-closure.d.ts +61 -0
- package/lib/utils/cli-runtime-closure.js +54 -17
- package/lib/utils/double-dash-args.d.ts +8 -0
- package/lib/utils/double-dash-args.js +64 -0
- package/lib/utils/publish-readback.d.ts +132 -0
- package/lib/utils/publish-readback.js +273 -0
- package/lib/utils/ship/changelog.d.ts +56 -0
- package/lib/utils/ship/changelog.js +166 -0
- package/lib/utils/ship/discover.d.ts +10 -1
- package/lib/utils/ship/discover.js +76 -2
- package/lib/utils/ship/entry-interpreter.d.ts +44 -0
- package/lib/utils/ship/entry-interpreter.js +109 -0
- package/lib/utils/ship/formats.js +5 -0
- package/lib/utils/ship/gi-namespaces.js +175 -12
- package/lib/utils/ship/layout.js +8 -4
- package/lib/utils/ship/plan.d.ts +5 -1
- package/lib/utils/ship/plan.js +21 -2
- package/lib/utils/ship/settings.d.ts +48 -2
- package/lib/utils/ship/settings.js +63 -3
- package/lib/utils/ship/stage-manifest.d.ts +22 -3
- package/lib/utils/ship/stage-manifest.js +40 -13
- package/lib/utils/ship/types.d.ts +13 -0
- package/package.json +19 -19
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
// Post-PUT read-back for `gjsify publish`: does the registry actually RESOLVE
|
|
2
|
+
// the version we just uploaded?
|
|
3
|
+
//
|
|
4
|
+
// THE INCIDENT (v0.46.0, run 33735989472). The job `Publish
|
|
5
|
+
// @gjsify/node-runtime-darwin-arm64 (bundled Node)` reported SUCCESS and
|
|
6
|
+
// published NOTHING. Its own log, with GJSIFY_PUBLISH_DEBUG=1:
|
|
7
|
+
//
|
|
8
|
+
// gjsify publish: PUT https://registry.npmjs.org/@gjsify%2fnode-runtime-darwin-arm64
|
|
9
|
+
// payload size: 53863410 bytes
|
|
10
|
+
// + @gjsify/node-runtime-darwin-arm64@0.46.0
|
|
11
|
+
//
|
|
12
|
+
// The job was green. Minutes later the registry had NEITHER half: the version
|
|
13
|
+
// endpoint 404'd, the tarball URL 404'd, `npm view … version` still answered
|
|
14
|
+
// 0.45.0, and the packument's `time` map had no 0.46.0 entry at all. Only a
|
|
15
|
+
// manual single-job rerun made it land (registry time 09:49:07.419Z). Payload
|
|
16
|
+
// size was NOT the cause — darwin-x64 at 55189094 bytes, i.e. LARGER, published
|
|
17
|
+
// fine in the same rerun. `res.ok` was checked and npm's answer really was 2xx,
|
|
18
|
+
// so the plain `+` success path is exactly what the code was written to print.
|
|
19
|
+
//
|
|
20
|
+
// WHAT THE 2xx MEANS, MEASURED. A 2xx from npm is an ACCEPTED write, not a
|
|
21
|
+
// durable one. Correlating all 199 `+ <name>@0.46.0` lines of that release's
|
|
22
|
+
// serial sweep with each packument's own `time["0.46.0"]`:
|
|
23
|
+
//
|
|
24
|
+
// 180 / 199 (90.5%) registry time is 0.4-2.4 s BEFORE our success line (median
|
|
25
|
+
// 0.6 s) — the version doc existed before the response came back
|
|
26
|
+
// 19 / 199 (9.5%) registry time is 56.2 s - 251.7 s AFTER the 2xx; the
|
|
27
|
+
// extreme is @gjsify/child_process, PUT 09:25:08.7, `+`
|
|
28
|
+
// printed 09:25:10.3, registry time 09:29:21.996Z
|
|
29
|
+
// (+4m11s). THREE of the 19 were recorded after the
|
|
30
|
+
// publish job's own completed_at (09:28:35Z).
|
|
31
|
+
// 1 not recorded until a manual rerun — the incident above.
|
|
32
|
+
//
|
|
33
|
+
// So "not there" and "not there YET" are the same observation for up to ~4.2
|
|
34
|
+
// minutes, and no client-side signal separates them. That is what this module
|
|
35
|
+
// is for: it turns "npm said 2xx" into "the registry serves it", and when it
|
|
36
|
+
// cannot, it says which of the two it saw and for how long it looked.
|
|
37
|
+
//
|
|
38
|
+
// THE MECHANISM, and why the lag is a WRITE and not a read cache. `dist.signatures`
|
|
39
|
+
// correlates 1:1 with the sign of that lag across all 199: every one of the 19
|
|
40
|
+
// late packages carries TWO signatures (same keyid, different sig), every one of
|
|
41
|
+
// the 180 carries ONE, and the client sent exactly one PUT per package (199
|
|
42
|
+
// distinct names, no retry, no 5xx in the log). So the late version document was
|
|
43
|
+
// written a second time server-side, and `time[version]` records that second
|
|
44
|
+
// write. Corroborated client-side rather than inferred: @gjsify/child_process's
|
|
45
|
+
// +251.7 s is exactly the package that failed all three attempt-1 node-runtime
|
|
46
|
+
// jobs at 09:29:05 with `ETARGET / No matching version found for
|
|
47
|
+
// @gjsify/child_process@^0.46.0` — 15 s BEFORE its recorded time and 4 min after
|
|
48
|
+
// its `+`. A lag of this kind therefore does not merely delay a report; it
|
|
49
|
+
// cascades into every job that installs the release.
|
|
50
|
+
//
|
|
51
|
+
// WHY THE PACKUMENT AND NOT THE TARBALL URL. One abbreviated-packument GET
|
|
52
|
+
// answers both halves that #1407 split apart (npm had stored the tarball and no
|
|
53
|
+
// packument there): the version record carries `dist.tarball`, so its presence
|
|
54
|
+
// is checked from the same response with no second request. It proves the
|
|
55
|
+
// registry ADVERTISES a tarball, not that the bytes are fetchable — a HEAD on
|
|
56
|
+
// the tarball would be a second round trip per package and is deliberately not
|
|
57
|
+
// done here. Same oracle and same headers as
|
|
58
|
+
// `scripts/verify-published-closure.mjs`, so the per-package check and the
|
|
59
|
+
// end-of-release closure check cannot disagree about what "published" means —
|
|
60
|
+
// an equality that had to be MADE: the closure probe asked for the version key
|
|
61
|
+
// alone, so the tarball half of every publish the sweep defers to it was checked
|
|
62
|
+
// by nothing. It now reads `dist.tarball` from the same document.
|
|
63
|
+
import { escapePackageName } from './publish-headers.js';
|
|
64
|
+
/**
|
|
65
|
+
* Backoff between probes, in ms; the last value repeats until the budget is out.
|
|
66
|
+
*
|
|
67
|
+
* Shaped by the distribution in the header, not picked for roundness: 90.5% of
|
|
68
|
+
* writes are already committed when the 2xx arrives, so the FIRST probe is
|
|
69
|
+
* immediate and costs one ~0.5 s GET for nine packages in ten. The tail is
|
|
70
|
+
* minutes long and coarse, so probing it finely would only add requests — after
|
|
71
|
+
* 30 s the interval stops shrinking.
|
|
72
|
+
*/
|
|
73
|
+
const BACKOFF_MS = [2_000, 4_000, 8_000, 15_000, 30_000];
|
|
74
|
+
/** Delay before probe `round + 2`. Past the table's end the last value repeats. */
|
|
75
|
+
export function backoffFor(round) {
|
|
76
|
+
return BACKOFF_MS[Math.min(Math.max(round, 0), BACKOFF_MS.length - 1)] ?? 30_000;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* The smallest request timeout a probe is ever given.
|
|
80
|
+
*
|
|
81
|
+
* A probe cut below the time an answer takes reports `error` — "we do not know" —
|
|
82
|
+
* where it would have read a 404, so the decisive LAST probe of a short budget
|
|
83
|
+
* would lose the verdict the whole read-back exists to produce. Measured against
|
|
84
|
+
* registry.npmjs.org from a European workstation, 21 abbreviated-packument GETs
|
|
85
|
+
* ran 0.48-0.62 s (median 0.57), so 2 s is ~3.5x the median and above every
|
|
86
|
+
* observation — the floor is what lets a probe answer, not a guess at latency.
|
|
87
|
+
*/
|
|
88
|
+
const MIN_PROBE_TIMEOUT_MS = 2_000;
|
|
89
|
+
/**
|
|
90
|
+
* A probe's request timeout: what was configured, never more than the budget has
|
|
91
|
+
* left to spend — but never less than a probe needs to answer.
|
|
92
|
+
*
|
|
93
|
+
* Configured and budget are otherwise independent, and that makes
|
|
94
|
+
* `--verify-timeout 5` — what the 199-package sweep passes — a promise the code
|
|
95
|
+
* does not keep: one unanswered request may block for the 30 s default, six times
|
|
96
|
+
* the budget it was given, and 199 of those cost ~100 min against the ~11% the
|
|
97
|
+
* deferral is argued on. Clamping bounds the wall clock at
|
|
98
|
+
* `budget + MIN_PROBE_TIMEOUT_MS` instead. The floor is the other half of the
|
|
99
|
+
* same honesty: clamping all the way to zero traded a 30 s overshoot for an
|
|
100
|
+
* `error` verdict on a probe that was about to return the 404 (measured — it
|
|
101
|
+
* turned the incident's own e2e row from `absent: 404` into `request failed
|
|
102
|
+
* (This operation was aborted)`).
|
|
103
|
+
*/
|
|
104
|
+
export function probeTimeoutFor(configuredMs, remainingMs) {
|
|
105
|
+
return Math.min(configuredMs, Math.max(remainingMs, MIN_PROBE_TIMEOUT_MS));
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Default polling budget: 300 s.
|
|
109
|
+
*
|
|
110
|
+
* The measured maximum lag between a 2xx and the registry recording the version
|
|
111
|
+
* was 251.7 s (@gjsify/child_process, v0.46.0), so a budget below ~4.2 minutes
|
|
112
|
+
* turns npm's normal queueing into a false red — and a false red at release time
|
|
113
|
+
* costs a manual re-run of a workflow that did its job. 300 s clears that
|
|
114
|
+
* maximum and is still short enough that a genuinely lost write is reported by
|
|
115
|
+
* the job that lost it rather than by a later sweep.
|
|
116
|
+
*
|
|
117
|
+
* THAT MAXIMUM IS A SAMPLE MAXIMUM, and the sample does not look truncated by
|
|
118
|
+
* nature. The 19 lags fall in buckets ~20-30 s apart — 56(2), 76(3), 96(3),
|
|
119
|
+
* 127(4), 157(3), 189(1), 251(3) — and the TOP bucket is as populated as the
|
|
120
|
+
* middle ones, which is the shape of a distribution cut off by the observation
|
|
121
|
+
* rather than one decaying to zero. One release is one sample, so a lag past
|
|
122
|
+
* 300 s is not ruled out; what makes 300 s affordable anyway is that the
|
|
123
|
+
* remediation is verified rather than assumed. The re-run this reports lands on
|
|
124
|
+
* the 409 path, and that path READS BACK too (see the conflict branch in
|
|
125
|
+
* `commands/publish.ts`), so a window that turns out to be too short costs one
|
|
126
|
+
* re-run that confirms — it does not hand back an unverified success.
|
|
127
|
+
*/
|
|
128
|
+
export const DEFAULT_VERIFY_BUDGET_MS = 300_000;
|
|
129
|
+
/**
|
|
130
|
+
* Poll the registry until `name@version` resolves, or the budget runs out.
|
|
131
|
+
*
|
|
132
|
+
* No side effects, no exits, no printing beyond the injected `log` — the caller
|
|
133
|
+
* owns presentation, same contract as {@link import('./publish-diagnose.js')}.
|
|
134
|
+
*/
|
|
135
|
+
export async function verifyPublishedVersion(input) {
|
|
136
|
+
const { registry, name, version, budgetMs } = input;
|
|
137
|
+
const doFetch = input.fetchImpl ?? ((...args) => globalThis.fetch(...args));
|
|
138
|
+
const sleep = input.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
139
|
+
const now = input.now ?? (() => Date.now());
|
|
140
|
+
const probeTimeoutMs = input.probeTimeoutMs ?? 30_000;
|
|
141
|
+
const registryClean = registry.endsWith('/') ? registry.slice(0, -1) : registry;
|
|
142
|
+
const url = `${registryClean}/${escapePackageName(name)}`;
|
|
143
|
+
const started = now();
|
|
144
|
+
let attempts = 0;
|
|
145
|
+
let last = { state: 'error', detail: 'no probe was sent' };
|
|
146
|
+
for (let round = 0;; round++) {
|
|
147
|
+
attempts++;
|
|
148
|
+
last = await probeOnce({
|
|
149
|
+
url,
|
|
150
|
+
version,
|
|
151
|
+
doFetch,
|
|
152
|
+
probeTimeoutMs: probeTimeoutFor(probeTimeoutMs, budgetMs - (now() - started)),
|
|
153
|
+
authorization: input.authorization,
|
|
154
|
+
});
|
|
155
|
+
input.log?.(`gjsify publish: read-back probe ${attempts} of ${name}@${version} → ${last.state}` +
|
|
156
|
+
(last.state === 'present' ? '' : ` (${last.detail})`));
|
|
157
|
+
if (last.state === 'present')
|
|
158
|
+
break;
|
|
159
|
+
const remaining = budgetMs - (now() - started);
|
|
160
|
+
if (remaining <= 0)
|
|
161
|
+
break;
|
|
162
|
+
await sleep(Math.min(backoffFor(round), remaining));
|
|
163
|
+
}
|
|
164
|
+
return { confirmed: last.state === 'present', attempts, elapsedMs: now() - started, url, last };
|
|
165
|
+
}
|
|
166
|
+
async function probeOnce(input) {
|
|
167
|
+
const { url, version, doFetch, probeTimeoutMs } = input;
|
|
168
|
+
// `AbortController` + a timer rather than `AbortSignal.timeout`, which the
|
|
169
|
+
// CLI does not use anywhere and which this file must not be the first to
|
|
170
|
+
// require of a GJS host.
|
|
171
|
+
const controller = new AbortController();
|
|
172
|
+
const timer = setTimeout(() => controller.abort(), probeTimeoutMs);
|
|
173
|
+
let res;
|
|
174
|
+
try {
|
|
175
|
+
res = await doFetch(url, {
|
|
176
|
+
headers: {
|
|
177
|
+
// Abbreviated packument: version keys + `dist`, a fraction of the
|
|
178
|
+
// bytes of the full document. `no-cache` because a CDN edge will
|
|
179
|
+
// otherwise answer with a document minted BEFORE the publish we
|
|
180
|
+
// are asking about — which is the whole question here.
|
|
181
|
+
accept: 'application/vnd.npm.install-v1+json',
|
|
182
|
+
'cache-control': 'no-cache',
|
|
183
|
+
...(input.authorization ? { authorization: input.authorization } : {}),
|
|
184
|
+
},
|
|
185
|
+
signal: controller.signal,
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
catch (err) {
|
|
189
|
+
// A real throw path: network failure, DNS, or our own abort above.
|
|
190
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
191
|
+
return { state: 'error', detail: `request failed (${msg})` };
|
|
192
|
+
}
|
|
193
|
+
finally {
|
|
194
|
+
clearTimeout(timer);
|
|
195
|
+
}
|
|
196
|
+
if (res.status === 404) {
|
|
197
|
+
return { state: 'absent', status: 404, detail: '404 — the registry serves no packument for this name' };
|
|
198
|
+
}
|
|
199
|
+
if (!res.ok) {
|
|
200
|
+
return { state: 'error', status: res.status, detail: `${res.status} ${res.statusText}` };
|
|
201
|
+
}
|
|
202
|
+
let doc;
|
|
203
|
+
try {
|
|
204
|
+
doc = await res.json();
|
|
205
|
+
}
|
|
206
|
+
catch (err) {
|
|
207
|
+
// A 200 whose body is not a packument answers nothing; `absent` would be
|
|
208
|
+
// a verdict we did not earn.
|
|
209
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
210
|
+
return { state: 'error', status: res.status, detail: `packument is not JSON (${msg})` };
|
|
211
|
+
}
|
|
212
|
+
const versions = doc?.versions;
|
|
213
|
+
const known = versions && typeof versions === 'object' ? Object.keys(versions) : [];
|
|
214
|
+
const entry = versions && typeof versions === 'object' ? versions[version] : undefined;
|
|
215
|
+
if (!entry) {
|
|
216
|
+
const newest = known.length > 0 ? known[known.length - 1] : '(none)';
|
|
217
|
+
return {
|
|
218
|
+
state: 'absent',
|
|
219
|
+
status: res.status,
|
|
220
|
+
detail: `packument carries ${known.length} version(s), newest ${newest} — ${version} is not among them`,
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
const tarball = entry.dist?.tarball;
|
|
224
|
+
if (typeof tarball !== 'string' || tarball.length === 0) {
|
|
225
|
+
// The #1407 half: a version record with no tarball to install.
|
|
226
|
+
return {
|
|
227
|
+
state: 'absent',
|
|
228
|
+
status: res.status,
|
|
229
|
+
detail: `packument has ${version} but its \`dist.tarball\` is missing`,
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
return { state: 'present', status: res.status, tarball };
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* The unconfirmed-publish diagnostic, ready for stderr.
|
|
236
|
+
*
|
|
237
|
+
* The message has to answer three questions the incident log could not, which is
|
|
238
|
+
* why it needed the registry to reconstruct at all: what was PUT, what was asked,
|
|
239
|
+
* and what came back.
|
|
240
|
+
*/
|
|
241
|
+
export function formatUnconfirmedPublish(opts) {
|
|
242
|
+
const { name, version, putUrl, putStatus, putStatusText, payloadBytes, readback } = opts;
|
|
243
|
+
const claim = opts.claim ?? 'accepted';
|
|
244
|
+
const seconds = (readback.elapsedMs / 1000).toFixed(1);
|
|
245
|
+
// Only ever called on an UNCONFIRMED read-back, so `last` is `absent` or
|
|
246
|
+
// `error` — both carry a `detail`; `present` has nothing to explain.
|
|
247
|
+
const answered = readback.last.state === 'present' ? 'served' : readback.last.detail;
|
|
248
|
+
const headline = claim === 'already-published'
|
|
249
|
+
? `npm says ${version} is ALREADY PUBLISHED but the registry does not serve it.`
|
|
250
|
+
: 'npm ACCEPTED the upload but the registry does not serve it.';
|
|
251
|
+
const remedy = claim === 'already-published'
|
|
252
|
+
? [
|
|
253
|
+
' A 409 is npm refusing to overwrite a version it holds, which is not the same as serving it: in',
|
|
254
|
+
' the v0.46.0 recovery @gjsify/node-runtime-darwin-arm64 was answered 409 at 09:48:49 and the',
|
|
255
|
+
' registry recorded 0.46.0 at 09:49:07, 18s later. Re-running answers the same 409, so it cannot',
|
|
256
|
+
' fix this — wait and re-read (`npm view <name>@<version>`), or check https://status.npmjs.org/.',
|
|
257
|
+
]
|
|
258
|
+
: [
|
|
259
|
+
' A 2xx from npm is an ACCEPTED write, not a durable one. In the v0.46.0 release 19 of 199',
|
|
260
|
+
' packages were recorded by the registry 56-252s after their 2xx, and ONE was never recorded',
|
|
261
|
+
' at all while its job stayed green — that is the failure this check exists to name.',
|
|
262
|
+
` Re-run this publish (\`--tolerate-republish\` no-ops if it landed meanwhile). If it keeps`,
|
|
263
|
+
' failing, the write was rejected downstream of the 2xx: check https://status.npmjs.org/.',
|
|
264
|
+
];
|
|
265
|
+
return [
|
|
266
|
+
`gjsify publish: ${name}@${version} — ${headline}`,
|
|
267
|
+
` PUT ${putUrl} (${payloadBytes} bytes) → ${putStatus} ${putStatusText}`,
|
|
268
|
+
` read-back GET ${readback.url} (accept: application/vnd.npm.install-v1+json, cache-control: no-cache)`,
|
|
269
|
+
` answered ${readback.last.state}: ${answered}`,
|
|
270
|
+
` after ${readback.attempts} probe(s) over ${seconds}s`,
|
|
271
|
+
...remedy,
|
|
272
|
+
].join('\n');
|
|
273
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { PackSettings } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* `Thu, 04 Sep 2026 12:34:56 +0000` — the trailer format Policy § 4.4 requires.
|
|
4
|
+
*
|
|
5
|
+
* Always UTC, and the offset is written out rather than derived: the build stamp is
|
|
6
|
+
* Unix seconds, so there is no zone to preserve, and a package built in two places
|
|
7
|
+
* must not differ by a timezone.
|
|
8
|
+
*
|
|
9
|
+
* @param unixSeconds the build stamp every header in the artifact shares
|
|
10
|
+
*/
|
|
11
|
+
export declare function rfc822(unixSeconds: number): string;
|
|
12
|
+
/**
|
|
13
|
+
* The bullet lines a project's own changelog carries for ONE version.
|
|
14
|
+
*
|
|
15
|
+
* Reads the Markdown release-note shape `release-it`'s conventional-changelog
|
|
16
|
+
* preset writes — `## [0.47.0](compare-url) (2026-09-03)`, or a bare `## 0.47.0`
|
|
17
|
+
* for a first release — and takes the `*`/`-` bullets under it up to the next
|
|
18
|
+
* version heading. Category subheadings (`### Bug Fixes`) are dropped: a Debian
|
|
19
|
+
* change block is a flat list, and a heading rendered as a bullet reads as a change
|
|
20
|
+
* that was made.
|
|
21
|
+
*
|
|
22
|
+
* Markdown links collapse to their text (`[#1510](https://…)` → `#1510`) and bold
|
|
23
|
+
* scopes lose their asterisks, because the destination is plain text that `apt
|
|
24
|
+
* changelog` prints to a terminal.
|
|
25
|
+
*
|
|
26
|
+
* @returns one string per bullet, empty when the file does not name this version
|
|
27
|
+
*/
|
|
28
|
+
export declare function changelogEntriesFor(version: string, source: string): string[];
|
|
29
|
+
/**
|
|
30
|
+
* The whole `changelog.Debian` file: one entry, for the version being packaged.
|
|
31
|
+
*
|
|
32
|
+
* `unstable` is the distribution and `medium` the urgency — the values `dh_make`
|
|
33
|
+
* seeds a package with, and the only honest ones for an artifact built outside any
|
|
34
|
+
* Debian suite: this package targets no release, and claiming one would put a
|
|
35
|
+
* specific false statement in a field `apt` reads.
|
|
36
|
+
*
|
|
37
|
+
* The trailer's spacing is load-bearing and is the usual way this file is got
|
|
38
|
+
* wrong: exactly one leading space, then `--`, one space, the maintainer, and TWO
|
|
39
|
+
* spaces before the date.
|
|
40
|
+
*
|
|
41
|
+
* TWO lintian tags this file still raises, both measured on lintian 2.117 against
|
|
42
|
+
* gjsify's own `.deb` and both left alone deliberately:
|
|
43
|
+
*
|
|
44
|
+
* - `initial-upload-closes-no-bugs`, because the Debian revision is `-1` and the
|
|
45
|
+
* entry closes no bug. It is a statement about the Debian ARCHIVE's ITP
|
|
46
|
+
* workflow — the first upload of a new source package should close its ITP bug
|
|
47
|
+
* — and a package built by `gjsify ship` is never uploaded there. There is no
|
|
48
|
+
* bug number that would be true to write, so the tag is inapplicable rather
|
|
49
|
+
* than unmet.
|
|
50
|
+
* - `changelog-not-compressed-with-max-compression`. Policy asks for `gzip -9`
|
|
51
|
+
* and neither `CompressionStream` nor `@gjsify/zlib` takes a level, so
|
|
52
|
+
* `gzipDeterministic` cannot ask for one. Ledgered in `status/open-todos.md`:
|
|
53
|
+
* the fix is a capability in the core, and stamping the header's XFL byte to
|
|
54
|
+
* claim max compression we did not use is not one.
|
|
55
|
+
*/
|
|
56
|
+
export declare function renderDebianChangelog(settings: PackSettings, source: string | undefined, mtime: number): string;
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
// `/usr/share/doc/<package>/changelog.Debian.gz` — Debian Policy § 4.4.
|
|
2
|
+
//
|
|
3
|
+
// WHY IT EXISTS AT ALL. Every `.deb` this writer produced shipped without one,
|
|
4
|
+
// which the first real lintian this project ever ran named as the only
|
|
5
|
+
// error-severity tag left once the copyright landed:
|
|
6
|
+
//
|
|
7
|
+
// E: gjsify: no-changelog usr/share/doc/gjsify/changelog.Debian.gz (non-native package)
|
|
8
|
+
//
|
|
9
|
+
// "non-native" is not a coincidence: `deb.ts` writes `Version: <version>-<release>`,
|
|
10
|
+
// and a version carrying a Debian revision is by definition a non-native package,
|
|
11
|
+
// whose changelog is `changelog.Debian.gz` rather than `changelog.gz`.
|
|
12
|
+
//
|
|
13
|
+
// WHAT THE ENTRY SAYS — the open question the ledger left, decided here.
|
|
14
|
+
//
|
|
15
|
+
// ONE ENTRY, for the version being packaged, and that is not a truncation. Policy
|
|
16
|
+
// § 4.4's file is the DEBIAN changelog: it records the packaging, not upstream's
|
|
17
|
+
// development. `gjsify ship` performs exactly one packaging event per version, so
|
|
18
|
+
// one entry is the whole history there is to tell. The upstream history has its own
|
|
19
|
+
// (optional) file, `changelog.gz`, and this writer does not claim to produce it.
|
|
20
|
+
//
|
|
21
|
+
// The BODY comes from the project's own changelog when one is found — `CHANGELOG.md`
|
|
22
|
+
// beside the project or up at its repository root, the same search the licence uses,
|
|
23
|
+
// overridable with `gjsify.ship.changelogFile`. That was the choice worth making:
|
|
24
|
+
// the GitHub release body says the same thing and is not reachable from a packing
|
|
25
|
+
// host that may be offline (ADR 0024 § A2), while `CHANGELOG.md` is in the tree the
|
|
26
|
+
// stage is assembled from. When nothing there names this version, the entry says so
|
|
27
|
+
// in one line rather than inventing content.
|
|
28
|
+
//
|
|
29
|
+
// AND ONLY THE PACKAGED VERSION GETS A DATE. A Debian entry needs a distribution, an
|
|
30
|
+
// urgency and an RFC822 timestamp; this writer has a real timestamp for exactly one
|
|
31
|
+
// of them — `mtime`, the build stamp every other header in the artifact already
|
|
32
|
+
// shares. Rendering the older versions would mean stamping each with a fabricated
|
|
33
|
+
// time, since `CHANGELOG.md` dates them to the DAY, and a Debian changelog is read
|
|
34
|
+
// by tools that compare those stamps.
|
|
35
|
+
//
|
|
36
|
+
// Deterministic by construction: the date is the build stamp, and the month and day
|
|
37
|
+
// names are spelled out here rather than taken from `toLocaleString`, which answers
|
|
38
|
+
// in the host's locale.
|
|
39
|
+
const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
|
40
|
+
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
41
|
+
/**
|
|
42
|
+
* `Thu, 04 Sep 2026 12:34:56 +0000` — the trailer format Policy § 4.4 requires.
|
|
43
|
+
*
|
|
44
|
+
* Always UTC, and the offset is written out rather than derived: the build stamp is
|
|
45
|
+
* Unix seconds, so there is no zone to preserve, and a package built in two places
|
|
46
|
+
* must not differ by a timezone.
|
|
47
|
+
*
|
|
48
|
+
* @param unixSeconds the build stamp every header in the artifact shares
|
|
49
|
+
*/
|
|
50
|
+
export function rfc822(unixSeconds) {
|
|
51
|
+
const at = new Date(unixSeconds * 1000);
|
|
52
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
53
|
+
return (`${DAYS[at.getUTCDay()]}, ${pad(at.getUTCDate())} ${MONTHS[at.getUTCMonth()]} ${at.getUTCFullYear()} ` +
|
|
54
|
+
`${pad(at.getUTCHours())}:${pad(at.getUTCMinutes())}:${pad(at.getUTCSeconds())} +0000`);
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* The bullet lines a project's own changelog carries for ONE version.
|
|
58
|
+
*
|
|
59
|
+
* Reads the Markdown release-note shape `release-it`'s conventional-changelog
|
|
60
|
+
* preset writes — `## [0.47.0](compare-url) (2026-09-03)`, or a bare `## 0.47.0`
|
|
61
|
+
* for a first release — and takes the `*`/`-` bullets under it up to the next
|
|
62
|
+
* version heading. Category subheadings (`### Bug Fixes`) are dropped: a Debian
|
|
63
|
+
* change block is a flat list, and a heading rendered as a bullet reads as a change
|
|
64
|
+
* that was made.
|
|
65
|
+
*
|
|
66
|
+
* Markdown links collapse to their text (`[#1510](https://…)` → `#1510`) and bold
|
|
67
|
+
* scopes lose their asterisks, because the destination is plain text that `apt
|
|
68
|
+
* changelog` prints to a terminal.
|
|
69
|
+
*
|
|
70
|
+
* @returns one string per bullet, empty when the file does not name this version
|
|
71
|
+
*/
|
|
72
|
+
export function changelogEntriesFor(version, source) {
|
|
73
|
+
const escaped = version.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
74
|
+
const heading = new RegExp(`^##+[ \\t]+\\[?${escaped}\\]?[^\\n]*$`, 'm');
|
|
75
|
+
const start = heading.exec(source);
|
|
76
|
+
if (start === null)
|
|
77
|
+
return [];
|
|
78
|
+
const rest = source.slice(start.index + start[0].length);
|
|
79
|
+
// The next VERSION heading, not the next heading of any depth: `### Features`
|
|
80
|
+
// is inside this release, `## [0.46.0]` is the next one.
|
|
81
|
+
const end = /^##[ \t]+\[?\d/m.exec(rest);
|
|
82
|
+
const body = end === null ? rest : rest.slice(0, end.index);
|
|
83
|
+
const entries = [];
|
|
84
|
+
for (const line of body.split('\n')) {
|
|
85
|
+
const bullet = /^[*-][ \t]+(.*\S)\s*$/.exec(line);
|
|
86
|
+
if (bullet === null)
|
|
87
|
+
continue;
|
|
88
|
+
entries.push(bullet[1]
|
|
89
|
+
.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
|
|
90
|
+
.replace(/\*\*([^*]+)\*\*/g, '$1')
|
|
91
|
+
.replace(/\s+/g, ' ')
|
|
92
|
+
.trim());
|
|
93
|
+
}
|
|
94
|
+
return entries;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* One bullet, wrapped so no line exceeds {@link WIDTH}.
|
|
98
|
+
*
|
|
99
|
+
* MEASURED, not stylistic: lintian raises `debian-changelog-line-too-long` past 80
|
|
100
|
+
* columns, and a conventional-commit subject plus its scope, its PR link and its
|
|
101
|
+
* `closes` list goes well past it. Continuation lines are indented to sit under the
|
|
102
|
+
* bullet text — Debian's change block only requires at least two leading spaces, and
|
|
103
|
+
* anything less than four would read as a new item.
|
|
104
|
+
*
|
|
105
|
+
* A single word longer than the width is left on its own line rather than broken: a
|
|
106
|
+
* URL is the usual case, and a hyphenated URL is worse than a long one.
|
|
107
|
+
*/
|
|
108
|
+
function wrapEntry(entry) {
|
|
109
|
+
const lines = [];
|
|
110
|
+
let line = ' *';
|
|
111
|
+
for (const word of entry.split(/\s+/)) {
|
|
112
|
+
if (word === '')
|
|
113
|
+
continue;
|
|
114
|
+
if (line.length + 1 + word.length > WIDTH && line !== ' *' && line !== ' ') {
|
|
115
|
+
lines.push(line);
|
|
116
|
+
line = ' ';
|
|
117
|
+
}
|
|
118
|
+
line += ` ${word}`;
|
|
119
|
+
}
|
|
120
|
+
lines.push(line);
|
|
121
|
+
return lines;
|
|
122
|
+
}
|
|
123
|
+
/** lintian's `debian-changelog-line-too-long` threshold. */
|
|
124
|
+
const WIDTH = 80;
|
|
125
|
+
/**
|
|
126
|
+
* The whole `changelog.Debian` file: one entry, for the version being packaged.
|
|
127
|
+
*
|
|
128
|
+
* `unstable` is the distribution and `medium` the urgency — the values `dh_make`
|
|
129
|
+
* seeds a package with, and the only honest ones for an artifact built outside any
|
|
130
|
+
* Debian suite: this package targets no release, and claiming one would put a
|
|
131
|
+
* specific false statement in a field `apt` reads.
|
|
132
|
+
*
|
|
133
|
+
* The trailer's spacing is load-bearing and is the usual way this file is got
|
|
134
|
+
* wrong: exactly one leading space, then `--`, one space, the maintainer, and TWO
|
|
135
|
+
* spaces before the date.
|
|
136
|
+
*
|
|
137
|
+
* TWO lintian tags this file still raises, both measured on lintian 2.117 against
|
|
138
|
+
* gjsify's own `.deb` and both left alone deliberately:
|
|
139
|
+
*
|
|
140
|
+
* - `initial-upload-closes-no-bugs`, because the Debian revision is `-1` and the
|
|
141
|
+
* entry closes no bug. It is a statement about the Debian ARCHIVE's ITP
|
|
142
|
+
* workflow — the first upload of a new source package should close its ITP bug
|
|
143
|
+
* — and a package built by `gjsify ship` is never uploaded there. There is no
|
|
144
|
+
* bug number that would be true to write, so the tag is inapplicable rather
|
|
145
|
+
* than unmet.
|
|
146
|
+
* - `changelog-not-compressed-with-max-compression`. Policy asks for `gzip -9`
|
|
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.
|
|
151
|
+
*/
|
|
152
|
+
export function renderDebianChangelog(settings, source, mtime) {
|
|
153
|
+
const entries = source === undefined ? [] : changelogEntriesFor(settings.version, source);
|
|
154
|
+
const fallback = settings.homepage
|
|
155
|
+
? `Release ${settings.version}. See ${settings.homepage} for the upstream changes.`
|
|
156
|
+
: `Release ${settings.version}.`;
|
|
157
|
+
const body = (entries.length > 0 ? entries : [fallback]).flatMap(wrapEntry);
|
|
158
|
+
return [
|
|
159
|
+
`${settings.binaryName} (${settings.version}-${settings.release}) unstable; urgency=medium`,
|
|
160
|
+
'',
|
|
161
|
+
...body,
|
|
162
|
+
'',
|
|
163
|
+
` -- ${settings.maintainer} ${rfc822(mtime)}`,
|
|
164
|
+
'',
|
|
165
|
+
].join('\n');
|
|
166
|
+
}
|
|
@@ -6,8 +6,17 @@ export interface DiscoverInput {
|
|
|
6
6
|
ship: ConfigDataShip;
|
|
7
7
|
/** `gjsify.flatpak.icon`, the pre-existing spelling of the same thing. */
|
|
8
8
|
flatpakIcon?: string;
|
|
9
|
-
/** The bundle path the project declares. */
|
|
9
|
+
/** The bundle path the project declares FOR THIS TARGET. */
|
|
10
10
|
declaredBundle?: string;
|
|
11
|
+
/**
|
|
12
|
+
* The entries other targets declare, which this one must not carry.
|
|
13
|
+
*
|
|
14
|
+
* See {@link withoutForeignEntries} — same class as the locale tree below,
|
|
15
|
+
* with a sharper edge: a foreign entry is not merely dead weight, it is a
|
|
16
|
+
* bundle this target's interpreter cannot load, inside an artifact that says
|
|
17
|
+
* it can.
|
|
18
|
+
*/
|
|
19
|
+
foreignBundles?: readonly string[];
|
|
11
20
|
}
|
|
12
21
|
export declare function discoverPayload(input: DiscoverInput): DiscoveredPayload;
|
|
13
22
|
/**
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// (`data/icons/hicolor/…`, `data/*.gschema.xml`) needs no configuration at all.
|
|
7
7
|
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
8
8
|
import { dirname, isAbsolute, join, posix, relative, resolve, sep } from 'node:path';
|
|
9
|
-
import { LICENSE_FILE_NAMES } from './settings.js';
|
|
9
|
+
import { CHANGELOG_FILE_NAMES, LICENSE_FILE_NAMES, } from './settings.js';
|
|
10
10
|
const ICON_EXTENSIONS = ['.svg', '.png'];
|
|
11
11
|
/**
|
|
12
12
|
* The face formats every FreeType opens, with no build option deciding it.
|
|
@@ -48,13 +48,14 @@ export function discoverPayload(input) {
|
|
|
48
48
|
// Measured on a probe package: the same `.mo` appeared at both paths. The `lib/` copy is
|
|
49
49
|
// dead weight — nothing looks there — and it is the same failure that once shipped the test
|
|
50
50
|
// suite: whatever is beside the bundle gets carried whether or not it belongs in a package.
|
|
51
|
-
bundleFiles: withoutLocaleTree(listFilesRecursive(bundleDir), bundleDir, projectDir, ship.localeDir),
|
|
51
|
+
bundleFiles: withoutForeignEntries(withoutLocaleTree(listFilesRecursive(bundleDir), bundleDir, projectDir, ship.localeDir), bundleDir, projectDir, input.foreignBundles ?? [], bundlePath),
|
|
52
52
|
iconFiles: discoverIcons(projectDir, ship.icon ?? input.flatpakIcon),
|
|
53
53
|
schemaFiles: discoverSchemas(projectDir, ship.schemas),
|
|
54
54
|
typelibFiles: discoverTypelibs(projectDir, ship.bundledTypelibs),
|
|
55
55
|
localeFiles,
|
|
56
56
|
fontFiles: discoverFonts(projectDir, ship.fonts),
|
|
57
57
|
licenseFile: discoverLicense(projectDir, ship.licenseFile),
|
|
58
|
+
changelogFile: discoverChangelog(projectDir, ship.changelogFile),
|
|
58
59
|
};
|
|
59
60
|
}
|
|
60
61
|
/**
|
|
@@ -168,6 +169,45 @@ function discoverLocales(projectDir, dir) {
|
|
|
168
169
|
}
|
|
169
170
|
return out;
|
|
170
171
|
}
|
|
172
|
+
/**
|
|
173
|
+
* Drop the entries declared for OTHER targets from this target's payload.
|
|
174
|
+
*
|
|
175
|
+
* `gjsify.ship.bundle` is per OS (#1545), so a project with two hosts builds
|
|
176
|
+
* `dist/app.gjs.mjs` and `dist/app.node.mjs` into ONE directory — and staging
|
|
177
|
+
* that directory wholesale hands each artifact the other's bundle. It is not
|
|
178
|
+
* dead weight like the locale copy: on macOS it lands inside the signed `.app`,
|
|
179
|
+
* and it is a file whose only property is that this artifact's interpreter
|
|
180
|
+
* cannot load it.
|
|
181
|
+
*
|
|
182
|
+
* Only the declared entries themselves, never a guess at what else belongs to
|
|
183
|
+
* them: a bundle's shared chunks are indistinguishable from the application's
|
|
184
|
+
* own files, and dropping one would break the artifact this list exists to keep
|
|
185
|
+
* whole.
|
|
186
|
+
*/
|
|
187
|
+
function withoutForeignEntries(bundleFiles, bundleDir, projectDir, foreign, bundlePath) {
|
|
188
|
+
if (foreign.length === 0)
|
|
189
|
+
return bundleFiles;
|
|
190
|
+
const dropped = new Set();
|
|
191
|
+
for (const entry of foreign) {
|
|
192
|
+
const abs = isAbsolute(entry) ? entry : resolve(projectDir, entry);
|
|
193
|
+
// THIS TARGET'S OWN ENTRY, under another spelling. The foreign set is
|
|
194
|
+
// compared as declared STRINGS, and `./dist/app.mjs`, `dist/../dist/app.mjs`
|
|
195
|
+
// and an absolute path are three spellings of one file — dropping it would
|
|
196
|
+
// stage a launcher whose bundle is not in the payload, which is a worse
|
|
197
|
+
// artifact than the one this exclusion exists to prevent. Paths are the
|
|
198
|
+
// only comparison that cannot be spelled around.
|
|
199
|
+
if (abs === bundlePath)
|
|
200
|
+
continue;
|
|
201
|
+
const rel = relative(bundleDir, abs);
|
|
202
|
+
// Outside this payload — another target may build somewhere else entirely,
|
|
203
|
+
// and then there is nothing here to subtract. Same shape as the locale
|
|
204
|
+
// tree above, including the POSIX normalisation Windows needs.
|
|
205
|
+
if (rel === '' || rel.startsWith('..') || isAbsolute(rel))
|
|
206
|
+
continue;
|
|
207
|
+
dropped.add(rel.split(sep).join(posix.sep));
|
|
208
|
+
}
|
|
209
|
+
return bundleFiles.filter((file) => !dropped.has(file));
|
|
210
|
+
}
|
|
171
211
|
function resolveBundle(input) {
|
|
172
212
|
const declared = input.declaredBundle;
|
|
173
213
|
if (declared === undefined) {
|
|
@@ -336,6 +376,40 @@ function discoverLicense(projectDir, configured) {
|
|
|
336
376
|
}
|
|
337
377
|
return undefined;
|
|
338
378
|
}
|
|
379
|
+
/**
|
|
380
|
+
* The project's own changelog — the prose the `.deb`'s `changelog.Debian.gz` is
|
|
381
|
+
* rendered from (Debian Policy § 4.4).
|
|
382
|
+
*
|
|
383
|
+
* SAME SEARCH AS THE LICENCE, deliberately, and for the same reason: a package
|
|
384
|
+
* inside a monorepo does not carry its own copy. `packages/infra/cli` has no
|
|
385
|
+
* CHANGELOG.md and the release train writes one at the repository root, so a
|
|
386
|
+
* `projectDir`-only search would find nothing for the one in-tree project that
|
|
387
|
+
* declares `gjsify.ship`. {@link licenseSearchDirs} stops at the first ancestor
|
|
388
|
+
* that is a project root, which is what keeps an unrelated parent directory's
|
|
389
|
+
* file out.
|
|
390
|
+
*
|
|
391
|
+
* Absence is NOT an error here, unlike the licence: Policy § 4.4 requires the
|
|
392
|
+
* FILE, not a particular history, and `renderDebianChangelog` writes a
|
|
393
|
+
* one-line entry naming the version when there is no prose to quote. Refusing
|
|
394
|
+
* would make a changelog a precondition for packaging anything.
|
|
395
|
+
*/
|
|
396
|
+
function discoverChangelog(projectDir, configured) {
|
|
397
|
+
if (configured !== undefined) {
|
|
398
|
+
const target = resolve(projectDir, configured);
|
|
399
|
+
if (!existsSync(target)) {
|
|
400
|
+
throw new Error(`gjsify ship: the configured changelog file ${configured} does not exist.`);
|
|
401
|
+
}
|
|
402
|
+
return target;
|
|
403
|
+
}
|
|
404
|
+
for (const dir of licenseSearchDirs(projectDir)) {
|
|
405
|
+
for (const name of CHANGELOG_FILE_NAMES) {
|
|
406
|
+
const target = join(dir, name);
|
|
407
|
+
if (existsSync(target))
|
|
408
|
+
return target;
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
return undefined;
|
|
412
|
+
}
|
|
339
413
|
/** `projectDir` first, then each ancestor up to and including the project root. */
|
|
340
414
|
function licenseSearchDirs(projectDir) {
|
|
341
415
|
const dirs = [];
|