@intentius/chant 0.124.33 → 0.124.34

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 (75) hide show
  1. package/dist/cli/handlers/promote.d.ts.map +1 -1
  2. package/dist/cli/handlers/promote.js +261 -73
  3. package/dist/cli/main.d.ts.map +1 -1
  4. package/dist/cli/main.js +18 -1
  5. package/dist/cli/parse-args.d.ts.map +1 -1
  6. package/dist/cli/parse-args.js +7 -0
  7. package/dist/cli/registry.d.ts +3 -1
  8. package/dist/cli/registry.d.ts.map +1 -1
  9. package/dist/components/promote-rounds.d.ts +102 -0
  10. package/dist/components/promote-rounds.d.ts.map +1 -0
  11. package/dist/components/promote-rounds.js +174 -0
  12. package/dist/components/release-gate.d.ts +30 -0
  13. package/dist/components/release-gate.d.ts.map +1 -1
  14. package/dist/components/release-gate.js +92 -42
  15. package/dist/graph-declared.d.ts +4 -0
  16. package/dist/graph-declared.d.ts.map +1 -1
  17. package/dist/graph-declared.js +11 -1
  18. package/dist/wave-runtime.mjs +39 -39
  19. package/dist/workspace/__fixtures__/contract-repo.d.ts.map +1 -1
  20. package/dist/workspace/__fixtures__/contract-repo.js +13 -2
  21. package/dist/workspace/check.schema.json +20 -2
  22. package/dist/workspace/checks/ci-workflow.d.ts +17 -0
  23. package/dist/workspace/checks/ci-workflow.d.ts.map +1 -0
  24. package/dist/workspace/checks/ci-workflow.js +123 -0
  25. package/dist/workspace/checks/security.d.ts +16 -1
  26. package/dist/workspace/checks/security.d.ts.map +1 -1
  27. package/dist/workspace/checks/security.js +4 -4
  28. package/dist/workspace/checks.d.ts +1 -1
  29. package/dist/workspace/checks.d.ts.map +1 -1
  30. package/dist/workspace/checks.js +4 -1
  31. package/dist/workspace/ci-init.d.ts +100 -0
  32. package/dist/workspace/ci-init.d.ts.map +1 -0
  33. package/dist/workspace/ci-init.js +434 -0
  34. package/dist/workspace/ci-workflow.d.ts +91 -0
  35. package/dist/workspace/ci-workflow.d.ts.map +1 -0
  36. package/dist/workspace/ci-workflow.js +33 -0
  37. package/dist/workspace/declaration.d.ts +18 -0
  38. package/dist/workspace/declaration.d.ts.map +1 -1
  39. package/dist/workspace/declaration.js +9 -2
  40. package/dist/workspace/declaration.schema.json +28 -0
  41. package/dist/workspace/init.js +2 -2
  42. package/dist/workspace/reason-codes.d.ts +9 -0
  43. package/dist/workspace/reason-codes.d.ts.map +1 -1
  44. package/dist/workspace/reason-codes.js +10 -0
  45. package/dist/workspace/release-workflow.d.ts +31 -0
  46. package/dist/workspace/release-workflow.d.ts.map +1 -0
  47. package/dist/workspace/release-workflow.js +150 -0
  48. package/package.json +1 -1
  49. package/src/cli/handlers/promote.ts +280 -83
  50. package/src/cli/main.ts +18 -1
  51. package/src/cli/parse-args.ts +5 -0
  52. package/src/cli/registry.ts +3 -1
  53. package/src/components/promote-rounds.test.ts +220 -0
  54. package/src/components/promote-rounds.ts +232 -0
  55. package/src/components/release-gate.test.ts +47 -0
  56. package/src/components/release-gate.ts +111 -46
  57. package/src/graph-declared-exports.test.ts +40 -0
  58. package/src/graph-declared.ts +9 -1
  59. package/src/workspace/__fixtures__/contract-repo.ts +11 -2
  60. package/src/workspace/check-contract.test.ts +3 -2
  61. package/src/workspace/check.schema.json +20 -2
  62. package/src/workspace/checks/ci-workflow.test.ts +150 -0
  63. package/src/workspace/checks/ci-workflow.ts +129 -0
  64. package/src/workspace/checks/security.ts +4 -4
  65. package/src/workspace/checks.ts +4 -1
  66. package/src/workspace/ci-init.test.ts +159 -0
  67. package/src/workspace/ci-init.ts +445 -0
  68. package/src/workspace/ci-report-url.test.ts +6 -1
  69. package/src/workspace/ci-workflow.ts +71 -0
  70. package/src/workspace/declaration.schema.json +28 -0
  71. package/src/workspace/declaration.ts +32 -3
  72. package/src/workspace/init.ts +2 -2
  73. package/src/workspace/reason-codes.test.ts +2 -0
  74. package/src/workspace/reason-codes.ts +10 -0
  75. package/src/workspace/release-workflow.ts +167 -0
@@ -44,6 +44,8 @@ const repoTemplate = repoFixture(() => {
44
44
  git(root, "symbolic-ref", "HEAD", "refs/heads/main");
45
45
  git(root, "config", "user.name", "t");
46
46
  git(root, "config", "user.email", "t@example.com");
47
+ git(root, "config", "gc.auto", "0");
48
+ git(root, "config", "maintenance.auto", "false");
47
49
  writeFiles(root, { "chant.workspace.json": declaration(MEMBERS), ".gitignore": ".chant/pr/\n", "app/a.txt": "a\n" });
48
50
  const commit = (msg: string) => {
49
51
  execFileSync("git", ["-c", "commit.gpgsign=false", "add", "-A"], { cwd: root, stdio: "ignore" });
@@ -77,10 +79,13 @@ async function cli(cwd: string, argv: string[], run: (ctx: CommandContext) => Pr
77
79
  return { code, out: out.join("\n"), err: err.join("\n") };
78
80
  }
79
81
 
82
+ // A job image of its own, so the job gets no setup lines: the default image's
83
+ // OpenTofu install downloads from the network on a runner without tofu, as
84
+ // in ci-run.test.ts.
80
85
  const runPlan = (w: ReturnType<typeof workspace>, extra: string[] = []) =>
81
86
  cli(
82
87
  w.app,
83
- ["ci", "run", "plan", "--env", "prod", "--base", w.base, "--pr", "7", "--forge", "forgejo", "--no-status", "--chant", w.chant, "-o", join(scratchDir("chant-report-url-out-"), "pr"), ...extra],
88
+ ["ci", "run", "plan", "--image", "chant-ci-report-url-test:local", "--env", "prod", "--base", w.base, "--pr", "7", "--forge", "forgejo", "--no-status", "--chant", w.chant, "-o", join(scratchDir("chant-report-url-out-"), "pr"), ...extra],
84
89
  runCiRun,
85
90
  );
86
91
 
@@ -0,0 +1,71 @@
1
+ /**
2
+ * The prescribed delivery workflows (#4168 section 7): `ci.workflow` in the
3
+ * declaration, at the top level or on a member, which wins.
4
+ *
5
+ * `trunk-curated`: a merge records the plan and deploys nothing (#4168 4.1),
6
+ * the quick checks run before the branch moves (4.4), and a release is cut
7
+ * from a green commit and promoted through the member's environments, gated
8
+ * in the last (4.2). `trunk-curated+apply-on-merge` is the same with the apply
9
+ * on merge turned back on (section 5).
10
+ *
11
+ * A workflow requires no security group: those are opt-in on their own
12
+ * (./security.ts). `chant workspace check` reports what a member under a
13
+ * workflow still lacks (./checks/ci-workflow.ts, WSP151), and `chant ci
14
+ * init` writes what it can. {@link CI_WORKFLOW_REQUIREMENTS} is the table the
15
+ * reference page prints.
16
+ */
17
+ import type { CiWorkflow, Declaration, Member } from "./declaration";
18
+
19
+ /** The marker a component stub `chant ci init` writes carries until someone fills it in. WSP151 reports a member whose only components carry it. */
20
+ export const STUB_MARKER = "TODO(chant ci init)";
21
+
22
+ export const CI_WORKFLOWS = ["trunk-curated", "trunk-curated+apply-on-merge"] as const satisfies readonly CiWorkflow[];
23
+
24
+ /** Whether `workflow` turns the apply on merge back on. */
25
+ export function appliesOnMerge(workflow: CiWorkflow): boolean {
26
+ return workflow === "trunk-curated+apply-on-merge";
27
+ }
28
+
29
+ /** The workflow `member` follows: its own `ci.workflow`, else the top level's, else null. */
30
+ export function workflowOf(declaration: Pick<Declaration, "ci">, member: Pick<Member, "ciWorkflow"> | null): CiWorkflow | null {
31
+ return member?.ciWorkflow ?? declaration.ci?.workflow ?? null;
32
+ }
33
+
34
+ /** One requirement of the workflows, with the `workspace check` code that reports it missing. */
35
+ export interface CiWorkflowRequirement {
36
+ /** The reason code WSP151 reports. */
37
+ code: string;
38
+ /** What the workflow needs. */
39
+ requirement: string;
40
+ /** What adds it. */
41
+ fix: string;
42
+ /** Whether it applies per member (true) or once for the workspace. */
43
+ perMember: boolean;
44
+ }
45
+
46
+ /**
47
+ * Every requirement, in the order `workspace check` reports them. The
48
+ * reference page's table is printed from this list (`ciWorkflowTable`), and a
49
+ * test compares the two.
50
+ */
51
+ export const CI_WORKFLOW_REQUIREMENTS = [
52
+ { code: "ci-workflow-green-undeclared", requirement: "`ci.green` is declared, so commits get a verdict and a release is cut only from a green one", fix: "`chant ci init`, or a hand-written `ci.green` block", perMember: false },
53
+ { code: "ci-workflow-no-pre-merge", requirement: "`ci.green.preMerge` names at least one phase, the quick checks that run before the branch moves", fix: "`preMerge` in `ci.green`", perMember: false },
54
+ { code: "ci-workflow-apply-in-green", requirement: "no phase in `ci.green.require` matches an apply job, which would wait on its own verdict", fix: "drop the apply job's pattern from the required phases", perMember: false },
55
+ { code: "ci-workflow-no-components", requirement: "the member declares a component (`*.component.ts`)", fix: "`chant ci init` writes a stub", perMember: true },
56
+ { code: "ci-workflow-no-environments", requirement: "the member's `chant.config.ts` declares `environments`, the promotion order", fix: "`environments` in `chant.config.ts`", perMember: true },
57
+ { code: "ci-workflow-ungated-env", requirement: "each component has a gate, so a promote to the last environment waits on an approval", fix: "a `gate()` step in the component", perMember: true },
58
+ { code: "ci-workflow-pipeline-missing", requirement: "a pipeline file runs the plan job, the merge job (`ci run record`, or `ci run apply` under apply on merge) and `chant ci tick`", fix: "`chant build --components --generate <forge> --pr-loop` and `chant ci workflow`", perMember: false },
59
+ { code: "ci-workflow-pipeline-differs", requirement: "the merge job matches the workflow: it records under `trunk-curated` and applies under `trunk-curated+apply-on-merge`", fix: "regenerate the pull-request pipeline, with `--apply-on-merge` only under apply on merge", perMember: false },
60
+ { code: "ci-workflow-publish-missing", requirement: "a pipeline file runs `chant release publish`", fix: "`chant release workflow`", perMember: false },
61
+ ] as const satisfies readonly CiWorkflowRequirement[];
62
+
63
+ export type CiWorkflowCode = (typeof CI_WORKFLOW_REQUIREMENTS)[number]["code"];
64
+
65
+ export const CI_WORKFLOW_FINDING_CODES = CI_WORKFLOW_REQUIREMENTS.map((r) => r.code) as readonly CiWorkflowCode[];
66
+
67
+ /** The requirements table the reference page carries, row for row. */
68
+ export function ciWorkflowTable(): string {
69
+ const rows = CI_WORKFLOW_REQUIREMENTS.map((r) => `| \`${r.code}\` | ${r.requirement} | ${r.perMember ? "each member" : "the workspace"} | ${r.fix} |`);
70
+ return ["| Code | Requirement | Checked for | Added by |", "|---|---|---|---|", ...rows].join("\n");
71
+ }
@@ -178,6 +178,9 @@
178
178
  "green": {
179
179
  "$ref": "#/$defs/ciGreen"
180
180
  },
181
+ "workflow": {
182
+ "$ref": "#/$defs/ciWorkflow"
183
+ },
181
184
  "reportUrl": {
182
185
  "type": "string",
183
186
  "minLength": 1,
@@ -491,6 +494,19 @@
491
494
  "minLength": 1,
492
495
  "description": "Where an embedded member's files come from. Versions live in the lock."
493
496
  },
497
+ "ci": {
498
+ "type": "object",
499
+ "additionalProperties": false,
500
+ "patternProperties": {
501
+ "^x-": true
502
+ },
503
+ "description": "This member's own CI settings (#4168 step 6). Added in schema 1 by chant 0.124.34, so a declaration that uses it sets minReader to 0.124.34 or newer.",
504
+ "properties": {
505
+ "workflow": {
506
+ "$ref": "#/$defs/ciWorkflow"
507
+ }
508
+ }
509
+ },
494
510
  "travel": {
495
511
  "type": "boolean",
496
512
  "description": "Whether the member goes with an export (#2524 D10, #2552). chant workspace export writes only members that set travel to true into the export member, and refuses to name one that does not. Added in schema 1 by chant 0.101.0, so a declaration that uses it sets minReader to 0.101.0 or newer."
@@ -1536,6 +1552,14 @@
1536
1552
  "$ref": "#/$defs/ciPhaseName"
1537
1553
  }
1538
1554
  },
1555
+ "preMerge": {
1556
+ "type": "array",
1557
+ "uniqueItems": true,
1558
+ "description": "The phases that must pass before the branch moves (#4168 4.4), by name: the quick checks a pull request's checks, a merge queue, chant ci serve landing a branch, or chant ci run --local give before a change lands. Each must be a declared phase. The trunk-curated workflow needs at least one. Added in schema 1 by chant 0.124.34, so a declaration that uses it sets minReader to 0.124.34 or newer.",
1559
+ "items": {
1560
+ "$ref": "#/$defs/ciPhaseName"
1561
+ }
1562
+ },
1539
1563
  "signedSince": {
1540
1564
  "type": "string",
1541
1565
  "pattern": "^[0-9a-f]{40}([0-9a-f]{24})?$",
@@ -1554,6 +1578,10 @@
1554
1578
  },
1555
1579
  "additionalProperties": false
1556
1580
  },
1581
+ "ciWorkflow": {
1582
+ "enum": ["trunk-curated", "trunk-curated+apply-on-merge"],
1583
+ "description": "A prescribed delivery workflow (#4168). trunk-curated: a merge records the plan and deploys nothing; releases are cut from green commits and promoted through each member's environments, gated in the last. trunk-curated+apply-on-merge: the same, with the apply on merge turned back on. chant workspace check reports what a member under one still lacks (WSP151), and chant ci init writes it. At the top level for every member, or on a member, which wins. Added in schema 1 by chant 0.124.34, so a declaration that uses it sets minReader to 0.124.34 or newer."
1584
+ },
1557
1585
  "ciPhaseName": {
1558
1586
  "type": "string",
1559
1587
  "pattern": "^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$"
@@ -221,6 +221,12 @@ export interface CiGreen {
221
221
  phases: CiPhase[];
222
222
  /** The phases a commit must pass, by name, in file order. */
223
223
  require: string[];
224
+ /**
225
+ * The phases that must pass before the branch moves (#4168 4.4), by name,
226
+ * in file order: the quick checks a pull request, a merge queue or a local
227
+ * run gives before a change lands. Absent or empty when the declaration names none.
228
+ */
229
+ preMerge?: string[];
224
230
  /**
225
231
  * The full sha of the first commit on the branch whose ci tags need a
226
232
  * statement signed by a runner key (#4031). A tag on a strict
@@ -244,9 +250,20 @@ export interface CiGreen {
244
250
  /** Where `ci tick` reads phase results (#4044): forge check runs, or chant Op runs. */
245
251
  export type CiSource = "checks" | "runs";
246
252
 
253
+ /**
254
+ * A prescribed delivery workflow (#4168 section 7). `trunk-curated`: a merge
255
+ * records the plan and deploys nothing, and releases are cut from green
256
+ * commits and promoted through the environments. `trunk-curated+apply-on-merge`
257
+ * adds the apply on merge as an opt-in. `chant workspace check` reports what a
258
+ * member under one still lacks (./checks/ci-workflow.ts, WSP151).
259
+ */
260
+ export type CiWorkflow = "trunk-curated" | "trunk-curated+apply-on-merge";
261
+
247
262
  /** The declaration's `ci` block (#3573). */
248
263
  export interface CiDeclaration {
249
264
  green: CiGreen | null;
265
+ /** `ci.workflow` (#4168 step 6): the workflow every member follows unless it names its own; absent when the declaration sets none. */
266
+ workflow?: CiWorkflow;
250
267
  /**
251
268
  * `ci.reportUrl` (#4160): where the pull-request jobs copy their reports
252
269
  * and `chant ci run` keeps the run index, `<reportUrl>/runs/index.json`.
@@ -573,6 +590,8 @@ export interface Member {
573
590
  travel: boolean;
574
591
  /** The security groups this member adds to the declaration's top-level `security` (#4168 step 7), in file order; empty when it names none. */
575
592
  security: SecurityGroup[];
593
+ /** The member's own `ci.workflow` (#4168 step 6), which wins over the top level's; null when it names none. */
594
+ ciWorkflow: CiWorkflow | null;
576
595
  suppress: Suppression[];
577
596
  /** The entry's JSON Pointer in the file, for messages. */
578
597
  pointer: string;
@@ -869,6 +888,7 @@ export function parseDeclaration(text: string, file: string, reader: string = re
869
888
  because: (e.because as string | undefined) ?? null,
870
889
  travel: e.travel === true,
871
890
  security: [...((e.security as SecurityGroup[] | undefined) ?? [])],
891
+ ciWorkflow: ((e.ci as { workflow?: CiWorkflow } | undefined)?.workflow) ?? null,
872
892
  suppress,
873
893
  pointer,
874
894
  };
@@ -1116,7 +1136,8 @@ function retentionOf(raw: unknown): RetentionOverride | null {
1116
1136
 
1117
1137
  function ciOf(raw: unknown, at: (pointer: string, key?: boolean) => ErrorLocation): CiDeclaration | null {
1118
1138
  if (raw === undefined) return null;
1119
- const block = raw as { reportUrl?: string; green?: { branch: string; window?: string; phases: Record<string, string[] | { runs: string[]; skipped?: CiSkipped; build?: CiBuild }>; require: string[]; signedSince?: string; source?: CiSource } };
1139
+ const block = raw as { reportUrl?: string; workflow?: CiWorkflow; green?: { branch: string; window?: string; phases: Record<string, string[] | { runs: string[]; skipped?: CiSkipped; build?: CiBuild }>; require: string[]; preMerge?: string[]; signedSince?: string; source?: CiSource } };
1140
+ const workflow = block.workflow !== undefined ? { workflow: block.workflow } : {};
1120
1141
  let reportUrl: string | null = null;
1121
1142
  if (block.reportUrl !== undefined) {
1122
1143
  // #4160: refused here as pr-plan and ci run refuse the flag, so a reader never gets a URL no job can write to.
@@ -1124,7 +1145,7 @@ function ciOf(raw: unknown, at: (pointer: string, key?: boolean) => ErrorLocatio
1124
1145
  if ("error" in target) throw new WorkspaceReadError("declaration-invalid", target.error, at("/ci/reportUrl"));
1125
1146
  reportUrl = target.url;
1126
1147
  }
1127
- if (block.green === undefined) return { green: null, reportUrl };
1148
+ if (block.green === undefined) return { green: null, ...workflow, reportUrl };
1128
1149
  const g = block.green;
1129
1150
  const phases: CiPhase[] = Object.entries(g.phases).map(([name, p]) => ({
1130
1151
  name,
@@ -1141,7 +1162,15 @@ function ciOf(raw: unknown, at: (pointer: string, key?: boolean) => ErrorLocatio
1141
1162
  at(`/ci/green/require/${i}`),
1142
1163
  );
1143
1164
  }
1144
- return { green: { branch: g.branch, window: g.window ?? DEFAULT_CI_WINDOW, phases, require: [...g.require], signedSince: g.signedSince ?? null, source: g.source ?? "checks", pointer: "/ci/green" }, reportUrl };
1165
+ for (const [i, name] of (g.preMerge ?? []).entries()) {
1166
+ if (phases.some((p) => p.name === name)) continue;
1167
+ throw new WorkspaceReadError(
1168
+ "declaration-invalid",
1169
+ `ci.green.preMerge names the phase ${JSON.stringify(name)}, which ci.green.phases does not declare; declared phases: ${phases.map((p) => p.name).join(", ")}`,
1170
+ at(`/ci/green/preMerge/${i}`),
1171
+ );
1172
+ }
1173
+ return { green: { branch: g.branch, window: g.window ?? DEFAULT_CI_WINDOW, phases, require: [...g.require], ...(g.preMerge !== undefined ? { preMerge: [...g.preMerge] } : {}), signedSince: g.signedSince ?? null, source: g.source ?? "checks", pointer: "/ci/green" }, ...workflow, reportUrl };
1145
1174
  }
1146
1175
 
1147
1176
  function agentsOf(raw: unknown, members: Member[], at: (pointer: string, key?: boolean) => ErrorLocation): AgentDeclaration[] {
@@ -559,7 +559,7 @@ export async function runWorkspaceInit(ctx: CommandContext): Promise<number> {
559
559
  console.error(
560
560
  formatError({
561
561
  message: `${join(root, existing[0])} already exists`,
562
- hint: "Edit it by hand, or run chant workspace ls to see what it declares.",
562
+ hint: "Edit it by hand, or run chant workspace ls to see what it declares. To set it up for a delivery workflow, run chant ci init --workflow trunk-curated.",
563
563
  }),
564
564
  );
565
565
  return 1;
@@ -612,7 +612,7 @@ export async function runWorkspaceInit(ctx: CommandContext): Promise<number> {
612
612
  return 0;
613
613
  }
614
614
  writeFileSync(target, text, { flag: "wx" });
615
- console.log(formatSuccess(`Wrote ${target}. chant workspace ls lists what it declares.`));
615
+ console.log(formatSuccess(`Wrote ${target}. chant workspace ls lists what it declares, and chant ci init --workflow trunk-curated sets up merging and releases.`));
616
616
  return 0;
617
617
  }
618
618
 
@@ -29,6 +29,7 @@ import { WORK_WARNING_CODES } from "./work";
29
29
  import { BOX_FINDING_CODES } from "./checks/boxes";
30
30
  import { DIAGRAM_FINDING_CODES } from "./checks/diagrams";
31
31
  import { SECURITY_FINDING_CODES } from "./checks/security";
32
+ import { CI_WORKFLOW_FINDING_CODES } from "./ci-workflow";
32
33
  import { WORK_ERROR_CODES, WORK_HISTORY_ERROR_CODES, WORK_LEASE_REFUSALS } from "./work-cli";
33
34
  import { RUN_STATEMENT_ERROR_CODES, RUNS_ERROR_CODES, RUNS_REASON_CODES, RUNS_WRITE_ERROR_CODES } from "./runs-cli";
34
35
  import { RUN_STATEMENT_FAILURE_CODES } from "./trust/run-statement";
@@ -68,6 +69,7 @@ const PER_COMMAND: Record<string, readonly string[]> = {
68
69
  BOX_FINDING_CODES,
69
70
  DIAGRAM_FINDING_CODES,
70
71
  SECURITY_FINDING_CODES,
72
+ CI_WORKFLOW_FINDING_CODES,
71
73
  STATUS_REASON_CODES,
72
74
  STATUS_ERROR_CODES,
73
75
  STATUS_GATE_REASON_CODES,
@@ -247,6 +247,16 @@ export const REASONS = {
247
247
  "security-runner-start-no-key": "Under isolated-runners, .chant/trust.json lists no key under runners for a provisioner to sign runner-start statements with.",
248
248
  "security-runner-start-no-role": "Under isolated-runners, no principal under runners in .chant/trust.json holds the runner role.",
249
249
  "security-trust-unreadable": "Under isolated-runners, .chant/trust.json can't be read, so no runner-start statement can be checked.",
250
+ // The ci.workflow requirements (check, #4168 step 6). Each is also a WSP151 finding.
251
+ "ci-workflow-green-undeclared": "Under ci.workflow, the declaration has no ci.green block, so no commit gets a verdict and no release can be cut.",
252
+ "ci-workflow-no-pre-merge": "Under ci.workflow, ci.green.preMerge names no phase, so nothing says which checks run before the branch moves.",
253
+ "ci-workflow-apply-in-green": "Under ci.workflow, a phase in ci.green.require matches an apply job, which would wait on its own verdict.",
254
+ "ci-workflow-no-components": "Under ci.workflow, a member declares no component, so nothing of it can be cut or promoted.",
255
+ "ci-workflow-no-environments": "Under ci.workflow, a member's chant.config.ts declares no environments, so a release has nowhere to be promoted to.",
256
+ "ci-workflow-ungated-env": "Under ci.workflow, a component has no gate, so a promote to the last environment waits on no approval.",
257
+ "ci-workflow-pipeline-missing": "Under ci.workflow, no pipeline file runs the plan job, the merge job or chant ci tick.",
258
+ "ci-workflow-pipeline-differs": "Under ci.workflow, the merge job does not match the workflow: it applies under trunk-curated, or records under trunk-curated+apply-on-merge.",
259
+ "ci-workflow-publish-missing": "Under ci.workflow, no pipeline file runs chant release publish.",
250
260
  // A box that holds a credential (check, #2726). Each is also a WSP finding.
251
261
  "box-credential-declared": "A file in a box member's directory carries a literal secret: a credential's shape, or a literal where a credential goes. A variable or secret-manager reference is not one.",
252
262
  "box-capability-unbrokered": "A capability in a member's box block names no broker, so the box would hold its credential.",
@@ -0,0 +1,167 @@
1
+ /**
2
+ * `chant release workflow` (#4168 step 6): the publish workflow, generated
3
+ * the way `chant ci workflow` writes the tick, where the guides used to have
4
+ * one pasted (guide/release-from-workstation.mdx).
5
+ *
6
+ * The job runs on a `release/**` tag, checks the tag out, fetches the ci tags
7
+ * and `chant/lifecycle`, builds what the targets upload, and runs `chant
8
+ * release publish` for the label the tag names. The security groups decide
9
+ * the rest, and nothing is on without one (#4168 section 8):
10
+ *
11
+ * - `supply-chain` adds `--require-provenance`, so a run with no OIDC token
12
+ * fails rather than publishing without provenance.
13
+ * - `verified-releases` adds a `chant release verify` step before the publish.
14
+ *
15
+ * The OIDC permission (`id-token: write`, or GitLab's `id_tokens`) is always
16
+ * there: it costs nothing, and with it what the job uploads carries
17
+ * provenance whether or not the group requires it.
18
+ */
19
+ import { GENERATED_MARKER } from "../discovery/files";
20
+ import type { SecurityGroup } from "./security";
21
+
22
+ export const RELEASE_WORKFLOW_FORGES = ["github", "forgejo", "gitlab"] as const;
23
+ export type ReleaseWorkflowForge = (typeof RELEASE_WORKFLOW_FORGES)[number];
24
+
25
+ /** Where each forge's publish workflow is written, relative to the repository root. */
26
+ export const RELEASE_WORKFLOW_FILES: Record<ReleaseWorkflowForge, string> = {
27
+ github: ".github/workflows/chant-release.yml",
28
+ forgejo: ".forgejo/workflows/chant-release.yml",
29
+ // GitLab reads one .gitlab-ci.yml, which includes this file: `include: { local: .gitlab/chant-release.yml }`.
30
+ gitlab: ".gitlab/chant-release.yml",
31
+ };
32
+
33
+ export interface ReleaseWorkflowInput {
34
+ forge: ReleaseWorkflowForge;
35
+ /** The security groups the workspace opts into at its root. */
36
+ groups: ReadonlySet<SecurityGroup>;
37
+ /** The command that installs what the build needs. Default `npm ci`. */
38
+ install?: string;
39
+ /** The command that builds what the targets upload, or none. Default `npm run build`. */
40
+ build?: string | null;
41
+ /** What runs chant. Default `npx chant`. */
42
+ chant?: string;
43
+ /** The workspace root relative to the repository root, `.` at the top. */
44
+ workspaceRoot?: string;
45
+ }
46
+
47
+ /** The flags `chant release publish` gets under the groups. */
48
+ export function publishFlags(groups: ReadonlySet<SecurityGroup>): string[] {
49
+ return [...(groups.has("supply-chain") ? ["--require-provenance"] : []), "--yes"];
50
+ }
51
+
52
+ /** The workflow file's text, without the generated-by header. */
53
+ export function renderReleaseWorkflow(input: ReleaseWorkflowInput): string {
54
+ const chant = input.chant ?? "npx chant";
55
+ const install = input.install ?? "npm ci";
56
+ const build = input.build === undefined ? "npm run build" : input.build;
57
+ const root = input.workspaceRoot && input.workspaceRoot !== "." ? input.workspaceRoot : null;
58
+ const verify = input.groups.has("verified-releases");
59
+ const flags = publishFlags(input.groups).join(" ");
60
+ if (input.forge === "gitlab") {
61
+ const label = '"${CI_COMMIT_TAG#release/}"';
62
+ const lines = [
63
+ "chant-release-publish:",
64
+ " rules:",
65
+ " - if: $CI_COMMIT_TAG =~ /^release\\//",
66
+ " id_tokens:",
67
+ " SIGSTORE_ID_TOKEN:",
68
+ " aud: sigstore",
69
+ " script:",
70
+ ...(root ? [` - cd ${root}`] : []),
71
+ ` - ${install}`,
72
+ ...(build ? [` - ${build}`] : []),
73
+ " - git fetch origin 'refs/tags/ci/*:refs/tags/ci/*'",
74
+ " - git fetch origin chant/lifecycle:chant/lifecycle",
75
+ ...(verify ? [` - ${chant} release verify ${label}`] : []),
76
+ ` - ${chant} release publish ${label} ${flags}`,
77
+ ];
78
+ return `${lines.join("\n")}\n`;
79
+ }
80
+ const label = '"${GITHUB_REF_NAME#release/}"';
81
+ const lines = [
82
+ "name: chant release publish",
83
+ "on:",
84
+ " push:",
85
+ ' tags: ["release/**"]',
86
+ "permissions:",
87
+ " contents: write",
88
+ " id-token: write",
89
+ "jobs:",
90
+ " publish:",
91
+ " runs-on: ubuntu-latest",
92
+ ...(root ? [" defaults:", " run:", ` working-directory: ${root}`] : []),
93
+ " steps:",
94
+ " - uses: actions/checkout@v4",
95
+ " with:",
96
+ " fetch-depth: 0",
97
+ " - uses: actions/setup-node@v4",
98
+ " with:",
99
+ " node-version: 24",
100
+ " registry-url: https://registry.npmjs.org",
101
+ ` - run: ${install}`,
102
+ ...(build ? [` - run: ${build}`] : []),
103
+ " - run: git fetch origin 'refs/tags/ci/*:refs/tags/ci/*'",
104
+ " - run: git fetch origin chant/lifecycle:chant/lifecycle",
105
+ ...(verify ? [` - run: ${chant} release verify ${label}`] : []),
106
+ ` - run: ${chant} release publish ${label} ${flags}`,
107
+ ];
108
+ return `${lines.join("\n")}\n`;
109
+ }
110
+
111
+ /** The header that marks the file generated, naming the command that regenerates it. */
112
+ export function releaseWorkflowHeader(command: string): string {
113
+ return `# ${GENERATED_MARKER}. Regenerate with: ${command} (in the workspace root)\n`;
114
+ }
115
+
116
+ /**
117
+ * `chant release workflow [--forge github|gitlab|forgejo] [--install <command>]
118
+ * [--build <command>|--no-build] [--chant <command>] [--output <file>]`: write
119
+ * the publish workflow for the workspace's forge, under its security groups.
120
+ */
121
+ export async function runReleaseWorkflow(ctx: import("../cli/registry").CommandContext): Promise<number> {
122
+ const { existsSync, mkdirSync, realpathSync, writeFileSync } = await import("node:fs");
123
+ const { dirname, join, relative, resolve, sep } = await import("node:path");
124
+ const { formatError, formatSuccess } = await import("../cli/format");
125
+ const { findWorkspaceRoot } = await import("../project-root");
126
+ const { gitTop } = await import("./tree");
127
+ const { securityGroupsAt } = await import("./security");
128
+ const { shellArg } = await import("./member-pipeline");
129
+ const usage = "chant release workflow [--forge github|gitlab|forgejo] [--install <command>] [--build <command>] [--chant <command>] [--output <file>]";
130
+ const { args } = ctx;
131
+ const found = findWorkspaceRoot(process.cwd());
132
+ if (!found) {
133
+ console.error(formatError({ message: "no chant.workspace.json between this directory and the git root", hint: usage }));
134
+ return 1;
135
+ }
136
+ const root = realpathSync(found.dir);
137
+ const repoRoot = gitTop(root) ?? root;
138
+ const forge = (args.forge ??
139
+ (existsSync(join(repoRoot, ".github", "workflows")) ? "github" : existsSync(join(repoRoot, ".forgejo", "workflows")) ? "forgejo" : existsSync(join(repoRoot, ".gitlab-ci.yml")) ? "gitlab" : "github")) as ReleaseWorkflowForge;
140
+ if (!(RELEASE_WORKFLOW_FORGES as readonly string[]).includes(forge)) {
141
+ console.error(formatError({ message: `--forge ${JSON.stringify(forge)} is not one of ${RELEASE_WORKFLOW_FORGES.join(", ")}`, hint: usage }));
142
+ return 1;
143
+ }
144
+ const groups = securityGroupsAt(root);
145
+ const workspaceRoot = relative(repoRoot, root).split(sep).join("/") || ".";
146
+ const text = renderReleaseWorkflow({
147
+ forge,
148
+ groups,
149
+ workspaceRoot,
150
+ ...(args.ciInstall ? { install: args.ciInstall } : {}),
151
+ ...(args.buildCommand ? { build: args.buildCommand } : {}),
152
+ ...(args.ciChant ? { chant: args.ciChant } : {}),
153
+ });
154
+ const target = args.output ? resolve(process.cwd(), args.output) : join(repoRoot, RELEASE_WORKFLOW_FILES[forge]);
155
+ const parts = ["chant", "release", "workflow", "--forge", forge];
156
+ if (args.ciInstall) parts.push("--install", args.ciInstall);
157
+ if (args.buildCommand) parts.push("--build", args.buildCommand);
158
+ if (args.ciChant) parts.push("--chant", args.ciChant);
159
+ if (args.output) parts.push("--output", relative(root, target).split(sep).join("/"));
160
+ mkdirSync(dirname(target), { recursive: true });
161
+ writeFileSync(target, releaseWorkflowHeader(parts.map(shellArg).join(" ")) + text);
162
+ const shown = relative(process.cwd(), target).split(sep).join("/") || target;
163
+ const extras = [groups.has("supply-chain") ? "requires OIDC provenance (supply-chain)" : null, groups.has("verified-releases") ? "verifies the release first (verified-releases)" : null].filter(Boolean);
164
+ console.log(formatSuccess(`Wrote ${shown}: publishes the release a release/** tag names${extras.length > 0 ? `, and ${extras.join(" and ")}` : ""}.`));
165
+ if (forge === "gitlab") console.log(`Include it from .gitlab-ci.yml: include: [{ local: ${RELEASE_WORKFLOW_FILES.gitlab} }]`);
166
+ return 0;
167
+ }