@gjsify/cli 0.64.3-next.262 → 0.64.5
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 +51 -51
- package/lib/commands/publish.d.ts +13 -0
- package/lib/commands/publish.js +93 -7
- package/lib/commands/ship.js +20 -2
- package/lib/utils/npm-oidc.d.ts +1 -1
- package/lib/utils/npm-oidc.js +5 -5
- package/lib/utils/publish-hold.d.ts +41 -1
- package/lib/utils/publish-hold.js +47 -7
- package/lib/utils/ship/app-runtime.d.ts +16 -1
- package/lib/utils/ship/app-runtime.js +17 -3
- package/lib/utils/ship/gtk-runtime-acquire.d.ts +36 -0
- package/lib/utils/ship/gtk-runtime-acquire.js +59 -0
- package/lib/utils/transient-retry.d.ts +37 -0
- package/lib/utils/transient-retry.js +72 -0
- package/package.json +21 -22
|
@@ -5,6 +5,7 @@ import { type WorkspacePin } from '../utils/publish-pins.js';
|
|
|
5
5
|
import { type HoldReason } from '../utils/publish-hold.js';
|
|
6
6
|
import { type Classification } from '../utils/publish-status.js';
|
|
7
7
|
import { OtpProvider } from '../utils/npm-otp.js';
|
|
8
|
+
import { type TransientRetryOptions } from '../utils/transient-retry.js';
|
|
8
9
|
interface PublishOptions {
|
|
9
10
|
path?: string;
|
|
10
11
|
tag?: string;
|
|
@@ -16,6 +17,10 @@ interface PublishOptions {
|
|
|
16
17
|
'verify-defer'?: boolean;
|
|
17
18
|
'hold-timeout'?: number;
|
|
18
19
|
'report-ledger'?: boolean;
|
|
20
|
+
'fail-on-unsettled'?: boolean;
|
|
21
|
+
'held-names'?: boolean;
|
|
22
|
+
'settle-ledger'?: boolean;
|
|
23
|
+
'settle-timeout'?: number;
|
|
19
24
|
ref?: string;
|
|
20
25
|
provenance?: boolean;
|
|
21
26
|
'dry-run'?: boolean;
|
|
@@ -24,6 +29,12 @@ interface PublishOptions {
|
|
|
24
29
|
'check-trusted'?: boolean;
|
|
25
30
|
}
|
|
26
31
|
export declare const publishCommand: Command<unknown, PublishOptions>;
|
|
32
|
+
/**
|
|
33
|
+
* Whether a 409 / 403 "previously published" answer counts as success. `--tolerate-republish`
|
|
34
|
+
* says yes; so does a PUT that was retried after a 5xx, since the first attempt may have landed
|
|
35
|
+
* and the retry then finds its own version.
|
|
36
|
+
*/
|
|
37
|
+
export declare function acceptsRepublish(status: number, text: string, tolerate: boolean, retriedPut: boolean): boolean;
|
|
27
38
|
/** Input to {@link publishWorkspace} — the token-auth-aware, OTP-shareable publish flow. */
|
|
28
39
|
export interface PublishWorkspaceInput {
|
|
29
40
|
/** Workspace directory to pack + publish. */
|
|
@@ -70,6 +81,8 @@ export interface PublishWorkspaceInput {
|
|
|
70
81
|
* holding this package. Default {@link DEFAULT_HOLD_WAIT_MS}.
|
|
71
82
|
*/
|
|
72
83
|
holdWaitMs?: number;
|
|
84
|
+
/** Retry policy for 5xx/429/network errors on the PUT. Tests pass `{ baseMs: 0 }`. */
|
|
85
|
+
putRetry?: TransientRetryOptions;
|
|
73
86
|
}
|
|
74
87
|
/** Discriminated result of {@link publishWorkspace}; the caller owns presentation. */
|
|
75
88
|
export type PublishOutcome = {
|
package/lib/commands/publish.js
CHANGED
|
@@ -50,11 +50,12 @@ import { getNpmTrustedToken, hasGithubOidcEnv, OidcExchangeError, OidcUnavailabl
|
|
|
50
50
|
import { diagnose404, is404DiagnosticCandidate } from '../utils/publish-diagnose.js';
|
|
51
51
|
import { DEFAULT_VERIFY_BUDGET_MS, formatUnconfirmedPublish, probePackageName, verifyPublishedVersion, } from '../utils/publish-readback.js';
|
|
52
52
|
import { collectWorkspacePins, REQUIRED_BLOCK } from '../utils/publish-pins.js';
|
|
53
|
-
import { appendLedger, DEFAULT_HOLD_WAIT_MS, findUnavailablePins, holdsPublish, readLedger, recordUnserved, renderLedgerSummary, summariseReasons, } from '../utils/publish-hold.js';
|
|
53
|
+
import { appendLedger, DEFAULT_HOLD_WAIT_MS, findUnavailablePins, holdsPublish, markHeldPublished, pendingEntries, readLedger, recordUnserved, renderLedgerSummary, settleUnserved, summariseReasons, } from '../utils/publish-hold.js';
|
|
54
54
|
import { describeClass, statusTokenFromEnv } from '../utils/publish-status.js';
|
|
55
55
|
import { loadNpmrc } from '../utils/load-npmrc.js';
|
|
56
56
|
import { OtpProvider, withOtpRetry, isOtpChallenge } from '../utils/npm-otp.js';
|
|
57
57
|
import { promptLine } from '../utils/prompt.js';
|
|
58
|
+
import { fetchWithTransientRetry } from '../utils/transient-retry.js';
|
|
58
59
|
export const publishCommand = {
|
|
59
60
|
command: 'publish [path]',
|
|
60
61
|
description: 'Pack + upload the workspace at <path> (default: cwd) to its npm registry. Drop-in for `npm publish` with workspace:^ rewrite handled automatically.',
|
|
@@ -102,6 +103,26 @@ export const publishCommand = {
|
|
|
102
103
|
description: 'Print the job-summary markdown for the publish ledger named by $GJSIFY_PUBLISH_LEDGER (versions npm accepted but does not serve, packages held because of them, the recovery command) and exit. Pair with `--ref <tag>`.',
|
|
103
104
|
type: 'boolean',
|
|
104
105
|
default: false,
|
|
106
|
+
})
|
|
107
|
+
.option('fail-on-unsettled', {
|
|
108
|
+
description: 'With --report-ledger: exit 1 when any package is still HELD or any accepted version is still unserved, naming them and the recovery command. The end-of-sweep gate: a sweep that left something behind must be red, not a warning.',
|
|
109
|
+
type: 'boolean',
|
|
110
|
+
default: false,
|
|
111
|
+
})
|
|
112
|
+
.option('held-names', {
|
|
113
|
+
description: 'Print, one per line, the packages the ledger shows HELD and not yet published, and exit. Feeds the held-package second pass (.github/scripts/retry-held-publish.sh).',
|
|
114
|
+
type: 'boolean',
|
|
115
|
+
default: false,
|
|
116
|
+
})
|
|
117
|
+
.option('settle-ledger', {
|
|
118
|
+
description: 'Wait up to --settle-timeout for every version the ledger records as accepted-but-unserved to be served (the longer read-back the sweep defers), record each as served, and exit.',
|
|
119
|
+
type: 'boolean',
|
|
120
|
+
default: false,
|
|
121
|
+
})
|
|
122
|
+
.option('settle-timeout', {
|
|
123
|
+
description: 'With --settle-ledger: seconds to wait. Versions poll concurrently, so this is the longest lag tolerated, not a sum. Default 600 (the fatal read-back default; measured lags reach 368 s).',
|
|
124
|
+
type: 'number',
|
|
125
|
+
default: DEFAULT_VERIFY_BUDGET_MS / 1000,
|
|
105
126
|
})
|
|
106
127
|
.option('ref', {
|
|
107
128
|
description: 'With --report-ledger: the git ref to put in the printed recovery command.',
|
|
@@ -158,10 +179,45 @@ export const publishCommand = {
|
|
|
158
179
|
const checkTrustedOnly = args['check-trusted'] === true;
|
|
159
180
|
const trustedFlag = args.trusted;
|
|
160
181
|
const verbose = Boolean(process.env.GJSIFY_PUBLISH_DEBUG);
|
|
182
|
+
const ledgerPath = process.env.GJSIFY_PUBLISH_LEDGER;
|
|
183
|
+
if (args['held-names'] === true) {
|
|
184
|
+
for (const e of pendingEntries(readLedger(ledgerPath)).held)
|
|
185
|
+
process.stdout.write(`${e.name}\n`);
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
if (args['settle-ledger'] === true) {
|
|
189
|
+
const settleSeconds = args['settle-timeout'] ?? DEFAULT_VERIFY_BUDGET_MS / 1000;
|
|
190
|
+
if (!Number.isFinite(settleSeconds) || settleSeconds < 0) {
|
|
191
|
+
console.error(`gjsify publish: --settle-timeout must be a non-negative number of seconds, got ${JSON.stringify(args['settle-timeout'])}`);
|
|
192
|
+
return process.exit(2);
|
|
193
|
+
}
|
|
194
|
+
const npmrc = await loadNpmrc(process.cwd());
|
|
195
|
+
const served = await settleUnserved({
|
|
196
|
+
entries: readLedger(ledgerPath),
|
|
197
|
+
ledgerPath,
|
|
198
|
+
registryFor: (name) => (process.env.npm_config_registry ?? registryFor(name, npmrc) ?? DEFAULT_REGISTRY).replace(/\/$/, ''),
|
|
199
|
+
budgetMs: Math.max(1, Math.round(settleSeconds * 1000)),
|
|
200
|
+
log: verbose ? (m) => console.error(m) : undefined,
|
|
201
|
+
});
|
|
202
|
+
for (const key of served)
|
|
203
|
+
process.stdout.write(`served after settling: ${key}\n`);
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
161
206
|
if (args['report-ledger'] === true) {
|
|
162
|
-
const
|
|
207
|
+
const entries = readLedger(ledgerPath);
|
|
208
|
+
const summary = renderLedgerSummary(entries, args.ref ?? '<tag>');
|
|
163
209
|
if (summary)
|
|
164
210
|
process.stdout.write(`${summary}\n`);
|
|
211
|
+
if (args['fail-on-unsettled'] === true) {
|
|
212
|
+
const { unserved, held } = pendingEntries(entries);
|
|
213
|
+
const names = [...new Set([...held, ...unserved].map((e) => e.name))];
|
|
214
|
+
if (names.length > 0) {
|
|
215
|
+
const ref = args.ref ?? '<tag>';
|
|
216
|
+
console.error(`gjsify publish: ${held.length} package(s) still HELD and ${unserved.length} version(s) still unserved after the second pass: ${names.join(' ')}\n` +
|
|
217
|
+
` recover: re-run THIS run (\`gh run rerun <run-id>\` — keeps the version; never a new dispatch on the next channel, which mints a new one), or for a release: gh workflow run release.yml --ref ${ref} -f skip_publish=false -f only='${names.join(' ')}'`);
|
|
218
|
+
return process.exit(1);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
165
221
|
return;
|
|
166
222
|
}
|
|
167
223
|
const holdSeconds = args['hold-timeout'] ?? DEFAULT_HOLD_WAIT_MS / 1000;
|
|
@@ -277,11 +333,29 @@ export const publishCommand = {
|
|
|
277
333
|
otpProvider,
|
|
278
334
|
seedOtpFirst: otp !== undefined,
|
|
279
335
|
verifyBudgetMs,
|
|
280
|
-
holdWaitMs: Math.round(holdSeconds * 1000),
|
|
336
|
+
holdWaitMs: Math.min(Math.round(holdSeconds * 1000), remainingHoldDeadlineMs()),
|
|
281
337
|
});
|
|
282
338
|
reportPublishOutcome(outcome, args.json === true, deferUnconfirmed);
|
|
283
339
|
},
|
|
284
340
|
};
|
|
341
|
+
/**
|
|
342
|
+
* Milliseconds left until `GJSIFY_HOLD_DEADLINE` (epoch ms), or Infinity when unset. The
|
|
343
|
+
* held-package second pass is one `gjsify publish` PROCESS per package, so each would otherwise
|
|
344
|
+
* pay the full `--hold-timeout` on the same blocker; a shared deadline bounds the whole pass.
|
|
345
|
+
*/
|
|
346
|
+
function remainingHoldDeadlineMs() {
|
|
347
|
+
const until = Number(process.env.GJSIFY_HOLD_DEADLINE);
|
|
348
|
+
return Number.isFinite(until) && until > 0 ? Math.max(0, until - Date.now()) : Infinity;
|
|
349
|
+
}
|
|
350
|
+
/**
|
|
351
|
+
* Whether a 409 / 403 "previously published" answer counts as success. `--tolerate-republish`
|
|
352
|
+
* says yes; so does a PUT that was retried after a 5xx, since the first attempt may have landed
|
|
353
|
+
* and the retry then finds its own version.
|
|
354
|
+
*/
|
|
355
|
+
export function acceptsRepublish(status, text, tolerate, retriedPut) {
|
|
356
|
+
const conflict = status === 409 || (status === 403 && /previously published/i.test(text));
|
|
357
|
+
return conflict && (tolerate || retriedPut);
|
|
358
|
+
}
|
|
285
359
|
/**
|
|
286
360
|
* Pack + PUT a single workspace to its npm registry, returning a structured
|
|
287
361
|
* outcome instead of writing/exiting. Extracted from the `publish` command so
|
|
@@ -465,13 +539,24 @@ export async function publishWorkspace(input) {
|
|
|
465
539
|
const bodyStr = JSON.stringify(payload);
|
|
466
540
|
// Each attempt clones the base headers and adds `npm-otp` (+ legacy
|
|
467
541
|
// auth-type, matching buildPublishHeaders) when a code is supplied.
|
|
468
|
-
|
|
542
|
+
//
|
|
543
|
+
// A transient 5xx/429/network error is retried here, inside the OTP loop (a 401 challenge is
|
|
544
|
+
// not transient and passes straight through). The first attempt may have LANDED despite its
|
|
545
|
+
// 5xx, so `retriedPut` makes a later 409 / "previously published" count as success below.
|
|
546
|
+
let retriedPut = false;
|
|
547
|
+
const doPut = async (code) => {
|
|
469
548
|
const reqHeaders = { ...headers };
|
|
470
549
|
if (code) {
|
|
471
550
|
reqHeaders['npm-otp'] = code;
|
|
472
551
|
reqHeaders['npm-auth-type'] = 'legacy';
|
|
473
552
|
}
|
|
474
|
-
|
|
553
|
+
const { response, retried } = await fetchWithTransientRetry(() => fetch(url, { method: 'PUT', headers: reqHeaders, body: bodyStr }), {
|
|
554
|
+
log: (m) => console.error(`gjsify publish: PUT ${packed.name}@${packed.version}: ${m}`),
|
|
555
|
+
...input.putRetry,
|
|
556
|
+
});
|
|
557
|
+
if (retried > 0)
|
|
558
|
+
retriedPut = true;
|
|
559
|
+
return response;
|
|
475
560
|
};
|
|
476
561
|
const res = await withOtpRetry(doPut, otpProvider, { seedFirstAttempt: input.seedOtpFirst });
|
|
477
562
|
if (verbose) {
|
|
@@ -604,6 +689,7 @@ export async function publishWorkspace(input) {
|
|
|
604
689
|
}),
|
|
605
690
|
};
|
|
606
691
|
}
|
|
692
|
+
markHeldPublished(ledgerPath, packed.name, packed.version);
|
|
607
693
|
return {
|
|
608
694
|
ok: true,
|
|
609
695
|
action: 'published',
|
|
@@ -633,8 +719,7 @@ export async function publishWorkspace(input) {
|
|
|
633
719
|
// this is the path a `publish-unconfirmed` re-run always lands on: exempting
|
|
634
720
|
// it would leave the documented remediation reporting a success nothing
|
|
635
721
|
// checked. Cost is one GET, on a path that only runs on a re-publish.
|
|
636
|
-
|
|
637
|
-
if (isRepublishConflict && tolerate) {
|
|
722
|
+
if (acceptsRepublish(res.status, text, tolerate, retriedPut)) {
|
|
638
723
|
const readback = await readBack();
|
|
639
724
|
if (readback && !readback.confirmed) {
|
|
640
725
|
return {
|
|
@@ -659,6 +744,7 @@ export async function publishWorkspace(input) {
|
|
|
659
744
|
}),
|
|
660
745
|
};
|
|
661
746
|
}
|
|
747
|
+
markHeldPublished(ledgerPath, packed.name, packed.version);
|
|
662
748
|
return {
|
|
663
749
|
ok: true,
|
|
664
750
|
action: 'republish-tolerated',
|
package/lib/commands/ship.js
CHANGED
|
@@ -26,12 +26,16 @@ import { CHANNELS, CHANNEL_ENV, parseDeclaredChannel } from '../utils/channel.js
|
|
|
26
26
|
import { mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
27
27
|
import { basename, dirname, join, relative, resolve } from 'node:path';
|
|
28
28
|
import { Config } from '../config.js';
|
|
29
|
+
import { ensurePkgDir } from './dlx.js';
|
|
29
30
|
import { renderDesktopEntry, renderMetainfoApp, renderMetainfoCli, validateAppMetadata, } from '../utils/app-metadata.js';
|
|
30
31
|
import { readPackageJson } from '../utils/pkg-json.js';
|
|
32
|
+
import { parseSpec } from '../utils/parse-spec.js';
|
|
33
|
+
import { cliVersion } from '../utils/publish-headers.js';
|
|
31
34
|
import { describeExit, spawnToCompletion } from '../utils/spawn.js';
|
|
32
35
|
import { buildDeb } from '../utils/ship/deb.js';
|
|
33
36
|
import { deriveDepends, warnAboutGjsFloor, warnAboutNodeFloor } from '../utils/ship/depends.js';
|
|
34
37
|
import { isGtkRuntimeTarget, stageAppRuntime } from '../utils/ship/app-runtime.js';
|
|
38
|
+
import { acquireGtkRuntime } from '../utils/ship/gtk-runtime-acquire.js';
|
|
35
39
|
import { discoverPayload } from '../utils/ship/discover.js';
|
|
36
40
|
import { appDirFor, appDirPayload, appImageHostRequirements, appImageRuntimeNotice, buildAppImage, findPinnedRuntime, stampAppDirTimes, } from '../utils/ship/appimage.js';
|
|
37
41
|
import { buildDmgImage, dmgVolumeDir, dmgVolumeName } from '../utils/ship/dmg.js';
|
|
@@ -411,7 +415,7 @@ async function assemble(args) {
|
|
|
411
415
|
// `.deb`/`.rpm` `Depends:` is the promise that `node` or `gjs` is on `PATH`, and
|
|
412
416
|
// carrying a second copy inside the payload would be a private interpreter no
|
|
413
417
|
// distribution update ever patches.
|
|
414
|
-
const runtime = resolveCarriedRuntime(layout, settings, projectDir);
|
|
418
|
+
const runtime = await resolveCarriedRuntime(layout, settings, projectDir);
|
|
415
419
|
const stageInputs = {
|
|
416
420
|
bundleFiles: discovered.bundleFiles,
|
|
417
421
|
launcher: renderLauncher(settings, basename(settings.bundlePath), layout, runtime.launcher),
|
|
@@ -761,21 +765,35 @@ function announceSigning(sign, notarize) {
|
|
|
761
765
|
* thing to make non-vacuous on this layout, since a JavaScript bundle has no
|
|
762
766
|
* cputype to disagree with.
|
|
763
767
|
*/
|
|
764
|
-
function resolveCarriedRuntime(layout, settings, projectDir) {
|
|
768
|
+
async function resolveCarriedRuntime(layout, settings, projectDir) {
|
|
765
769
|
const empty = { files: [], launcher: {}, found: [], missing: [] };
|
|
766
770
|
if (layout.os === 'linux' || settings.app !== 'node')
|
|
767
771
|
return empty;
|
|
768
772
|
const target = `${layout.os}-${settings.arch}`;
|
|
769
773
|
if (!isGtkRuntimeTarget(target))
|
|
770
774
|
return empty;
|
|
775
|
+
// The closure is fetched, not installed: a project that lists it puts a second
|
|
776
|
+
// GLib next to Homebrew's in its dev tree (see `gtk-runtime-acquire.ts`).
|
|
777
|
+
const acquired = await acquireGtkRuntime({
|
|
778
|
+
target,
|
|
779
|
+
version: cliVersion(),
|
|
780
|
+
discovery: { cwd: projectDir },
|
|
781
|
+
ensure: async (spec) => (await ensurePkgDir(parseSpec(spec), { verbose: false, cacheMaxAge: DLX_CACHE_MAX_AGE_MIN, frozen: false }))
|
|
782
|
+
.pkgDir,
|
|
783
|
+
log: (message) => console.log(`${LOG} ${message}`),
|
|
784
|
+
});
|
|
771
785
|
return stageAppRuntime({
|
|
772
786
|
layout,
|
|
773
787
|
identity: settings,
|
|
774
788
|
target,
|
|
775
789
|
cwd: projectDir,
|
|
776
790
|
interpreter: isNodeRuntimeTarget(target) ? resolveNodeRuntime(target, { cwd: projectDir }) : null,
|
|
791
|
+
...(acquired.kind === 'fetched' ? { fetchedGtkRuntimeDir: acquired.dir } : {}),
|
|
792
|
+
...(acquired.kind === 'failed' ? { gtkFetchFailure: acquired.reason } : {}),
|
|
777
793
|
});
|
|
778
794
|
}
|
|
795
|
+
/** How long a fetched runtime stays in the dlx cache — the same week `gjsify showcase` uses. */
|
|
796
|
+
const DLX_CACHE_MAX_AGE_MIN = 60 * 24 * 7;
|
|
779
797
|
/**
|
|
780
798
|
* Phase two: pack a stage that was assembled somewhere else.
|
|
781
799
|
*
|
package/lib/utils/npm-oidc.d.ts
CHANGED
|
@@ -14,7 +14,7 @@ interface OidcExchangeOptions {
|
|
|
14
14
|
* `--tolerate-untrusted-new` and the misconfig hints still fire on the first response.
|
|
15
15
|
*/
|
|
16
16
|
maxRetries?: number;
|
|
17
|
-
/** Base backoff in ms for transient retries; doubles each attempt (capped
|
|
17
|
+
/** Base backoff in ms for transient retries; doubles each attempt (capped 30s, `Retry-After` honored). Default 1000. Tests pass 0. */
|
|
18
18
|
retryBaseMs?: number;
|
|
19
19
|
}
|
|
20
20
|
export interface OidcExchangeResult {
|
package/lib/utils/npm-oidc.js
CHANGED
|
@@ -13,8 +13,7 @@
|
|
|
13
13
|
//
|
|
14
14
|
// Reference: refs/npm-cli/lib/utils/oidc.js
|
|
15
15
|
// Original: Copyright (c) npm contributors. Artistic-2.0.
|
|
16
|
-
|
|
17
|
-
const TRANSIENT_EXCHANGE_STATUSES = new Set([408, 425, 429, 500, 502, 503, 504]);
|
|
16
|
+
import { isTransientStatus, retryDelayMs } from './transient-retry.js';
|
|
18
17
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
19
18
|
export class OidcUnavailableError extends Error {
|
|
20
19
|
reason;
|
|
@@ -146,7 +145,7 @@ export async function exchangeOidcForNpmToken(args) {
|
|
|
146
145
|
catch (netErr) {
|
|
147
146
|
// Network-level failure (DNS, connection reset, TLS): transient.
|
|
148
147
|
if (attempt < maxRetries) {
|
|
149
|
-
const delay =
|
|
148
|
+
const delay = retryDelayMs(attempt, retryBaseMs);
|
|
150
149
|
log?.(`gjsify oidc: network error for ${packageName} (attempt ${attempt + 1}/${maxRetries + 1}), retrying in ${delay}ms`);
|
|
151
150
|
await sleep(delay);
|
|
152
151
|
continue;
|
|
@@ -156,8 +155,9 @@ export async function exchangeOidcForNpmToken(args) {
|
|
|
156
155
|
const text = await res.text().catch(() => '');
|
|
157
156
|
if (!res.ok) {
|
|
158
157
|
// Only TRANSIENT npm-side failures retry — see `maxRetries`.
|
|
159
|
-
if (
|
|
160
|
-
|
|
158
|
+
if (isTransientStatus(res.status) && attempt < maxRetries) {
|
|
159
|
+
// `Retry-After` wins over the backoff when npm sends one (429/503 during an incident).
|
|
160
|
+
const delay = retryDelayMs(attempt, retryBaseMs, res.headers.get('retry-after'));
|
|
161
161
|
log?.(`gjsify oidc: transient ${res.status} ${res.statusText} for ${packageName} (attempt ${attempt + 1}/${maxRetries + 1}), retrying in ${delay}ms`);
|
|
162
162
|
await sleep(delay);
|
|
163
163
|
continue;
|
|
@@ -27,7 +27,10 @@ export type LedgerEntry =
|
|
|
27
27
|
version: string;
|
|
28
28
|
reasons: HoldReason[];
|
|
29
29
|
}
|
|
30
|
-
/**
|
|
30
|
+
/**
|
|
31
|
+
* The version is now served; it supersedes an earlier `unserved` OR `held` entry (last entry
|
|
32
|
+
* wins) — a later probe found it, or the held-package second pass published it.
|
|
33
|
+
*/
|
|
31
34
|
| {
|
|
32
35
|
ev: 'served';
|
|
33
36
|
name: string;
|
|
@@ -86,4 +89,41 @@ export declare function recordUnserved(input: {
|
|
|
86
89
|
* and why, and the exact command that publishes the held set once their
|
|
87
90
|
* dependencies are installable. `undefined` when nothing needs saying.
|
|
88
91
|
*/
|
|
92
|
+
type UnservedEntry = Extract<LedgerEntry, {
|
|
93
|
+
ev: 'unserved';
|
|
94
|
+
}>;
|
|
95
|
+
type HeldEntry = Extract<LedgerEntry, {
|
|
96
|
+
ev: 'held';
|
|
97
|
+
}>;
|
|
98
|
+
/** One entry per `name@version` — the LAST one — keeping only those still not served. */
|
|
99
|
+
export declare function pendingEntries(entries: readonly LedgerEntry[]): {
|
|
100
|
+
unserved: UnservedEntry[];
|
|
101
|
+
held: HeldEntry[];
|
|
102
|
+
};
|
|
103
|
+
/**
|
|
104
|
+
* Record that a package this run once HELD has now been published, so its dependents stop
|
|
105
|
+
* holding on it (`judgePin` reads the last entry) and the end-of-job check stops naming it.
|
|
106
|
+
*/
|
|
107
|
+
export declare function markHeldPublished(path: string | undefined, name: string, version: string): void;
|
|
108
|
+
export interface SettleUnservedInput {
|
|
109
|
+
entries: readonly LedgerEntry[];
|
|
110
|
+
ledgerPath: string | undefined;
|
|
111
|
+
/** Registry for a package name (scoped registries differ). */
|
|
112
|
+
registryFor: (name: string) => string;
|
|
113
|
+
/** One shared deadline: the entries poll concurrently, so the cost is the longest lag, not the sum. */
|
|
114
|
+
budgetMs: number;
|
|
115
|
+
authorization?: string;
|
|
116
|
+
fetchImpl?: typeof fetch;
|
|
117
|
+
sleep?: (ms: number) => Promise<void>;
|
|
118
|
+
now?: () => number;
|
|
119
|
+
log?: (msg: string) => void;
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Wait for versions npm ACCEPTED but did not serve inside the sweep's 5 s read-back, and append
|
|
123
|
+
* `served` for each one that shows up. This is the longer read-back the sweep deliberately defers
|
|
124
|
+
* (publishing.md: lags reach 252–368 s). A TERMINAL class (blocked/deleted/staged/missing) is a
|
|
125
|
+
* spent number — waiting cannot help, so it is skipped. Returns the names confirmed.
|
|
126
|
+
*/
|
|
127
|
+
export declare function settleUnserved(input: SettleUnservedInput): Promise<string[]>;
|
|
89
128
|
export declare function renderLedgerSummary(entries: readonly LedgerEntry[], ref: string): string | undefined;
|
|
129
|
+
export {};
|
|
@@ -181,15 +181,55 @@ export async function recordUnserved(input) {
|
|
|
181
181
|
});
|
|
182
182
|
return classification;
|
|
183
183
|
}
|
|
184
|
+
/** One entry per `name@version` — the LAST one — keeping only those still not served. */
|
|
185
|
+
export function pendingEntries(entries) {
|
|
186
|
+
const last = new Map();
|
|
187
|
+
for (const e of entries)
|
|
188
|
+
last.set(`${e.name}@${e.version}`, e);
|
|
189
|
+
const pending = [...last.values()];
|
|
190
|
+
return {
|
|
191
|
+
unserved: pending.filter((e) => e.ev === 'unserved'),
|
|
192
|
+
held: pending.filter((e) => e.ev === 'held'),
|
|
193
|
+
};
|
|
194
|
+
}
|
|
184
195
|
/**
|
|
185
|
-
*
|
|
186
|
-
*
|
|
187
|
-
* dependencies are installable. `undefined` when nothing needs saying.
|
|
196
|
+
* Record that a package this run once HELD has now been published, so its dependents stop
|
|
197
|
+
* holding on it (`judgePin` reads the last entry) and the end-of-job check stops naming it.
|
|
188
198
|
*/
|
|
199
|
+
export function markHeldPublished(path, name, version) {
|
|
200
|
+
const last = [...readLedger(path)].reverse().find((e) => e.name === name && e.version === version);
|
|
201
|
+
if (last?.ev === 'held')
|
|
202
|
+
appendLedger(path, { ev: 'served', name, version });
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Wait for versions npm ACCEPTED but did not serve inside the sweep's 5 s read-back, and append
|
|
206
|
+
* `served` for each one that shows up. This is the longer read-back the sweep deliberately defers
|
|
207
|
+
* (publishing.md: lags reach 252–368 s). A TERMINAL class (blocked/deleted/staged/missing) is a
|
|
208
|
+
* spent number — waiting cannot help, so it is skipped. Returns the names confirmed.
|
|
209
|
+
*/
|
|
210
|
+
export async function settleUnserved(input) {
|
|
211
|
+
const waiting = pendingEntries(input.entries).unserved.filter((e) => !isTerminalClass(e.cls));
|
|
212
|
+
const confirmed = await Promise.all(waiting.map(async (e) => {
|
|
213
|
+
const readback = await verifyPublishedVersion({
|
|
214
|
+
registry: input.registryFor(e.name),
|
|
215
|
+
name: e.name,
|
|
216
|
+
version: e.version,
|
|
217
|
+
budgetMs: input.budgetMs,
|
|
218
|
+
authorization: input.authorization,
|
|
219
|
+
fetchImpl: input.fetchImpl,
|
|
220
|
+
sleep: input.sleep,
|
|
221
|
+
now: input.now,
|
|
222
|
+
log: input.log,
|
|
223
|
+
});
|
|
224
|
+
if (!readback.confirmed)
|
|
225
|
+
return undefined;
|
|
226
|
+
appendLedger(input.ledgerPath, { ev: 'served', name: e.name, version: e.version });
|
|
227
|
+
return `${e.name}@${e.version}`;
|
|
228
|
+
}));
|
|
229
|
+
return confirmed.filter((x) => x !== undefined);
|
|
230
|
+
}
|
|
189
231
|
export function renderLedgerSummary(entries, ref) {
|
|
190
|
-
const
|
|
191
|
-
const unserved = entries.filter((e) => e.ev === 'unserved' && !served.has(`${e.name}@${e.version}`));
|
|
192
|
-
const held = entries.filter((e) => e.ev === 'held');
|
|
232
|
+
const { unserved, held } = pendingEntries(entries);
|
|
193
233
|
if (unserved.length === 0 && held.length === 0)
|
|
194
234
|
return undefined;
|
|
195
235
|
const lines = ['### npm versions that were accepted but are not installable', ''];
|
|
@@ -205,7 +245,7 @@ export function renderLedgerSummary(entries, ref) {
|
|
|
205
245
|
for (const e of held)
|
|
206
246
|
lines.push(`| \`${e.name}\` | ${e.version} | ${summariseReasons(e.reasons)} |`);
|
|
207
247
|
lines.push('', 'Once the dependencies above are served by npm (a `validating` version appears on its own, or is approved/released by a maintainer), publish the held set with:', '', '```sh', `gh workflow run release.yml --ref ${ref} -f skip_publish=false -f only='${[...new Set(held.map((e) => e.name))].join(' ')}'`, '```');
|
|
208
|
-
if (
|
|
248
|
+
if (unserved.some((e) => e.cls === 'blocked' || e.cls === 'deleted')) {
|
|
209
249
|
lines.push('', 'A `blocked` or `deleted` version is a spent number: re-running the release cannot serve it, the package needs a version bump.');
|
|
210
250
|
}
|
|
211
251
|
}
|
|
@@ -49,6 +49,13 @@ export interface ResolveRuntimeOptions {
|
|
|
49
49
|
* is Bun's global-cache behaviour, which is otherwise unreachable from Node.
|
|
50
50
|
*/
|
|
51
51
|
resolve?: ResolveInstalledPackageOptions['resolve'];
|
|
52
|
+
/**
|
|
53
|
+
* The directory of a `@gjsify/gtk-runtime-<target>` package that
|
|
54
|
+
* {@link acquireGtkRuntime} fetched for THIS run — the last candidate, after
|
|
55
|
+
* everything the author installed or pointed at. See `gtk-runtime-acquire.ts`
|
|
56
|
+
* for why it is fetched rather than installed.
|
|
57
|
+
*/
|
|
58
|
+
fetchedGtkRuntimeDir?: string;
|
|
52
59
|
}
|
|
53
60
|
/**
|
|
54
61
|
* Locate the relocated GTK closure for `target`, or `null`.
|
|
@@ -68,7 +75,11 @@ export interface ResolveRuntimeOptions {
|
|
|
68
75
|
* for a ship run.
|
|
69
76
|
* 3. the sibling monorepo directory `../gtk-runtime-<target>/gtk`, which is what
|
|
70
77
|
* an in-repo `npm run build:bundle` writes.
|
|
71
|
-
* 4. the published `@gjsify/gtk-runtime-<target>` package's `gtk
|
|
78
|
+
* 4. the published `@gjsify/gtk-runtime-<target>` package's `gtk/`, when the
|
|
79
|
+
* author installed it.
|
|
80
|
+
* 5. the same package fetched for this run (`fetchedGtkRuntimeDir`) — NOT in
|
|
81
|
+
* node-gi's probe, because node-gi never fetches: it finds the closure inside
|
|
82
|
+
* the `.app` this function put it in, and the layout there is the same.
|
|
72
83
|
*
|
|
73
84
|
* A candidate counts only when it holds BOTH the loadable-code directory and the
|
|
74
85
|
* typelib directory — node-gi's own probe, restated here rather than weakened,
|
|
@@ -195,6 +206,10 @@ export interface StageAppRuntimeInput {
|
|
|
195
206
|
nodePath: string;
|
|
196
207
|
licensePath: string;
|
|
197
208
|
} | null;
|
|
209
|
+
/** See {@link ResolveRuntimeOptions.fetchedGtkRuntimeDir}. */
|
|
210
|
+
fetchedGtkRuntimeDir?: string;
|
|
211
|
+
/** Why the fetch produced nothing — named in the `missing` line, so the cause is not guessed at. */
|
|
212
|
+
gtkFetchFailure?: string;
|
|
198
213
|
}
|
|
199
214
|
/**
|
|
200
215
|
* Stage the interpreter, the GTK closure and the addon into one bundle.
|
|
@@ -123,7 +123,11 @@ export const NODE_GI_PACKAGE = '@gjsify/node-gi';
|
|
|
123
123
|
* for a ship run.
|
|
124
124
|
* 3. the sibling monorepo directory `../gtk-runtime-<target>/gtk`, which is what
|
|
125
125
|
* an in-repo `npm run build:bundle` writes.
|
|
126
|
-
* 4. the published `@gjsify/gtk-runtime-<target>` package's `gtk
|
|
126
|
+
* 4. the published `@gjsify/gtk-runtime-<target>` package's `gtk/`, when the
|
|
127
|
+
* author installed it.
|
|
128
|
+
* 5. the same package fetched for this run (`fetchedGtkRuntimeDir`) — NOT in
|
|
129
|
+
* node-gi's probe, because node-gi never fetches: it finds the closure inside
|
|
130
|
+
* the `.app` this function put it in, and the layout there is the same.
|
|
127
131
|
*
|
|
128
132
|
* A candidate counts only when it holds BOTH the loadable-code directory and the
|
|
129
133
|
* typelib directory — node-gi's own probe, restated here rather than weakened,
|
|
@@ -155,6 +159,12 @@ export function resolveGtkRuntime(target, options = {}) {
|
|
|
155
159
|
const entry = resolveStagedPackage(packageName, options);
|
|
156
160
|
if (entry !== null)
|
|
157
161
|
candidates.push({ source: packageName, dir: join(dirname(entry), 'gtk') });
|
|
162
|
+
if (options.fetchedGtkRuntimeDir !== undefined) {
|
|
163
|
+
candidates.push({
|
|
164
|
+
source: `${packageName} (fetched for this ship run)`,
|
|
165
|
+
dir: join(options.fetchedGtkRuntimeDir, 'gtk'),
|
|
166
|
+
});
|
|
167
|
+
}
|
|
158
168
|
for (const candidate of candidates) {
|
|
159
169
|
const libDir = join(candidate.dir, nativeSubdir);
|
|
160
170
|
const typelibDir = join(candidate.dir, 'girepository-1.0');
|
|
@@ -340,8 +350,10 @@ export function stageAppRuntime(input) {
|
|
|
340
350
|
// resolver then refuses -- a hint that is wrong is worse than none,
|
|
341
351
|
// because it costs the reader the time to follow it first.
|
|
342
352
|
const nativeSubdir = input.target.startsWith('win32-') ? 'bin' : 'lib';
|
|
343
|
-
|
|
344
|
-
|
|
353
|
+
const why = input.gtkFetchFailure === undefined ? '' : ` — fetching it failed: ${input.gtkFetchFailure}`;
|
|
354
|
+
missing.push(`no GTK closure${why} — point \`GJSIFY_GTK_RUNTIME\` at a bundle directory holding ` +
|
|
355
|
+
`\`${nativeSubdir}/\` and \`girepository-1.0/\`, or install \`${gtkRuntimePackageName(input.target)}\` ` +
|
|
356
|
+
'where the build can reach it');
|
|
345
357
|
}
|
|
346
358
|
// node-gi's JavaScript, which the bundle `require`s by name. Staged whenever
|
|
347
359
|
// the package is resolvable and reported separately from the addon: a bundle
|
|
@@ -394,6 +406,8 @@ function discoveryOptions(input) {
|
|
|
394
406
|
options.env = input.env;
|
|
395
407
|
if (input.resolve !== undefined)
|
|
396
408
|
options.resolve = input.resolve;
|
|
409
|
+
if (input.fetchedGtkRuntimeDir !== undefined)
|
|
410
|
+
options.fetchedGtkRuntimeDir = input.fetchedGtkRuntimeDir;
|
|
397
411
|
return options;
|
|
398
412
|
}
|
|
399
413
|
/**
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { type GtkRuntimeTarget, type ResolveRuntimeOptions } from './app-runtime.js';
|
|
2
|
+
export type AcquiredGtkRuntime =
|
|
3
|
+
/** Something already resolves — nothing was fetched, and nothing will be. */
|
|
4
|
+
{
|
|
5
|
+
kind: 'present';
|
|
6
|
+
}
|
|
7
|
+
/** Fetched; `dir` is the PACKAGE directory, holding `gtk/`. */
|
|
8
|
+
| {
|
|
9
|
+
kind: 'fetched';
|
|
10
|
+
dir: string;
|
|
11
|
+
}
|
|
12
|
+
/** Could not be had; `reason` is what to tell the person, verbatim. */
|
|
13
|
+
| {
|
|
14
|
+
kind: 'failed';
|
|
15
|
+
reason: string;
|
|
16
|
+
};
|
|
17
|
+
export interface AcquireGtkRuntimeInput {
|
|
18
|
+
target: GtkRuntimeTarget;
|
|
19
|
+
/** The version to fetch: the CLI's own, because addon and closure are a matched pair. */
|
|
20
|
+
version: string;
|
|
21
|
+
/** The same discovery options the staging step will use — so "present" means what it will mean there. */
|
|
22
|
+
discovery?: ResolveRuntimeOptions;
|
|
23
|
+
/**
|
|
24
|
+
* Fetch a package spec into a cache and return its directory. Injected: the
|
|
25
|
+
* real one is `ensurePkgDir`, and a test must not touch a registry.
|
|
26
|
+
*/
|
|
27
|
+
ensure: (spec: string) => Promise<string>;
|
|
28
|
+
/** One progress line. The closure is ~100 MB, so a silent first run looks like a hang. */
|
|
29
|
+
log?: (message: string) => void;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Make sure a GTK closure is reachable for `target`, fetching it only if nothing
|
|
33
|
+
* else provides one. Never throws: a ship run with no closure is a PARTIAL result
|
|
34
|
+
* that `stageAppRuntime` reports, not an error (see its doc).
|
|
35
|
+
*/
|
|
36
|
+
export declare function acquireGtkRuntime(input: AcquireGtkRuntimeInput): Promise<AcquiredGtkRuntime>;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// Getting the GTK closure for a ship run when the project does not carry it.
|
|
2
|
+
//
|
|
3
|
+
// WHY IT IS FETCHED AND NOT INSTALLED. `@gjsify/gtk-runtime-<target>` is 100 MB of
|
|
4
|
+
// GLib, GObject, GTK and friends, and a project that lists it — as
|
|
5
|
+
// `optionalDependencies`, which the `os`/`cpu` gate turns into "installed on every
|
|
6
|
+
// Mac" — gets it into its own `node_modules`. On macOS that breaks every build and
|
|
7
|
+
// every run that goes through a Homebrew GJS. Every darwin prebuild
|
|
8
|
+
// carries the rpath `@loader_path` → `…/gtk-runtime-darwin-<arch>/gtk/lib` →
|
|
9
|
+
// `<brew>/lib` (ADR 0023: bundle before system, `docs/prebuilds.md`), so the first
|
|
10
|
+
// `dlopen` out of `node_modules/@gjsify/*-native-darwin-*` finds the bundle's
|
|
11
|
+
// GLib/GObject beside the one `gjs` already loaded from Homebrew: two GObject type
|
|
12
|
+
// registries in one process, which shows as `No signal 'context_response' on
|
|
13
|
+
// object 'GVolumeMonitor'` and a segfault, or as `GNotificationCenterDelegate is
|
|
14
|
+
// implemented in both …`. The invariant ADR 0023 names — one registry per process
|
|
15
|
+
// — is violated by the dev tree itself.
|
|
16
|
+
//
|
|
17
|
+
// The closure is only ever needed at the moment it is COPIED into a bundle, so it
|
|
18
|
+
// is acquired then: into the dlx cache (`ensurePkgDir`, the same machinery
|
|
19
|
+
// `gjsify showcase` already uses for the same package), never into the project.
|
|
20
|
+
// What this does NOT change: the search order in `resolveGtkRuntime()` keeps its
|
|
21
|
+
// four existing candidates, so a project that still installs the package ships
|
|
22
|
+
// exactly as before — it merely stays exposed to the dev-tree failure above until
|
|
23
|
+
// it drops the dependency.
|
|
24
|
+
//
|
|
25
|
+
// EXACT VERSION, no range and no fallback. The addon's `@rpath` is
|
|
26
|
+
// `@loader_path/gtk/lib` — addon and closure are a matched pair published from the
|
|
27
|
+
// same release — so "the nearest published version" would be a guess about GTK ABI
|
|
28
|
+
// that `assertPayloadMatchesArch` cannot check. A CLI version with no published
|
|
29
|
+
// runtime (a dev or `next` build) fails with the reason named, and
|
|
30
|
+
// `GJSIFY_GTK_RUNTIME` is the way past it.
|
|
31
|
+
import { gtkRuntimePackageName, resolveGtkRuntime, } from './app-runtime.js';
|
|
32
|
+
/**
|
|
33
|
+
* Make sure a GTK closure is reachable for `target`, fetching it only if nothing
|
|
34
|
+
* else provides one. Never throws: a ship run with no closure is a PARTIAL result
|
|
35
|
+
* that `stageAppRuntime` reports, not an error (see its doc).
|
|
36
|
+
*/
|
|
37
|
+
export async function acquireGtkRuntime(input) {
|
|
38
|
+
const discovery = input.discovery ?? {};
|
|
39
|
+
if (resolveGtkRuntime(input.target, discovery) !== null)
|
|
40
|
+
return { kind: 'present' };
|
|
41
|
+
const name = gtkRuntimePackageName(input.target);
|
|
42
|
+
const spec = `${name}@${input.version}`;
|
|
43
|
+
input.log?.(`${name} is not installed here, and is not meant to be — fetching ${spec} into the cache ` +
|
|
44
|
+
'(about 100 MB, once)');
|
|
45
|
+
let dir;
|
|
46
|
+
try {
|
|
47
|
+
dir = await input.ensure(spec);
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
return { kind: 'failed', reason: error instanceof Error ? error.message : String(error) };
|
|
51
|
+
}
|
|
52
|
+
// The same completeness probe the staging step applies, so a package that
|
|
53
|
+
// resolved but holds no `gtk/` (an unpopulated checkout, a truncated cache
|
|
54
|
+
// entry) is reported HERE, with its name, instead of as a bare "no GTK closure".
|
|
55
|
+
if (resolveGtkRuntime(input.target, { ...discovery, fetchedGtkRuntimeDir: dir }) === null) {
|
|
56
|
+
return { kind: 'failed', reason: `${spec} was fetched to ${dir} but holds no complete gtk/ closure` };
|
|
57
|
+
}
|
|
58
|
+
return { kind: 'fetched', dir };
|
|
59
|
+
}
|