@intentius/sql-yodeler 0.3.0 → 0.5.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 (220) hide show
  1. package/README.md +2 -2
  2. package/dist/apply-steps/index.d.ts +65 -0
  3. package/dist/apply-steps/index.js +106 -0
  4. package/dist/apply-steps/index.js.map +1 -0
  5. package/dist/approval-mode/index.d.ts +78 -0
  6. package/dist/approval-mode/index.js +105 -0
  7. package/dist/approval-mode/index.js.map +1 -0
  8. package/dist/chant/access.d.ts +84 -0
  9. package/dist/chant/access.js +155 -0
  10. package/dist/chant/access.js.map +1 -0
  11. package/dist/chant/drift.d.ts +17 -1
  12. package/dist/chant/drift.js +55 -2
  13. package/dist/chant/drift.js.map +1 -1
  14. package/dist/chant/plan.d.ts +18 -2
  15. package/dist/chant/plan.js +21 -3
  16. package/dist/chant/plan.js.map +1 -1
  17. package/dist/ci/login.d.ts +78 -0
  18. package/dist/ci/login.js +199 -0
  19. package/dist/ci/login.js.map +1 -0
  20. package/dist/ci/render.d.ts +64 -10
  21. package/dist/ci/render.js +385 -88
  22. package/dist/ci/render.js.map +1 -1
  23. package/dist/cleanup/index.d.ts +92 -0
  24. package/dist/cleanup/index.js +213 -0
  25. package/dist/cleanup/index.js.map +1 -0
  26. package/dist/commands/apply.js +178 -20
  27. package/dist/commands/apply.js.map +1 -1
  28. package/dist/commands/approve.d.ts +13 -0
  29. package/dist/commands/approve.js +206 -24
  30. package/dist/commands/approve.js.map +1 -1
  31. package/dist/commands/ci.js +12 -0
  32. package/dist/commands/ci.js.map +1 -1
  33. package/dist/commands/cleanup.d.ts +13 -0
  34. package/dist/commands/cleanup.js +158 -0
  35. package/dist/commands/cleanup.js.map +1 -0
  36. package/dist/commands/config.d.ts +3 -0
  37. package/dist/commands/config.js +136 -0
  38. package/dist/commands/config.js.map +1 -0
  39. package/dist/commands/drift.js +33 -3
  40. package/dist/commands/drift.js.map +1 -1
  41. package/dist/commands/index.js +6 -0
  42. package/dist/commands/index.js.map +1 -1
  43. package/dist/commands/init.d.ts +11 -0
  44. package/dist/commands/init.js +98 -10
  45. package/dist/commands/init.js.map +1 -1
  46. package/dist/commands/lint.js +67 -10
  47. package/dist/commands/lint.js.map +1 -1
  48. package/dist/commands/new.js +135 -54
  49. package/dist/commands/new.js.map +1 -1
  50. package/dist/commands/plan.js +39 -5
  51. package/dist/commands/plan.js.map +1 -1
  52. package/dist/commands/test.d.ts +3 -0
  53. package/dist/commands/test.js +164 -0
  54. package/dist/commands/test.js.map +1 -0
  55. package/dist/config/define.d.ts +175 -2
  56. package/dist/config/define.js +2 -0
  57. package/dist/config/define.js.map +1 -1
  58. package/dist/config/load.js +78 -10
  59. package/dist/config/load.js.map +1 -1
  60. package/dist/config/settings.d.ts +20 -0
  61. package/dist/config/settings.js +28 -0
  62. package/dist/config/settings.js.map +1 -1
  63. package/dist/credentials.d.ts +119 -0
  64. package/dist/credentials.js +119 -0
  65. package/dist/credentials.js.map +1 -0
  66. package/dist/exit.js +1 -1
  67. package/dist/exit.js.map +1 -1
  68. package/dist/index.d.ts +5 -1
  69. package/dist/index.js +2 -0
  70. package/dist/index.js.map +1 -1
  71. package/dist/init/adopt.js +1 -1
  72. package/dist/init/adopt.js.map +1 -1
  73. package/dist/init/clickhouse-access.d.ts +64 -0
  74. package/dist/init/clickhouse-access.js +211 -0
  75. package/dist/init/clickhouse-access.js.map +1 -0
  76. package/dist/init/index.d.ts +2 -0
  77. package/dist/init/index.js +2 -0
  78. package/dist/init/index.js.map +1 -1
  79. package/dist/init/live.d.ts +4 -0
  80. package/dist/init/live.js +16 -0
  81. package/dist/init/live.js.map +1 -1
  82. package/dist/init/second.d.ts +70 -0
  83. package/dist/init/second.js +149 -0
  84. package/dist/init/second.js.map +1 -0
  85. package/dist/lint/access.d.ts +18 -0
  86. package/dist/lint/access.js +34 -0
  87. package/dist/lint/access.js.map +1 -0
  88. package/dist/lint/annotations.d.ts +49 -0
  89. package/dist/lint/annotations.js +82 -0
  90. package/dist/lint/annotations.js.map +1 -0
  91. package/dist/lint/check.js +25 -7
  92. package/dist/lint/check.js.map +1 -1
  93. package/dist/lint/config.d.ts +7 -1
  94. package/dist/lint/config.js +11 -5
  95. package/dist/lint/config.js.map +1 -1
  96. package/dist/lint/index.d.ts +4 -0
  97. package/dist/lint/index.js +4 -0
  98. package/dist/lint/index.js.map +1 -1
  99. package/dist/lint/naming.d.ts +62 -0
  100. package/dist/lint/naming.js +138 -0
  101. package/dist/lint/naming.js.map +1 -0
  102. package/dist/lint/rules.d.ts +37 -4
  103. package/dist/lint/rules.js +16 -4
  104. package/dist/lint/rules.js.map +1 -1
  105. package/dist/lint/schema-checks.d.ts +63 -0
  106. package/dist/lint/schema-checks.js +128 -0
  107. package/dist/lint/schema-checks.js.map +1 -0
  108. package/dist/lint/statements.d.ts +22 -1
  109. package/dist/lint/statements.js +40 -1
  110. package/dist/lint/statements.js.map +1 -1
  111. package/dist/mcp/tools.d.ts +1 -1
  112. package/dist/mcp/tools.js +1 -1
  113. package/dist/mcp/tools.js.map +1 -1
  114. package/dist/migrations/checkpoint.js +0 -2
  115. package/dist/migrations/checkpoint.js.map +1 -1
  116. package/dist/migrations/checks.d.ts +19 -1
  117. package/dist/migrations/checks.js +15 -0
  118. package/dist/migrations/checks.js.map +1 -1
  119. package/dist/migrations/create.d.ts +13 -4
  120. package/dist/migrations/create.js +24 -9
  121. package/dist/migrations/create.js.map +1 -1
  122. package/dist/migrations/routines.d.ts +5 -15
  123. package/dist/migrations/routines.js +5 -30
  124. package/dist/migrations/routines.js.map +1 -1
  125. package/dist/pr/model.d.ts +4 -1
  126. package/dist/pr/model.js +13 -5
  127. package/dist/pr/model.js.map +1 -1
  128. package/dist/pr/render.d.ts +4 -1
  129. package/dist/pr/render.js +22 -8
  130. package/dist/pr/render.js.map +1 -1
  131. package/dist/pr/tenants.d.ts +37 -0
  132. package/dist/pr/tenants.js +138 -0
  133. package/dist/pr/tenants.js.map +1 -0
  134. package/dist/probe.d.ts +27 -0
  135. package/dist/probe.js +106 -0
  136. package/dist/probe.js.map +1 -0
  137. package/dist/project.d.ts +16 -0
  138. package/dist/project.js +29 -1
  139. package/dist/project.js.map +1 -1
  140. package/dist/replay/clickhouse.d.ts +31 -0
  141. package/dist/replay/clickhouse.js +103 -6
  142. package/dist/replay/clickhouse.js.map +1 -1
  143. package/dist/replay/replay.d.ts +5 -2
  144. package/dist/replay/replay.js.map +1 -1
  145. package/dist/report/audit.d.ts +1 -1
  146. package/dist/report/audit.js +20 -3
  147. package/dist/report/audit.js.map +1 -1
  148. package/dist/schemas.d.ts +3 -1
  149. package/dist/schemas.js +2 -0
  150. package/dist/schemas.js.map +1 -1
  151. package/dist/sources/ddl.d.ts +15 -20
  152. package/dist/sources/ddl.js +34 -328
  153. package/dist/sources/ddl.js.map +1 -1
  154. package/dist/sources/index.js +2 -2
  155. package/dist/sources/index.js.map +1 -1
  156. package/dist/steps/backfill.d.ts +40 -5
  157. package/dist/steps/backfill.js +128 -8
  158. package/dist/steps/backfill.js.map +1 -1
  159. package/dist/steps/index.d.ts +19 -1
  160. package/dist/steps/index.js +34 -7
  161. package/dist/steps/index.js.map +1 -1
  162. package/dist/steps/kinds.d.ts +42 -4
  163. package/dist/steps/kinds.js +88 -14
  164. package/dist/steps/kinds.js.map +1 -1
  165. package/dist/steps/postgres-migration.d.ts +3 -0
  166. package/dist/steps/postgres-migration.js +7 -3
  167. package/dist/steps/postgres-migration.js.map +1 -1
  168. package/dist/steps/rebuild.d.ts +15 -6
  169. package/dist/steps/rebuild.js +12 -9
  170. package/dist/steps/rebuild.js.map +1 -1
  171. package/dist/tenants/index.d.ts +2 -1
  172. package/dist/tenants/index.js +3 -4
  173. package/dist/tenants/index.js.map +1 -1
  174. package/dist/tenants/wave.d.ts +128 -0
  175. package/dist/tenants/wave.js +254 -0
  176. package/dist/tenants/wave.js.map +1 -0
  177. package/dist/testing/define.d.ts +62 -0
  178. package/dist/testing/define.js +32 -0
  179. package/dist/testing/define.js.map +1 -0
  180. package/dist/testing/files.d.ts +5 -0
  181. package/dist/testing/files.js +18 -0
  182. package/dist/testing/files.js.map +1 -0
  183. package/dist/testing/report.d.ts +14 -0
  184. package/dist/testing/report.js +83 -0
  185. package/dist/testing/report.js.map +1 -0
  186. package/dist/testing/run.d.ts +104 -0
  187. package/dist/testing/run.js +328 -0
  188. package/dist/testing/run.js.map +1 -0
  189. package/dist/versioned/approval.d.ts +6 -0
  190. package/dist/versioned/approval.js +11 -5
  191. package/dist/versioned/approval.js.map +1 -1
  192. package/dist/versioned/checks.d.ts +8 -2
  193. package/dist/versioned/checks.js +34 -13
  194. package/dist/versioned/checks.js.map +1 -1
  195. package/dist/versioned/driver.d.ts +2 -2
  196. package/dist/versioned/engine.d.ts +10 -0
  197. package/dist/versioned/engine.js +8 -4
  198. package/dist/versioned/engine.js.map +1 -1
  199. package/dist/versioned/explain.d.ts +2 -0
  200. package/dist/versioned/explain.js +3 -1
  201. package/dist/versioned/explain.js.map +1 -1
  202. package/dist/versioned/op.d.ts +4 -0
  203. package/dist/versioned/op.js +5 -2
  204. package/dist/versioned/op.js.map +1 -1
  205. package/dist/versioned/state.js +1 -1
  206. package/dist/versioned/state.js.map +1 -1
  207. package/dist/waves/index.d.ts +58 -13
  208. package/dist/waves/index.js +109 -24
  209. package/dist/waves/index.js.map +1 -1
  210. package/dist/waves/record.d.ts +10 -0
  211. package/dist/waves/record.js.map +1 -1
  212. package/package.json +8 -7
  213. package/schemas/apply.schema.json +1 -1
  214. package/schemas/cleanup.schema.json +40 -0
  215. package/schemas/init.schema.json +51 -22
  216. package/schemas/lint-rules.schema.json +4 -0
  217. package/schemas/new.schema.json +41 -3
  218. package/schemas/plan.schema.json +39 -0
  219. package/schemas/report.schema.json +1 -1
  220. package/schemas/test.schema.json +41 -0
package/dist/ci/render.js CHANGED
@@ -71,12 +71,18 @@
71
71
  import { existsSync } from "node:fs";
72
72
  import { join, resolve } from "node:path";
73
73
  import { pathToFileURL } from "node:url";
74
+ import { APPROVAL_MODES } from "../config/define.js";
75
+ import { audience, CLOUD_OF, jobLogins, loginSteps } from "./login.js";
76
+ import { RECORD_PLANS_ENV } from "../approval-mode/index.js";
77
+ import { cloudToken, credentialsOf, roleVariables } from "../credentials.js";
74
78
  import { loadYodelConfig } from "../config/load.js";
79
+ import { testFiles } from "../testing/files.js";
80
+ import { CODE_QUALITY_FILE } from "../lint/annotations.js";
75
81
  import { emitYAMLEntry, parseYAMLDocument } from "@intentius/chant/yaml";
76
82
  import { Concurrency, Job as ActionsJob, Permissions, Service as ActionsService, Step, Workflow as ActionsWorkflow, } from "@intentius/chant-lexicon-github";
77
83
  import { githubSerializer } from "@intentius/chant-lexicon-github/serializer";
78
84
  import { forgejoSerializer } from "@intentius/chant-lexicon-forgejo/serializer";
79
- import { Artifacts as GitLabArtifacts, Image, Job as GitLabJob, Rule, Service as GitLabService, Workflow as GitLabWorkflow, WorkflowRule, } from "@intentius/chant-lexicon-gitlab";
85
+ import { Artifacts as GitLabArtifacts, Image, Job as GitLabJob, Pipeline as GitLabPipeline, Rule, Service as GitLabService, Workflow as GitLabWorkflow, WorkflowRule, } from "@intentius/chant-lexicon-gitlab";
80
86
  import { gitlabSerializer } from "@intentius/chant-lexicon-gitlab/serializer";
81
87
  export const FORGES = ["github", "gitlab", "forgejo"];
82
88
  /** The image every job runs in, where the forge runs jobs in a container. It needs git, so not -slim. */
@@ -94,16 +100,87 @@ const SETUP_NODE = "actions/setup-node@v7";
94
100
  export const prefixOf = (env) => env.toUpperCase().replace(/[^A-Z0-9]/g, "_");
95
101
  /** DEV_CLICKHOUSE for dev on ClickHouse: what an environment's variables start with. */
96
102
  export const variablePrefix = (env, dialect) => `${prefixOf(env)}_${dialect.toUpperCase()}`;
97
- /** The variables a job reading `env` holds: the URL and the reader. */
98
- export const readerVariables = (env, dialect) => {
103
+ /** The variables a job reading `env` holds: the URL and the reader (the variables credentials() names; none for a token-minted password). */
104
+ export const readerVariables = (env, dialect, creds) => {
99
105
  const p = variablePrefix(env, dialect);
100
- return [`${p}_URL`, `${p}_READER_USER`, `${p}_READER_PASSWORD`];
106
+ return [`${p}_URL`, ...(creds?.reader.variables ?? [`${p}_READER_USER`, `${p}_READER_PASSWORD`])];
101
107
  };
102
108
  /** The variables the apply job for `env` holds: the URL and the writer. */
103
- export const writerVariables = (env, dialect) => {
109
+ export const writerVariables = (env, dialect, creds) => {
104
110
  const p = variablePrefix(env, dialect);
105
- return [`${p}_URL`, `${p}_WRITER_USER`, `${p}_WRITER_PASSWORD`];
111
+ return [`${p}_URL`, ...(creds?.writer.variables ?? [`${p}_WRITER_USER`, `${p}_WRITER_PASSWORD`])];
106
112
  };
113
+ // ── Cloud identity (#50) ────────────────────────────────────────────────────
114
+ //
115
+ // A role whose password is minted by a cloud token source (rds-iam,
116
+ // cloud-sql-iam, entra) mints it with the cloud's command line, from the
117
+ // job's identity. A job holding one gets an OIDC token from the forge: on
118
+ // GitHub the workflow grants `id-token: write`; on GitLab the job declares
119
+ // an ID token for the cloud's audience. Forgejo issues no OIDC token to a
120
+ // job (its dialect drops `permissions:`), so there the runner's own cloud
121
+ // identity mints.
122
+ /** The GitLab ID token per cloud: its variable and its audience. */
123
+ export const ID_TOKENS = {
124
+ "rds-iam": { variable: "AWS_ID_TOKEN", aud: "sts.amazonaws.com" },
125
+ "cloud-sql-iam": { variable: "GCP_ID_TOKEN", aud: "https://iam.googleapis.com/" },
126
+ entra: { variable: "AZURE_ID_TOKEN", aud: "api://AzureADTokenExchange" },
127
+ };
128
+ /** The cloud token sources the roles `held` mint from, each once, in order. */
129
+ const cloudsOf = (envs, held) => [
130
+ ...new Set(held.map(([env, role]) => envs.credentials?.[env]?.[role].cloud).filter((c) => c !== undefined)),
131
+ ];
132
+ /** A GitLab job's `id_tokens` for `clouds`, each for its login's audience when the job logs in (#132): GCP's is the workload identity provider's own. */
133
+ const idTokens = (clouds, logins = []) => Object.fromEntries(clouds.map((c) => {
134
+ const login = logins.find((l) => l.cloud === CLOUD_OF[c]);
135
+ return [ID_TOKENS[c].variable, { aud: login ? audience(login) : ID_TOKENS[c].aud }];
136
+ }));
137
+ /** The cloud logins (#132) of a job holding the roles `held`, from yodel.config.ts `ci.login`. */
138
+ const loginsOf = (envs, held) => jobLogins(envs.login, held, (env, role) => envs.credentials?.[env]?.[role].cloud);
139
+ /** A job's login steps as the github lexicon's Steps. */
140
+ const actionsLogin = (forge, logins) => loginSteps(forge, logins).steps.map((st) => new Step({ name: st.name, run: st.lines.join("\n") }));
141
+ /** A job's login steps as plain step objects, for YAML chant rendered and yodel edits. */
142
+ const rawLogin = (forge, logins) => loginSteps(forge, logins).steps.map((st) => ({ name: st.name, run: st.lines.join("\n") }));
143
+ /** A GitLab job's login: its script lines and its variables. */
144
+ const gitlabLogin = (logins) => {
145
+ const out = loginSteps("gitlab", logins);
146
+ return { lines: out.steps.flatMap((st) => st.lines), variables: out.variables };
147
+ };
148
+ /** Puts `steps` before the first step `at` picks (refusing a job without one). */
149
+ function insertSteps(steps, add, at, what) {
150
+ if (add.length === 0)
151
+ return;
152
+ const i = steps.findIndex(at);
153
+ if (i < 0)
154
+ throw new Error(`${what}: no step to log in before`);
155
+ steps.splice(i, 0, ...add);
156
+ }
157
+ /** Puts `lines` before the first script line `at` picks (refusing a job without one). */
158
+ function insertLines(script, add, at, what) {
159
+ if (add.length === 0)
160
+ return;
161
+ const i = script?.findIndex(at) ?? -1;
162
+ if (!script || i < 0)
163
+ throw new Error(`${what}: no script line to log in before`);
164
+ script.splice(i, 0, ...add);
165
+ }
166
+ const runsChant = (st) => typeof st.run === "string" && st.run.startsWith("chant run ");
167
+ /** The roles a pull request's plan job for `env` holds: the environment's reader and the reader of the one it requires. */
168
+ const planRoles = (waves, env) => [env, ...requiredOf(waves, env)].map((e) => [e, "reader"]);
169
+ /** The roles a wave job for `env` holds: the writer, and the reader of the environment it requires. */
170
+ const waveRoles = (waves, env) => [[env, "writer"], ...requiredOf(waves, env).map((e) => [e, "reader"])];
171
+ /** The header lines saying what the cloud identity is for, when a job here holds a cloud-minted role. */
172
+ function identityHeader(forge, clouds) {
173
+ if (clouds.length === 0)
174
+ return [];
175
+ const names = clouds.join(", ");
176
+ if (forge === "github")
177
+ return [`# Cloud identity: a password here is a ${names} token, minted from the job's`, "# identity, so the workflow grants id-token: write (an OIDC token)."];
178
+ if (forge === "gitlab")
179
+ return [`# Cloud identity: a password here is a ${names} token, minted from the job's`, `# identity: the jobs that hold one declare id_tokens (${clouds.map((c) => ID_TOKENS[c].variable).join(", ")}).`];
180
+ return [`# Cloud identity: a password here is a ${names} token. Forgejo gives a job no`, "# OIDC token, so the runner's own cloud identity mints it."];
181
+ }
182
+ /** The write probe a pull request job runs before its plan: it holds no writer for `envs`. */
183
+ export const writeProbe = (envs) => `yodel config check ${envs.join(" ")} --write-probe`;
107
184
  const secret = (name) => `\${{ secrets.${name} }}`;
108
185
  const secrets = (names) => Object.fromEntries(names.map((n) => [n, secret(n)]));
109
186
  /**
@@ -153,7 +230,17 @@ export const REPLAY_SERVICE = {
153
230
  /** Waits up to 90 s for the replay server's port; a shell-free one-liner, so every forge runs it the same. */
154
231
  export const waitFor = (host, port) => `node -e 'const net = require("net"); let n = 0; (function t() { const s = net.connect(${String(port)}, "${host}", () => { s.end(); process.exit(0); }); s.on("error", () => { if (++n > 90) { console.error("${host}:${String(port)} did not answer"); process.exit(1); } setTimeout(t, 1000); }); })()'`;
155
232
  export const LINT = "yodel lint";
233
+ /**
234
+ * The lint job's `yodel lint`, with its findings where the forge shows them
235
+ * (#129): workflow commands on GitHub and Forgejo, which annotate the
236
+ * migration files in the pull request; on GitLab the code quality report,
237
+ * which the job keeps as its `artifacts:reports:codequality`
238
+ * ({@link CODE_QUALITY_FILE}) and the merge request shows.
239
+ */
240
+ export const lintCommand = (forge) => (forge === "gitlab" ? `${LINT} --format gitlab --output ${CODE_QUALITY_FILE}` : `${LINT} --format github`);
156
241
  export const REPLAY = `yodel lint --replay ${REPLAY_PROFILE} --reset`;
242
+ /** yodel test (#54), after the replay check on the same throwaway server, its outcome as JUnit XML. */
243
+ export const TEST = `yodel test --env ${REPLAY_PROFILE} --reset --junit yodel-test.xml`;
157
244
  /** Fails a pull request whose pipelines differ from what yodel ci renders. */
158
245
  export const CI_CHECK = "yodel ci --check";
159
246
  /**
@@ -180,15 +267,19 @@ export const reportCommand = (env, tc = { image: IMAGE, yodelImage: false }) =>
180
267
  export const reportArtifact = (job) => `yodel-report-${job}`;
181
268
  const UPLOAD_ARTIFACT = "actions/upload-artifact@v4";
182
269
  /**
183
- * The comment at the top of every file. Added here, not by chant: neither the
184
- * github nor the gitlab lexicon's serializer writes YAML comments
185
- * (INTENTIUS/chant#3667, INTENTIUS/chant#3669).
270
+ * The comment at the top of every file, as `#` lines. The files declared here
271
+ * (the review workflows and .gitlab-ci.yml) take theirs as the Workflow's or
272
+ * Pipeline's `comment`, which chant writes. The files chant's
273
+ * generateOpWavesPipeline and generateOpsPipeline write take no comment, so
274
+ * withHeader puts it above their YAML.
186
275
  */
187
276
  const HEADER = [
188
277
  "# Rendered by yodel ci from chant.config.ts, yodel.config.ts and ops/.",
189
278
  "# Edit those and render again; yodel ci --check says when this file is stale.",
190
279
  ];
191
280
  const withHeader = (header, yaml) => `${[...header, ""].join("\n")}${yaml}`;
281
+ /** `#` lines as a declaration's `comment`: the text without its `# `, which the serializer writes back. */
282
+ const asComment = (header) => header.map((l) => l.replace(/^# ?/, "")).join("\n");
192
283
  /** A serializer's output for one file: its YAML, refusing anything else (a second file, a warning). */
193
284
  function oneFile(forge, out) {
194
285
  if (typeof out === "string")
@@ -211,10 +302,9 @@ function reportSteps(env, job, tc) {
211
302
  }
212
303
  /** `inContainer`: the job runs in the toolchain's image, which has node, so it needs no setup-node. */
213
304
  function setupSteps(inContainer, tc) {
214
- // Step inputs are strings in the lexicon's schema (INTENTIUS/chant#3670); the actions read them as strings either way.
215
- const steps = [new Step({ uses: CHECKOUT, with: { "fetch-depth": "0" } })];
305
+ const steps = [new Step({ uses: CHECKOUT, with: { "fetch-depth": 0 } })];
216
306
  if (!inContainer)
217
- steps.push(new Step({ uses: SETUP_NODE, with: { "node-version": "22", cache: "npm" } }));
307
+ steps.push(new Step({ uses: SETUP_NODE, with: { "node-version": 22, cache: "npm" } }));
218
308
  steps.push(...installLines(tc, "github").map((run) => new Step({ run })), new Step({ name: "Fetch the approvals (chant/lifecycle)", run: FETCH_APPROVALS }));
219
309
  return steps;
220
310
  }
@@ -225,7 +315,7 @@ const runner = (container, tc) => ({ "runs-on": "ubuntu-latest", ...(container ?
225
315
  function commentToken(forge) {
226
316
  return forge === "github" ? { GITHUB_TOKEN: "${{ github.token }}" } : { FORGEJO_TOKEN: "${{ github.token }}" };
227
317
  }
228
- function lintJob(forge, dialect, replay, tc) {
318
+ function lintJob(forge, dialect, replay, tc, tests = false) {
229
319
  // With the replay check, the job runs in IMAGE on every forge and reaches
230
320
  // its replay server by the service's name on the job's own network. On
231
321
  // GitHub's VM the service would be on localhost through a published port,
@@ -235,7 +325,7 @@ function lintJob(forge, dialect, replay, tc) {
235
325
  const steps = [
236
326
  ...setupSteps(container, tc),
237
327
  new Step({ name: "The pipelines are up to date", run: CI_CHECK }),
238
- new Step({ name: "yodel lint", run: LINT }),
328
+ new Step({ name: "yodel lint", run: lintCommand(forge) }),
239
329
  ];
240
330
  if (!replay)
241
331
  return new ActionsJob({ ...runner(container, tc), steps });
@@ -254,52 +344,66 @@ function lintJob(forge, dialect, replay, tc) {
254
344
  ...steps,
255
345
  new Step({ name: `Wait for the replay server (${svc.name})`, run: waitFor(svc.name, svc.port) }),
256
346
  new Step({ name: "yodel lint --replay", run: REPLAY }),
347
+ ...(tests ? [new Step({ name: "yodel test", run: TEST })] : []),
257
348
  ],
258
349
  });
259
350
  }
260
351
  export function actionsReview(forge, envs_) {
261
352
  const { dialect, names: envs, replay, waves, jobs: own } = envs_;
262
353
  const tc = toolchainOf(envs_);
263
- const jobs = { lint: lintJob(forge, dialect, replay, tc) };
354
+ const jobs = { lint: lintJob(forge, dialect, replay, tc, envs_.tests === true) };
264
355
  for (const env of envs) {
356
+ const read = [env, ...requiredOf(waves, env)];
265
357
  jobs[`plan-${env}`] = new ActionsJob({
266
358
  ...runner(inContainer(forge, tc), tc),
267
359
  // The plan shows where each migration has run: the required environment's reader too.
268
- env: secrets([...[env, ...requiredOf(waves, env)].flatMap((e) => readerVariables(e, dialect)), ...WEBHOOK_VARIABLES]),
360
+ env: secrets([...read.flatMap((e) => readerVariables(e, dialect, envs_.credentials?.[e])), ...WEBHOOK_VARIABLES]),
269
361
  steps: [
270
362
  // The plan explains what moved since an approval from git's history of migrations/.
271
363
  ...setupSteps(inContainer(forge, tc), tc),
364
+ // The cloud login (#132), when a reader here is cloud-minted.
365
+ ...actionsLogin(forge, loginsOf(envs_, planRoles(waves, env))),
366
+ new Step({ name: "This job cannot write", run: writeProbe(read) }),
272
367
  new Step({ name: `yodel plan ${env} --comment`, run: `yodel plan ${env} --comment`, env: commentToken(forge) }),
273
368
  ...reportSteps(env, `plan-${env}`, tc),
274
369
  ],
275
370
  });
276
371
  }
277
372
  Object.assign(jobs, ownJobs(own?.actions, jobs, "actions"));
278
- const workflow = new ActionsWorkflow({
279
- name: "yodel-pr",
280
- on: { pull_request: { branches: [MAIN] } },
281
- permissions: new Permissions({ contents: "read", "pull-requests": "write" }),
282
- concurrency: new Concurrency({ group: "yodel-pr-${{ github.event.pull_request.number }}", "cancel-in-progress": true }),
283
- jobs,
284
- });
373
+ const clouds = cloudsOf(envs_, envs.flatMap((env) => planRoles(waves, env)));
285
374
  const header = [
286
375
  ...HEADER,
287
376
  "#",
288
377
  "# Pull requests: yodel lint and the replay check (into a throwaway server the",
289
378
  "# lint job starts), and the plan comment per environment with the",
290
- "# environment's reader credentials. No job here holds a writer.",
379
+ "# environment's reader credentials. No job here holds a writer, and each plan",
380
+ "# job shows it first: yodel config check --write-probe tries a write and",
381
+ "# fails the job when the server lets it through.",
291
382
  ...(forge === "forgejo"
292
383
  ? ["# The comment is posted with the job's own token (Forgejo gives a same-repository", "# pull request one that can comment)."]
293
384
  : []),
385
+ ...identityHeader(forge, clouds),
294
386
  ];
387
+ const workflow = new ActionsWorkflow({
388
+ comment: asComment(header),
389
+ name: "yodel-pr",
390
+ on: { pull_request: { branches: [MAIN] } },
391
+ permissions: new Permissions({ contents: "read", "pull-requests": "write", ...(clouds.length > 0 ? { "id-token": "write" } : {}) }),
392
+ concurrency: new Concurrency({ group: "yodel-pr-${{ github.event.pull_request.number }}", "cancel-in-progress": true }),
393
+ jobs,
394
+ });
295
395
  return {
296
396
  path: `${forge === "github" ? ".github" : ".forgejo"}/workflows/yodel-pr.yml`,
297
- content: withHeader(header, renderActions(forge, workflow)),
397
+ content: renderActions(forge, workflow),
298
398
  };
299
399
  }
300
400
  // ── GitLab CI ───────────────────────────────────────────────────────────────
301
401
  export const GITLAB_WATCH_FILE = ".gitlab/yodel-watch.gitlab-ci.yml";
302
402
  export const GITLAB_APPLY_FILE = ".gitlab/yodel-apply.gitlab-ci.yml";
403
+ /** The stage of the scheduled resume job in GITLAB_APPLY_FILE, as chant names it. */
404
+ const RESUME_STAGE = "resume";
405
+ /** The stage of the merge request job that records the plans a pr-review wave's review approves (#48), as chant names it. */
406
+ const PLANS_STAGE = "plans";
303
407
  /** The stage of each wave's job in GITLAB_APPLY_FILE, as chant names it. */
304
408
  const waveStages = (waves) => waves.map((w, i) => `wave-${String(i + 1)}-${w.env}`);
305
409
  export function gitlabPipeline(envs_) {
@@ -325,50 +429,42 @@ export function gitlabPipeline(envs_) {
325
429
  rules: mr(),
326
430
  ...(replay
327
431
  ? {
328
- script: [CI_CHECK, LINT, waitFor(svc.name, svc.port), REPLAY],
432
+ script: [CI_CHECK, lintCommand("gitlab"), waitFor(svc.name, svc.port), REPLAY, ...(envs_.tests === true ? [TEST] : [])],
329
433
  services: [new GitLabService({ name: svc.image, alias: svc.name, variables: svc.env })],
330
434
  variables: { [`${variablePrefix(REPLAY_PROFILE, dialect)}_URL`]: svc.url(svc.name), ...svc.jobEnv },
331
435
  }
332
- : { script: [CI_CHECK, LINT] }),
436
+ : { script: [CI_CHECK, lintCommand("gitlab")] }),
437
+ // The code quality report, kept when lint fails too: that is when it has something to show.
438
+ artifacts: new GitLabArtifacts({ when: "always", reports: { codequality: CODE_QUALITY_FILE } }),
333
439
  }));
334
440
  for (const env of envs) {
441
+ const clouds = cloudsOf(envs_, planRoles(waves, env));
442
+ const logins = loginsOf(envs_, planRoles(waves, env));
443
+ const login = gitlabLogin(logins);
335
444
  jobs.set(`plan-${env}`, new GitLabJob({
336
445
  extends: ".yodel",
337
446
  stage: "review",
338
447
  rules: mr(),
339
- script: [`yodel plan ${env} --comment`],
448
+ ...(Object.keys(login.variables).length > 0 ? { variables: login.variables } : {}),
449
+ script: [...login.lines, writeProbe([env, ...requiredOf(waves, env)]), `yodel plan ${env} --comment`],
450
+ ...(clouds.length > 0 ? { id_tokens: idTokens(clouds, logins) } : {}),
340
451
  after_script: [reportCommand(env, tc)],
341
452
  artifacts: new GitLabArtifacts({ name: reportArtifact(`plan-${env}`), when: "always", paths: [`${REPORT_DIR}/`] }),
342
453
  }));
343
454
  }
344
455
  for (const [name, job] of Object.entries(ownJobs(own?.gitlab, Object.fromEntries(jobs), "gitlab")))
345
456
  jobs.set(name, job);
346
- const serialize = (entities) => oneFile("gitlab", gitlabSerializer.serialize(entities));
347
- // Exception (INTENTIUS/chant#3669): the gitlab serializer writes no
348
- // top-level include or variables, and its stages are only the ones the
349
- // declared jobs name, so not the watch file's ops. It writes those stages
350
- // first; they are replaced by GITLAB_STAGES, and include and variables are
351
- // written beside them.
352
- const jobsYaml = serialize(jobs);
353
- const end = jobsYaml.indexOf("\n\n");
354
- const derivedStages = parseYAMLDocument(jobsYaml.slice(0, end + 1)).stages;
355
- if (!jobsYaml.startsWith("stages:\n") || !Array.isArray(derivedStages) || derivedStages[0] !== "review")
356
- throw new Error("gitlab: expected the jobs' YAML to start with their stages, review first");
357
- const derived = jobsYaml.slice(0, end + 2);
358
- // The stages this file's jobs use (review, then any the project's own jobs add), then the apply file's waves and the watch file's ops.
359
- const stages = [...new Set([...derivedStages.map(String), ...waveStages(waves), "ops"])];
360
- const topLevel = [
361
- emitYAMLEntry("include", [{ local: GITLAB_APPLY_FILE }, { local: GITLAB_WATCH_FILE }]),
362
- emitYAMLEntry("stages", stages),
363
- emitYAMLEntry("variables", { GIT_DEPTH: "0" }),
364
- ];
365
- const yaml = `${serialize(new Map([["workflow", workflow]]))}\n${topLevel.join("\n\n")}\n\n${jobsYaml.slice(derived.length)}`;
366
- const variables = envs.flatMap((env) => [
367
- `# ${readerVariables(env, dialect).join(", ")}: not protected (merge request`,
368
- "# pipelines run on unprotected branches), masked; no environment scope.",
369
- `# ${writerVariables(env, dialect).slice(1).join(", ")}: protected, masked,`,
370
- `# environment scope "${env}": only ${env}'s wave job declares that environment.`,
371
- ]);
457
+ const variables = envs.flatMap((env) => {
458
+ const creds = envs_.credentials?.[env];
459
+ const writer = writerVariables(env, dialect, creds).slice(1);
460
+ return [
461
+ `# ${readerVariables(env, dialect, creds).join(", ")}: not protected (merge request`,
462
+ "# pipelines run on unprotected branches), masked; no environment scope.",
463
+ ...(writer.length > 0
464
+ ? [`# ${writer.join(", ")}: protected, masked,`, `# environment scope "${env}": only ${env}'s wave job declares that environment.`]
465
+ : []),
466
+ ];
467
+ });
372
468
  const header = [
373
469
  ...HEADER,
374
470
  "#",
@@ -380,14 +476,35 @@ export function gitlabPipeline(envs_) {
380
476
  "# protected or scoped to an environment, so the split between reader and",
381
477
  "# writer is in the variables' settings (Settings > CI/CD > Variables):",
382
478
  ...variables,
383
- "# GITLAB_TOKEN: a project access token with the api scope (Reporter), for",
384
- "# the plan comment and the drift watch's tracking issue; CI_JOB_TOKEN",
385
- "# cannot write merge request notes or issues.",
479
+ ...(envs_.resume
480
+ ? [
481
+ "# GITLAB_TOKEN: a project access token with the api scope (Developer), for",
482
+ "# the plan comment, the drift watch's tracking issue and the resume job's",
483
+ "# retry of a wave whose approval arrived; CI_JOB_TOKEN cannot write merge",
484
+ "# request notes or issues, or retry a job.",
485
+ ]
486
+ : [
487
+ "# GITLAB_TOKEN: a project access token with the api scope (Reporter), for",
488
+ "# the plan comment and the drift watch's tracking issue; CI_JOB_TOKEN",
489
+ "# cannot write merge request notes or issues.",
490
+ ]),
386
491
  "# The jobs that hold no writer read with the reader: chant.config.ts picks an",
387
492
  "# environment's writer only under YODEL_CREDENTIALS=writer:<env>, which only",
388
493
  "# that environment's wave job sets.",
494
+ ...identityHeader("gitlab", cloudsOf(envs_, envs.flatMap((env) => [...planRoles(waves, env), ...waveRoles(waves, env), [env, "reader"]]))),
389
495
  ];
390
- return { path: ".gitlab-ci.yml", content: withHeader(header, yaml) };
496
+ // The stages this file's jobs use (review, then any the project's own jobs add), then the apply file's waves and the watch file's ops.
497
+ // A pr-review wave (#48) adds the merge request job that records the plans, in chant's stage "plans".
498
+ const jobStages = [...jobs.values()].map((job) => job.props?.stage).filter((stage) => typeof stage === "string");
499
+ const plans = waves.some((w) => w.approval === "pr-review") ? [PLANS_STAGE] : [];
500
+ const pipeline = new GitLabPipeline({
501
+ comment: asComment(header),
502
+ include: [{ local: GITLAB_APPLY_FILE }, { local: GITLAB_WATCH_FILE }],
503
+ stages: [...new Set([...jobStages, ...plans, ...waveStages(waves), ...(envs_.resume ? [RESUME_STAGE] : []), "ops"])],
504
+ variables: { GIT_DEPTH: "0" },
505
+ });
506
+ const entities = new Map([["pipeline", pipeline], ["workflow", workflow], ...jobs]);
507
+ return { path: ".gitlab-ci.yml", content: oneFile("gitlab", gitlabSerializer.serialize(entities)) };
391
508
  }
392
509
  const WAVE_GATES = ["always", "on-destructive", "never"];
393
510
  /** The environment `env`'s wave requires, as a list. */
@@ -402,7 +519,7 @@ export const APPLY_PIPELINE = "yodel-apply";
402
519
  * The waves spec. What the file holds is the same for every forge; each
403
520
  * forge's jobs get their own variables on top (`variables`).
404
521
  */
405
- export function wavesSpec(waves, variables) {
522
+ export function wavesSpec(waves, variables, resume) {
406
523
  const first = waves[0];
407
524
  if (!first)
408
525
  throw new Error("no waves to render");
@@ -417,8 +534,10 @@ export function wavesSpec(waves, variables) {
417
534
  runs: (w.targets ?? [w.env]).map((target) => ({ target, op: `migrate-${w.env}` })),
418
535
  gate: w.gate,
419
536
  ...(w.shares !== undefined && w.shares > 1 ? { shares: w.shares } : {}),
537
+ ...(w.approval !== undefined && w.approval !== "ledger" ? { approval: w.approval } : {}),
420
538
  ...(variables ? { environment: { name: w.env }, variables: variables(w.env) } : {}),
421
539
  })),
540
+ ...(resume ? { resume } : {}),
422
541
  };
423
542
  }
424
543
  /** The waves spec file and the apply pipeline on each forge. */
@@ -429,13 +548,19 @@ export async function applyPipelines(dir, envs) {
429
548
  const out = [{ path: WAVES_SPEC, content: `${JSON.stringify(wavesSpec(waves), null, 2)}\n` }];
430
549
  // A GitLab job already has the project's variables, a writer's only under its environment.
431
550
  const variables = (forge) => (env) => {
432
- const required = waves.find((w) => w.env === env)?.requires;
551
+ const wave = waves.find((w) => w.env === env);
552
+ const required = wave?.requires;
433
553
  return {
434
554
  YODEL_CREDENTIALS: `writer:${env}`,
435
555
  ...APPLY_LOCK,
436
556
  ...(forge === "gitlab"
437
557
  ? {}
438
- : { ...secrets([...writerVariables(env, dialect), ...WEBHOOK_VARIABLES]), ...(required ? secrets(readerVariables(required, dialect)) : {}) }),
558
+ : {
559
+ ...secrets([...writerVariables(env, dialect, envs.credentials?.[env]), ...WEBHOOK_VARIABLES]),
560
+ ...(required ? secrets(readerVariables(required, dialect, envs.credentials?.[required])) : {}),
561
+ // A pr-review wave (#48) reads the merged pull request's reviews with the job's token.
562
+ ...(wave?.approval === "pr-review" ? { GITHUB_TOKEN: "${{ github.token }}" } : {}),
563
+ }),
439
564
  };
440
565
  };
441
566
  const header = [
@@ -446,13 +571,35 @@ export async function applyPipelines(dir, envs) {
446
571
  `# wave's gate policy is read from ${WAVES_SPEC} as the commit before the push`,
447
572
  "# has it (yodel.config.ts waves, rendered), and a wave that needs an approval",
448
573
  "# exits 3 with the chant approve command, so the waves after it do not run.",
449
- "# Approve, push chant/lifecycle, and run the failed job again. These are the",
450
- "# only jobs that hold a writer's credentials, one environment's each, with",
574
+ "# yodel approve <env> records the approval and starts the waiting job again",
575
+ "# (or approve, push chant/lifecycle, and run the failed job again). These are",
576
+ "# the only jobs that hold a writer's credentials, one environment's each, with",
451
577
  "# the reader of the environment before (waves[].requires): yodel apply refuses",
452
578
  "# a migration that environment's history does not record as applied.",
453
579
  ];
580
+ const resumeHeader = (forge) => [
581
+ ...HEADER,
582
+ "#",
583
+ `# On its schedule: chant run resume --op ${APPLY_PIPELINE} starts again each apply wave`,
584
+ "# that waits at its gate and whose approval has arrived since. It approves",
585
+ "# nothing; a wave whose approval does not count waits again.",
586
+ ...(forge === "github"
587
+ ? ["# It re-runs the run's failed jobs with the job's token (actions: write)."]
588
+ : ["# Forgejo has no re-run: it dispatches yodel-apply.yml again on main, with the", "# CHANT_FORGE_TOKEN secret, a token with the write:repository scope."]),
589
+ ];
590
+ const waveClouds = cloudsOf(envs, waves.flatMap((w) => waveRoles(waves, w.env)));
591
+ const plansHeader = [
592
+ ...HEADER,
593
+ "#",
594
+ "# A pull request into main, when a wave's approval is pr-review (yodel.config.ts",
595
+ "# waves[].approval): chant run wave --record-plans plans every wave at the pull",
596
+ "# request's head, as it will be after the merge, and records the digests on",
597
+ "# chant/lifecycle. After the merge, an approving review of that head by a writer",
598
+ "# other than its author approves the digest it recorded, and only that one. It",
599
+ "# holds no writer's credentials: it plans with each environment's reader.",
600
+ ];
454
601
  for (const forge of FORGES) {
455
- const result = await generateOpWavesPipeline(wavesSpec(waves, variables(forge)), forge, {
602
+ const result = await generateOpWavesPipeline(wavesSpec(waves, variables(forge), envs.resume), forge, {
456
603
  specFile: WAVES_SPEC,
457
604
  image: tc.image,
458
605
  beforeScript: [...installLines(tc, forge), FETCH_APPROVALS],
@@ -462,7 +609,15 @@ export async function applyPipelines(dir, envs) {
462
609
  throw new Error(`${forge}: ${result.error ?? "generateOpWavesPipeline rendered nothing"}`);
463
610
  for (const file of result.files) {
464
611
  const at = forge === "gitlab" ? GITLAB_APPLY_FILE : `${forge === "github" ? ".github" : ".forgejo"}/workflows/${file.name}`;
465
- out.push({ path: at, content: withHeader(header, withWaveReports(forge, file.yaml.endsWith("\n") ? file.yaml : `${file.yaml}\n`, waves, tc)) });
612
+ const yaml = file.yaml.endsWith("\n") ? file.yaml : `${file.yaml}\n`;
613
+ // The resume workflow (GitHub, Forgejo) has no wave job to report on.
614
+ if (forge !== "gitlab" && file.name === `${APPLY_PIPELINE}-resume.yml`)
615
+ out.push({ path: at, content: withHeader(resumeHeader(forge), yaml) });
616
+ // The pull request workflow that records the plans a review approves (#48).
617
+ else if (forge !== "gitlab" && file.name === `${APPLY_PIPELINE}-plans.yml`)
618
+ out.push({ path: at, content: withHeader(plansHeader, withPlansReaders(forge, yaml, waves, dialect, envs)) });
619
+ else
620
+ out.push({ path: at, content: withHeader([...header, ...identityHeader(forge, waveClouds)], withWaveReports(forge, yaml, waves, tc, envs)) });
466
621
  }
467
622
  }
468
623
  return out;
@@ -475,7 +630,7 @@ export async function applyPipelines(dir, envs) {
475
630
  * back chant's text unchanged before anything is added, so a change in
476
631
  * chant's emitter fails here instead of rendering something else.
477
632
  */
478
- function withWaveReports(forge, yaml, waves, tc) {
633
+ function withWaveReports(forge, yaml, waves, tc, envs = {}) {
479
634
  // A wide wave's jobs are wave-<k>-<env>-decide and -share-<i>.
480
635
  const envOf = (job) => waves.find((w, i) => new RegExp(`^wave-${String(i + 1)}-${w.env.replace(/[^A-Za-z0-9]/g, "\\$&")}(-decide|-share-\\d+)?$`).test(job))?.env;
481
636
  const body = yaml.search(/^[^#\n]/m);
@@ -485,11 +640,38 @@ function withWaveReports(forge, yaml, waves, tc) {
485
640
  const emit = (d) => `${header}${Object.entries(d).map(([k, v]) => emitYAMLEntry(k, v)).join("\n\n")}\n`;
486
641
  if (emit(doc) !== yaml)
487
642
  throw new Error("gitlab: the apply waves do not emit back to chant's text");
643
+ // The merge request job that records the plans a review approves (#48): the waves as after the merge.
644
+ const record = doc[`${APPLY_PIPELINE}-record-plans`];
645
+ if (record) {
646
+ record.variables = { ...(record.variables ?? {}), [RECORD_PLANS_ENV]: "1" };
647
+ // It holds readers only, and shows it first, as every merge request job does (#50, #130).
648
+ const waveEnvs = [...new Set(waves.map((w) => w.env))];
649
+ const at = record.script?.findIndex((l) => l.includes("--record-plans")) ?? -1;
650
+ if (!record.script || at < 0)
651
+ throw new Error("gitlab: the record-plans job has no chant run wave --record-plans line");
652
+ record.script.splice(at, 0, writeProbe(waveEnvs));
653
+ const readerRoles = waveEnvs.map((e) => [e, "reader"]);
654
+ const readers = cloudsOf(envs, readerRoles);
655
+ const logins = loginsOf(envs, readerRoles);
656
+ if (readers.length > 0)
657
+ record.id_tokens = idTokens(readers, logins);
658
+ const login = gitlabLogin(logins);
659
+ insertLines(record.script, login.lines, (l) => l === writeProbe(waveEnvs), "gitlab: the record-plans job");
660
+ if (Object.keys(login.variables).length > 0)
661
+ record.variables = { ...record.variables, ...login.variables };
662
+ }
488
663
  for (const [name, job] of Object.entries(doc)) {
489
664
  const env = envOf(name);
490
665
  if (!env || typeof job !== "object" || job === null)
491
666
  continue;
492
- Object.assign(job, { after_script: [reportCommand(env, tc)], artifacts: { name: reportArtifact(name), when: "always", paths: [`${REPORT_DIR}/`] } });
667
+ const clouds = cloudsOf(envs, waveRoles(waves, env));
668
+ const logins = loginsOf(envs, waveRoles(waves, env));
669
+ const login = gitlabLogin(logins);
670
+ const w = job;
671
+ insertLines(w.script, login.lines, (l) => l.startsWith("chant run "), `gitlab: ${name}`);
672
+ if (Object.keys(login.variables).length > 0)
673
+ w.variables = { ...w.variables, ...login.variables };
674
+ Object.assign(job, { after_script: [reportCommand(env, tc)], artifacts: { name: reportArtifact(name), when: "always", paths: [`${REPORT_DIR}/`] }, ...(clouds.length > 0 ? { id_tokens: idTokens(clouds, logins) } : {}) });
493
675
  }
494
676
  return emit(doc);
495
677
  }
@@ -497,18 +679,52 @@ function withWaveReports(forge, yaml, waves, tc) {
497
679
  if (at < 0)
498
680
  throw new Error(`${forge}: the apply waves have no jobs`);
499
681
  const jobs = doc.jobs;
500
- const emit = () => `${yaml.slice(0, at + 1)}${emitYAMLEntry("jobs", jobs)}\n`;
501
- if (emit() !== yaml)
682
+ const top = yaml.slice(0, at + 1);
683
+ const emitJobs = () => `${emitYAMLEntry("jobs", jobs)}\n`;
684
+ if (`${top}${emitJobs()}` !== yaml)
502
685
  throw new Error(`${forge}: the apply waves do not emit back to chant's text`);
503
686
  for (const [name, job] of Object.entries(jobs)) {
504
687
  const env = envOf(name);
505
688
  if (!env)
506
689
  continue;
690
+ // The cloud login (#132) before the wave's chant run, when a role it holds is cloud-minted.
691
+ insertSteps(job.steps, rawLogin(forge, loginsOf(envs, waveRoles(waves, env))), runsChant, `${forge}: ${name}`);
507
692
  job.steps.push({ name: `yodel report ${env}`, if: "always()", run: reportCommand(env, tc) },
508
693
  // chant's forgejo output is already in Forgejo's dialect, so the action is named as the forgejo lexicon names it.
509
694
  { name: "Keep the report", if: "always()", uses: forge === "forgejo" ? `https://code.forgejo.org/${UPLOAD_ARTIFACT}` : UPLOAD_ARTIFACT, with: { name: reportArtifact(name), path: REPORT_DIR, "if-no-files-found": "ignore" } });
510
695
  }
511
- return emit();
696
+ return `${withIdToken(forge, top, cloudsOf(envs, waves.flatMap((w) => waveRoles(waves, w.env))))}${emitJobs()}`;
697
+ }
698
+ /**
699
+ * The plans workflow (GitHub, Forgejo) for a pr-review wave (#48): chant's,
700
+ * with each environment's reader (the waves are planned on every one, and a
701
+ * wave reads the history of the one it requires) and {@link RECORD_PLANS_ENV}
702
+ * on its job. The output is parsed and emitted again, checked first to give
703
+ * back chant's text unchanged.
704
+ */
705
+ function withPlansReaders(forge, yaml, waves, dialect, creds = {}) {
706
+ const at = yaml.indexOf("\njobs:\n");
707
+ if (at < 0)
708
+ throw new Error(`${forge}: the plans workflow has no jobs`);
709
+ const doc = parseYAMLDocument(yaml);
710
+ const top = yaml.slice(0, at + 1);
711
+ const emitJobs = () => `${emitYAMLEntry("jobs", doc.jobs)}\n`;
712
+ if (`${top}${emitJobs()}` !== yaml)
713
+ throw new Error(`${forge}: the plans workflow does not emit back to chant's text`);
714
+ const job = doc.jobs["record-plans"];
715
+ if (!job)
716
+ throw new Error(`${forge}: the plans workflow has no record-plans job`);
717
+ const envs = [...new Set(waves.map((w) => w.env))];
718
+ const steps = job.steps;
719
+ // It holds readers only, and shows it before it plans, as every pull request job does (#50, #130).
720
+ const recordAt = steps.findIndex((st) => typeof st.run === "string" && st.run.includes("--record-plans"));
721
+ if (recordAt < 0)
722
+ throw new Error(`${forge}: the record-plans job has no chant run wave --record-plans step`);
723
+ steps.splice(recordAt, 0, ...(forge === "gitlab" ? [] : rawLogin(forge, loginsOf(creds, envs.map((e) => [e, "reader"])))), { name: "This job cannot write", run: writeProbe(envs) });
724
+ delete job.steps;
725
+ Object.assign(job, { env: { [RECORD_PLANS_ENV]: "1", ...secrets(envs.flatMap((e) => readerVariables(e, dialect, creds.credentials?.[e]))) }, steps });
726
+ // A reader whose password is a cloud token (#50) is minted from the job's identity.
727
+ return `${withIdToken(forge, top, cloudsOf(creds, envs.map((e) => [e, "reader"])))}${emitJobs()}`;
512
728
  }
513
729
  /** yodel.config.ts `waves`, checked against the environments; one wave per environment, gate `always`, without it. */
514
730
  export async function readWaves(dir, envs) {
@@ -527,28 +743,28 @@ export async function readWaves(dir, envs) {
527
743
  const requires = w.requires === false ? undefined : w.requires === undefined ? (i > 0 ? String(all[i - 1]?.env) : undefined) : typeof w.requires === "string" ? w.requires : JSON.stringify(w.requires);
528
744
  if (requires !== undefined && !envs.includes(requires))
529
745
  throw new Error(`yodel.config.ts: waves[${String(i)}].requires ${requires} is not an environment (${envs.join(", ")})`);
530
- return { env, gate, ...(requires ? { requires } : {}), ...(typeof w.shares === "number" && w.shares > 1 ? { shares: w.shares } : {}) };
746
+ const approval = w.approval;
747
+ if (approval !== undefined && !APPROVAL_MODES.includes(approval))
748
+ throw new Error(`yodel.config.ts: waves[${String(i)}].approval ${String(w.approval)} is not ${APPROVAL_MODES.join(", ")}`);
749
+ return { env, gate, ...(requires ? { requires } : {}), ...(typeof w.shares === "number" && w.shares > 1 ? { shares: w.shares } : {}), ...(approval && approval !== "ledger" ? { approval } : {}) };
531
750
  });
532
751
  }
533
752
  /**
534
- * Each wave of an environment with a tenant set (#56), with a run per
535
- * tenant. A set given by a query is read from the server now, with the
536
- * environment's profile: run yodel ci again when the tenants change.
753
+ * Each wave of an environment with a tenant set (#56), with its runs: a
754
+ * run per tenant of a list or a file, or for a set given by a query,
755
+ * `shares` slots (one without) that read the tenants when the wave runs.
756
+ * The query is not run here, so the pipeline stays the same as tenants come
757
+ * and go; a list or a file is in the repository, and changing it is
758
+ * rendering again.
537
759
  */
538
760
  export async function withTenants(dir, waves) {
539
761
  const { loadProject } = await import("../project.js");
540
- const { listTenants, tenantTarget } = await import("../tenants/index.js");
762
+ const { waveTargets } = await import("../tenants/wave.js");
541
763
  const project = await loadProject(dir);
542
764
  const out = [];
543
765
  for (const w of waves) {
544
- const tenants = await listTenants(project, w.env);
545
- if (tenants === undefined) {
546
- out.push(w);
547
- continue;
548
- }
549
- if (tenants.length === 0)
550
- throw new Error(`${w.env}'s tenant set lists no tenant, so its wave would apply nothing`);
551
- out.push({ ...w, targets: tenants.map((t) => tenantTarget(w.env, t)) });
766
+ const targets = await waveTargets(project, w.env, w.shares);
767
+ out.push(targets === undefined ? w : { ...w, targets });
552
768
  }
553
769
  return out;
554
770
  }
@@ -558,16 +774,72 @@ export async function withTenants(dir, waves) {
558
774
  * writer's only under its environment, which the watch does not declare), and
559
775
  * `NAME: $NAME` there would refer to itself.
560
776
  */
561
- export function watchSpec(env, dialect, forge) {
777
+ export function watchSpec(env, dialect, forge, creds) {
562
778
  if (forge === "gitlab")
563
779
  return { name: `watch-${env}` };
564
780
  // The job's token, for the tracking issue: GitHub grants it issues: write here, Forgejo's can write already.
565
781
  return {
566
782
  name: `watch-${env}`,
567
- variables: { ...secrets([...readerVariables(env, dialect), ...WEBHOOK_VARIABLES]), GITHUB_TOKEN: "${{ github.token }}" },
568
- ...(forge === "github" ? { permissions: { issues: "write" } } : {}),
783
+ variables: { ...secrets([...readerVariables(env, dialect, creds), ...WEBHOOK_VARIABLES]), GITHUB_TOKEN: "${{ github.token }}" },
784
+ ...(forge === "github" ? { permissions: { issues: "write", ...(creds?.reader.cloud ? { "id-token": "write" } : {}) } } : {}),
569
785
  };
570
786
  }
787
+ /** A GitHub workflow's top-level `permissions:` with `id-token: write` added, when a job holds a cloud-minted role. */
788
+ function withIdToken(forge, top, clouds) {
789
+ if (forge !== "github" || clouds.length === 0)
790
+ return top;
791
+ const added = top.replace(/^permissions:\n((?: {2}\S.*\n)*)/m, (_m, scopes) => `permissions:\n${scopes} id-token: write\n`);
792
+ if (added === top)
793
+ throw new Error("github: the apply waves have no top-level permissions to add id-token: write to");
794
+ return added;
795
+ }
796
+ /**
797
+ * A GitHub or Forgejo watch file with the cloud login (#132) before the
798
+ * job's chant run, when its reader is cloud-minted and logs in. Parsed and
799
+ * emitted again, checked against chant's text first.
800
+ */
801
+ function withWatchLogin(forge, yaml, env, envs) {
802
+ const login = rawLogin(forge, loginsOf(envs, [[env, "reader"]]));
803
+ if (login.length === 0)
804
+ return yaml;
805
+ const at = yaml.indexOf("\njobs:\n");
806
+ if (at < 0)
807
+ throw new Error(`${forge}: the watch for ${env} has no jobs`);
808
+ const doc = parseYAMLDocument(yaml);
809
+ const top = yaml.slice(0, at + 1);
810
+ const emitJobs = () => `${emitYAMLEntry("jobs", doc.jobs)}\n`;
811
+ if (`${top}${emitJobs()}` !== yaml)
812
+ throw new Error(`${forge}: the watch for ${env} does not emit back to chant's text`);
813
+ const job = doc.jobs[`watch-${env}`];
814
+ if (!job)
815
+ throw new Error(`${forge}: the watch for ${env} has no watch-${env} job`);
816
+ insertSteps(job.steps, login, runsChant, `${forge}: watch-${env}`);
817
+ return `${top}${emitJobs()}`;
818
+ }
819
+ /** The GitLab watch file with `id_tokens` (and the cloud login, #132) on each watch job whose reader is cloud-minted. Parsed and emitted again, checked against chant's text first. */
820
+ function withWatchIdTokens(yaml, envs) {
821
+ const clouds = (env) => cloudsOf(envs, [[env, "reader"]]);
822
+ if (!envs.names.some((env) => clouds(env).length > 0))
823
+ return yaml;
824
+ const body = yaml.search(/^[^#\n]/m);
825
+ const header = yaml.slice(0, body);
826
+ const doc = parseYAMLDocument(yaml);
827
+ const emit = (d) => `${header}${Object.entries(d).map(([k, v]) => emitYAMLEntry(k, v)).join("\n\n")}\n`;
828
+ if (emit(doc) !== yaml)
829
+ throw new Error("gitlab: the watch file does not emit back to chant's text");
830
+ for (const env of envs.names) {
831
+ const job = doc[`watch-${env}`];
832
+ if (clouds(env).length === 0 || job === undefined)
833
+ continue;
834
+ const logins = loginsOf(envs, [[env, "reader"]]);
835
+ const login = gitlabLogin(logins);
836
+ insertLines(job.script, login.lines, (l) => l.startsWith("chant run "), `gitlab: watch-${env}`);
837
+ if (Object.keys(login.variables).length > 0)
838
+ job.variables = { ...job.variables, ...login.variables };
839
+ Object.assign(job, { id_tokens: idTokens(clouds(env), logins) });
840
+ }
841
+ return emit(doc);
842
+ }
571
843
  export async function watchPipelines(dir, envs_) {
572
844
  const { dialect, names: envs } = envs_;
573
845
  const tc = toolchainOf(envs_);
@@ -584,19 +856,24 @@ export async function watchPipelines(dir, envs_) {
584
856
  throw new Error(`${forge}: ${result.error ?? "generateOpsPipeline rendered nothing"}`);
585
857
  for (const file of result.files) {
586
858
  const at = forge === "gitlab" ? GITLAB_WATCH_FILE : `${forge === "github" ? ".github" : ".forgejo"}/workflows/${file.name}`;
587
- out.push({ path: at, content: `${HEADER.join("\n")}\n${file.yaml.endsWith("\n") ? file.yaml : `${file.yaml}\n`}` });
859
+ const yaml = file.yaml.endsWith("\n") ? file.yaml : `${file.yaml}\n`;
860
+ // A GitHub or Forgejo file is one environment's watch: its name is watch-<env>.yml.
861
+ const env = file.name.replace(/^watch-|\.yml$/g, "");
862
+ out.push({ path: at, content: `${HEADER.join("\n")}\n${forge === "gitlab" ? withWatchIdTokens(yaml, envs_) : withWatchLogin(forge, yaml, env, envs_)}` });
588
863
  }
589
864
  };
590
865
  // `chant run` exits 0 when a watch finds drift; the last line, yodel drift, fails the job then.
591
866
  for (const forge of ["github", "forgejo"]) {
592
867
  for (const env of envs)
593
- await emit(forge, [watchSpec(env, dialect, forge)], [watchDrift(env)]);
868
+ await emit(forge, [watchSpec(env, dialect, forge, envs_.credentials?.[env])], [watchDrift(env)]);
594
869
  }
595
870
  // GitLab: one file for every watch, so the last line takes the environment from the job's own name.
596
871
  await emit("gitlab", envs.map((env) => watchSpec(env, dialect, "gitlab")), [watchDrift('"${CI_JOB_NAME#watch-}"')]);
597
872
  return out;
598
873
  }
599
874
  // ── The project ─────────────────────────────────────────────────────────────
875
+ /** Whether the project has a test file for yodel test (tests/*.test.ts). */
876
+ const hasTests = (dir) => testFiles(dir).length > 0;
600
877
  /** The dialect and the environments chant.config.ts declares, in order. */
601
878
  export async function environments(dir) {
602
879
  const { loadChantConfig } = await import("@intentius/chant/config");
@@ -617,16 +894,36 @@ export async function environments(dir) {
617
894
  }
618
895
  }
619
896
  const ci = (await readConfig(dir))?.ci;
897
+ const credentials = credentialsIn(sql?.profiles ?? {}, envs);
620
898
  return {
621
899
  dialect,
622
900
  names: envs,
901
+ ...(Object.keys(credentials).length > 0 ? { credentials } : {}),
623
902
  replay,
903
+ ...(hasTests(dir) ? { tests: true } : {}),
624
904
  waves: await withTenants(dir, await readWaves(dir, envs)),
625
905
  forges: ci?.forges ?? FORGES,
626
906
  ...(ci?.jobs ? { jobs: await loadProjectJobs(dir, ci.jobs) } : {}),
627
907
  ...(ci?.image ? { image: ci.image } : {}),
908
+ ...(ci?.resume ? { resume: { schedule: ci.resume.schedule } } : {}),
909
+ ...(ci?.login ? { login: ci.login } : {}),
628
910
  };
629
911
  }
912
+ /** The roles of each environment whose profile is made with credentials(). */
913
+ export function credentialsIn(profiles, envs) {
914
+ const out = {};
915
+ for (const env of envs) {
916
+ const c = credentialsOf(profiles[env]);
917
+ if (!c)
918
+ continue;
919
+ const needs = (role) => {
920
+ const cloud = cloudToken(c, role);
921
+ return { variables: roleVariables(c, role), ...(cloud ? { cloud } : {}) };
922
+ };
923
+ out[env] = { reader: needs("reader"), writer: needs("writer") };
924
+ }
925
+ return out;
926
+ }
630
927
  /** yodel.config.ts, checked; undefined when the project has none. */
631
928
  async function readConfig(dir) {
632
929
  return (await loadYodelConfig(dir))?.config;