triflux 10.45.0 → 10.47.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/bin/tfx-live.mjs +576 -53
  2. package/config/routing-policy.json +1 -1
  3. package/hooks/session-start-fast.mjs +6 -0
  4. package/hub/dynamic-routing-engine.mjs +1 -1
  5. package/hub/lib/cto-env.mjs +2 -3
  6. package/hub/server.mjs +2 -51
  7. package/hub/team/recovery-store.mjs +1 -1
  8. package/hub/team/retry-state-machine.mjs +9 -1
  9. package/hub/team/synapse-registry.mjs +14 -11
  10. package/hub/team/uds-orchestrator.mjs +384 -83
  11. package/hub/team/worktree-lifecycle.mjs +8 -48
  12. package/hub/tray-lifecycle.mjs +1 -1
  13. package/hub/workers/codex-mcp.mjs +1 -1
  14. package/hud/providers/codex.mjs +91 -8
  15. package/hud/providers/cto.mjs +3 -0
  16. package/package.json +1 -1
  17. package/scripts/__tests__/release-governance.test.mjs +235 -11
  18. package/scripts/__tests__/release-publish-ancestor.test.mjs +67 -0
  19. package/scripts/__tests__/release-verify-wait.test.mjs +169 -0
  20. package/scripts/__tests__/resolve-auto-release.test.mjs +216 -0
  21. package/scripts/__tests__/tfx-route-bash-node-parity.test.mjs +1 -1
  22. package/scripts/__tests__/tfx-route-node-entry.test.mjs +4 -4
  23. package/scripts/lib/agent-route-policy.mjs +18 -17
  24. package/scripts/lib/codex-profile-config.mjs +19 -5
  25. package/scripts/release/publish.mjs +16 -1
  26. package/scripts/release/resolve-auto-release.mjs +136 -0
  27. package/scripts/release/verify.mjs +74 -25
  28. package/scripts/setup.mjs +20 -7
  29. package/scripts/tfx-route.sh +27 -24
  30. package/skills/tfx-auto/SKILL.md +5 -4
  31. package/skills/tfx-live/SKILL.md +112 -7
  32. package/skills/tfx-profile/SKILL.md +9 -7
  33. package/skills/tfx-setup/SKILL.md +1 -1
  34. package/skills/tfx-ship/SKILL.md +43 -36
  35. package/tui/codex-profile.mjs +2 -3
  36. package/tui/setup.mjs +4 -3
  37. package/hub/team/git-preflight.mjs +0 -286
@@ -38,6 +38,8 @@ export function classifyBucket(bucket) {
38
38
 
39
39
  // primary/secondary를 window_minutes 기준으로 정규화
40
40
  // HUD 규약: primary=5h, secondary=1w
41
+ // window_minutes 가 비어 있으면 길이를 모르므로 보고된 칸에 둔다(app-server 스키마에서
42
+ // windowDurationMins 는 선택 필드). 길이를 알지만 5h/1w 가 아닌 버킷은 버린다.
41
43
  export function normalizeBuckets(rl) {
42
44
  let primary = null;
43
45
  let secondary = null;
@@ -46,6 +48,12 @@ export function normalizeBuckets(rl) {
46
48
  if (kind === "five_hour") primary = bucket;
47
49
  else if (kind === "weekly") secondary = bucket;
48
50
  }
51
+ if (!primary && rl.primary && rl.primary.window_minutes == null) {
52
+ primary = rl.primary;
53
+ }
54
+ if (!secondary && rl.secondary && rl.secondary.window_minutes == null) {
55
+ secondary = rl.secondary;
56
+ }
49
57
  return { primary, secondary };
50
58
  }
51
59
 
@@ -104,7 +112,35 @@ function isNewerSnapshot(candidate, current) {
104
112
  );
105
113
  }
106
114
 
107
- function selectCodexWindowSnapshot(snapshots, nowSec, windowKey) {
115
+ function positiveWindowMinutes(bucket) {
116
+ const minutes = Number(bucket?.window_minutes);
117
+ return minutes > 0 ? minutes : null;
118
+ }
119
+
120
+ // 계정 하나에는 같은 종류의 창이 한 번에 하나만 열린다. 브로커 계정이 없으면
121
+ // ~/.codex/sessions 와 HUD 라벨이 모두 ~/.codex/auth.json 계정 하나의 것이다. 그래서
122
+ // 세션 로그에 더 늦게 리셋되는 창이 나타났고 옛 창이 그 창이 열리기 전에만 관측됐다면,
123
+ // 옛 창은 조기 리셋(2026-09-23 실측)이나 계정 전환으로 끝난 창이다. 프로브로만 본 창은
124
+ // 다른 계정일 수 있어 근거로 쓰지 않는다. 새 창이 열린 뒤에도 관측된 창은 함께 도는
125
+ // 계정이므로 max-used 후보로 남긴다.
126
+ function isReplacedWindowGroup(group, other) {
127
+ if (other === group || !other.seenInSessions) return false;
128
+ if (other.minResetAt <= group.maxResetAt || !other.windowMinutes) {
129
+ return false;
130
+ }
131
+ const lastSeenMs = Date.parse(group.latest.timestamp);
132
+ if (!Number.isFinite(lastSeenMs)) return false;
133
+ // resets_at 은 실측으로 아래로 약 30초, 위로 1초 흔들리므로 가장 늦은 값으로 개시 시각을 잡는다.
134
+ const otherOpenedAtMs = (other.maxResetAt - other.windowMinutes * 60) * 1000;
135
+ return lastSeenMs <= otherOpenedAtMs;
136
+ }
137
+
138
+ function selectCodexWindowSnapshot(
139
+ snapshots,
140
+ nowSec,
141
+ windowKey,
142
+ replaceEndedWindows,
143
+ ) {
108
144
  const groups = [];
109
145
  const ungrouped = [];
110
146
 
@@ -126,19 +162,29 @@ function selectCodexWindowSnapshot(snapshots, nowSec, windowKey) {
126
162
  minResetAt: resetAt,
127
163
  maxResetAt: resetAt,
128
164
  latest: snapshot,
165
+ seenInSessions: !snapshot.probe,
166
+ windowMinutes: positiveWindowMinutes(snapshot[windowKey]),
129
167
  };
130
168
  groups.push(group);
131
169
  continue;
132
170
  }
133
171
 
172
+ if (!snapshot.probe) group.seenInSessions = true;
173
+ group.windowMinutes ??= positiveWindowMinutes(snapshot[windowKey]);
134
174
  group.minResetAt = Math.min(group.minResetAt, resetAt);
135
175
  group.maxResetAt = Math.max(group.maxResetAt, resetAt);
136
176
  if (isNewerSnapshot(snapshot, group.latest)) group.latest = snapshot;
137
177
  }
138
178
 
139
- const activeGroups = groups.filter(
179
+ const openGroups = groups.filter(
140
180
  (group) => Number(group.latest[windowKey]?.resets_at) > nowSec,
141
181
  );
182
+ const activeGroups = replaceEndedWindows
183
+ ? openGroups.filter(
184
+ (group) =>
185
+ !openGroups.some((other) => isReplacedWindowGroup(group, other)),
186
+ )
187
+ : openGroups;
142
188
  let selected = null;
143
189
  if (activeGroups.length > 0) {
144
190
  selected = activeGroups
@@ -168,16 +214,18 @@ function selectCodexWindowSnapshot(snapshots, nowSec, windowKey) {
168
214
  return { snapshot: selected, activeGroupCount: activeGroups.length };
169
215
  }
170
216
 
171
- function selectCodexSnapshot(snapshots, nowSec) {
217
+ function selectCodexSnapshot(snapshots, nowSec, replaceEndedWindows) {
172
218
  const primarySelection = selectCodexWindowSnapshot(
173
219
  snapshots,
174
220
  nowSec,
175
221
  "primary",
222
+ replaceEndedWindows,
176
223
  );
177
224
  const secondarySelection = selectCodexWindowSnapshot(
178
225
  snapshots,
179
226
  nowSec,
180
227
  "secondary",
228
+ replaceEndedWindows,
181
229
  );
182
230
  const primarySnapshot = primarySelection.snapshot;
183
231
  if (!primarySnapshot) return null;
@@ -198,7 +246,7 @@ function selectCodexSnapshot(snapshots, nowSec) {
198
246
  return selected;
199
247
  }
200
248
 
201
- function mergeRateLimitSnapshots(snapshots, nowSec) {
249
+ function mergeRateLimitSnapshots(snapshots, nowSec, replaceEndedWindows) {
202
250
  const merged = {};
203
251
  const codexSnapshots = [];
204
252
  for (const snapshot of snapshots) {
@@ -211,7 +259,11 @@ function mergeRateLimitSnapshots(snapshots, nowSec) {
211
259
  }
212
260
  }
213
261
 
214
- const codex = selectCodexSnapshot(codexSnapshots, nowSec);
262
+ const codex = selectCodexSnapshot(
263
+ codexSnapshots,
264
+ nowSec,
265
+ replaceEndedWindows,
266
+ );
215
267
  if (codex) merged.codex = codex;
216
268
  return merged;
217
269
  }
@@ -227,9 +279,16 @@ export function getCodexRateLimits({
227
279
  now = new Date(),
228
280
  maxLinesPerFile = 800,
229
281
  extraSnapshots = [],
282
+ // 교체된 창 판정(isReplacedWindowGroup). 브로커 Codex 계정이 있으면 끈다.
283
+ replaceEndedWindows = true,
230
284
  } = {}) {
231
285
  let syntheticBucket = null; // 최근 token_count에서 합성 (행 활성화 + 토큰 데이터용)
232
- const recentSnapshots = [...extraSnapshots];
286
+ // 프로브도 세션 로그와 같은 window_minutes 기준으로 5h/1w 슬롯을 정한다.
287
+ // app-server 는 5h 창이 없으면 주간 창을 primary 로 보낸다(2026-09-23 실측).
288
+ const recentSnapshots = extraSnapshots.map((snapshot) => ({
289
+ ...snapshot,
290
+ ...normalizeBuckets(snapshot),
291
+ }));
233
292
  const nowSec = Math.floor(now.getTime() / 1000);
234
293
 
235
294
  // 7일간 스캔: 실제 rate_limits 우선, 합성 버킷은 폴백
@@ -313,7 +372,11 @@ export function getCodexRateLimits({
313
372
  }
314
373
 
315
374
  const snapshots = dayOffset <= 1 ? recentSnapshots : daySnapshots;
316
- const mergedBuckets = mergeRateLimitSnapshots(snapshots, nowSec);
375
+ const mergedBuckets = mergeRateLimitSnapshots(
376
+ snapshots,
377
+ nowSec,
378
+ replaceEndedWindows,
379
+ );
317
380
  if (Object.keys(mergedBuckets).length > 0) {
318
381
  if (syntheticBucket) {
319
382
  const main =
@@ -369,10 +432,30 @@ export async function collectProbeSnapshots(now = new Date()) {
369
432
  );
370
433
  }
371
434
 
435
+ // 브로커 계정은 자기 CODEX_HOME 에서 돌기 때문에, 옛 창이 전환 전 계정의 창이어도 그
436
+ // 계정이 아직 쓰이고 있을 수 있다. accounts.json 의 codex 항목과 프로브용 인증 캐시로 판정한다.
437
+ export function hasBrokerCodexAccounts({
438
+ brokerDir = CODEX_BROKER_AUTH_CACHE_DIR,
439
+ } = {}) {
440
+ const accounts = readJson(join(brokerDir, "accounts.json"), null);
441
+ if (Array.isArray(accounts?.codex) && accounts.codex.length > 0) return true;
442
+ try {
443
+ return readdirSync(brokerDir).some((file) =>
444
+ /^codex-auth-.+\.json$/.test(file),
445
+ );
446
+ } catch {
447
+ return false;
448
+ }
449
+ }
450
+
372
451
  export async function refreshCodexRateLimitsCache() {
373
452
  const now = new Date();
374
453
  const extraSnapshots = await collectProbeSnapshots(now);
375
- const buckets = getCodexRateLimits({ now, extraSnapshots });
454
+ const buckets = getCodexRateLimits({
455
+ now,
456
+ extraSnapshots,
457
+ replaceEndedWindows: !hasBrokerCodexAccounts(),
458
+ });
376
459
  // buckets가 null이어도 캐시 갱신 (stale 데이터 제거)
377
460
  writeJsonSafe(CODEX_QUOTA_CACHE_PATH, { timestamp: Date.now(), buckets });
378
461
  return buckets;
@@ -1,5 +1,6 @@
1
1
  import { readFileSync, statSync } from "node:fs";
2
2
  import { join } from "node:path";
3
+ import { resolveRoleControlSnapshot } from "../../hub/lib/cto-env.mjs";
3
4
  import {
4
5
  CTO_LAKE_CURRENT_RELATIVE_PATH,
5
6
  CTO_STATUS_LINE_MAX_CHARS,
@@ -53,6 +54,8 @@ function extractStatusLine(lines) {
53
54
  }
54
55
 
55
56
  export function readCtoStatus(options = {}) {
57
+ if (!resolveRoleControlSnapshot(options.env).auto_collect_enabled)
58
+ return null;
56
59
  try {
57
60
  const currentPath = resolveCurrentPath(options);
58
61
  const stat = statSync(currentPath);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "triflux",
3
- "version": "10.45.0",
3
+ "version": "10.47.0",
4
4
  "description": "CLI-first multi-model orchestrator for Claude Code — route tasks to Codex, Antigravity, and Claude",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,4 +1,5 @@
1
1
  import assert from "node:assert/strict";
2
+ import { spawnSync } from "node:child_process";
2
3
  import { mkdirSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
3
4
  import { tmpdir } from "node:os";
4
5
  import { join } from "node:path";
@@ -279,15 +280,15 @@ describe("release governance scripts", () => {
279
280
  releaseWorkflow,
280
281
  /TFX_MACHINE_PROFILE_PATH:\s*\$\{\{ runner\.temp \}\}\/triflux-release-ci-machine-profile\.env/,
281
282
  );
282
- assert.match(releaseWorkflow, /publish\.mjs.*--skip-npm.*--allow-existing/);
283
- assert.match(releaseWorkflow, /publish\.mjs.*--tag-only/);
283
+ assert.match(releaseWorkflow, /--skip-npm.*--allow-existing/);
284
+ assert.match(releaseWorkflow, /--tag-only/);
284
285
  assert.match(
285
286
  releaseWorkflow,
286
- /gh workflow run npm-publish\.yml --ref \$\{\{ github\.ref_name \}\}/,
287
+ /gh workflow run npm-publish\.yml --ref "v\$RELEASE_VERSION"/,
287
288
  );
288
289
  assert.match(releaseWorkflow, /npm-publish\.yml/);
289
290
  assert.match(releaseWorkflow, /workflow_dispatch/);
290
- assert.match(releaseWorkflow, /publish_ref="\$\{\{ github\.ref_name \}\}"/);
291
+ assert.match(releaseWorkflow, /publish_ref="v\$RELEASE_VERSION"/);
291
292
  assert.match(releaseWorkflow, /gh run list/);
292
293
  assert.doesNotMatch(
293
294
  releaseWorkflow,
@@ -299,26 +300,98 @@ describe("release governance scripts", () => {
299
300
  "utf8",
300
301
  );
301
302
  assert.match(npmPublishWorkflow, /npm view "\$name@\$version" version/);
302
- assert.doesNotMatch(npmPublishWorkflow, /NODE_AUTH_TOKEN:\s*\$\{\{/);
303
+ assert.doesNotMatch(npmPublishWorkflow, /NPM_TOKEN|NODE_AUTH_TOKEN/);
304
+ assert.match(npmPublishWorkflow, /id-token:\s*write/);
305
+ assert.match(npmPublishWorkflow, /workflow_dispatch:\s+inputs:\s+npm_tag:/);
303
306
  assert.equal(
304
307
  (
305
308
  npmPublishWorkflow.match(
306
- /NPM_TOKEN:\s*\$\{\{\s*secrets\.NPM_TOKEN\s*\}\}/g,
309
+ /npm publish --provenance --access public --tag "\$NPM_TAG"/g,
307
310
  ) || []
308
311
  ).length,
309
312
  3,
310
313
  );
311
- assert.equal(
312
- (npmPublishWorkflow.match(/export NODE_AUTH_TOKEN="\$NPM_TOKEN"/g) || [])
313
- .length,
314
- 3,
315
- );
316
314
  assert.equal(
317
315
  (npmPublishWorkflow.match(/already published; skipping/g) || []).length,
318
316
  3,
319
317
  );
320
318
  });
321
319
 
320
+ it("auto releases only successful main push CI from this repository", () => {
321
+ const workflow = readFileSync(
322
+ new URL("../../.github/workflows/release.yml", import.meta.url),
323
+ "utf8",
324
+ );
325
+ assert.match(
326
+ workflow,
327
+ /workflow_run:\s+workflows: \[ci\]\s+types: \[completed\]\s+branches: \[main\]/,
328
+ );
329
+ assert.match(workflow, /github\.event_name == 'workflow_dispatch'/);
330
+ assert.match(
331
+ workflow,
332
+ /github\.event\.workflow_run\.conclusion == 'success'/,
333
+ );
334
+ assert.match(workflow, /github\.event\.workflow_run\.event == 'push'/);
335
+ assert.match(
336
+ workflow,
337
+ /github\.event\.workflow_run\.head_repository\.full_name == github\.repository/,
338
+ );
339
+ assert.match(
340
+ workflow,
341
+ /concurrency:\s+group: release\s+cancel-in-progress: false/,
342
+ );
343
+ assert.match(workflow, /ref:.*workflow_run\.head_sha.*github\.sha/);
344
+ assert.doesNotMatch(workflow, /^concurrency:/m);
345
+ assert.match(workflow, /^ {2}resolve:/m);
346
+ assert.match(
347
+ workflow,
348
+ /^ {2}release:\n {4}needs: resolve\n {4}if: needs\.resolve\.outputs\.release == 'true'/m,
349
+ );
350
+ assert.match(workflow, /ref: \$\{\{ needs\.resolve\.outputs\.sha \}\}/);
351
+ for (const output of [
352
+ "release",
353
+ "version",
354
+ "channel",
355
+ "mode",
356
+ "npm_tag",
357
+ "sha",
358
+ ]) {
359
+ assert.ok(
360
+ workflow.includes(`${output}: \${{ steps.resolve.outputs.${output} }}`),
361
+ );
362
+ }
363
+ assert.match(
364
+ workflow,
365
+ /if \[\[ "\$RELEASE_MODE" == "auto" \]\]; then\s+args\+=\(--allow-ancestor\)/,
366
+ );
367
+ assert.match(workflow, /fetch-depth: 0/);
368
+ assert.match(workflow, /fetch-tags: true/);
369
+ assert.match(workflow, /id: resolve/);
370
+ assert.match(workflow, /resolve-auto-release\.mjs/);
371
+ assert.match(
372
+ workflow,
373
+ /if \[\[ "\$RELEASE_MODE" == "auto" \]\]; then\s+args\+=\(--skip-tests\)/,
374
+ );
375
+ assert.match(workflow, /git rev-parse "\$publish_ref\^\{commit\}"/);
376
+ assert.match(workflow, /\.headBranch == \$publish_ref/);
377
+ assert.match(workflow, /\.headSha == \$sha/);
378
+ assert.match(workflow, /id: dispatch/);
379
+ assert.doesNotMatch(workflow, /started_at|createdAt|-120 seconds/);
380
+ assert.match(workflow, /BASH_REMATCH\[1\]/);
381
+ assert.match(workflow, /gh run view "\$run_id" --json status,conclusion/);
382
+ assert.match(workflow, /previous_ids/);
383
+ assert.match(workflow, /index\(\$id\)/);
384
+ assert.match(workflow, /gh workflow run npm-publish\.yml.*-f npm_tag=/);
385
+ assert.match(
386
+ workflow,
387
+ /NPM_TAG: \$\{\{ needs\.resolve\.outputs\.npm_tag \}\}/,
388
+ );
389
+ assert.match(
390
+ workflow,
391
+ /verify\.mjs.*--npm-wait-seconds 900 --npm-pending-ok/,
392
+ );
393
+ });
394
+
322
395
  it("verify reports explicit npm package/version failures", async () => {
323
396
  const root = makeRepo();
324
397
  try {
@@ -507,3 +580,154 @@ describe("release governance scripts", () => {
507
580
  }
508
581
  });
509
582
  });
583
+
584
+ function workflowStep(file, name) {
585
+ const workflow = readFileSync(
586
+ new URL(`../../.github/workflows/${file}`, import.meta.url),
587
+ "utf8",
588
+ );
589
+ const step = workflow
590
+ .split(` - name: ${name}\n`)[1]
591
+ ?.split(/\n {6}- name: /)[0];
592
+ assert.ok(step, `missing workflow step: ${name}`);
593
+ const script = step.split(" run: |\n")[1];
594
+ assert.ok(script, `missing shell script: ${name}`);
595
+ return script.replace(/^ {10}/gm, "");
596
+ }
597
+
598
+ it("waits for the dispatched run ID and excludes previous runs in the URL-less fallback", () => {
599
+ const root = mkdtempSync(join(tmpdir(), "tfx-dispatch-"));
600
+ const output = join(root, "output");
601
+ const calls = join(root, "calls");
602
+ const fakeCommands = `
603
+ gh() {
604
+ printf '%s\\n' "$*" >> "$GH_CALLS"
605
+ case "$1 $2" in
606
+ 'api --paginate') printf '41\\n' ;;
607
+ 'workflow run') printf '%s\\n' "$DISPATCH_REPLY" ;;
608
+ 'run list') printf '%s\\n' '[{"databaseId":41,"headBranch":"v1.2.3","headSha":"head"},{"databaseId":42,"headBranch":"v1.2.3","headSha":"head"},{"databaseId":43,"headBranch":"v1.2.3","headSha":"other"}]' ;;
609
+ 'run view')
610
+ [[ "$3" == 42 ]] || return 91
611
+ [[ "$4" == --log-failed ]] && return 0
612
+ printf '{"status":"completed","conclusion":"%s"}\\n' "$CONCLUSION" ;;
613
+ *) return 92 ;;
614
+ esac
615
+ }
616
+ git() { printf 'head\\n'; }
617
+ sleep() { return 93; }
618
+ `;
619
+ try {
620
+ for (const [reply, conclusion, status] of [
621
+ ["Created https://github.com/test/repo/actions/runs/42", "success", 0],
622
+ ["", "success", 0],
623
+ ["Created https://github.com/test/repo/actions/runs/42", "failure", 1],
624
+ ]) {
625
+ rmSync(output, { force: true });
626
+ rmSync(calls, { force: true });
627
+ const env = {
628
+ ...process.env,
629
+ GITHUB_OUTPUT: output,
630
+ GH_CALLS: calls,
631
+ GITHUB_REPOSITORY: "test/repo",
632
+ RELEASE_VERSION: "1.2.3",
633
+ NPM_TAG: "canary",
634
+ DISPATCH_REPLY: reply,
635
+ CONCLUSION: conclusion,
636
+ };
637
+ const dispatch = spawnSync(
638
+ "bash",
639
+ [
640
+ "-e",
641
+ "-o",
642
+ "pipefail",
643
+ "-c",
644
+ fakeCommands + workflowStep("release.yml", "Dispatch npm publish"),
645
+ ],
646
+ { env, encoding: "utf8" },
647
+ );
648
+ assert.equal(dispatch.status, 0, dispatch.stderr);
649
+ const outputs = Object.fromEntries(
650
+ readFileSync(output, "utf8")
651
+ .trim()
652
+ .split("\n")
653
+ .map((line) => [
654
+ line.slice(0, line.indexOf("=")),
655
+ line.slice(line.indexOf("=") + 1),
656
+ ]),
657
+ );
658
+ assert.deepEqual(JSON.parse(outputs.previous_ids), [41]);
659
+ assert.equal(outputs.run_id || "", reply ? "42" : "");
660
+ const wait = spawnSync(
661
+ "bash",
662
+ [
663
+ "-e",
664
+ "-o",
665
+ "pipefail",
666
+ "-c",
667
+ fakeCommands + workflowStep("release.yml", "Wait for npm publish"),
668
+ ],
669
+ {
670
+ env: {
671
+ ...env,
672
+ DISPATCH_RUN_ID: outputs.run_id || "",
673
+ PREVIOUS_RUN_IDS: outputs.previous_ids,
674
+ },
675
+ encoding: "utf8",
676
+ },
677
+ );
678
+ assert.equal(wait.status, status, wait.stderr);
679
+ const log = readFileSync(calls, "utf8");
680
+ assert.match(
681
+ log,
682
+ /workflow run npm-publish.yml --ref v1.2.3 -f npm_tag=canary/,
683
+ );
684
+ assert.match(log, /run view 42 --json status,conclusion/);
685
+ assert.doesNotMatch(log, /run view 41/);
686
+ assert.equal(log.includes("run list"), !reply);
687
+ assert.equal(log.includes("--log-failed"), conclusion === "failure");
688
+ }
689
+ } finally {
690
+ rmSync(root, { recursive: true, force: true });
691
+ }
692
+ });
693
+
694
+ it("resolves npm tags for dispatch and tag pushes and rejects unknown tags", () => {
695
+ const root = mkdtempSync(join(tmpdir(), "tfx-npm-tag-"));
696
+ const output = join(root, "env");
697
+ try {
698
+ for (const [input, version, expected] of [
699
+ ["", "1.2.3", "latest"],
700
+ ["", "1.2.3-rc.1", "canary"],
701
+ ["canary", "1.2.3", "canary"],
702
+ ["latest", "1.2.3-rc.1", "latest"],
703
+ ["invalid", "1.2.3", null],
704
+ ]) {
705
+ rmSync(output, { force: true });
706
+ const result = spawnSync(
707
+ "bash",
708
+ [
709
+ "-e",
710
+ "-o",
711
+ "pipefail",
712
+ "-c",
713
+ `node() { printf '%s\\n' "$TEST_VERSION"; }\n${workflowStep("npm-publish.yml", "Resolve npm tag")}`,
714
+ ],
715
+ {
716
+ env: {
717
+ ...process.env,
718
+ INPUT_NPM_TAG: input,
719
+ TEST_VERSION: version,
720
+ GITHUB_ENV: output,
721
+ },
722
+ encoding: "utf8",
723
+ },
724
+ );
725
+ assert.equal(result.status, expected ? 0 : 1, result.stderr);
726
+ if (expected)
727
+ assert.equal(readFileSync(output, "utf8"), `NPM_TAG=${expected}\n`);
728
+ else assert.match(result.stdout + result.stderr, /Invalid npm tag/);
729
+ }
730
+ } finally {
731
+ rmSync(root, { recursive: true, force: true });
732
+ }
733
+ });
@@ -0,0 +1,67 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+
4
+ import { publishRelease } from "../release/publish.mjs";
5
+
6
+ function commandStub({ ancestor = true } = {}) {
7
+ const commands = [];
8
+ const execFileSyncFn = (command, args) => {
9
+ commands.push([command, ...args].join(" "));
10
+ if (command === "git" && args[0] === "rev-parse") {
11
+ return args[1] === "HEAD" ? "older\n" : "newer\n";
12
+ }
13
+ if (command === "git" && args[0] === "merge-base") {
14
+ if (!ancestor) throw new Error("not an ancestor");
15
+ return "";
16
+ }
17
+ return "";
18
+ };
19
+ return { commands, execFileSyncFn };
20
+ }
21
+
22
+ function publishOptions(execFileSyncFn, allowAncestor) {
23
+ return {
24
+ rootDir: process.cwd(),
25
+ dryRun: false,
26
+ publishNpm: false,
27
+ pushBranch: false,
28
+ createGithubRelease: false,
29
+ execFileSyncFn,
30
+ allowAncestor,
31
+ };
32
+ }
33
+
34
+ describe("tag-only ancestor guard", () => {
35
+ it("allows a release commit that is an ancestor of the current branch", async () => {
36
+ const { commands, execFileSyncFn } = commandStub();
37
+ await publishRelease(publishOptions(execFileSyncFn, true));
38
+
39
+ assert.ok(
40
+ commands.some((command) =>
41
+ command.startsWith("git merge-base --is-ancestor HEAD origin/"),
42
+ ),
43
+ );
44
+ assert.ok(commands.some((command) => /^git push origin v\d/.test(command)));
45
+ });
46
+
47
+ it("rejects a release commit outside the current branch ancestry", async () => {
48
+ const { commands, execFileSyncFn } = commandStub({ ancestor: false });
49
+ await assert.rejects(
50
+ publishRelease(publishOptions(execFileSyncFn, true)),
51
+ /Refusing tag-only publish/,
52
+ );
53
+ assert.ok(!commands.some((command) => command.startsWith("git push")));
54
+ });
55
+
56
+ it("keeps the exact HEAD comparison without --allow-ancestor", async () => {
57
+ const { commands, execFileSyncFn } = commandStub();
58
+ await assert.rejects(
59
+ publishRelease(publishOptions(execFileSyncFn, false)),
60
+ /Refusing tag-only publish/,
61
+ );
62
+ assert.ok(
63
+ !commands.some((command) => command.startsWith("git merge-base")),
64
+ );
65
+ assert.ok(!commands.some((command) => command.startsWith("git push")));
66
+ });
67
+ });