@gjsify/cli 0.45.0 → 0.47.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 +144 -135
- package/lib/commands/onboard.js +11 -0
- package/lib/commands/publish.d.ts +41 -0
- package/lib/commands/publish.js +222 -2
- package/lib/commands/ship.js +89 -76
- package/lib/commands/upgrade.d.ts +23 -0
- package/lib/commands/upgrade.js +95 -12
- package/lib/templates/install.mjs.tmpl +2 -2
- package/lib/types/config-data.d.ts +64 -0
- package/lib/utils/gi-search-path.js +11 -3
- package/lib/utils/publish-readback.d.ts +132 -0
- package/lib/utils/publish-readback.js +273 -0
- package/lib/utils/ship/discover.js +85 -0
- package/lib/utils/ship/formats.d.ts +23 -0
- package/lib/utils/ship/formats.js +44 -0
- package/lib/utils/ship/launcher.js +57 -0
- package/lib/utils/ship/layout.d.ts +64 -2
- package/lib/utils/ship/layout.js +169 -12
- package/lib/utils/ship/msi.js +24 -7
- package/lib/utils/ship/payload.js +22 -3
- package/lib/utils/ship/pe-launcher.d.ts +29 -0
- package/lib/utils/ship/pe-launcher.js +663 -0
- package/lib/utils/ship/plan.js +35 -0
- package/lib/utils/ship/plist.d.ts +41 -1
- package/lib/utils/ship/plist.js +59 -1
- package/lib/utils/ship/rpm.js +7 -0
- package/lib/utils/ship/settings.d.ts +76 -5
- package/lib/utils/ship/settings.js +89 -8
- package/lib/utils/ship/share-dirs.d.ts +30 -0
- package/lib/utils/ship/share-dirs.js +30 -0
- package/lib/utils/ship/signing.d.ts +122 -6
- package/lib/utils/ship/signing.js +256 -67
- package/lib/utils/ship/stage-writer.js +20 -1
- package/lib/utils/ship/types.d.ts +34 -2
- package/lib/utils/system-gi.d.ts +20 -5
- package/lib/utils/system-gi.js +36 -13
- package/package.json +19 -19
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/** One registry answer, classified. `absent` and `error` are NOT the same fact. */
|
|
2
|
+
export type ReadbackProbe = {
|
|
3
|
+
state: 'present';
|
|
4
|
+
status: number;
|
|
5
|
+
tarball: string;
|
|
6
|
+
} | {
|
|
7
|
+
state: 'absent';
|
|
8
|
+
status: number;
|
|
9
|
+
detail: string;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* We do not KNOW. A 5xx, a timeout, unparseable JSON — treating any of them
|
|
13
|
+
* as "absent" would fabricate a "never published" verdict out of a registry
|
|
14
|
+
* hiccup, the same distinction `verify-published-closure.mjs` draws between
|
|
15
|
+
* `false` and an `Error`.
|
|
16
|
+
*/
|
|
17
|
+
| {
|
|
18
|
+
state: 'error';
|
|
19
|
+
status?: number;
|
|
20
|
+
detail: string;
|
|
21
|
+
};
|
|
22
|
+
export interface ReadbackResult {
|
|
23
|
+
/** True iff a probe saw the exact version with a tarball on it. */
|
|
24
|
+
confirmed: boolean;
|
|
25
|
+
/** Probes actually sent (≥ 1). */
|
|
26
|
+
attempts: number;
|
|
27
|
+
elapsedMs: number;
|
|
28
|
+
/** The URL that was asked, verbatim — it belongs in the failure message. */
|
|
29
|
+
url: string;
|
|
30
|
+
/** What the registry said last. On `confirmed` this is the `present` probe. */
|
|
31
|
+
last: ReadbackProbe;
|
|
32
|
+
}
|
|
33
|
+
export interface VerifyPublishedVersionInput {
|
|
34
|
+
/** Registry URL, with or without a trailing slash. */
|
|
35
|
+
registry: string;
|
|
36
|
+
/** Full package name including scope. */
|
|
37
|
+
name: string;
|
|
38
|
+
version: string;
|
|
39
|
+
/** Total polling budget in ms. Must be > 0 — a disabled read-back is the caller's decision. */
|
|
40
|
+
budgetMs: number;
|
|
41
|
+
/** Per-probe request timeout. Default 30 s, matching the closure script. */
|
|
42
|
+
probeTimeoutMs?: number;
|
|
43
|
+
/**
|
|
44
|
+
* The `authorization` header the PUT used, forwarded verbatim.
|
|
45
|
+
*
|
|
46
|
+
* A registry that requires a credential to READ answers 401/403 to an
|
|
47
|
+
* anonymous packument GET, which classifies as `error` — so an intact
|
|
48
|
+
* publish to GitHub Packages, an Artifactory repo or a Verdaccio with
|
|
49
|
+
* `access: $authenticated` reports `publish-unconfirmed` and, on the fatal
|
|
50
|
+
* default, exits 1 after the whole budget on a publish that worked.
|
|
51
|
+
* Measured against the first version of this file: 15 read-back GETs, every
|
|
52
|
+
* one anonymous, one second after a PUT that carried a token. Same origin,
|
|
53
|
+
* same credential, one request later — it adds no exposure the PUT did not
|
|
54
|
+
* already have.
|
|
55
|
+
*/
|
|
56
|
+
authorization?: string;
|
|
57
|
+
/** Injected in tests; resolved at call time so a `globalThis.fetch` stub is honoured. */
|
|
58
|
+
fetchImpl?: typeof fetch;
|
|
59
|
+
sleep?: (ms: number) => Promise<void>;
|
|
60
|
+
now?: () => number;
|
|
61
|
+
/** Called once per probe under `GJSIFY_PUBLISH_DEBUG`. */
|
|
62
|
+
log?: (msg: string) => void;
|
|
63
|
+
}
|
|
64
|
+
/** Delay before probe `round + 2`. Past the table's end the last value repeats. */
|
|
65
|
+
export declare function backoffFor(round: number): number;
|
|
66
|
+
/**
|
|
67
|
+
* A probe's request timeout: what was configured, never more than the budget has
|
|
68
|
+
* left to spend — but never less than a probe needs to answer.
|
|
69
|
+
*
|
|
70
|
+
* Configured and budget are otherwise independent, and that makes
|
|
71
|
+
* `--verify-timeout 5` — what the 199-package sweep passes — a promise the code
|
|
72
|
+
* does not keep: one unanswered request may block for the 30 s default, six times
|
|
73
|
+
* the budget it was given, and 199 of those cost ~100 min against the ~11% the
|
|
74
|
+
* deferral is argued on. Clamping bounds the wall clock at
|
|
75
|
+
* `budget + MIN_PROBE_TIMEOUT_MS` instead. The floor is the other half of the
|
|
76
|
+
* same honesty: clamping all the way to zero traded a 30 s overshoot for an
|
|
77
|
+
* `error` verdict on a probe that was about to return the 404 (measured — it
|
|
78
|
+
* turned the incident's own e2e row from `absent: 404` into `request failed
|
|
79
|
+
* (This operation was aborted)`).
|
|
80
|
+
*/
|
|
81
|
+
export declare function probeTimeoutFor(configuredMs: number, remainingMs: number): number;
|
|
82
|
+
/**
|
|
83
|
+
* Default polling budget: 300 s.
|
|
84
|
+
*
|
|
85
|
+
* The measured maximum lag between a 2xx and the registry recording the version
|
|
86
|
+
* was 251.7 s (@gjsify/child_process, v0.46.0), so a budget below ~4.2 minutes
|
|
87
|
+
* turns npm's normal queueing into a false red — and a false red at release time
|
|
88
|
+
* costs a manual re-run of a workflow that did its job. 300 s clears that
|
|
89
|
+
* maximum and is still short enough that a genuinely lost write is reported by
|
|
90
|
+
* the job that lost it rather than by a later sweep.
|
|
91
|
+
*
|
|
92
|
+
* THAT MAXIMUM IS A SAMPLE MAXIMUM, and the sample does not look truncated by
|
|
93
|
+
* nature. The 19 lags fall in buckets ~20-30 s apart — 56(2), 76(3), 96(3),
|
|
94
|
+
* 127(4), 157(3), 189(1), 251(3) — and the TOP bucket is as populated as the
|
|
95
|
+
* middle ones, which is the shape of a distribution cut off by the observation
|
|
96
|
+
* rather than one decaying to zero. One release is one sample, so a lag past
|
|
97
|
+
* 300 s is not ruled out; what makes 300 s affordable anyway is that the
|
|
98
|
+
* remediation is verified rather than assumed. The re-run this reports lands on
|
|
99
|
+
* the 409 path, and that path READS BACK too (see the conflict branch in
|
|
100
|
+
* `commands/publish.ts`), so a window that turns out to be too short costs one
|
|
101
|
+
* re-run that confirms — it does not hand back an unverified success.
|
|
102
|
+
*/
|
|
103
|
+
export declare const DEFAULT_VERIFY_BUDGET_MS = 300000;
|
|
104
|
+
/**
|
|
105
|
+
* Poll the registry until `name@version` resolves, or the budget runs out.
|
|
106
|
+
*
|
|
107
|
+
* No side effects, no exits, no printing beyond the injected `log` — the caller
|
|
108
|
+
* owns presentation, same contract as {@link import('./publish-diagnose.js')}.
|
|
109
|
+
*/
|
|
110
|
+
export declare function verifyPublishedVersion(input: VerifyPublishedVersionInput): Promise<ReadbackResult>;
|
|
111
|
+
/**
|
|
112
|
+
* The unconfirmed-publish diagnostic, ready for stderr.
|
|
113
|
+
*
|
|
114
|
+
* The message has to answer three questions the incident log could not, which is
|
|
115
|
+
* why it needed the registry to reconstruct at all: what was PUT, what was asked,
|
|
116
|
+
* and what came back.
|
|
117
|
+
*/
|
|
118
|
+
export declare function formatUnconfirmedPublish(opts: {
|
|
119
|
+
name: string;
|
|
120
|
+
version: string;
|
|
121
|
+
putUrl: string;
|
|
122
|
+
putStatus: number;
|
|
123
|
+
putStatusText: string;
|
|
124
|
+
payloadBytes: number;
|
|
125
|
+
readback: ReadbackResult;
|
|
126
|
+
/**
|
|
127
|
+
* What npm claimed before the read-back disagreed. `accepted` is a 2xx;
|
|
128
|
+
* `already-published` is a tolerated 409, where the remediation is the
|
|
129
|
+
* opposite one — a re-run answers the same conflict.
|
|
130
|
+
*/
|
|
131
|
+
claim?: 'accepted' | 'already-published';
|
|
132
|
+
}): string;
|
|
@@ -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
|
+
}
|
|
@@ -8,6 +8,30 @@ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
|
8
8
|
import { dirname, isAbsolute, join, posix, relative, resolve, sep } from 'node:path';
|
|
9
9
|
import { LICENSE_FILE_NAMES } from './settings.js';
|
|
10
10
|
const ICON_EXTENSIONS = ['.svg', '.png'];
|
|
11
|
+
/**
|
|
12
|
+
* The face formats every FreeType opens, with no build option deciding it.
|
|
13
|
+
*
|
|
14
|
+
* TrueType, OpenType and their collections. Deliberately short: the point of the
|
|
15
|
+
* set is that a file in it resolves inside the app's OWN runtime closure as well
|
|
16
|
+
* as it does on the machine that packaged it.
|
|
17
|
+
*/
|
|
18
|
+
const FONT_EXTENSIONS = ['.ttf', '.otf', '.ttc', '.otc'];
|
|
19
|
+
/**
|
|
20
|
+
* Web-font wrappers, refused BY NAME rather than filtered away.
|
|
21
|
+
*
|
|
22
|
+
* They are the mistake worth a diagnostic. An `OFL.txt` beside the faces is
|
|
23
|
+
* ignored the way a stray file in an icon directory is; a `.woff2` is a font
|
|
24
|
+
* somebody put there on purpose, and dropping it silently costs the same as
|
|
25
|
+
* staging it — the app ships without the family and Pango substitutes, at exit 0.
|
|
26
|
+
*
|
|
27
|
+
* Measured in BOTH directions, which is why this is a refusal rather than a
|
|
28
|
+
* warning: `fc-query` reads a `.woff2`'s family fine on Fedora 44 (fontconfig
|
|
29
|
+
* 2.17.0, FreeType built with brotli), and that support is
|
|
30
|
+
* `FT_CONFIG_OPTION_USE_BROTLI` — a property of whichever FreeType the shipped
|
|
31
|
+
* artifact loads, which for a `.app` or a Windows program directory is the one
|
|
32
|
+
* inside `@gjsify/gtk-runtime-<target>` and not this host's.
|
|
33
|
+
*/
|
|
34
|
+
const WEB_FONT_EXTENSIONS = ['.woff', '.woff2', '.eot'];
|
|
11
35
|
export function discoverPayload(input) {
|
|
12
36
|
const { projectDir, ship } = input;
|
|
13
37
|
const bundlePath = resolveBundle(input);
|
|
@@ -29,6 +53,7 @@ export function discoverPayload(input) {
|
|
|
29
53
|
schemaFiles: discoverSchemas(projectDir, ship.schemas),
|
|
30
54
|
typelibFiles: discoverTypelibs(projectDir, ship.bundledTypelibs),
|
|
31
55
|
localeFiles,
|
|
56
|
+
fontFiles: discoverFonts(projectDir, ship.fonts),
|
|
32
57
|
licenseFile: discoverLicense(projectDir, ship.licenseFile),
|
|
33
58
|
};
|
|
34
59
|
}
|
|
@@ -186,6 +211,66 @@ function listIcons(dir) {
|
|
|
186
211
|
.map((rel) => join(dir, rel.split('/').join(sep)))
|
|
187
212
|
.sort();
|
|
188
213
|
}
|
|
214
|
+
/**
|
|
215
|
+
* Font faces the application SHIPS (`gjsify.ship.fonts`), or the GNOME-shaped
|
|
216
|
+
* default `data/fonts` when it exists.
|
|
217
|
+
*
|
|
218
|
+
* A FILE or a DIRECTORY, like `icon`, and a directory is walked rather than
|
|
219
|
+
* globbed because a family arrives as several faces and often with its licence
|
|
220
|
+
* beside them.
|
|
221
|
+
*
|
|
222
|
+
* The refusals are the interesting half, and both cover the same failure: a font
|
|
223
|
+
* that installs and is never found. Pango does not report a missing family — it
|
|
224
|
+
* substitutes, so the app renders in the wrong typeface with no error, no exit
|
|
225
|
+
* code and nothing for CI to see. That is why a configured path holding no usable
|
|
226
|
+
* face fails here rather than staging an empty directory, and why a web-font
|
|
227
|
+
* wrapper is named rather than filtered (see {@link WEB_FONT_EXTENSIONS}).
|
|
228
|
+
*/
|
|
229
|
+
function discoverFonts(projectDir, configured) {
|
|
230
|
+
const declared = configured ?? 'data/fonts';
|
|
231
|
+
const root = configured === undefined ? join(projectDir, 'data', 'fonts') : resolve(projectDir, configured);
|
|
232
|
+
if (!existsSync(root)) {
|
|
233
|
+
if (configured === undefined)
|
|
234
|
+
return [];
|
|
235
|
+
throw new Error(`gjsify ship: the configured font path ${configured} does not exist.`);
|
|
236
|
+
}
|
|
237
|
+
if (!statSync(root).isDirectory()) {
|
|
238
|
+
assertNotWebFont(root, declared);
|
|
239
|
+
if (!FONT_EXTENSIONS.some((ext) => root.toLowerCase().endsWith(ext))) {
|
|
240
|
+
throw new Error(`gjsify ship: \`gjsify.ship.fonts\` names ${configured}, which is not a font face ` +
|
|
241
|
+
`fontconfig will open (${FONT_EXTENSIONS.join(', ')}). ` +
|
|
242
|
+
'A file it cannot read installs and is never found, and Pango substitutes a fallback ' +
|
|
243
|
+
'family rather than reporting anything.');
|
|
244
|
+
}
|
|
245
|
+
return [root];
|
|
246
|
+
}
|
|
247
|
+
const out = [];
|
|
248
|
+
for (const rel of listFilesRecursive(root)) {
|
|
249
|
+
assertNotWebFont(rel, declared);
|
|
250
|
+
if (FONT_EXTENSIONS.some((ext) => rel.toLowerCase().endsWith(ext))) {
|
|
251
|
+
out.push(join(root, rel.split('/').join(sep)));
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
if (out.length === 0 && configured !== undefined) {
|
|
255
|
+
throw new Error(`gjsify ship: \`gjsify.ship.fonts\` (${configured}) holds no font face ` +
|
|
256
|
+
`(${FONT_EXTENSIONS.join(', ')}). Declaring fonts that ship nothing is a promise the ` +
|
|
257
|
+
'package does not keep — and its symptom is a substituted family, not an error.');
|
|
258
|
+
}
|
|
259
|
+
return out.sort();
|
|
260
|
+
}
|
|
261
|
+
/** A `.woff`/`.woff2`/`.eot` is named, never dropped — {@link WEB_FONT_EXTENSIONS} carries the reason. */
|
|
262
|
+
function assertNotWebFont(path, where) {
|
|
263
|
+
const lower = path.toLowerCase();
|
|
264
|
+
const wrapper = WEB_FONT_EXTENSIONS.find((ext) => lower.endsWith(ext));
|
|
265
|
+
if (wrapper === undefined)
|
|
266
|
+
return;
|
|
267
|
+
throw new Error(`gjsify ship: \`gjsify.ship.fonts\` (${where}) carries ${path}, and \`${wrapper}\` is a web-font ` +
|
|
268
|
+
'wrapper whose support is a FreeType BUILD option — brotli for woff2, zlib for woff. Whether it ' +
|
|
269
|
+
'opens is therefore a property of the FreeType the SHIPPED artifact loads (inside ' +
|
|
270
|
+
'`@gjsify/gtk-runtime-<target>` for a `.app` or a Windows program directory), not of the machine ' +
|
|
271
|
+
`that packaged it. Ship the desktop face instead (${FONT_EXTENSIONS.join(', ')}); a font that ` +
|
|
272
|
+
'fails to open is not an error, it is Pango quietly substituting a different typeface.');
|
|
273
|
+
}
|
|
189
274
|
function discoverSchemas(projectDir, configured) {
|
|
190
275
|
const roots = configured !== undefined ? [resolve(projectDir, configured)] : [join(projectDir, 'data')];
|
|
191
276
|
for (const root of roots) {
|
|
@@ -116,3 +116,26 @@ export declare function configuredFormats(raw: readonly string[], layout: Layout
|
|
|
116
116
|
formats: FormatDescriptor[];
|
|
117
117
|
dropped: FormatId[];
|
|
118
118
|
};
|
|
119
|
+
/**
|
|
120
|
+
* Can this format's container hold a notarisation ticket?
|
|
121
|
+
*
|
|
122
|
+
* A SWITCH WITH A `never` GUARD and not a field, deliberately — the difference
|
|
123
|
+
* from `FormatDescriptor.host` is who owns the fact. Where a format can be PACKED
|
|
124
|
+
* is a property of this project's toolchain and belongs on the descriptor; whether
|
|
125
|
+
* `stapler` will write a ticket into it is Apple's list, unchanged by anything
|
|
126
|
+
* here, and six of the nine rows are only "no" because notarisation is a macOS
|
|
127
|
+
* concept they have nothing to do with. Putting it in a `switch` keeps the answer
|
|
128
|
+
* beside its citation and still makes a tenth format a compile error.
|
|
129
|
+
*
|
|
130
|
+
* `stapler(1)`: *"stapler works only with UDIF disk images, signed \"flat\"
|
|
131
|
+
* installer packages, and certain code-signed executable bundles such as
|
|
132
|
+
* \".app\"."* And Apple's notarisation guide for the exception that decides the
|
|
133
|
+
* zip: *"While you can notarize a ZIP archive, you can't staple to it directly.
|
|
134
|
+
* Instead, run stapler against each item that you added to the archive. Then
|
|
135
|
+
* create a new ZIP file containing the stapled items for distribution."*
|
|
136
|
+
*
|
|
137
|
+
* `macos-app` is `true` here even though `notarytool` cannot SUBMIT a directory:
|
|
138
|
+
* the two questions are separate, and a `.app` is precisely the item Apple's
|
|
139
|
+
* remedy above tells you to staple after submitting the archive around it.
|
|
140
|
+
*/
|
|
141
|
+
export declare function canCarryTicket(id: FormatId): boolean;
|
|
@@ -947,3 +947,47 @@ export function configuredFormats(raw, layout) {
|
|
|
947
947
|
dropped: named.filter((name) => FORMATS[name].layoutOs !== layout.os),
|
|
948
948
|
};
|
|
949
949
|
}
|
|
950
|
+
/**
|
|
951
|
+
* Can this format's container hold a notarisation ticket?
|
|
952
|
+
*
|
|
953
|
+
* A SWITCH WITH A `never` GUARD and not a field, deliberately — the difference
|
|
954
|
+
* from `FormatDescriptor.host` is who owns the fact. Where a format can be PACKED
|
|
955
|
+
* is a property of this project's toolchain and belongs on the descriptor; whether
|
|
956
|
+
* `stapler` will write a ticket into it is Apple's list, unchanged by anything
|
|
957
|
+
* here, and six of the nine rows are only "no" because notarisation is a macOS
|
|
958
|
+
* concept they have nothing to do with. Putting it in a `switch` keeps the answer
|
|
959
|
+
* beside its citation and still makes a tenth format a compile error.
|
|
960
|
+
*
|
|
961
|
+
* `stapler(1)`: *"stapler works only with UDIF disk images, signed \"flat\"
|
|
962
|
+
* installer packages, and certain code-signed executable bundles such as
|
|
963
|
+
* \".app\"."* And Apple's notarisation guide for the exception that decides the
|
|
964
|
+
* zip: *"While you can notarize a ZIP archive, you can't staple to it directly.
|
|
965
|
+
* Instead, run stapler against each item that you added to the archive. Then
|
|
966
|
+
* create a new ZIP file containing the stapled items for distribution."*
|
|
967
|
+
*
|
|
968
|
+
* `macos-app` is `true` here even though `notarytool` cannot SUBMIT a directory:
|
|
969
|
+
* the two questions are separate, and a `.app` is precisely the item Apple's
|
|
970
|
+
* remedy above tells you to staple after submitting the archive around it.
|
|
971
|
+
*/
|
|
972
|
+
export function canCarryTicket(id) {
|
|
973
|
+
switch (id) {
|
|
974
|
+
case 'macos-app':
|
|
975
|
+
case 'macos-app-dmg':
|
|
976
|
+
return true;
|
|
977
|
+
case 'macos-app-zip':
|
|
978
|
+
// Nothing outside darwin has a ticket to carry: `stapler` is Apple's tool
|
|
979
|
+
// and no other OS pre-clears an artifact at all (§ A5 — Windows SmartScreen
|
|
980
|
+
// accrues reputation per file hash instead).
|
|
981
|
+
case 'deb':
|
|
982
|
+
case 'rpm':
|
|
983
|
+
case 'flatpak':
|
|
984
|
+
case 'windows-dir':
|
|
985
|
+
case 'windows-dir-zip':
|
|
986
|
+
case 'msi':
|
|
987
|
+
return false;
|
|
988
|
+
default: {
|
|
989
|
+
const unhandled = id;
|
|
990
|
+
throw new Error(`gjsify ship: no ticket answer for format "${String(unhandled)}".`);
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
}
|