@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
|
@@ -146,6 +146,27 @@ const SCRIPT_COUPLINGS = [
|
|
|
146
146
|
runE2E: true,
|
|
147
147
|
why: '@gjsify/create-app build → node scripts/process-template.mjs reads templates/',
|
|
148
148
|
},
|
|
149
|
+
{
|
|
150
|
+
// THE SAME SHAPE AS `.githooks/` ABOVE, one layer up: the guard's trigger names
|
|
151
|
+
// the TEST's path instead of the thing under test. `AppRegistry.runApplication`
|
|
152
|
+
// holds three lines observable only by RUNNING the loop —
|
|
153
|
+
// `registerBuiltinWidgets()`, the `toShellOptions` passthrough and
|
|
154
|
+
// `provideWindowChrome()` — and their one external observer is
|
|
155
|
+
// `tests/e2e/react-native-devtools`, which main.yml's `e2e` job now runs under
|
|
156
|
+
// xvfb + a session bus (#1550). Without this entry a change confined to
|
|
157
|
+
// `packages/framework/react-native/**` seeds its own workspace, the e2e tier
|
|
158
|
+
// stays OFF (it turns on for a `tests/e2e/**` touch), and the suite is silent
|
|
159
|
+
// for exactly the PRs it exists to catch. Measured on #1540: deleting
|
|
160
|
+
// `provideWindowChrome()` left format, lint, tsc and all in-process assertions
|
|
161
|
+
// green while the application drew two header bars.
|
|
162
|
+
//
|
|
163
|
+
// No extra seeds — the package is a workspace and already seeds itself. The
|
|
164
|
+
// TIER is the point, as for `.githooks/`.
|
|
165
|
+
match: /^packages\/framework\/react-native\//,
|
|
166
|
+
seeds: [],
|
|
167
|
+
runE2E: true,
|
|
168
|
+
why: 'tests/e2e/react-native-devtools is the only external observer of AppRegistry.runApplication',
|
|
169
|
+
},
|
|
149
170
|
];
|
|
150
171
|
export function classifyAndExpand(workspaces, changedFiles) {
|
|
151
172
|
const files = changedFiles.map((f) => f.replace(/\\/g, '/')).filter((f) => f.length > 0);
|
|
@@ -255,7 +276,15 @@ export function classifyAndExpand(workspaces, changedFiles) {
|
|
|
255
276
|
global: false,
|
|
256
277
|
reason: `test-only (${remaining.length} file(s) in ${only})`,
|
|
257
278
|
workspaces: [only],
|
|
258
|
-
|
|
279
|
+
// `|| couplingRunE2E`: this shortcut skips CLOSURE EXPANSION, and a
|
|
280
|
+
// declared tier is not part of the closure. Dropping it here made a
|
|
281
|
+
// coupling's reach depend on the SHAPE of the changed path — the
|
|
282
|
+
// `.githooks/` entry survived only because a hook is not a spec file,
|
|
283
|
+
// while `packages/framework/react-native/src/widgets.spec.ts` fell
|
|
284
|
+
// through and turned the e2e tier back off. Measured by the vector
|
|
285
|
+
// "a react-native spec file ALSO turns the e2e tier on", which was RED
|
|
286
|
+
// when it was written (#1550).
|
|
287
|
+
runE2E: touchedE2E || couplingRunE2E,
|
|
259
288
|
runIntegration: touchedIntegration || isIntegrationWorkspace(only),
|
|
260
289
|
skipAll: false,
|
|
261
290
|
};
|
package/lib/commands/dlx.js
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
import { runGjsBundle } from '../utils/run-gjs.js';
|
|
14
14
|
import { parseSpec } from '../utils/parse-spec.js';
|
|
15
15
|
import { resolveGjsEntry } from '../utils/resolve-gjs-entry.js';
|
|
16
|
+
import { doubleDashArgs } from '../utils/double-dash-args.js';
|
|
16
17
|
import { cacheDirFor, createCacheKey, getValidCachedPkg, makePrepareDir, resolveInstalledPkgDir, symlinkSwap, } from '../utils/dlx-cache.js';
|
|
17
18
|
import { installPackages, makeProgressReporter } from '../utils/install-backend.js';
|
|
18
19
|
export const dlxCommand = {
|
|
@@ -25,7 +26,11 @@ export const dlxCommand = {
|
|
|
25
26
|
// --help` shows ts-for-gir's --help instead of gjsify dlx's.
|
|
26
27
|
// Without `populate--`, the trailing `--help` is consumed at
|
|
27
28
|
// the gjsify level and the bundle never sees it.
|
|
28
|
-
|
|
29
|
+
// `parse-positional-numbers: false` alongside it: the `--` tail and the
|
|
30
|
+
// `[extraArgs..]` positional are the caller's argv, not numbers, and yargs
|
|
31
|
+
// otherwise retypes a bare `5` (#1531). `--cache-max-age` declares
|
|
32
|
+
// `type: 'number'` and is unaffected.
|
|
33
|
+
.parserConfiguration({ 'populate--': true, 'parse-positional-numbers': false })
|
|
29
34
|
.positional('spec', {
|
|
30
35
|
description: 'Package spec (`name`, `name@version`, `@scope/name@spec`, or local path).',
|
|
31
36
|
type: 'string',
|
|
@@ -84,8 +89,7 @@ export const dlxCommand = {
|
|
|
84
89
|
// number)[]`), so flags like `--help` reach the bundle untouched.
|
|
85
90
|
// Merge those into the positional extraArgs the splitter sees so
|
|
86
91
|
// both call shapes share one downstream path.
|
|
87
|
-
const
|
|
88
|
-
const extraArgsCombined = [...(args.extraArgs ?? []), ...passthroughDoubleDash];
|
|
92
|
+
const extraArgsCombined = [...(args.extraArgs ?? []), ...doubleDashArgs(args)];
|
|
89
93
|
const { binName, extraArgs } = splitBinAndArgs(pkgDir, args.binOrArg, extraArgsCombined);
|
|
90
94
|
const entry = resolveGjsEntry(pkgDir, binName);
|
|
91
95
|
if (entry.fromFallback) {
|
package/lib/commands/foreach.js
CHANGED
|
@@ -15,6 +15,7 @@ import { affectedClosure, buildDependencyGraph, discoverWorkspaces, filterWorksp
|
|
|
15
15
|
import { findWorkspaceRoot } from '../utils/workspace-root.js';
|
|
16
16
|
import { cliRuntimeClosure } from '../utils/cli-runtime-closure.js';
|
|
17
17
|
import { prefixLines } from '../utils/prefixed-output.js';
|
|
18
|
+
import { doubleDashArgs } from '../utils/double-dash-args.js';
|
|
18
19
|
import { BuildCacheRunner, buildCacheEnabledByEnv } from '../utils/build-cache.js';
|
|
19
20
|
// The ONE runner-selection rule, shared rather than re-derived. This file used to
|
|
20
21
|
// carry its own copy; `workspace.ts` grew the bootstrap-CLI branch and the copy
|
|
@@ -267,6 +268,12 @@ export const foreachCommand = {
|
|
|
267
268
|
// gjsify foreach --exec -- npm publish --tag latest
|
|
268
269
|
// without yargs grabbing --tag/--access/etc.
|
|
269
270
|
'populate--': true,
|
|
271
|
+
// …and keep that tail as the TEXT the caller typed. Without
|
|
272
|
+
// this yargs types a bare number in the array as a `number`,
|
|
273
|
+
// which is how `--verify-timeout 5 --tag latest` reached 209
|
|
274
|
+
// npm publishes as `--verify-timeout --tag latest` (#1531).
|
|
275
|
+
// Declared `type: 'number'` options (`--jobs`) are unaffected.
|
|
276
|
+
'parse-positional-numbers': false,
|
|
270
277
|
}),
|
|
271
278
|
handler: async (args) => {
|
|
272
279
|
// Walk up to the monorepo root — foreach is sometimes invoked
|
|
@@ -286,7 +293,7 @@ export const foreachCommand = {
|
|
|
286
293
|
// Script mode silently DROPPED everything after `--` (only --exec
|
|
287
294
|
// consumed args['--']). Forward them to each child invocation,
|
|
288
295
|
// matching `yarn workspaces foreach run <script> -- <flags>`.
|
|
289
|
-
const fromDoubleDash = (args
|
|
296
|
+
const fromDoubleDash = doubleDashArgs(args);
|
|
290
297
|
if (fromDoubleDash.length > 0)
|
|
291
298
|
cmdArgs = [...cmdArgs, ...fromDoubleDash];
|
|
292
299
|
}
|
|
@@ -294,7 +301,7 @@ export const foreachCommand = {
|
|
|
294
301
|
// With populate--:true, anything after the literal `--`
|
|
295
302
|
// separator lands in top-level args['--']. yargs DOES NOT
|
|
296
303
|
// attach it to args._ — it's a sibling array.
|
|
297
|
-
const fromDoubleDash = (args
|
|
304
|
+
const fromDoubleDash = doubleDashArgs(args);
|
|
298
305
|
if (fromDoubleDash.length > 0) {
|
|
299
306
|
if (!cmd) {
|
|
300
307
|
cmd = fromDoubleDash[0];
|
package/lib/commands/onboard.js
CHANGED
|
@@ -660,6 +660,17 @@ function describePublishFailure(pub) {
|
|
|
660
660
|
return 'OIDC/token auth failed';
|
|
661
661
|
case 'diagnostic':
|
|
662
662
|
return `HTTP 404 (${pub.diag.reason})`;
|
|
663
|
+
case 'publish-unconfirmed':
|
|
664
|
+
// npm claimed the version and the registry does not serve it. Counted
|
|
665
|
+
// as a FAILURE here on purpose: onboard's next step is `configureTrust`
|
|
666
|
+
// on a package it just decided exists. `onboard` passes `tolerate:
|
|
667
|
+
// true`, so `already-published` is the claim it meets on a RE-RUN —
|
|
668
|
+
// and #1407 is exactly a re-run that counted a half-stored publish
|
|
669
|
+
// as done.
|
|
670
|
+
return (`npm ${pub.claim === 'already-published' ? 'reported it already published' : 'accepted the upload'} ` +
|
|
671
|
+
`(HTTP ${pub.putStatus}) but ${pub.name}@${pub.version} did not ` +
|
|
672
|
+
`resolve on ${pub.registry} after ${pub.readback.attempts} probe(s) over ` +
|
|
673
|
+
`${(pub.readback.elapsedMs / 1000).toFixed(1)}s`);
|
|
663
674
|
case 'error':
|
|
664
675
|
return `HTTP ${pub.status} ${pub.statusText}`;
|
|
665
676
|
default:
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Command } from '../types/index.js';
|
|
2
2
|
import { type Diagnose404Result } from '../utils/publish-diagnose.js';
|
|
3
|
+
import { type ReadbackResult } from '../utils/publish-readback.js';
|
|
3
4
|
import { OtpProvider } from '../utils/npm-otp.js';
|
|
4
5
|
interface PublishOptions {
|
|
5
6
|
path?: string;
|
|
@@ -8,6 +9,8 @@ interface PublishOptions {
|
|
|
8
9
|
otp?: string;
|
|
9
10
|
'tolerate-republish'?: boolean;
|
|
10
11
|
'tolerate-untrusted-new'?: boolean;
|
|
12
|
+
'verify-timeout'?: number;
|
|
13
|
+
'verify-defer'?: boolean;
|
|
11
14
|
provenance?: boolean;
|
|
12
15
|
'dry-run'?: boolean;
|
|
13
16
|
json?: boolean;
|
|
@@ -37,6 +40,13 @@ export interface PublishWorkspaceInput {
|
|
|
37
40
|
otpProvider: OtpProvider;
|
|
38
41
|
/** Send the cached code on the first PUT (publish `--otp`). */
|
|
39
42
|
seedOtpFirst: boolean;
|
|
43
|
+
/**
|
|
44
|
+
* Polling budget for the post-PUT read-back, in ms. Default
|
|
45
|
+
* {@link DEFAULT_VERIFY_BUDGET_MS}; `0` skips the read-back entirely and the
|
|
46
|
+
* returned `published` outcome then carries no `readback` — an UNVERIFIED
|
|
47
|
+
* success, which is the pre-v0.46.0 behaviour and why this defaults on.
|
|
48
|
+
*/
|
|
49
|
+
verifyBudgetMs?: number;
|
|
40
50
|
}
|
|
41
51
|
/** Discriminated result of {@link publishWorkspace}; the caller owns presentation. */
|
|
42
52
|
export type PublishOutcome = {
|
|
@@ -49,12 +59,43 @@ export type PublishOutcome = {
|
|
|
49
59
|
integrity: string;
|
|
50
60
|
tag: string;
|
|
51
61
|
registry: string;
|
|
62
|
+
/** The read-back that CONFIRMED it. `undefined` only when disabled. */
|
|
63
|
+
readback?: ReadbackResult;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* npm returned 2xx and the registry does not serve the version. Its own
|
|
67
|
+
* outcome on purpose: not `published` (nothing is installable), not
|
|
68
|
+
* `republish-tolerated` (npm never claimed it existed), not `error` (the
|
|
69
|
+
* HTTP exchange succeeded). The v0.46.0 incident had no name at all — it
|
|
70
|
+
* came out as `+ name@version`, exit 0.
|
|
71
|
+
*/
|
|
72
|
+
| {
|
|
73
|
+
ok: false;
|
|
74
|
+
action: 'publish-unconfirmed';
|
|
75
|
+
/**
|
|
76
|
+
* What npm claimed before the read-back disagreed: a 2xx (`accepted`)
|
|
77
|
+
* or a tolerated 409 (`already-published`). It selects the remediation
|
|
78
|
+
* sentence, which is the OPPOSITE one for a conflict — re-running
|
|
79
|
+
* answers the same 409.
|
|
80
|
+
*/
|
|
81
|
+
claim: 'accepted' | 'already-published';
|
|
82
|
+
name: string;
|
|
83
|
+
version: string;
|
|
84
|
+
registry: string;
|
|
85
|
+
/** The PUT that was accepted. */
|
|
86
|
+
putStatus: number;
|
|
87
|
+
putStatusText: string;
|
|
88
|
+
putUrl: string;
|
|
89
|
+
payloadBytes: number;
|
|
90
|
+
readback: ReadbackResult;
|
|
52
91
|
} | {
|
|
53
92
|
ok: true;
|
|
54
93
|
action: 'republish-tolerated';
|
|
55
94
|
name: string;
|
|
56
95
|
version: string;
|
|
57
96
|
status: number;
|
|
97
|
+
/** The read-back that CONFIRMED the conflicting version. */
|
|
98
|
+
readback?: ReadbackResult;
|
|
58
99
|
} | {
|
|
59
100
|
ok: true;
|
|
60
101
|
action: 'skipped-untrusted-new';
|
package/lib/commands/publish.js
CHANGED
|
@@ -19,6 +19,12 @@
|
|
|
19
19
|
// a sigstore signer that gjsify doesn't ship; we record the flag in the
|
|
20
20
|
// publish manifest but don't sign yet. Surface a warning so callers know.
|
|
21
21
|
// - --dry-run: pack only, no PUT.
|
|
22
|
+
// - --verify-timeout / --verify-defer: the post-PUT READ-BACK. npm's 2xx is an
|
|
23
|
+
// accepted write, not a durable one, so a successful PUT is confirmed by
|
|
24
|
+
// asking the registry for that exact version before `+ name@version` is
|
|
25
|
+
// printed. The incident, the measured lag distribution and the choice of
|
|
26
|
+
// window live in `utils/publish-readback.ts`; WHERE the read-back runs and
|
|
27
|
+
// what it costs is argued at its call site below.
|
|
22
28
|
//
|
|
23
29
|
// The request body matches npm's "publish" payload shape:
|
|
24
30
|
// {
|
|
@@ -36,11 +42,13 @@
|
|
|
36
42
|
import { readFileSync } from 'node:fs';
|
|
37
43
|
import { tmpdir } from 'node:os';
|
|
38
44
|
import { join, resolve } from 'node:path';
|
|
45
|
+
import { base64Encode } from '../utils/base64.js';
|
|
39
46
|
import { DEFAULT_REGISTRY, registryFor } from '@gjsify/npm-registry';
|
|
40
47
|
import { buildPublishHeaders, escapePackageName } from '../utils/publish-headers.js';
|
|
41
48
|
import { packWorkspace } from './pack.js';
|
|
42
49
|
import { getNpmTrustedToken, hasGithubOidcEnv, OidcExchangeError, OidcUnavailableError } from '../utils/npm-oidc.js';
|
|
43
50
|
import { diagnose404, is404DiagnosticCandidate } from '../utils/publish-diagnose.js';
|
|
51
|
+
import { DEFAULT_VERIFY_BUDGET_MS, formatUnconfirmedPublish, verifyPublishedVersion, } from '../utils/publish-readback.js';
|
|
44
52
|
import { loadNpmrc } from '../utils/load-npmrc.js';
|
|
45
53
|
import { OtpProvider, withOtpRetry, isOtpChallenge } from '../utils/npm-otp.js';
|
|
46
54
|
import { promptLine } from '../utils/prompt.js';
|
|
@@ -71,6 +79,16 @@ export const publishCommand = {
|
|
|
71
79
|
description: 'Skip (exit 0) when OIDC token exchange returns `package not found` AND no fallback token is configured — i.e. a never-before-published `@scope/<name>` whose Trusted Publisher entry hasn\'t been set up on npmjs.com yet. Without this flag, one un-bootstrapped new package breaks the entire serialized `gjsify foreach publish` loop. Pair with `--tolerate-republish` in CI release workflows so a fresh-merged package gracefully skips its first CI publish, leaving the manual-bootstrap step to a maintainer (see AGENTS.md "New @gjsify/* package: first-publish + Trusted Publisher bootstrap").',
|
|
72
80
|
type: 'boolean',
|
|
73
81
|
default: false,
|
|
82
|
+
})
|
|
83
|
+
.option('verify-timeout', {
|
|
84
|
+
description: 'Seconds to keep asking the registry for the just-published version before giving up. npm answers 2xx to a write it has only ACCEPTED: in the v0.46.0 release 19 of 199 packages were recorded 56-252s after their 2xx and one was never recorded at all, so a shorter window turns normal queueing into a false red. `0` disables the read-back — for a registry with no packument read path.',
|
|
85
|
+
type: 'number',
|
|
86
|
+
default: DEFAULT_VERIFY_BUDGET_MS / 1000,
|
|
87
|
+
})
|
|
88
|
+
.option('verify-defer', {
|
|
89
|
+
description: 'Report an unconfirmed read-back and exit 0 instead of 1. ONLY for a caller that re-checks the same set afterwards — `gjsify run npm:publish` passes it because `scripts/verify-published-closure.mjs` sweeps the whole roster at the end of release.yml. Everywhere else an unconfirmed publish must fail the job that made it.',
|
|
90
|
+
type: 'boolean',
|
|
91
|
+
default: false,
|
|
74
92
|
})
|
|
75
93
|
.option('provenance', {
|
|
76
94
|
description: "Pass-through flag — recorded in the payload but no signing happens (gjsify doesn't ship a sigstore signer yet).",
|
|
@@ -107,6 +125,17 @@ export const publishCommand = {
|
|
|
107
125
|
const otp = args.otp;
|
|
108
126
|
const tolerate = args['tolerate-republish'] === true;
|
|
109
127
|
const tolerateUntrustedNew = args['tolerate-untrusted-new'] === true;
|
|
128
|
+
const verifySeconds = args['verify-timeout'] ?? DEFAULT_VERIFY_BUDGET_MS / 1000;
|
|
129
|
+
// A non-number reaches here as NaN (yargs coerces `--verify-timeout abc`
|
|
130
|
+
// silently), and NaN would fall through every `> 0` test and turn the
|
|
131
|
+
// read-back OFF without saying so — the failure mode this whole change
|
|
132
|
+
// exists to remove. Refuse it instead.
|
|
133
|
+
if (!Number.isFinite(verifySeconds) || verifySeconds < 0) {
|
|
134
|
+
console.error(`gjsify publish: --verify-timeout must be a non-negative number of seconds, got ${JSON.stringify(args['verify-timeout'])}`);
|
|
135
|
+
return process.exit(2);
|
|
136
|
+
}
|
|
137
|
+
const verifyBudgetMs = Math.round(verifySeconds * 1000);
|
|
138
|
+
const deferUnconfirmed = args['verify-defer'] === true;
|
|
110
139
|
const provenance = args.provenance === true;
|
|
111
140
|
const dryRun = args['dry-run'] === true;
|
|
112
141
|
const checkTrustedOnly = args['check-trusted'] === true;
|
|
@@ -219,8 +248,9 @@ export const publishCommand = {
|
|
|
219
248
|
json: args.json === true,
|
|
220
249
|
otpProvider,
|
|
221
250
|
seedOtpFirst: otp !== undefined,
|
|
251
|
+
verifyBudgetMs,
|
|
222
252
|
});
|
|
223
|
-
reportPublishOutcome(outcome, args.json === true);
|
|
253
|
+
reportPublishOutcome(outcome, args.json === true, deferUnconfirmed);
|
|
224
254
|
},
|
|
225
255
|
};
|
|
226
256
|
/**
|
|
@@ -329,12 +359,128 @@ export async function publishWorkspace(input) {
|
|
|
329
359
|
return fetch(url, { method: 'PUT', headers: reqHeaders, body: bodyStr });
|
|
330
360
|
};
|
|
331
361
|
const res = await withOtpRetry(doPut, otpProvider, { seedFirstAttempt: input.seedOtpFirst });
|
|
362
|
+
if (verbose) {
|
|
363
|
+
// The debug output used to print the request and then the `+` success
|
|
364
|
+
// line, so a 200 and a 201 were indistinguishable and no body was ever
|
|
365
|
+
// recorded — which is why reconstructing the v0.46.0 incident needed the
|
|
366
|
+
// registry rather than the log it already had. Print what npm answered.
|
|
367
|
+
// `clone()` because the error path below reads `res.text()` itself.
|
|
368
|
+
console.error(` response: ${res.status} ${res.statusText}`);
|
|
369
|
+
const peek = await res
|
|
370
|
+
.clone()
|
|
371
|
+
.text()
|
|
372
|
+
.catch(() => '');
|
|
373
|
+
if (peek)
|
|
374
|
+
console.error(` body: ${peek.slice(0, 300)}`);
|
|
375
|
+
}
|
|
376
|
+
/**
|
|
377
|
+
* Ask the registry for what we just PUT. `undefined` when the read-back is
|
|
378
|
+
* off (`--verify-timeout 0`).
|
|
379
|
+
*
|
|
380
|
+
* The PUT's own `authorization` goes with it: a registry that requires a
|
|
381
|
+
* credential to READ answers 401/403 to an anonymous packument GET, which is
|
|
382
|
+
* an `error` probe, so an intact publish to GitHub Packages or an
|
|
383
|
+
* authenticated Verdaccio would spend the whole budget and then report
|
|
384
|
+
* `publish-unconfirmed`. Same origin, same credential, one request later.
|
|
385
|
+
*/
|
|
386
|
+
const readBack = async () => {
|
|
387
|
+
const budgetMs = input.verifyBudgetMs ?? DEFAULT_VERIFY_BUDGET_MS;
|
|
388
|
+
if (budgetMs <= 0)
|
|
389
|
+
return undefined;
|
|
390
|
+
return verifyPublishedVersion({
|
|
391
|
+
registry: registryClean,
|
|
392
|
+
name: packed.name,
|
|
393
|
+
version: packed.version,
|
|
394
|
+
budgetMs,
|
|
395
|
+
authorization: headers['authorization'],
|
|
396
|
+
log: verbose ? (m) => console.error(m) : undefined,
|
|
397
|
+
});
|
|
398
|
+
};
|
|
332
399
|
// A surviving OTP challenge means the provider gave up (no code entered /
|
|
333
400
|
// non-interactive). Surface it so the caller can point at --otp.
|
|
334
401
|
if (await isOtpChallenge(res)) {
|
|
335
402
|
return { ok: false, action: 'otp-required', name: packed.name, version: packed.version, status: res.status };
|
|
336
403
|
}
|
|
337
404
|
if (res.ok) {
|
|
405
|
+
// WHERE THE READ-BACK BELONGS — per package, here, immediately.
|
|
406
|
+
//
|
|
407
|
+
// This path runs ~209 times per release (199 in the serial
|
|
408
|
+
// `foreach --exec` sweep behind `npm:publish`, plus the per-bundle
|
|
409
|
+
// single-package jobs), so its cost is measured rather than assumed:
|
|
410
|
+
//
|
|
411
|
+
// CONFIRMED ON THE FIRST PROBE — 180 of 199 packages in v0.46.0. One
|
|
412
|
+
// abbreviated-packument GET, 0.59 s against registry.npmjs.org, so
|
|
413
|
+
// ~2 min across the whole sweep against a publish job that ran 33 min.
|
|
414
|
+
//
|
|
415
|
+
// NOT CONFIRMED YET — the other 19. Here the read-back waits out
|
|
416
|
+
// npm's own write queue, and those residual lags SUM TO 2554 s: a
|
|
417
|
+
// sweep that waited them all out with the 300 s default would have
|
|
418
|
+
// added ~43 min to that 33 min job. That single number decides the
|
|
419
|
+
// shape below.
|
|
420
|
+
//
|
|
421
|
+
// A BATCHED END-OF-SWEEP CHECK CANNOT LIVE IN THIS FILE. `npm:publish`
|
|
422
|
+
// is `gjsify foreach --exec -- gjsify publish`, one CHILD PROCESS per
|
|
423
|
+
// package: there is no in-process hook after the last one to collect a
|
|
424
|
+
// pending set in. So the batched half already sits where it can —
|
|
425
|
+
// `scripts/verify-published-closure.mjs`, its own job at the end of
|
|
426
|
+
// `release.yml`, rounds and retries over the whole roster. It is what
|
|
427
|
+
// named the missing package in the incident.
|
|
428
|
+
//
|
|
429
|
+
// AND A FATAL READ-BACK INSIDE THAT SWEEP WOULD ABORT THE RELEASE, which
|
|
430
|
+
// is the argument the 2554 s only quantifies. `foreach --exec` THROWS on
|
|
431
|
+
// a non-zero child (`foreach.ts`: `exited with code ${result.code}`),
|
|
432
|
+
// and pack time has already rewritten every `workspace:*` to the exact
|
|
433
|
+
// released version — so one lagging package would stop the sweep
|
|
434
|
+
// mid-roster and leave a PARTIAL publish, the state
|
|
435
|
+
// `verify-published-closure.mjs` exists because it is worse than no
|
|
436
|
+
// publish at all. A deferral here is not leniency; it is the only shape
|
|
437
|
+
// that does not trade this incident for that one.
|
|
438
|
+
//
|
|
439
|
+
// Hence BOTH, split along that cost. The read-back runs per package
|
|
440
|
+
// everywhere and is FATAL by default — that is what turns the
|
|
441
|
+
// incident's green single-package job red, for up to 300 s of
|
|
442
|
+
// patience on a job that otherwise ran 59 s. The
|
|
443
|
+
// 199-package sweep passes `--verify-defer --verify-timeout 5`: it
|
|
444
|
+
// still CONFIRMS nine packages in ten and still names the suspect
|
|
445
|
+
// against the package that PUT it, but the minutes-long tail is left to
|
|
446
|
+
// the closure job that re-asks the same question over the same set —
|
|
447
|
+
// which had to be MADE able to answer it (`dist.tarball`, and a
|
|
448
|
+
// propagation retry that never fired for a roster-only name).
|
|
449
|
+
//
|
|
450
|
+
// What the deferred annotation is NOT is the ledger. At a 5 s budget
|
|
451
|
+
// every one of a release's ~19 lagging packages emits one, and GitHub
|
|
452
|
+
// renders only the first few annotations per step (documented as 10 per
|
|
453
|
+
// level; not verified from a run here). The closure job is the fatal
|
|
454
|
+
// half; the annotation points a human at the name.
|
|
455
|
+
const readback = await readBack();
|
|
456
|
+
if (readback?.confirmed && verbose) {
|
|
457
|
+
// The CONFIRMED half of the read-back is printed too, and it is not
|
|
458
|
+
// decoration: it is the only client-side measurement of when a
|
|
459
|
+
// version became RESOLVABLE, as against `time[version]`, which is
|
|
460
|
+
// when the registry recorded the write. The two are assumed equal by
|
|
461
|
+
// the 4.2-minute figure and the 300 s window, and nothing had ever
|
|
462
|
+
// measured the gap. The pre-registered comparison — and what each
|
|
463
|
+
// outcome would change — is in #1509's description; this line, under
|
|
464
|
+
// GJSIFY_PUBLISH_DEBUG (which release.yml sets), is its input. Note
|
|
465
|
+
// the value is quantised by the backoff grid, so it is the UPPER end
|
|
466
|
+
// of a bracket whose lower end is the previous probe.
|
|
467
|
+
console.error(` read-back: confirmed after ${readback.attempts} probe(s) in ${readback.elapsedMs} ms`);
|
|
468
|
+
}
|
|
469
|
+
if (readback && !readback.confirmed) {
|
|
470
|
+
return {
|
|
471
|
+
ok: false,
|
|
472
|
+
action: 'publish-unconfirmed',
|
|
473
|
+
claim: 'accepted',
|
|
474
|
+
name: packed.name,
|
|
475
|
+
version: packed.version,
|
|
476
|
+
registry: registryClean,
|
|
477
|
+
putStatus: res.status,
|
|
478
|
+
putStatusText: res.statusText,
|
|
479
|
+
putUrl: url,
|
|
480
|
+
payloadBytes: bodyStr.length,
|
|
481
|
+
readback,
|
|
482
|
+
};
|
|
483
|
+
}
|
|
338
484
|
return {
|
|
339
485
|
ok: true,
|
|
340
486
|
action: 'published',
|
|
@@ -345,18 +491,50 @@ export async function publishWorkspace(input) {
|
|
|
345
491
|
integrity: packed.integrity,
|
|
346
492
|
tag,
|
|
347
493
|
registry: registryClean,
|
|
494
|
+
readback,
|
|
348
495
|
};
|
|
349
496
|
}
|
|
350
497
|
const text = await res.text().catch(() => '<no body>');
|
|
351
498
|
// "version already published" — 409 Conflict, or 403 + "previously published".
|
|
499
|
+
//
|
|
500
|
+
// READ BACK HERE TOO, and the reason it was first written the other way is
|
|
501
|
+
// worth keeping: a 2xx is npm accepting a write it has not necessarily
|
|
502
|
+
// durably applied, whereas a 409 is npm ASSERTING the version exists and
|
|
503
|
+
// refusing to overwrite it — sounding like a positive statement about the
|
|
504
|
+
// registry's own state rather than a promise about a queued one. The
|
|
505
|
+
// incident's own recovery says otherwise. Attempt 3 re-PUT
|
|
506
|
+
// @gjsify/node-runtime-darwin-arm64 and was answered `409 already
|
|
507
|
+
// published` at 09:48:49.19, while that packument's `time["0.46.0"]` is
|
|
508
|
+
// 09:49:07.419 — 18 s LATER — and the closure job had found the name absent
|
|
509
|
+
// at 09:44:28. So the assertion can precede the version being served, and
|
|
510
|
+
// this is the path a `publish-unconfirmed` re-run always lands on: exempting
|
|
511
|
+
// it would leave the documented remediation reporting a success nothing
|
|
512
|
+
// checked. Cost is one GET, on a path that only runs on a re-publish.
|
|
352
513
|
const isRepublishConflict = res.status === 409 || (res.status === 403 && /previously published/i.test(text));
|
|
353
514
|
if (isRepublishConflict && tolerate) {
|
|
515
|
+
const readback = await readBack();
|
|
516
|
+
if (readback && !readback.confirmed) {
|
|
517
|
+
return {
|
|
518
|
+
ok: false,
|
|
519
|
+
action: 'publish-unconfirmed',
|
|
520
|
+
claim: 'already-published',
|
|
521
|
+
name: packed.name,
|
|
522
|
+
version: packed.version,
|
|
523
|
+
registry: registryClean,
|
|
524
|
+
putStatus: res.status,
|
|
525
|
+
putStatusText: res.statusText,
|
|
526
|
+
putUrl: url,
|
|
527
|
+
payloadBytes: bodyStr.length,
|
|
528
|
+
readback,
|
|
529
|
+
};
|
|
530
|
+
}
|
|
354
531
|
return {
|
|
355
532
|
ok: true,
|
|
356
533
|
action: 'republish-tolerated',
|
|
357
534
|
name: packed.name,
|
|
358
535
|
version: packed.version,
|
|
359
536
|
status: res.status,
|
|
537
|
+
readback,
|
|
360
538
|
};
|
|
361
539
|
}
|
|
362
540
|
// 404 diagnostic (token-auth): disambiguate dead-token vs missing-package.
|
|
@@ -384,8 +562,13 @@ export async function publishWorkspace(input) {
|
|
|
384
562
|
/**
|
|
385
563
|
* Present a {@link PublishOutcome} on stdout/stderr and exit non-zero on
|
|
386
564
|
* failure — the single-command `gjsify publish` behaviour, reproduced exactly.
|
|
565
|
+
*
|
|
566
|
+
* `deferUnconfirmed` (`--verify-defer`) affects exactly one outcome: it downgrades
|
|
567
|
+
* `publish-unconfirmed` from exit 1 to a printed warning, for a caller that
|
|
568
|
+
* re-checks the same set afterwards. It is policy, so it lives here and not in
|
|
569
|
+
* {@link publishWorkspace}, which stays a pure fact-finder.
|
|
387
570
|
*/
|
|
388
|
-
function reportPublishOutcome(outcome, asJson) {
|
|
571
|
+
function reportPublishOutcome(outcome, asJson, deferUnconfirmed = false) {
|
|
389
572
|
switch (outcome.action) {
|
|
390
573
|
case 'published': {
|
|
391
574
|
const out = {
|
|
@@ -404,6 +587,44 @@ function reportPublishOutcome(outcome, asJson) {
|
|
|
404
587
|
process.stdout.write(`+ ${outcome.name}@${outcome.version}\n`);
|
|
405
588
|
return;
|
|
406
589
|
}
|
|
590
|
+
case 'publish-unconfirmed': {
|
|
591
|
+
const message = formatUnconfirmedPublish(outcome);
|
|
592
|
+
if (asJson) {
|
|
593
|
+
process.stdout.write(`${JSON.stringify({
|
|
594
|
+
ok: false,
|
|
595
|
+
action: 'publish-unconfirmed',
|
|
596
|
+
name: outcome.name,
|
|
597
|
+
version: outcome.version,
|
|
598
|
+
registry: outcome.registry,
|
|
599
|
+
claim: outcome.claim,
|
|
600
|
+
putStatus: outcome.putStatus,
|
|
601
|
+
probes: outcome.readback.attempts,
|
|
602
|
+
elapsedMs: outcome.readback.elapsedMs,
|
|
603
|
+
readbackUrl: outcome.readback.url,
|
|
604
|
+
readbackState: outcome.readback.last.state,
|
|
605
|
+
deferred: deferUnconfirmed,
|
|
606
|
+
}, null, 2)}\n`);
|
|
607
|
+
}
|
|
608
|
+
else {
|
|
609
|
+
process.stderr.write(`${message}\n`);
|
|
610
|
+
}
|
|
611
|
+
// Under `--verify-defer` the multi-line message is one entry in a
|
|
612
|
+
// serial log carrying a line per package, i.e. exactly the channel
|
|
613
|
+
// the incident's `+` line hid in. An annotation renders beside the
|
|
614
|
+
// job's own check and needs no writable file, the same reason
|
|
615
|
+
// `verify-published-closure.mjs` emits its notes as `::warning::`.
|
|
616
|
+
// On STDERR, because `--json` owns stdout.
|
|
617
|
+
if (deferUnconfirmed) {
|
|
618
|
+
if (process.env.GITHUB_ACTIONS) {
|
|
619
|
+
const claimed = outcome.claim === 'already-published' ? 'was reported already published' : 'was accepted';
|
|
620
|
+
process.stderr.write(`::warning title=Publish unconfirmed::${outcome.name}@${outcome.version} ${claimed} ` +
|
|
621
|
+
`(HTTP ${outcome.putStatus}) but did not resolve on ${outcome.registry} within ` +
|
|
622
|
+
`${(outcome.readback.elapsedMs / 1000).toFixed(1)}s — the release-closure job must confirm it\n`);
|
|
623
|
+
}
|
|
624
|
+
return;
|
|
625
|
+
}
|
|
626
|
+
return process.exit(1);
|
|
627
|
+
}
|
|
407
628
|
case 'republish-tolerated': {
|
|
408
629
|
const out = {
|
|
409
630
|
ok: true,
|
|
@@ -569,14 +790,6 @@ function buildPublishPayload(opts) {
|
|
|
569
790
|
payload.access = access;
|
|
570
791
|
return payload;
|
|
571
792
|
}
|
|
572
|
-
function base64Encode(bytes) {
|
|
573
|
-
let str = '';
|
|
574
|
-
const chunk = 0x8000;
|
|
575
|
-
for (let i = 0; i < bytes.length; i += chunk) {
|
|
576
|
-
str += String.fromCharCode(...bytes.subarray(i, i + chunk));
|
|
577
|
-
}
|
|
578
|
-
return btoa(str);
|
|
579
|
-
}
|
|
580
793
|
function handleOidcFailure(err, packageName, asJson) {
|
|
581
794
|
if (err instanceof OidcUnavailableError) {
|
|
582
795
|
const msg = `gjsify publish: OIDC not available — ${err.message}`;
|
package/lib/commands/run.js
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
// to disambiguate can pass `./<file>` explicitly.
|
|
16
16
|
import { readFileSync, statSync } from 'node:fs';
|
|
17
17
|
import { basename, delimiter, join, resolve } from 'node:path';
|
|
18
|
+
import { doubleDashArgs } from '../utils/double-dash-args.js';
|
|
18
19
|
import { describeExit, spawnToCompletion } from '../utils/spawn.js';
|
|
19
20
|
import { runGjsBundle } from '../utils/run-gjs.js';
|
|
20
21
|
import { runRuntimeBundle } from '../utils/run-node.js';
|
|
@@ -93,6 +94,11 @@ export const runCommand = {
|
|
|
93
94
|
// Without this, anything after `--` lands in args._ and is
|
|
94
95
|
// silently dropped by the handler.
|
|
95
96
|
'populate--': true,
|
|
97
|
+
// …and hand that tail on as the TEXT the caller typed. Without
|
|
98
|
+
// this yargs types a bare number in the array — and in the
|
|
99
|
+
// `[args..]` positional — as a `number`, which the reader below
|
|
100
|
+
// used to drop outright (#1531).
|
|
101
|
+
'parse-positional-numbers': false,
|
|
96
102
|
// Forward UNKNOWN flags to the target without requiring `--`:
|
|
97
103
|
// gjsify run dist/app.gjs.mjs serve --port 8080 --year 2025
|
|
98
104
|
// Without this, the top-level `.strict()` (cli-app.ts) rejects
|
|
@@ -111,8 +117,7 @@ export const runCommand = {
|
|
|
111
117
|
// than silently discarding them (without it they land in args._ and
|
|
112
118
|
// are never forwarded to the child).
|
|
113
119
|
const positionalArgs = args.args ?? [];
|
|
114
|
-
const
|
|
115
|
-
const extraArgs = [...positionalArgs, ...doubleDashArgs];
|
|
120
|
+
const extraArgs = [...positionalArgs, ...doubleDashArgs(args)];
|
|
116
121
|
// `--node-script` (explicit) → run an UNBUNDLED Node-style script on the
|
|
117
122
|
// host runtime; under GJS it is bundled `--app gjs` on the fly first.
|
|
118
123
|
// Checked before `--runtime` and `--workspace`: it is a file mode, and
|
package/lib/commands/ship.js
CHANGED
|
@@ -42,13 +42,14 @@ import { isUnder, SHARE } from '../utils/ship/share-dirs.js';
|
|
|
42
42
|
import { compileSchemasForStage } from '../utils/ship/schemas.js';
|
|
43
43
|
import { buildMsi, MSI_PAYLOAD_DIR } from '../utils/ship/msi.js';
|
|
44
44
|
import { buildZip, zipEntriesFromPayload } from '../utils/ship/zip.js';
|
|
45
|
+
import { assertEntryRunsUnder } from '../utils/ship/entry-interpreter.js';
|
|
45
46
|
import { scanGiNamespaces } from '../utils/ship/gi-namespaces.js';
|
|
46
47
|
import { assertLauncherMatchesInterpreter, assertPayloadMatchesArch, buildTimestamp, isArchIndependent, linuxInstallDependent, readPayloadFacts, } from '../utils/ship/payload.js';
|
|
47
48
|
import { assertOverlayIsLicensed, planOverlay, planStage } from '../utils/ship/plan.js';
|
|
48
49
|
import { assertHostCanSign, notarizeArtifact, stapleArtifact, resolveNotaryPlan, resolveSignPlan, signPayload, } from '../utils/ship/signing.js';
|
|
49
50
|
import { renderLauncher } from '../utils/ship/launcher.js';
|
|
50
51
|
import { buildRpm } from '../utils/ship/rpm.js';
|
|
51
|
-
import {
|
|
52
|
+
import { resolveShipApp, resolveShipBundle, resolveShipSettings, } from '../utils/ship/settings.js';
|
|
52
53
|
import { assertExpectedTarget, assertFormatsStaged, formatTarget, overlayFiles, readStageManifest, resolveStageMtime, warnOnToolSkew, writeStageManifest, STAGE_MANIFEST_FILE, } from '../utils/ship/stage-manifest.js';
|
|
53
54
|
import { directorySize, readStage, writePayload, writeStage } from '../utils/ship/stage-writer.js';
|
|
54
55
|
const LOG = '[gjsify ship]';
|
|
@@ -280,12 +281,22 @@ async function assemble(args) {
|
|
|
280
281
|
assertCanPack(format);
|
|
281
282
|
if (!args['skip-build'])
|
|
282
283
|
await runProjectBuild(projectDir);
|
|
284
|
+
// THE OTHER HALF of the resolution above, and they are one decision: the
|
|
285
|
+
// runtime a target execs and the entry it hands that runtime (#1545). Two
|
|
286
|
+
// keys, resolved by two twin functions, compared by `assertEntryRunsUnder`
|
|
287
|
+
// as soon as both answers and the file exist.
|
|
288
|
+
const resolvedBundle = resolveShipBundle({ pkg, ship, layoutOs: layout.os });
|
|
289
|
+
if (resolvedBundle.overridden) {
|
|
290
|
+
console.log(`${LOG} entry for ${layout.name}: ${resolvedBundle.declared} — \`${resolvedBundle.key}\` ` +
|
|
291
|
+
'overrides `gjsify.main`.');
|
|
292
|
+
}
|
|
283
293
|
const discovered = discoverPayload({
|
|
284
294
|
projectDir,
|
|
285
295
|
pkg,
|
|
286
296
|
ship,
|
|
287
297
|
flatpakIcon: flatpak.icon,
|
|
288
|
-
declaredBundle:
|
|
298
|
+
declaredBundle: resolvedBundle.declared,
|
|
299
|
+
foreignBundles: resolvedBundle.foreign,
|
|
289
300
|
});
|
|
290
301
|
const { settings, metadata, warnings } = resolveShipSettings({
|
|
291
302
|
projectDir,
|
|
@@ -308,6 +319,21 @@ async function assemble(args) {
|
|
|
308
319
|
// the packing host.
|
|
309
320
|
for (const format of formats)
|
|
310
321
|
assertFormatCanRunInterpreter(format, settings.app);
|
|
322
|
+
// The entry, read ONCE: the pair check below and the namespace scan further
|
|
323
|
+
// down both ask what this file imports, and a bundle is the biggest file in
|
|
324
|
+
// the project.
|
|
325
|
+
const bundleSource = readFileSync(settings.bundlePath, 'utf-8');
|
|
326
|
+
// BEFORE anything is staged, for the reason above and one more: this is the
|
|
327
|
+
// refusal that says the artifact could never have started, and the run that
|
|
328
|
+
// reports it must not also print a stage path.
|
|
329
|
+
assertEntryRunsUnder({
|
|
330
|
+
interpreter: settings.app,
|
|
331
|
+
source: bundleSource,
|
|
332
|
+
entry: relative(projectDir, settings.bundlePath) || settings.bundlePath,
|
|
333
|
+
appKey: resolvedApp.key,
|
|
334
|
+
bundleKey: resolvedBundle.key,
|
|
335
|
+
layoutOs: layout.os,
|
|
336
|
+
});
|
|
311
337
|
const mtime = buildTimestamp(settings.bundlePath);
|
|
312
338
|
const metadataInputs = {
|
|
313
339
|
appId: settings.appId,
|
|
@@ -354,6 +380,8 @@ async function assemble(args) {
|
|
|
354
380
|
metainfo: translated.metainfo,
|
|
355
381
|
desktopEntry: translated.desktopEntry,
|
|
356
382
|
licenseText: settings.licenseFile === undefined ? undefined : readFileSync(settings.licenseFile, 'utf-8'),
|
|
383
|
+
changelogText: settings.changelogFile === undefined ? undefined : readFileSync(settings.changelogFile, 'utf-8'),
|
|
384
|
+
mtime,
|
|
357
385
|
};
|
|
358
386
|
const outRoot = resolve(projectDir, settings.outDir);
|
|
359
387
|
const stageDir = join(outRoot, 'stage');
|
|
@@ -466,7 +494,7 @@ async function assemble(args) {
|
|
|
466
494
|
// `gi://` specifiers are what the emitted `Depends:` is derived from
|
|
467
495
|
// (ADR 0024 § 6), and the packing host has the staged copy but no way to
|
|
468
496
|
// tell which staged file is the entry.
|
|
469
|
-
const namespaces = scanGiNamespaces(
|
|
497
|
+
const namespaces = scanGiNamespaces(bundleSource);
|
|
470
498
|
if (args.verbose)
|
|
471
499
|
console.log(`${LOG} gi namespaces: ${namespaces.join(', ') || '(none)'}`);
|
|
472
500
|
// PLACED, like the payload, and through the same map. `planOverlay` answers a
|
|
@@ -481,7 +509,7 @@ async function assemble(args) {
|
|
|
481
509
|
// overlay reaches it by the same route.
|
|
482
510
|
const overlay = new Map();
|
|
483
511
|
for (const format of formats) {
|
|
484
|
-
overlay.set(format.id, planOverlay(settings, format, stageInputs).map((file) => ({
|
|
512
|
+
overlay.set(format.id, (await planOverlay(settings, format, stageInputs)).map((file) => ({
|
|
485
513
|
...file,
|
|
486
514
|
path: place(layout, settings, file.path),
|
|
487
515
|
})));
|