@adia-ai/adia-ui-forge 0.8.31 → 0.8.33

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.
@@ -17,7 +17,7 @@
17
17
  // Part of the adia-release skill. Mechanizes the dispatch loop
18
18
  // + handles the npm-latest ordering rule for batch pushes.
19
19
 
20
- import { execSync } from 'node:child_process';
20
+ import { execFileSync } from 'node:child_process';
21
21
  import process from 'node:process';
22
22
  import { assertMonorepoRoot } from './assert-monorepo-root.mjs';
23
23
  import { PACKAGE_NAMES } from './package-paths.mjs';
@@ -49,10 +49,14 @@ function parseArgs(argv) {
49
49
  console.log('--after Verify npm latest is at the given version before dispatching this one.');
50
50
  console.log(' Used in batch push to ensure publish ordering.');
51
51
  console.log('--verify-triggered Instead of dispatching, check whether each per-package publish-<pkg>.yml');
52
- console.log(' ALREADY has a run for the <pkg>-vX.Y.Z tag (i.e. push-on-tag fired), and');
53
- console.log(' re-dispatch ONLY the misses. This is the recovery for the batch-tag-push');
54
- console.log(' skip (recovery-paths.md §Scenario 7) a single `git push <12 tags>` can');
55
- console.log(' trigger ZERO publish workflows. Idempotent: re-dispatches nothing if all 11 fired.');
52
+ console.log(' has a SUCCESSFUL run for the <pkg>-vX.Y.Z tag, and re-dispatch the misses.');
53
+ console.log(' Conclusion-aware (gh#763): a cancelled/failed/timed-out run is a miss, not');
54
+ console.log(' a pass; in-progress/queued runs get a bounded wait. Every re-dispatch is');
55
+ console.log(' gated behind an npm-registry check if <pkg>@<version> is already on the');
56
+ console.log(' registry the publish succeeded regardless of run conclusion (a run can');
57
+ console.log(' publish, then be stamped cancelled by the job clock), and re-dispatching');
58
+ console.log(' would only 403. Covers the batch-tag-push skip (recovery-paths.md');
59
+ console.log(' §Scenario 7) AND dead runs from runner starvation. Idempotent.');
56
60
  console.log(`--scope npm scope the packages publish under (default: ${DEFAULT_SCOPE};`);
57
61
  console.log(' or set $ADIA_NPM_SCOPE). The --after npm-latest check uses this scope.');
58
62
  process.exit(0);
@@ -65,18 +69,23 @@ function parseArgs(argv) {
65
69
  return args;
66
70
  }
67
71
 
68
- function run(cmd, dry) {
72
+ // All gh/npm invocations go through execFileSync with ARGUMENT ARRAYS —
73
+ // never a shell string. --version/--scope are CLI-attacker-controlled;
74
+ // interpolating them into a shell command is command injection (PR #774
75
+ // review). `run` logs a printable form in dry mode and execs the array.
76
+ function run(bin, argv, dry, opts = {}) {
77
+ const printable = `${bin} ${argv.join(' ')}`;
69
78
  if (dry) {
70
- console.log(` [dry] ${cmd}`);
79
+ console.log(` [dry] ${printable}`);
71
80
  return '';
72
81
  }
73
- return execSync(cmd, { encoding: 'utf8' });
82
+ return execFileSync(bin, argv, { encoding: 'utf8', ...opts });
74
83
  }
75
84
 
76
85
  function checkAfter(afterVersion, scope) {
77
86
  console.log(`Checking npm latest is at ${afterVersion} before dispatching...`);
78
87
  try {
79
- const latest = execSync(`npm view ${scope}/web-components dist-tags.latest`, { encoding: 'utf8' }).trim();
88
+ const latest = run('npm', ['view', `${scope}/web-components`, 'dist-tags.latest'], false).trim();
80
89
  if (latest !== afterVersion) {
81
90
  console.error(`error: npm ${scope}/web-components latest is '${latest}', expected '${afterVersion}'`);
82
91
  console.error(' This means the previous batch hasn\'t completed publishing.');
@@ -92,9 +101,8 @@ function checkAfter(afterVersion, scope) {
92
101
 
93
102
  function dispatch(pkg, version, dry) {
94
103
  const ref = `${pkg}-v${version}`;
95
- const cmd = `gh workflow run "publish-${pkg}.yml" --ref "${ref}"`;
96
104
  try {
97
- const out = run(cmd, dry);
105
+ const out = run('gh', ['workflow', 'run', `publish-${pkg}.yml`, '--ref', ref], dry);
98
106
  if (!dry && out) {
99
107
  const url = out.split('\n').find((l) => l.startsWith('http')) || '';
100
108
  console.log(` ✓ dispatched ${pkg} (${url.trim()})`);
@@ -114,53 +122,163 @@ async function sleep(ms) {
114
122
  return new Promise((res) => setTimeout(res, ms));
115
123
  }
116
124
 
117
- // Has publish-<pkg>.yml already run for the <pkg>-vX.Y.Z tag? Tag-triggered runs
125
+ // Classify a run's {status, conclusion} into what --verify-triggered should do
126
+ // with it. Pure — selftest exercises it with negative controls.
127
+ // 'success' — completed + success: the publish run is satisfied.
128
+ // 'pending' — queued / in_progress / waiting / requested: recheck (bounded wait).
129
+ // 'dead' — completed but NOT success (cancelled / failure / timed_out /
130
+ // skipped / anything unknown): re-dispatch candidate. gh#763: the
131
+ // v0.8.29 starvation wave stamped runs `cancelled` — one of them
132
+ // had already published to npm, which is why every re-dispatch is
133
+ // additionally gated behind the registry check below.
134
+ function classifyRun(status, conclusion) {
135
+ if (status !== 'completed') return 'pending';
136
+ return conclusion === 'success' ? 'success' : 'dead';
137
+ }
138
+
139
+ // Latest run of publish-<pkg>.yml for the <pkg>-vX.Y.Z tag. Tag-triggered runs
118
140
  // report the tag in the `headBranch` field, so we filter the run list by it.
119
- // Returns true if at least one run exists for that tag, false otherwise.
141
+ // Returns 'none' | 'success' | 'pending' | 'dead' | 'unknown' existence
142
+ // alone is NOT satisfaction (gh#763: a cancelled run used to count as
143
+ // "triggered"), and a FAILED QUERY is NOT verified absence (PR #774 review:
144
+ // fail-open here read a gh rate limit as "no run" and re-dispatched over a
145
+ // possibly in-flight publish).
120
146
  function triggeredForTag(pkg, version, dry) {
121
147
  const tag = `${pkg}-v${version}`;
122
- const cmd = `gh run list --workflow="publish-${pkg}.yml" --branch "${tag}" --limit 1 --json databaseId -q '.[0].databaseId // empty'`;
148
+ const argv = ['run', 'list', `--workflow=publish-${pkg}.yml`, '--branch', tag, '--limit', '1', '--json', 'status,conclusion', '-q', '.[0] // empty'];
123
149
  if (dry) {
124
- console.log(` [dry] ${cmd}`);
125
- return false; // dry preview assumes not-triggered so the re-dispatch path is shown
150
+ console.log(` [dry] gh ${argv.join(' ')}`);
151
+ return 'none'; // dry preview assumes not-triggered so the re-dispatch path is shown
126
152
  }
127
153
  try {
128
- const out = execSync(cmd, { encoding: 'utf8' }).trim();
129
- return out.length > 0;
154
+ const out = execFileSync('gh', argv, { encoding: 'utf8' }).trim();
155
+ if (!out) return 'none'; // gh answered: genuinely zero runs for the tag
156
+ const run = JSON.parse(out);
157
+ return classifyRun(run.status, run.conclusion);
130
158
  } catch {
131
- // gh error (auth / rate limit / missing workflow) — treat as "unknown".
132
- // Conservative: report not-triggered so the miss is re-dispatched rather than
133
- // silently skipped. A redundant re-dispatch is harmless (publish is idempotent
134
- // per the workflow's own guard); a skipped publish is the failure we're fixing.
135
- return false;
159
+ // gh error (auth / rate limit / missing workflow) — we could not observe
160
+ // the runs. Fail CLOSED: 'unknown' stops the loop with a nonzero result
161
+ // instead of dispatching blind.
162
+ return 'unknown';
136
163
  }
137
164
  }
138
165
 
139
- // The batch-tag-push-skip recovery: verify each per-package publish workflow
140
- // actually triggered off its pushed tag, and re-dispatch ONLY the misses.
141
- // Idempotent re-dispatches nothing if all 11 fired.
142
- function verifyTriggeredAndRedispatch(version, dry) {
143
- console.log(`\nVerifying publish-on-tag triggered for all ${PACKAGES.length} packages (v${version}):`);
166
+ // Registry truth: is <scope>/<pkg>@<version> already published? A workflow
167
+ // run's conclusion is NOT publish truth the v0.8.29 compose run published
168
+ // successfully, then got stamped `cancelled` by the 10m job clock. If the
169
+ // version is on the registry, the publish succeeded, full stop; re-dispatching
170
+ // would only hit npm's publish-over-published 403.
171
+ // Returns true (published) | false (npm answered: version absent, E404) |
172
+ // null (query FAILED — network/auth; absence NOT verified, fail closed:
173
+ // PR #774 review — a transient npm failure must not re-open the 403).
174
+ function onRegistry(pkg, version, scope, dry) {
175
+ const argv = ['view', `${scope}/${pkg}@${version}`, 'version'];
176
+ if (dry) {
177
+ console.log(` [dry] npm ${argv.join(' ')}`);
178
+ return false; // dry preview assumes not-published so the re-dispatch path is shown
179
+ }
180
+ try {
181
+ return execFileSync('npm', argv, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim() === version;
182
+ } catch (e) {
183
+ const stderr = `${e.stderr || ''}`;
184
+ if (stderr.includes('E404') || stderr.includes('404 Not Found')) return false; // definitive: not published
185
+ return null; // npm/network error — absence unverified
186
+ }
187
+ }
188
+
189
+ // What to do with a package whose latest run is not a clean success, given
190
+ // registry truth (`published`: true | false | null-unverified). Pure —
191
+ // selftest proves the 403-avoidance and fail-closed invariants here.
192
+ // published === true → 'satisfied' — npm has the version; the publish
193
+ // succeeded whatever the run conclusion says. NEVER
194
+ // re-dispatch over an already-published version (403).
195
+ // either query failed→ 'unknown' — run state or registry state was NOT
196
+ // observed (rate limit, network). Fail closed: stop
197
+ // with a nonzero result, never dispatch blind.
198
+ // pending, absent → 'wait' — a run may still be publishing; do not
199
+ // race it with a second dispatch.
200
+ // dead/none, absent → 'redispatch' — no run, or a cancelled/failed/
201
+ // timed-out one, and the registry lacks the version.
202
+ function decidePackage(state, published) {
203
+ if (published === true) return 'satisfied';
204
+ if (state === 'unknown' || published === null) return 'unknown';
205
+ if (state === 'pending') return 'wait';
206
+ return 'redispatch';
207
+ }
208
+
209
+ // Bounded wait for in-progress/queued runs before judging them: a healthy
210
+ // publish run finishes in ~30s; 6 × 20s covers a slow queue without stalling
211
+ // the recovery loop forever.
212
+ const PENDING_RECHECKS = 6;
213
+ const PENDING_RECHECK_SECONDS = 20;
214
+
215
+ // The publish-run recovery: verify each per-package publish workflow has a
216
+ // SUCCESSFUL run for its tag (batch-tag-push skip AND dead runs — cancelled /
217
+ // failed / timed_out, e.g. runner starvation), wait out in-progress runs, and
218
+ // re-dispatch only true misses — never over a version the registry already has.
219
+ // Idempotent — re-dispatches nothing if all publishes landed.
220
+ async function verifyTriggeredAndRedispatch(version, scope, dry) {
221
+ console.log(`\nVerifying publish-on-tag succeeded for all ${PACKAGES.length} packages (v${version}):`);
222
+ let pending = PACKAGES;
223
+ const unresolved = new Map(); // pkg → state, after the bounded wait
224
+ for (let attempt = 0; attempt <= PENDING_RECHECKS && pending.length > 0; attempt++) {
225
+ if (attempt > 0) {
226
+ console.log(` … ${pending.length} run(s) still pending — recheck ${attempt}/${PENDING_RECHECKS} in ${PENDING_RECHECK_SECONDS}s`);
227
+ await sleep(PENDING_RECHECK_SECONDS * 1000);
228
+ }
229
+ const stillPending = [];
230
+ for (const pkg of pending) {
231
+ const state = triggeredForTag(pkg, version, dry);
232
+ if (state === 'success') {
233
+ console.log(` ✓ ${pkg} — successful publish run for ${pkg}-v${version}`);
234
+ } else if (state === 'pending' && attempt < PENDING_RECHECKS && !dry) {
235
+ stillPending.push(pkg);
236
+ } else {
237
+ unresolved.set(pkg, state);
238
+ }
239
+ }
240
+ pending = stillPending;
241
+ }
242
+
243
+ // Registry gate: for every non-success, npm is the arbiter — already
244
+ // published → satisfied regardless of run conclusion; absent → re-dispatch.
144
245
  const misses = [];
145
- for (const pkg of PACKAGES) {
146
- if (triggeredForTag(pkg, version, dry)) {
147
- console.log(` ✓ ${pkg} publish run found for ${pkg}-v${version}`);
246
+ for (const [pkg, state] of unresolved) {
247
+ const decision = decidePackage(state, onRegistry(pkg, version, scope, dry));
248
+ if (decision === 'satisfied') {
249
+ console.log(` ✓ ${pkg} — run state '${state}' but ${scope}/${pkg}@${version} IS on the registry → published, no re-dispatch`);
250
+ } else if (decision === 'unknown') {
251
+ console.log(` ! ${pkg} — run or registry state could NOT be observed (gh/npm query failed) → NOT dispatching blind; fix gh/npm access and re-run --verify-triggered`);
252
+ misses.push({ pkg, redispatch: false });
253
+ } else if (decision === 'wait') {
254
+ console.log(` ? ${pkg} — run still pending after bounded wait and not on the registry → NOT re-dispatching over an in-flight run; re-run --verify-triggered once it settles`);
255
+ misses.push({ pkg, redispatch: false });
148
256
  } else {
149
- console.log(` ✗ ${pkg} NO publish run for ${pkg}-v${version} (batch-push skip) will re-dispatch`);
150
- misses.push(pkg);
257
+ const why = state === 'none' ? 'NO publish run (batch-push skip)' : 'run cancelled/failed/timed-out';
258
+ console.log(` ✗ ${pkg} — ${why} for ${pkg}-v${version}, not on the registry → will re-dispatch`);
259
+ misses.push({ pkg, redispatch: true });
151
260
  }
152
261
  }
153
262
  if (misses.length === 0) {
154
- console.log(`\n[verify-triggered] all ${PACKAGES.length} publish workflows triggered — nothing to re-dispatch.`);
263
+ console.log(`\n[verify-triggered] all ${PACKAGES.length} publishes verified (successful run or on-registry) — nothing to re-dispatch.`);
155
264
  return 0;
156
265
  }
157
- console.log(`\n[verify-triggered] ${misses.length} miss(es) re-dispatching via workflow_dispatch:`);
266
+ const toDispatch = misses.filter((m) => m.redispatch);
158
267
  let redispatched = 0;
159
- for (const pkg of misses) {
160
- if (dispatch(pkg, version, dry)) redispatched++;
268
+ if (toDispatch.length > 0) {
269
+ console.log(`\n[verify-triggered] ${toDispatch.length} miss(es) re-dispatching via workflow_dispatch:`);
270
+ for (const { pkg } of toDispatch) {
271
+ if (dispatch(pkg, version, dry)) redispatched++;
272
+ }
273
+ console.log(`\n[verify-triggered] re-dispatched ${redispatched}/${toDispatch.length} ${dry ? '(dry)' : ''}`);
274
+ if (!dry) {
275
+ console.log(' Registry is the final arbiter — confirm each with:');
276
+ console.log(` npm view ${scope}/<pkg>@${version} version`);
277
+ }
161
278
  }
162
- console.log(`\n[verify-triggered] re-dispatched ${redispatched}/${misses.length} ${dry ? '(dry)' : ''}`);
163
- return redispatched === misses.length ? 0 : 1;
279
+ const unresolvedCount = misses.length - toDispatch.length;
280
+ if (unresolvedCount > 0) console.log(`\n[verify-triggered] ${unresolvedCount} package(s) unresolved (run still pending, or gh/npm query failed) — re-run --verify-triggered once they settle / access is restored.`);
281
+ return redispatched === toDispatch.length && unresolvedCount === 0 ? 0 : 1;
164
282
  }
165
283
 
166
284
  async function main() {
@@ -169,10 +287,11 @@ async function main() {
169
287
  assertMonorepoRoot(process.cwd());
170
288
  if (args.after) checkAfter(args.after, args.scope);
171
289
 
172
- // Recovery mode — verify each publish workflow triggered off its pushed tag,
173
- // re-dispatch only the misses (the batch-tag-push skip). Skips the blind dispatch.
290
+ // Recovery mode — verify each publish workflow SUCCEEDED for its pushed tag
291
+ // (or the version is on the registry), re-dispatch only true misses (batch-
292
+ // tag-push skip + dead runs, gh#763). Skips the blind dispatch.
174
293
  if (args.verifyTriggered) {
175
- const rc = verifyTriggeredAndRedispatch(args.version, args.dry);
294
+ const rc = await verifyTriggeredAndRedispatch(args.version, args.scope, args.dry);
176
295
  process.exit(rc);
177
296
  }
178
297
 
@@ -211,31 +330,75 @@ async function main() {
211
330
  if (succeeded !== PACKAGES.length) process.exit(1);
212
331
  }
213
332
 
214
- // dry mode never shells out (run()/dispatch() short-circuit before
215
- // execSync), so this exercises the real functions without touching gh/npm.
216
- function selftest() {
333
+ // dry mode never shells out (run()/dispatch()/triggeredForTag()/onRegistry()
334
+ // short-circuit before execFileSync), so this exercises the real functions
335
+ // without touching gh/npm.
336
+ async function selftest() {
337
+ const fail = (msg) => { console.error(`selftest FAIL: ${msg}`); process.exit(1); };
338
+
217
339
  let allOk = true;
218
340
  for (const pkg of PACKAGES) {
219
341
  if (!dispatch(pkg, '9.9.9', true /* dry */)) allOk = false;
220
342
  }
221
- if (!allOk) { console.error('selftest FAIL: dry dispatch() reported a failure for at least one package'); process.exit(1); }
343
+ if (!allOk) fail('dry dispatch() reported a failure for at least one package');
344
+
345
+ // classifyRun() — the gh#763 core: conclusion decides, existence does not.
346
+ if (classifyRun('completed', 'success') !== 'success') fail("classifyRun(completed,success) must be 'success'");
347
+ // Negative controls: the exact states the v0.8.29 starvation wave produced
348
+ // must NOT satisfy the verify — a cancelled run counting as "triggered" is
349
+ // the defect this script no longer has.
350
+ for (const dead of ['cancelled', 'failure', 'timed_out', 'skipped', null]) {
351
+ if (classifyRun('completed', dead) !== 'dead') fail(`classifyRun(completed,${dead}) must be 'dead', never satisfied`);
352
+ }
353
+ for (const status of ['queued', 'in_progress', 'waiting', 'requested']) {
354
+ if (classifyRun(status, null) !== 'pending') fail(`classifyRun(${status}) must be 'pending' (bounded recheck)`);
355
+ }
356
+
357
+ // decidePackage() — the registry gate. Negative controls: the v0.8.29
358
+ // a2ui-compose shape (run cancelled, version PUBLISHED) must never
359
+ // re-dispatch — that's the publish-over-published 403.
360
+ if (decidePackage('dead', true) !== 'satisfied') fail('decidePackage(dead, published) must be satisfied — never re-dispatch over a published version');
361
+ if (decidePackage('none', true) !== 'satisfied') fail('decidePackage(none, published) must be satisfied');
362
+ if (decidePackage('pending', true) !== 'satisfied') fail('decidePackage(pending, published) must be satisfied');
363
+ if (decidePackage('dead', false) !== 'redispatch') fail('decidePackage(dead, absent) must re-dispatch');
364
+ if (decidePackage('none', false) !== 'redispatch') fail('decidePackage(none, absent) must re-dispatch');
365
+ if (decidePackage('pending', false) !== 'wait') fail('decidePackage(pending, absent) must wait, never race an in-flight run');
366
+ // Fail-closed negative controls (PR #774 review): a FAILED query is not
367
+ // verified absence — 'unknown' must never dispatch. A gh rate limit could
368
+ // hide an in-flight run; a transient npm failure could hide an
369
+ // already-published version (the 403 again).
370
+ for (const state of ['unknown', 'none', 'dead', 'pending']) {
371
+ if (decidePackage(state, null) !== 'unknown') fail(`decidePackage(${state}, registry-unverified) must be 'unknown' — never dispatch blind`);
372
+ }
373
+ for (const published of [false, null]) {
374
+ if (decidePackage('unknown', published) !== 'unknown') fail(`decidePackage(run-unknown, ${published}) must be 'unknown' — never dispatch blind`);
375
+ }
376
+ // Positive registry truth still wins even when the run query failed:
377
+ if (decidePackage('unknown', true) !== 'satisfied') fail('decidePackage(unknown, published) must be satisfied — registry is the arbiter');
378
+
379
+ // triggeredForTag() in dry mode must conservatively report 'none' — the
380
+ // whole point is re-dispatching misses; silently assuming "triggered"
381
+ // would let a real miss through.
382
+ if (triggeredForTag('web-components', '9.9.9', true) !== 'none') {
383
+ fail("dry triggeredForTag() must report 'none' (conservative default)");
384
+ }
222
385
 
223
- // triggeredForTag() in dry mode must conservatively report "not yet
224
- // triggered" verifyTriggeredAndRedispatch's whole point is to re-dispatch
225
- // misses; silently assuming "triggered" would let a real miss through.
226
- if (triggeredForTag('web-components', '9.9.9', true) !== false) {
227
- console.error('selftest FAIL: dry triggeredForTag() must report false (conservative default)'); process.exit(1);
386
+ // onRegistry() in dry mode must report not-published — assuming published
387
+ // would suppress the re-dispatch preview AND, inverted, the real registry
388
+ // gate is what prevents the publish-over-published 403.
389
+ if (onRegistry('web-components', '9.9.9', '@adia-ai', true) !== false) {
390
+ fail('dry onRegistry() must report false (conservative default)');
228
391
  }
229
392
 
230
- const rc = verifyTriggeredAndRedispatch('9.9.9', true);
231
- if (rc !== 0) { console.error(`selftest FAIL: dry verifyTriggeredAndRedispatch() should report rc=0, got ${rc}`); process.exit(1); }
393
+ const rc = await verifyTriggeredAndRedispatch('9.9.9', '@adia-ai', true);
394
+ if (rc !== 0) fail(`dry verifyTriggeredAndRedispatch() should report rc=0, got ${rc}`);
232
395
 
233
396
  console.log('selftest OK');
234
397
  }
235
398
 
236
399
  const topArgv = process.argv.slice(2);
237
400
  if (topArgv[0] === 'selftest') {
238
- selftest();
401
+ selftest().catch((e) => { console.error(`selftest FAIL: ${e.message}`); process.exit(1); });
239
402
  } else {
240
403
  main().catch((e) => {
241
404
  console.error(`error: ${e.message}`);