@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.
Files changed (35) hide show
  1. package/dist/cli.gjs.mjs +87 -83
  2. package/lib/commands/affected-classify.js +30 -1
  3. package/lib/commands/dlx.js +7 -3
  4. package/lib/commands/foreach.js +9 -2
  5. package/lib/commands/onboard.js +11 -0
  6. package/lib/commands/publish.d.ts +41 -0
  7. package/lib/commands/publish.js +223 -10
  8. package/lib/commands/run.js +7 -2
  9. package/lib/commands/ship.js +32 -4
  10. package/lib/types/config-data.d.ts +37 -1
  11. package/lib/utils/base64.d.ts +34 -0
  12. package/lib/utils/base64.js +47 -0
  13. package/lib/utils/cli-runtime-closure.d.ts +61 -0
  14. package/lib/utils/cli-runtime-closure.js +54 -17
  15. package/lib/utils/double-dash-args.d.ts +8 -0
  16. package/lib/utils/double-dash-args.js +64 -0
  17. package/lib/utils/publish-readback.d.ts +132 -0
  18. package/lib/utils/publish-readback.js +273 -0
  19. package/lib/utils/ship/changelog.d.ts +56 -0
  20. package/lib/utils/ship/changelog.js +166 -0
  21. package/lib/utils/ship/discover.d.ts +10 -1
  22. package/lib/utils/ship/discover.js +76 -2
  23. package/lib/utils/ship/entry-interpreter.d.ts +44 -0
  24. package/lib/utils/ship/entry-interpreter.js +109 -0
  25. package/lib/utils/ship/formats.js +5 -0
  26. package/lib/utils/ship/gi-namespaces.js +175 -12
  27. package/lib/utils/ship/layout.js +8 -4
  28. package/lib/utils/ship/plan.d.ts +5 -1
  29. package/lib/utils/ship/plan.js +21 -2
  30. package/lib/utils/ship/settings.d.ts +48 -2
  31. package/lib/utils/ship/settings.js +63 -3
  32. package/lib/utils/ship/stage-manifest.d.ts +22 -3
  33. package/lib/utils/ship/stage-manifest.js +40 -13
  34. package/lib/utils/ship/types.d.ts +13 -0
  35. 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
- runE2E: touchedE2E,
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
  };
@@ -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
- .parserConfiguration({ 'populate--': true })
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 passthroughDoubleDash = (args['--'] ?? []).map((v) => String(v));
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) {
@@ -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['--'] ?? []).filter((v) => typeof v === 'string');
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['--'] ?? []).filter((v) => typeof v === 'string');
304
+ const fromDoubleDash = doubleDashArgs(args);
298
305
  if (fromDoubleDash.length > 0) {
299
306
  if (!cmd) {
300
307
  cmd = fromDoubleDash[0];
@@ -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';
@@ -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}`;
@@ -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 doubleDashArgs = (args['--'] ?? []).filter((v) => typeof v === 'string');
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
@@ -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 { declaredBundlePath, resolveShipApp, resolveShipSettings, } from '../utils/ship/settings.js';
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: declaredBundlePath(pkg, ship),
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(readFileSync(settings.bundlePath, 'utf-8'));
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
  })));