triflux 10.45.0 → 10.46.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.
@@ -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
+ });
@@ -0,0 +1,169 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdtempSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { it } from "node:test";
6
+
7
+ import { writeJson } from "../release/lib.mjs";
8
+ import { verifyRelease } from "../release/verify.mjs";
9
+
10
+ function makeRepo() {
11
+ const root = mkdtempSync(join(tmpdir(), "tfx-verify-wait-"));
12
+ writeJson(join(root, "package.json"), { version: "1.2.3" });
13
+ writeJson(join(root, "scripts/release/version-manifest.json"), {
14
+ canonicalFile: "package.json",
15
+ canonicalPath: ["version"],
16
+ targets: [{ file: "package.json", paths: [["version"]] }],
17
+ });
18
+ return root;
19
+ }
20
+
21
+ it("waits for all three npm packages to show the release version", async () => {
22
+ const root = makeRepo();
23
+ let now = 0;
24
+ const sleeps = [];
25
+ const calls = new Map();
26
+ try {
27
+ const result = await verifyRelease({
28
+ rootDir: root,
29
+ version: "1.2.3",
30
+ dryRun: false,
31
+ npmWaitSeconds: 40,
32
+ npmPendingOk: true,
33
+ nowFn: () => now,
34
+ sleepFn: async (ms) => {
35
+ sleeps.push(ms);
36
+ now += ms;
37
+ },
38
+ execFileSyncFn: (command, args) => {
39
+ if (command === "gh") return '{"tagName":"v1.2.3"}';
40
+ assert.equal(command, "npm");
41
+ const count = (calls.get(args[1]) || 0) + 1;
42
+ calls.set(args[1], count);
43
+ return count === 1 ? "1.2.2\n" : "1.2.3\n";
44
+ },
45
+ });
46
+ assert.equal(result.ok, true);
47
+ assert.equal(
48
+ result.checks.some((check) => check.status === "pending"),
49
+ false,
50
+ );
51
+ assert.deepEqual(sleeps, [20_000]);
52
+ assert.deepEqual([...calls.values()], [2, 2, 2]);
53
+ } finally {
54
+ rmSync(root, { recursive: true, force: true });
55
+ }
56
+ });
57
+
58
+ it("passes with pending npm checks and emits one Actions warning at the deadline", async () => {
59
+ const root = makeRepo();
60
+ let now = 0;
61
+ const calls = new Map();
62
+ const warnings = [];
63
+ const originalActions = process.env.GITHUB_ACTIONS;
64
+ const originalLog = console.log;
65
+ process.env.GITHUB_ACTIONS = "true";
66
+ console.log = (message) => warnings.push(message);
67
+ try {
68
+ const result = await verifyRelease({
69
+ rootDir: root,
70
+ version: "1.2.3",
71
+ dryRun: false,
72
+ npmWaitSeconds: 20,
73
+ npmPendingOk: true,
74
+ nowFn: () => now,
75
+ sleepFn: async (ms) => {
76
+ now += ms;
77
+ },
78
+ execFileSyncFn: (command, args) => {
79
+ if (command === "gh") return '{"tagName":"v1.2.3"}';
80
+ const count = (calls.get(args[1]) || 0) + 1;
81
+ calls.set(args[1], count);
82
+ if (args[1].startsWith("@triflux/core")) return "1.2.3\n";
83
+ if (args[1].startsWith("@triflux/remote") && count === 2) {
84
+ throw new Error("registry timeout");
85
+ }
86
+ return args[1].startsWith("@triflux/remote") ? "1.2.2\n" : "1.2.1\n";
87
+ },
88
+ });
89
+ assert.equal(result.ok, true);
90
+ assert.deepEqual([...calls.values()], [2, 2, 2]);
91
+ assert.deepEqual(
92
+ result.checks
93
+ .filter((check) => check.status === "pending")
94
+ .map((check) => check.name),
95
+ ["npm-view @triflux/remote", "npm-view triflux"],
96
+ );
97
+ assert.match(
98
+ result.checks.find((check) => check.name === "npm-view @triflux/remote")
99
+ .detail,
100
+ /last seen 1\.2\.2; npm publish-time scan may still be processing/,
101
+ );
102
+ assert.match(
103
+ result.checks.find((check) => check.name === "npm-view triflux").detail,
104
+ /last seen 1\.2\.1; npm publish-time scan may still be processing/,
105
+ );
106
+ assert.equal(
107
+ result.checks.find((check) => check.name === "github-release").ok,
108
+ true,
109
+ );
110
+ assert.equal(warnings.length, 1);
111
+ assert.match(warnings[0], /^::warning::npm registry pending/);
112
+ } finally {
113
+ console.log = originalLog;
114
+ if (originalActions === undefined) delete process.env.GITHUB_ACTIONS;
115
+ else process.env.GITHUB_ACTIONS = originalActions;
116
+ rmSync(root, { recursive: true, force: true });
117
+ }
118
+ });
119
+
120
+ it("fails at one shared deadline and reports last seen versions after lookup errors", async () => {
121
+ const root = makeRepo();
122
+ let now = 0;
123
+ const sleeps = [];
124
+ const calls = new Map();
125
+ try {
126
+ const result = await verifyRelease({
127
+ rootDir: root,
128
+ version: "1.2.3",
129
+ dryRun: false,
130
+ npmWaitSeconds: 20,
131
+ nowFn: () => now,
132
+ sleepFn: async (ms) => {
133
+ sleeps.push(ms);
134
+ now += ms;
135
+ },
136
+ execFileSyncFn: (command, args) => {
137
+ if (command === "gh") return '{"tagName":"v1.2.3"}';
138
+ assert.equal(command, "npm");
139
+ const count = (calls.get(args[1]) || 0) + 1;
140
+ calls.set(args[1], count);
141
+ if (args[1].startsWith("@triflux/remote") && count === 2) {
142
+ throw new Error("registry timeout");
143
+ }
144
+ return args[1].startsWith("triflux") ? "1.2.3\n" : "1.2.2\n";
145
+ },
146
+ });
147
+ assert.equal(result.ok, false);
148
+ assert.deepEqual(sleeps, [20_000]);
149
+ assert.deepEqual([...calls.values()], [2, 2, 2]);
150
+ assert.match(
151
+ result.checks.find((check) => check.name === "npm-view @triflux/core")
152
+ .detail,
153
+ /got 1\.2\.2/,
154
+ );
155
+ assert.match(
156
+ result.checks.find((check) => check.name === "npm-view @triflux/remote")
157
+ .detail,
158
+ /last seen 1\.2\.2/,
159
+ );
160
+ } finally {
161
+ rmSync(root, { recursive: true, force: true });
162
+ }
163
+ });
164
+
165
+ it("rejects invalid npm wait durations", async () => {
166
+ for (const npmWaitSeconds of [-1, Number.POSITIVE_INFINITY, "oops"]) {
167
+ await assert.rejects(verifyRelease({ npmWaitSeconds }), /npm-wait-seconds/);
168
+ }
169
+ });