@intentius/sql-yodeler 0.3.0 → 0.4.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 (201) hide show
  1. package/README.md +1 -1
  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 +87 -0
  9. package/dist/chant/access.js +169 -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/render.d.ts +54 -10
  18. package/dist/ci/render.js +249 -52
  19. package/dist/ci/render.js.map +1 -1
  20. package/dist/cleanup/index.d.ts +86 -0
  21. package/dist/cleanup/index.js +207 -0
  22. package/dist/cleanup/index.js.map +1 -0
  23. package/dist/commands/apply.js +153 -20
  24. package/dist/commands/apply.js.map +1 -1
  25. package/dist/commands/approve.d.ts +13 -0
  26. package/dist/commands/approve.js +206 -24
  27. package/dist/commands/approve.js.map +1 -1
  28. package/dist/commands/ci.js +12 -0
  29. package/dist/commands/ci.js.map +1 -1
  30. package/dist/commands/cleanup.d.ts +13 -0
  31. package/dist/commands/cleanup.js +138 -0
  32. package/dist/commands/cleanup.js.map +1 -0
  33. package/dist/commands/config.d.ts +3 -0
  34. package/dist/commands/config.js +136 -0
  35. package/dist/commands/config.js.map +1 -0
  36. package/dist/commands/drift.js +34 -3
  37. package/dist/commands/drift.js.map +1 -1
  38. package/dist/commands/index.js +6 -0
  39. package/dist/commands/index.js.map +1 -1
  40. package/dist/commands/lint.js +30 -6
  41. package/dist/commands/lint.js.map +1 -1
  42. package/dist/commands/new.js +133 -34
  43. package/dist/commands/new.js.map +1 -1
  44. package/dist/commands/plan.js +42 -5
  45. package/dist/commands/plan.js.map +1 -1
  46. package/dist/commands/test.d.ts +3 -0
  47. package/dist/commands/test.js +164 -0
  48. package/dist/commands/test.js.map +1 -0
  49. package/dist/config/define.d.ts +117 -2
  50. package/dist/config/define.js +2 -0
  51. package/dist/config/define.js.map +1 -1
  52. package/dist/config/load.js +74 -10
  53. package/dist/config/load.js.map +1 -1
  54. package/dist/config/settings.d.ts +18 -0
  55. package/dist/config/settings.js +26 -0
  56. package/dist/config/settings.js.map +1 -1
  57. package/dist/credentials.d.ts +119 -0
  58. package/dist/credentials.js +119 -0
  59. package/dist/credentials.js.map +1 -0
  60. package/dist/exit.js +1 -1
  61. package/dist/exit.js.map +1 -1
  62. package/dist/index.d.ts +5 -1
  63. package/dist/index.js +2 -0
  64. package/dist/index.js.map +1 -1
  65. package/dist/init/adopt.js +1 -1
  66. package/dist/init/adopt.js.map +1 -1
  67. package/dist/init/clickhouse-access.d.ts +64 -0
  68. package/dist/init/clickhouse-access.js +211 -0
  69. package/dist/init/clickhouse-access.js.map +1 -0
  70. package/dist/init/live.d.ts +4 -0
  71. package/dist/init/live.js +16 -0
  72. package/dist/init/live.js.map +1 -1
  73. package/dist/lint/access.d.ts +18 -0
  74. package/dist/lint/access.js +34 -0
  75. package/dist/lint/access.js.map +1 -0
  76. package/dist/lint/check.js +23 -6
  77. package/dist/lint/check.js.map +1 -1
  78. package/dist/lint/config.d.ts +7 -1
  79. package/dist/lint/config.js +11 -5
  80. package/dist/lint/config.js.map +1 -1
  81. package/dist/lint/index.d.ts +2 -0
  82. package/dist/lint/index.js +2 -0
  83. package/dist/lint/index.js.map +1 -1
  84. package/dist/lint/naming.d.ts +62 -0
  85. package/dist/lint/naming.js +138 -0
  86. package/dist/lint/naming.js.map +1 -0
  87. package/dist/lint/rules.d.ts +37 -4
  88. package/dist/lint/rules.js +16 -4
  89. package/dist/lint/rules.js.map +1 -1
  90. package/dist/lint/schema-checks.d.ts +63 -0
  91. package/dist/lint/schema-checks.js +128 -0
  92. package/dist/lint/schema-checks.js.map +1 -0
  93. package/dist/lint/statements.d.ts +22 -1
  94. package/dist/lint/statements.js +40 -1
  95. package/dist/lint/statements.js.map +1 -1
  96. package/dist/mcp/tools.d.ts +1 -1
  97. package/dist/mcp/tools.js +1 -1
  98. package/dist/mcp/tools.js.map +1 -1
  99. package/dist/migrations/checkpoint.js +0 -2
  100. package/dist/migrations/checkpoint.js.map +1 -1
  101. package/dist/migrations/checks.d.ts +19 -1
  102. package/dist/migrations/checks.js +15 -0
  103. package/dist/migrations/checks.js.map +1 -1
  104. package/dist/migrations/create.d.ts +13 -4
  105. package/dist/migrations/create.js +24 -9
  106. package/dist/migrations/create.js.map +1 -1
  107. package/dist/migrations/routines.d.ts +5 -15
  108. package/dist/migrations/routines.js +5 -30
  109. package/dist/migrations/routines.js.map +1 -1
  110. package/dist/pr/model.d.ts +4 -1
  111. package/dist/pr/model.js +13 -5
  112. package/dist/pr/model.js.map +1 -1
  113. package/dist/pr/render.d.ts +4 -1
  114. package/dist/pr/render.js +22 -8
  115. package/dist/pr/render.js.map +1 -1
  116. package/dist/pr/tenants.d.ts +37 -0
  117. package/dist/pr/tenants.js +138 -0
  118. package/dist/pr/tenants.js.map +1 -0
  119. package/dist/probe.d.ts +27 -0
  120. package/dist/probe.js +106 -0
  121. package/dist/probe.js.map +1 -0
  122. package/dist/project.d.ts +16 -0
  123. package/dist/project.js +29 -1
  124. package/dist/project.js.map +1 -1
  125. package/dist/replay/clickhouse.d.ts +31 -0
  126. package/dist/replay/clickhouse.js +103 -6
  127. package/dist/replay/clickhouse.js.map +1 -1
  128. package/dist/replay/replay.d.ts +5 -2
  129. package/dist/replay/replay.js.map +1 -1
  130. package/dist/report/audit.d.ts +1 -1
  131. package/dist/report/audit.js +20 -3
  132. package/dist/report/audit.js.map +1 -1
  133. package/dist/schemas.d.ts +3 -1
  134. package/dist/schemas.js +2 -0
  135. package/dist/schemas.js.map +1 -1
  136. package/dist/sources/ddl.d.ts +15 -20
  137. package/dist/sources/ddl.js +34 -328
  138. package/dist/sources/ddl.js.map +1 -1
  139. package/dist/sources/index.js +2 -2
  140. package/dist/sources/index.js.map +1 -1
  141. package/dist/steps/backfill.d.ts +40 -5
  142. package/dist/steps/backfill.js +128 -8
  143. package/dist/steps/backfill.js.map +1 -1
  144. package/dist/steps/index.d.ts +19 -1
  145. package/dist/steps/index.js +34 -7
  146. package/dist/steps/index.js.map +1 -1
  147. package/dist/steps/kinds.d.ts +42 -4
  148. package/dist/steps/kinds.js +88 -14
  149. package/dist/steps/kinds.js.map +1 -1
  150. package/dist/steps/postgres-migration.d.ts +3 -0
  151. package/dist/steps/postgres-migration.js +7 -3
  152. package/dist/steps/postgres-migration.js.map +1 -1
  153. package/dist/steps/rebuild.d.ts +15 -6
  154. package/dist/steps/rebuild.js +12 -9
  155. package/dist/steps/rebuild.js.map +1 -1
  156. package/dist/tenants/index.d.ts +2 -1
  157. package/dist/tenants/index.js +3 -4
  158. package/dist/tenants/index.js.map +1 -1
  159. package/dist/tenants/wave.d.ts +128 -0
  160. package/dist/tenants/wave.js +254 -0
  161. package/dist/tenants/wave.js.map +1 -0
  162. package/dist/testing/define.d.ts +62 -0
  163. package/dist/testing/define.js +32 -0
  164. package/dist/testing/define.js.map +1 -0
  165. package/dist/testing/files.d.ts +5 -0
  166. package/dist/testing/files.js +18 -0
  167. package/dist/testing/files.js.map +1 -0
  168. package/dist/testing/report.d.ts +14 -0
  169. package/dist/testing/report.js +83 -0
  170. package/dist/testing/report.js.map +1 -0
  171. package/dist/testing/run.d.ts +104 -0
  172. package/dist/testing/run.js +328 -0
  173. package/dist/testing/run.js.map +1 -0
  174. package/dist/versioned/approval.d.ts +6 -0
  175. package/dist/versioned/approval.js +11 -5
  176. package/dist/versioned/approval.js.map +1 -1
  177. package/dist/versioned/checks.d.ts +8 -2
  178. package/dist/versioned/checks.js +34 -13
  179. package/dist/versioned/checks.js.map +1 -1
  180. package/dist/versioned/driver.d.ts +2 -2
  181. package/dist/versioned/engine.d.ts +10 -0
  182. package/dist/versioned/engine.js +8 -4
  183. package/dist/versioned/engine.js.map +1 -1
  184. package/dist/versioned/explain.d.ts +2 -0
  185. package/dist/versioned/explain.js +3 -1
  186. package/dist/versioned/explain.js.map +1 -1
  187. package/dist/versioned/state.js +1 -1
  188. package/dist/versioned/state.js.map +1 -1
  189. package/dist/waves/index.d.ts +58 -13
  190. package/dist/waves/index.js +109 -24
  191. package/dist/waves/index.js.map +1 -1
  192. package/dist/waves/record.d.ts +10 -0
  193. package/dist/waves/record.js.map +1 -1
  194. package/package.json +7 -7
  195. package/schemas/apply.schema.json +1 -1
  196. package/schemas/cleanup.schema.json +40 -0
  197. package/schemas/lint-rules.schema.json +4 -0
  198. package/schemas/new.schema.json +41 -3
  199. package/schemas/plan.schema.json +39 -0
  200. package/schemas/report.schema.json +1 -1
  201. package/schemas/test.schema.json +41 -0
package/dist/ci/render.js CHANGED
@@ -71,7 +71,11 @@
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 { RECORD_PLANS_ENV } from "../approval-mode/index.js";
76
+ import { cloudToken, credentialsOf, roleVariables } from "../credentials.js";
74
77
  import { loadYodelConfig } from "../config/load.js";
78
+ import { testFiles } from "../testing/files.js";
75
79
  import { emitYAMLEntry, parseYAMLDocument } from "@intentius/chant/yaml";
76
80
  import { Concurrency, Job as ActionsJob, Permissions, Service as ActionsService, Step, Workflow as ActionsWorkflow, } from "@intentius/chant-lexicon-github";
77
81
  import { githubSerializer } from "@intentius/chant-lexicon-github/serializer";
@@ -94,16 +98,54 @@ const SETUP_NODE = "actions/setup-node@v7";
94
98
  export const prefixOf = (env) => env.toUpperCase().replace(/[^A-Z0-9]/g, "_");
95
99
  /** DEV_CLICKHOUSE for dev on ClickHouse: what an environment's variables start with. */
96
100
  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) => {
101
+ /** The variables a job reading `env` holds: the URL and the reader (the variables credentials() names; none for a token-minted password). */
102
+ export const readerVariables = (env, dialect, creds) => {
99
103
  const p = variablePrefix(env, dialect);
100
- return [`${p}_URL`, `${p}_READER_USER`, `${p}_READER_PASSWORD`];
104
+ return [`${p}_URL`, ...(creds?.reader.variables ?? [`${p}_READER_USER`, `${p}_READER_PASSWORD`])];
101
105
  };
102
106
  /** The variables the apply job for `env` holds: the URL and the writer. */
103
- export const writerVariables = (env, dialect) => {
107
+ export const writerVariables = (env, dialect, creds) => {
104
108
  const p = variablePrefix(env, dialect);
105
- return [`${p}_URL`, `${p}_WRITER_USER`, `${p}_WRITER_PASSWORD`];
109
+ return [`${p}_URL`, ...(creds?.writer.variables ?? [`${p}_WRITER_USER`, `${p}_WRITER_PASSWORD`])];
106
110
  };
111
+ // ── Cloud identity (#50) ────────────────────────────────────────────────────
112
+ //
113
+ // A role whose password is minted by a cloud token source (rds-iam,
114
+ // cloud-sql-iam, entra) mints it with the cloud's command line, from the
115
+ // job's identity. A job holding one gets an OIDC token from the forge: on
116
+ // GitHub the workflow grants `id-token: write`; on GitLab the job declares
117
+ // an ID token for the cloud's audience. Forgejo issues no OIDC token to a
118
+ // job (its dialect drops `permissions:`), so there the runner's own cloud
119
+ // identity mints.
120
+ /** The GitLab ID token per cloud: its variable and its audience. */
121
+ export const ID_TOKENS = {
122
+ "rds-iam": { variable: "AWS_ID_TOKEN", aud: "sts.amazonaws.com" },
123
+ "cloud-sql-iam": { variable: "GCP_ID_TOKEN", aud: "https://iam.googleapis.com/" },
124
+ entra: { variable: "AZURE_ID_TOKEN", aud: "api://AzureADTokenExchange" },
125
+ };
126
+ /** The cloud token sources the roles `held` mint from, each once, in order. */
127
+ const cloudsOf = (envs, held) => [
128
+ ...new Set(held.map(([env, role]) => envs.credentials?.[env]?.[role].cloud).filter((c) => c !== undefined)),
129
+ ];
130
+ /** A GitLab job's `id_tokens` for `clouds`. */
131
+ const idTokens = (clouds) => Object.fromEntries(clouds.map((c) => [ID_TOKENS[c].variable, { aud: ID_TOKENS[c].aud }]));
132
+ /** The roles a pull request's plan job for `env` holds: the environment's reader and the reader of the one it requires. */
133
+ const planRoles = (waves, env) => [env, ...requiredOf(waves, env)].map((e) => [e, "reader"]);
134
+ /** The roles a wave job for `env` holds: the writer, and the reader of the environment it requires. */
135
+ const waveRoles = (waves, env) => [[env, "writer"], ...requiredOf(waves, env).map((e) => [e, "reader"])];
136
+ /** The header lines saying what the cloud identity is for, when a job here holds a cloud-minted role. */
137
+ function identityHeader(forge, clouds) {
138
+ if (clouds.length === 0)
139
+ return [];
140
+ const names = clouds.join(", ");
141
+ if (forge === "github")
142
+ 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)."];
143
+ if (forge === "gitlab")
144
+ 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(", ")}).`];
145
+ 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."];
146
+ }
147
+ /** The write probe a pull request job runs before its plan: it holds no writer for `envs`. */
148
+ export const writeProbe = (envs) => `yodel config check ${envs.join(" ")} --write-probe`;
107
149
  const secret = (name) => `\${{ secrets.${name} }}`;
108
150
  const secrets = (names) => Object.fromEntries(names.map((n) => [n, secret(n)]));
109
151
  /**
@@ -154,6 +196,8 @@ export const REPLAY_SERVICE = {
154
196
  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
197
  export const LINT = "yodel lint";
156
198
  export const REPLAY = `yodel lint --replay ${REPLAY_PROFILE} --reset`;
199
+ /** yodel test (#54), after the replay check on the same throwaway server, its outcome as JUnit XML. */
200
+ export const TEST = `yodel test --env ${REPLAY_PROFILE} --reset --junit yodel-test.xml`;
157
201
  /** Fails a pull request whose pipelines differ from what yodel ci renders. */
158
202
  export const CI_CHECK = "yodel ci --check";
159
203
  /**
@@ -225,7 +269,7 @@ const runner = (container, tc) => ({ "runs-on": "ubuntu-latest", ...(container ?
225
269
  function commentToken(forge) {
226
270
  return forge === "github" ? { GITHUB_TOKEN: "${{ github.token }}" } : { FORGEJO_TOKEN: "${{ github.token }}" };
227
271
  }
228
- function lintJob(forge, dialect, replay, tc) {
272
+ function lintJob(forge, dialect, replay, tc, tests = false) {
229
273
  // With the replay check, the job runs in IMAGE on every forge and reaches
230
274
  // its replay server by the service's name on the job's own network. On
231
275
  // GitHub's VM the service would be on localhost through a published port,
@@ -254,31 +298,35 @@ function lintJob(forge, dialect, replay, tc) {
254
298
  ...steps,
255
299
  new Step({ name: `Wait for the replay server (${svc.name})`, run: waitFor(svc.name, svc.port) }),
256
300
  new Step({ name: "yodel lint --replay", run: REPLAY }),
301
+ ...(tests ? [new Step({ name: "yodel test", run: TEST })] : []),
257
302
  ],
258
303
  });
259
304
  }
260
305
  export function actionsReview(forge, envs_) {
261
306
  const { dialect, names: envs, replay, waves, jobs: own } = envs_;
262
307
  const tc = toolchainOf(envs_);
263
- const jobs = { lint: lintJob(forge, dialect, replay, tc) };
308
+ const jobs = { lint: lintJob(forge, dialect, replay, tc, envs_.tests === true) };
264
309
  for (const env of envs) {
310
+ const read = [env, ...requiredOf(waves, env)];
265
311
  jobs[`plan-${env}`] = new ActionsJob({
266
312
  ...runner(inContainer(forge, tc), tc),
267
313
  // 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]),
314
+ env: secrets([...read.flatMap((e) => readerVariables(e, dialect, envs_.credentials?.[e])), ...WEBHOOK_VARIABLES]),
269
315
  steps: [
270
316
  // The plan explains what moved since an approval from git's history of migrations/.
271
317
  ...setupSteps(inContainer(forge, tc), tc),
318
+ new Step({ name: "This job cannot write", run: writeProbe(read) }),
272
319
  new Step({ name: `yodel plan ${env} --comment`, run: `yodel plan ${env} --comment`, env: commentToken(forge) }),
273
320
  ...reportSteps(env, `plan-${env}`, tc),
274
321
  ],
275
322
  });
276
323
  }
277
324
  Object.assign(jobs, ownJobs(own?.actions, jobs, "actions"));
325
+ const clouds = cloudsOf(envs_, envs.flatMap((env) => planRoles(waves, env)));
278
326
  const workflow = new ActionsWorkflow({
279
327
  name: "yodel-pr",
280
328
  on: { pull_request: { branches: [MAIN] } },
281
- permissions: new Permissions({ contents: "read", "pull-requests": "write" }),
329
+ permissions: new Permissions({ contents: "read", "pull-requests": "write", ...(clouds.length > 0 ? { "id-token": "write" } : {}) }),
282
330
  concurrency: new Concurrency({ group: "yodel-pr-${{ github.event.pull_request.number }}", "cancel-in-progress": true }),
283
331
  jobs,
284
332
  });
@@ -287,10 +335,13 @@ export function actionsReview(forge, envs_) {
287
335
  "#",
288
336
  "# Pull requests: yodel lint and the replay check (into a throwaway server the",
289
337
  "# lint job starts), and the plan comment per environment with the",
290
- "# environment's reader credentials. No job here holds a writer.",
338
+ "# environment's reader credentials. No job here holds a writer, and each plan",
339
+ "# job shows it first: yodel config check --write-probe tries a write and",
340
+ "# fails the job when the server lets it through.",
291
341
  ...(forge === "forgejo"
292
342
  ? ["# The comment is posted with the job's own token (Forgejo gives a same-repository", "# pull request one that can comment)."]
293
343
  : []),
344
+ ...identityHeader(forge, clouds),
294
345
  ];
295
346
  return {
296
347
  path: `${forge === "github" ? ".github" : ".forgejo"}/workflows/yodel-pr.yml`,
@@ -300,6 +351,10 @@ export function actionsReview(forge, envs_) {
300
351
  // ── GitLab CI ───────────────────────────────────────────────────────────────
301
352
  export const GITLAB_WATCH_FILE = ".gitlab/yodel-watch.gitlab-ci.yml";
302
353
  export const GITLAB_APPLY_FILE = ".gitlab/yodel-apply.gitlab-ci.yml";
354
+ /** The stage of the scheduled resume job in GITLAB_APPLY_FILE, as chant names it. */
355
+ const RESUME_STAGE = "resume";
356
+ /** The stage of the merge request job that records the plans a pr-review wave's review approves (#48), as chant names it. */
357
+ const PLANS_STAGE = "plans";
303
358
  /** The stage of each wave's job in GITLAB_APPLY_FILE, as chant names it. */
304
359
  const waveStages = (waves) => waves.map((w, i) => `wave-${String(i + 1)}-${w.env}`);
305
360
  export function gitlabPipeline(envs_) {
@@ -325,18 +380,20 @@ export function gitlabPipeline(envs_) {
325
380
  rules: mr(),
326
381
  ...(replay
327
382
  ? {
328
- script: [CI_CHECK, LINT, waitFor(svc.name, svc.port), REPLAY],
383
+ script: [CI_CHECK, LINT, waitFor(svc.name, svc.port), REPLAY, ...(envs_.tests === true ? [TEST] : [])],
329
384
  services: [new GitLabService({ name: svc.image, alias: svc.name, variables: svc.env })],
330
385
  variables: { [`${variablePrefix(REPLAY_PROFILE, dialect)}_URL`]: svc.url(svc.name), ...svc.jobEnv },
331
386
  }
332
387
  : { script: [CI_CHECK, LINT] }),
333
388
  }));
334
389
  for (const env of envs) {
390
+ const clouds = cloudsOf(envs_, planRoles(waves, env));
335
391
  jobs.set(`plan-${env}`, new GitLabJob({
336
392
  extends: ".yodel",
337
393
  stage: "review",
338
394
  rules: mr(),
339
- script: [`yodel plan ${env} --comment`],
395
+ script: [writeProbe([env, ...requiredOf(waves, env)]), `yodel plan ${env} --comment`],
396
+ ...(clouds.length > 0 ? { id_tokens: idTokens(clouds) } : {}),
340
397
  after_script: [reportCommand(env, tc)],
341
398
  artifacts: new GitLabArtifacts({ name: reportArtifact(`plan-${env}`), when: "always", paths: [`${REPORT_DIR}/`] }),
342
399
  }));
@@ -356,19 +413,26 @@ export function gitlabPipeline(envs_) {
356
413
  throw new Error("gitlab: expected the jobs' YAML to start with their stages, review first");
357
414
  const derived = jobsYaml.slice(0, end + 2);
358
415
  // 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"])];
416
+ // A pr-review wave (#48) adds the merge request job that records the plans, in chant's stage "plans".
417
+ const plans = waves.some((w) => w.approval === "pr-review") ? [PLANS_STAGE] : [];
418
+ const stages = [...new Set([...derivedStages.map(String), ...plans, ...waveStages(waves), ...(envs_.resume ? [RESUME_STAGE] : []), "ops"])];
360
419
  const topLevel = [
361
420
  emitYAMLEntry("include", [{ local: GITLAB_APPLY_FILE }, { local: GITLAB_WATCH_FILE }]),
362
421
  emitYAMLEntry("stages", stages),
363
422
  emitYAMLEntry("variables", { GIT_DEPTH: "0" }),
364
423
  ];
365
424
  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
- ]);
425
+ const variables = envs.flatMap((env) => {
426
+ const creds = envs_.credentials?.[env];
427
+ const writer = writerVariables(env, dialect, creds).slice(1);
428
+ return [
429
+ `# ${readerVariables(env, dialect, creds).join(", ")}: not protected (merge request`,
430
+ "# pipelines run on unprotected branches), masked; no environment scope.",
431
+ ...(writer.length > 0
432
+ ? [`# ${writer.join(", ")}: protected, masked,`, `# environment scope "${env}": only ${env}'s wave job declares that environment.`]
433
+ : []),
434
+ ];
435
+ });
372
436
  const header = [
373
437
  ...HEADER,
374
438
  "#",
@@ -380,12 +444,22 @@ export function gitlabPipeline(envs_) {
380
444
  "# protected or scoped to an environment, so the split between reader and",
381
445
  "# writer is in the variables' settings (Settings > CI/CD > Variables):",
382
446
  ...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.",
447
+ ...(envs_.resume
448
+ ? [
449
+ "# GITLAB_TOKEN: a project access token with the api scope (Developer), for",
450
+ "# the plan comment, the drift watch's tracking issue and the resume job's",
451
+ "# retry of a wave whose approval arrived; CI_JOB_TOKEN cannot write merge",
452
+ "# request notes or issues, or retry a job.",
453
+ ]
454
+ : [
455
+ "# GITLAB_TOKEN: a project access token with the api scope (Reporter), for",
456
+ "# the plan comment and the drift watch's tracking issue; CI_JOB_TOKEN",
457
+ "# cannot write merge request notes or issues.",
458
+ ]),
386
459
  "# The jobs that hold no writer read with the reader: chant.config.ts picks an",
387
460
  "# environment's writer only under YODEL_CREDENTIALS=writer:<env>, which only",
388
461
  "# that environment's wave job sets.",
462
+ ...identityHeader("gitlab", cloudsOf(envs_, envs.flatMap((env) => [...planRoles(waves, env), ...waveRoles(waves, env), [env, "reader"]]))),
389
463
  ];
390
464
  return { path: ".gitlab-ci.yml", content: withHeader(header, yaml) };
391
465
  }
@@ -402,7 +476,7 @@ export const APPLY_PIPELINE = "yodel-apply";
402
476
  * The waves spec. What the file holds is the same for every forge; each
403
477
  * forge's jobs get their own variables on top (`variables`).
404
478
  */
405
- export function wavesSpec(waves, variables) {
479
+ export function wavesSpec(waves, variables, resume) {
406
480
  const first = waves[0];
407
481
  if (!first)
408
482
  throw new Error("no waves to render");
@@ -417,8 +491,10 @@ export function wavesSpec(waves, variables) {
417
491
  runs: (w.targets ?? [w.env]).map((target) => ({ target, op: `migrate-${w.env}` })),
418
492
  gate: w.gate,
419
493
  ...(w.shares !== undefined && w.shares > 1 ? { shares: w.shares } : {}),
494
+ ...(w.approval !== undefined && w.approval !== "ledger" ? { approval: w.approval } : {}),
420
495
  ...(variables ? { environment: { name: w.env }, variables: variables(w.env) } : {}),
421
496
  })),
497
+ ...(resume ? { resume } : {}),
422
498
  };
423
499
  }
424
500
  /** The waves spec file and the apply pipeline on each forge. */
@@ -429,13 +505,19 @@ export async function applyPipelines(dir, envs) {
429
505
  const out = [{ path: WAVES_SPEC, content: `${JSON.stringify(wavesSpec(waves), null, 2)}\n` }];
430
506
  // A GitLab job already has the project's variables, a writer's only under its environment.
431
507
  const variables = (forge) => (env) => {
432
- const required = waves.find((w) => w.env === env)?.requires;
508
+ const wave = waves.find((w) => w.env === env);
509
+ const required = wave?.requires;
433
510
  return {
434
511
  YODEL_CREDENTIALS: `writer:${env}`,
435
512
  ...APPLY_LOCK,
436
513
  ...(forge === "gitlab"
437
514
  ? {}
438
- : { ...secrets([...writerVariables(env, dialect), ...WEBHOOK_VARIABLES]), ...(required ? secrets(readerVariables(required, dialect)) : {}) }),
515
+ : {
516
+ ...secrets([...writerVariables(env, dialect, envs.credentials?.[env]), ...WEBHOOK_VARIABLES]),
517
+ ...(required ? secrets(readerVariables(required, dialect, envs.credentials?.[required])) : {}),
518
+ // A pr-review wave (#48) reads the merged pull request's reviews with the job's token.
519
+ ...(wave?.approval === "pr-review" ? { GITHUB_TOKEN: "${{ github.token }}" } : {}),
520
+ }),
439
521
  };
440
522
  };
441
523
  const header = [
@@ -446,13 +528,35 @@ export async function applyPipelines(dir, envs) {
446
528
  `# wave's gate policy is read from ${WAVES_SPEC} as the commit before the push`,
447
529
  "# has it (yodel.config.ts waves, rendered), and a wave that needs an approval",
448
530
  "# 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",
531
+ "# yodel approve <env> records the approval and starts the waiting job again",
532
+ "# (or approve, push chant/lifecycle, and run the failed job again). These are",
533
+ "# the only jobs that hold a writer's credentials, one environment's each, with",
451
534
  "# the reader of the environment before (waves[].requires): yodel apply refuses",
452
535
  "# a migration that environment's history does not record as applied.",
453
536
  ];
537
+ const resumeHeader = (forge) => [
538
+ ...HEADER,
539
+ "#",
540
+ `# On its schedule: chant run resume --op ${APPLY_PIPELINE} starts again each apply wave`,
541
+ "# that waits at its gate and whose approval has arrived since. It approves",
542
+ "# nothing; a wave whose approval does not count waits again.",
543
+ ...(forge === "github"
544
+ ? ["# It re-runs the run's failed jobs with the job's token (actions: write)."]
545
+ : ["# 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."]),
546
+ ];
547
+ const waveClouds = cloudsOf(envs, waves.flatMap((w) => waveRoles(waves, w.env)));
548
+ const plansHeader = [
549
+ ...HEADER,
550
+ "#",
551
+ "# A pull request into main, when a wave's approval is pr-review (yodel.config.ts",
552
+ "# waves[].approval): chant run wave --record-plans plans every wave at the pull",
553
+ "# request's head, as it will be after the merge, and records the digests on",
554
+ "# chant/lifecycle. After the merge, an approving review of that head by a writer",
555
+ "# other than its author approves the digest it recorded, and only that one. It",
556
+ "# holds no writer's credentials: it plans with each environment's reader.",
557
+ ];
454
558
  for (const forge of FORGES) {
455
- const result = await generateOpWavesPipeline(wavesSpec(waves, variables(forge)), forge, {
559
+ const result = await generateOpWavesPipeline(wavesSpec(waves, variables(forge), envs.resume), forge, {
456
560
  specFile: WAVES_SPEC,
457
561
  image: tc.image,
458
562
  beforeScript: [...installLines(tc, forge), FETCH_APPROVALS],
@@ -462,7 +566,15 @@ export async function applyPipelines(dir, envs) {
462
566
  throw new Error(`${forge}: ${result.error ?? "generateOpWavesPipeline rendered nothing"}`);
463
567
  for (const file of result.files) {
464
568
  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)) });
569
+ const yaml = file.yaml.endsWith("\n") ? file.yaml : `${file.yaml}\n`;
570
+ // The resume workflow (GitHub, Forgejo) has no wave job to report on.
571
+ if (forge !== "gitlab" && file.name === `${APPLY_PIPELINE}-resume.yml`)
572
+ out.push({ path: at, content: withHeader(resumeHeader(forge), yaml) });
573
+ // The pull request workflow that records the plans a review approves (#48).
574
+ else if (forge !== "gitlab" && file.name === `${APPLY_PIPELINE}-plans.yml`)
575
+ out.push({ path: at, content: withHeader(plansHeader, withPlansReaders(forge, yaml, waves, dialect, envs)) });
576
+ else
577
+ out.push({ path: at, content: withHeader([...header, ...identityHeader(forge, waveClouds)], withWaveReports(forge, yaml, waves, tc, envs)) });
466
578
  }
467
579
  }
468
580
  return out;
@@ -475,7 +587,7 @@ export async function applyPipelines(dir, envs) {
475
587
  * back chant's text unchanged before anything is added, so a change in
476
588
  * chant's emitter fails here instead of rendering something else.
477
589
  */
478
- function withWaveReports(forge, yaml, waves, tc) {
590
+ function withWaveReports(forge, yaml, waves, tc, envs = {}) {
479
591
  // A wide wave's jobs are wave-<k>-<env>-decide and -share-<i>.
480
592
  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
593
  const body = yaml.search(/^[^#\n]/m);
@@ -485,11 +597,20 @@ function withWaveReports(forge, yaml, waves, tc) {
485
597
  const emit = (d) => `${header}${Object.entries(d).map(([k, v]) => emitYAMLEntry(k, v)).join("\n\n")}\n`;
486
598
  if (emit(doc) !== yaml)
487
599
  throw new Error("gitlab: the apply waves do not emit back to chant's text");
600
+ // The merge request job that records the plans a review approves (#48): the waves as after the merge.
601
+ const record = doc[`${APPLY_PIPELINE}-record-plans`];
602
+ if (record) {
603
+ record.variables = { ...(record.variables ?? {}), [RECORD_PLANS_ENV]: "1" };
604
+ const readers = cloudsOf(envs, [...new Set(waves.map((w) => w.env))].map((e) => [e, "reader"]));
605
+ if (readers.length > 0)
606
+ record.id_tokens = idTokens(readers);
607
+ }
488
608
  for (const [name, job] of Object.entries(doc)) {
489
609
  const env = envOf(name);
490
610
  if (!env || typeof job !== "object" || job === null)
491
611
  continue;
492
- Object.assign(job, { after_script: [reportCommand(env, tc)], artifacts: { name: reportArtifact(name), when: "always", paths: [`${REPORT_DIR}/`] } });
612
+ const clouds = cloudsOf(envs, waveRoles(waves, env));
613
+ Object.assign(job, { after_script: [reportCommand(env, tc)], artifacts: { name: reportArtifact(name), when: "always", paths: [`${REPORT_DIR}/`] }, ...(clouds.length > 0 ? { id_tokens: idTokens(clouds) } : {}) });
493
614
  }
494
615
  return emit(doc);
495
616
  }
@@ -497,8 +618,9 @@ function withWaveReports(forge, yaml, waves, tc) {
497
618
  if (at < 0)
498
619
  throw new Error(`${forge}: the apply waves have no jobs`);
499
620
  const jobs = doc.jobs;
500
- const emit = () => `${yaml.slice(0, at + 1)}${emitYAMLEntry("jobs", jobs)}\n`;
501
- if (emit() !== yaml)
621
+ const top = yaml.slice(0, at + 1);
622
+ const emitJobs = () => `${emitYAMLEntry("jobs", jobs)}\n`;
623
+ if (`${top}${emitJobs()}` !== yaml)
502
624
  throw new Error(`${forge}: the apply waves do not emit back to chant's text`);
503
625
  for (const [name, job] of Object.entries(jobs)) {
504
626
  const env = envOf(name);
@@ -508,7 +630,33 @@ function withWaveReports(forge, yaml, waves, tc) {
508
630
  // chant's forgejo output is already in Forgejo's dialect, so the action is named as the forgejo lexicon names it.
509
631
  { 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
632
  }
511
- return emit();
633
+ return `${withIdToken(forge, top, cloudsOf(envs, waves.flatMap((w) => waveRoles(waves, w.env))))}${emitJobs()}`;
634
+ }
635
+ /**
636
+ * The plans workflow (GitHub, Forgejo) for a pr-review wave (#48): chant's,
637
+ * with each environment's reader (the waves are planned on every one, and a
638
+ * wave reads the history of the one it requires) and {@link RECORD_PLANS_ENV}
639
+ * on its job. The output is parsed and emitted again, checked first to give
640
+ * back chant's text unchanged.
641
+ */
642
+ function withPlansReaders(forge, yaml, waves, dialect, creds = {}) {
643
+ const at = yaml.indexOf("\njobs:\n");
644
+ if (at < 0)
645
+ throw new Error(`${forge}: the plans workflow has no jobs`);
646
+ const doc = parseYAMLDocument(yaml);
647
+ const top = yaml.slice(0, at + 1);
648
+ const emitJobs = () => `${emitYAMLEntry("jobs", doc.jobs)}\n`;
649
+ if (`${top}${emitJobs()}` !== yaml)
650
+ throw new Error(`${forge}: the plans workflow does not emit back to chant's text`);
651
+ const job = doc.jobs["record-plans"];
652
+ if (!job)
653
+ throw new Error(`${forge}: the plans workflow has no record-plans job`);
654
+ const envs = [...new Set(waves.map((w) => w.env))];
655
+ const steps = job.steps;
656
+ delete job.steps;
657
+ Object.assign(job, { env: { [RECORD_PLANS_ENV]: "1", ...secrets(envs.flatMap((e) => readerVariables(e, dialect, creds.credentials?.[e]))) }, steps });
658
+ // A reader whose password is a cloud token (#50) is minted from the job's identity.
659
+ return `${withIdToken(forge, top, cloudsOf(creds, envs.map((e) => [e, "reader"])))}${emitJobs()}`;
512
660
  }
513
661
  /** yodel.config.ts `waves`, checked against the environments; one wave per environment, gate `always`, without it. */
514
662
  export async function readWaves(dir, envs) {
@@ -527,28 +675,28 @@ export async function readWaves(dir, envs) {
527
675
  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
676
  if (requires !== undefined && !envs.includes(requires))
529
677
  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 } : {}) };
678
+ const approval = w.approval;
679
+ if (approval !== undefined && !APPROVAL_MODES.includes(approval))
680
+ throw new Error(`yodel.config.ts: waves[${String(i)}].approval ${String(w.approval)} is not ${APPROVAL_MODES.join(", ")}`);
681
+ return { env, gate, ...(requires ? { requires } : {}), ...(typeof w.shares === "number" && w.shares > 1 ? { shares: w.shares } : {}), ...(approval && approval !== "ledger" ? { approval } : {}) };
531
682
  });
532
683
  }
533
684
  /**
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.
685
+ * Each wave of an environment with a tenant set (#56), with its runs: a
686
+ * run per tenant of a list or a file, or for a set given by a query,
687
+ * `shares` slots (one without) that read the tenants when the wave runs.
688
+ * The query is not run here, so the pipeline stays the same as tenants come
689
+ * and go; a list or a file is in the repository, and changing it is
690
+ * rendering again.
537
691
  */
538
692
  export async function withTenants(dir, waves) {
539
693
  const { loadProject } = await import("../project.js");
540
- const { listTenants, tenantTarget } = await import("../tenants/index.js");
694
+ const { waveTargets } = await import("../tenants/wave.js");
541
695
  const project = await loadProject(dir);
542
696
  const out = [];
543
697
  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)) });
698
+ const targets = await waveTargets(project, w.env, w.shares);
699
+ out.push(targets === undefined ? w : { ...w, targets });
552
700
  }
553
701
  return out;
554
702
  }
@@ -558,16 +706,43 @@ export async function withTenants(dir, waves) {
558
706
  * writer's only under its environment, which the watch does not declare), and
559
707
  * `NAME: $NAME` there would refer to itself.
560
708
  */
561
- export function watchSpec(env, dialect, forge) {
709
+ export function watchSpec(env, dialect, forge, creds) {
562
710
  if (forge === "gitlab")
563
711
  return { name: `watch-${env}` };
564
712
  // The job's token, for the tracking issue: GitHub grants it issues: write here, Forgejo's can write already.
565
713
  return {
566
714
  name: `watch-${env}`,
567
- variables: { ...secrets([...readerVariables(env, dialect), ...WEBHOOK_VARIABLES]), GITHUB_TOKEN: "${{ github.token }}" },
568
- ...(forge === "github" ? { permissions: { issues: "write" } } : {}),
715
+ variables: { ...secrets([...readerVariables(env, dialect, creds), ...WEBHOOK_VARIABLES]), GITHUB_TOKEN: "${{ github.token }}" },
716
+ ...(forge === "github" ? { permissions: { issues: "write", ...(creds?.reader.cloud ? { "id-token": "write" } : {}) } } : {}),
569
717
  };
570
718
  }
719
+ /** A GitHub workflow's top-level `permissions:` with `id-token: write` added, when a job holds a cloud-minted role. */
720
+ function withIdToken(forge, top, clouds) {
721
+ if (forge !== "github" || clouds.length === 0)
722
+ return top;
723
+ const added = top.replace(/^permissions:\n((?: {2}\S.*\n)*)/m, (_m, scopes) => `permissions:\n${scopes} id-token: write\n`);
724
+ if (added === top)
725
+ throw new Error("github: the apply waves have no top-level permissions to add id-token: write to");
726
+ return added;
727
+ }
728
+ /** The GitLab watch file with `id_tokens` on each watch job whose reader is cloud-minted. Parsed and emitted again, checked against chant's text first. */
729
+ function withWatchIdTokens(yaml, envs) {
730
+ const clouds = (env) => cloudsOf(envs, [[env, "reader"]]);
731
+ if (!envs.names.some((env) => clouds(env).length > 0))
732
+ return yaml;
733
+ const body = yaml.search(/^[^#\n]/m);
734
+ const header = yaml.slice(0, body);
735
+ const doc = parseYAMLDocument(yaml);
736
+ const emit = (d) => `${header}${Object.entries(d).map(([k, v]) => emitYAMLEntry(k, v)).join("\n\n")}\n`;
737
+ if (emit(doc) !== yaml)
738
+ throw new Error("gitlab: the watch file does not emit back to chant's text");
739
+ for (const env of envs.names) {
740
+ const job = doc[`watch-${env}`];
741
+ if (clouds(env).length > 0 && typeof job === "object" && job !== null)
742
+ Object.assign(job, { id_tokens: idTokens(clouds(env)) });
743
+ }
744
+ return emit(doc);
745
+ }
571
746
  export async function watchPipelines(dir, envs_) {
572
747
  const { dialect, names: envs } = envs_;
573
748
  const tc = toolchainOf(envs_);
@@ -584,19 +759,22 @@ export async function watchPipelines(dir, envs_) {
584
759
  throw new Error(`${forge}: ${result.error ?? "generateOpsPipeline rendered nothing"}`);
585
760
  for (const file of result.files) {
586
761
  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`}` });
762
+ const yaml = file.yaml.endsWith("\n") ? file.yaml : `${file.yaml}\n`;
763
+ out.push({ path: at, content: `${HEADER.join("\n")}\n${forge === "gitlab" ? withWatchIdTokens(yaml, envs_) : yaml}` });
588
764
  }
589
765
  };
590
766
  // `chant run` exits 0 when a watch finds drift; the last line, yodel drift, fails the job then.
591
767
  for (const forge of ["github", "forgejo"]) {
592
768
  for (const env of envs)
593
- await emit(forge, [watchSpec(env, dialect, forge)], [watchDrift(env)]);
769
+ await emit(forge, [watchSpec(env, dialect, forge, envs_.credentials?.[env])], [watchDrift(env)]);
594
770
  }
595
771
  // GitLab: one file for every watch, so the last line takes the environment from the job's own name.
596
772
  await emit("gitlab", envs.map((env) => watchSpec(env, dialect, "gitlab")), [watchDrift('"${CI_JOB_NAME#watch-}"')]);
597
773
  return out;
598
774
  }
599
775
  // ── The project ─────────────────────────────────────────────────────────────
776
+ /** Whether the project has a test file for yodel test (tests/*.test.ts). */
777
+ const hasTests = (dir) => testFiles(dir).length > 0;
600
778
  /** The dialect and the environments chant.config.ts declares, in order. */
601
779
  export async function environments(dir) {
602
780
  const { loadChantConfig } = await import("@intentius/chant/config");
@@ -617,16 +795,35 @@ export async function environments(dir) {
617
795
  }
618
796
  }
619
797
  const ci = (await readConfig(dir))?.ci;
798
+ const credentials = credentialsIn(sql?.profiles ?? {}, envs);
620
799
  return {
621
800
  dialect,
622
801
  names: envs,
802
+ ...(Object.keys(credentials).length > 0 ? { credentials } : {}),
623
803
  replay,
804
+ ...(hasTests(dir) ? { tests: true } : {}),
624
805
  waves: await withTenants(dir, await readWaves(dir, envs)),
625
806
  forges: ci?.forges ?? FORGES,
626
807
  ...(ci?.jobs ? { jobs: await loadProjectJobs(dir, ci.jobs) } : {}),
627
808
  ...(ci?.image ? { image: ci.image } : {}),
809
+ ...(ci?.resume ? { resume: { schedule: ci.resume.schedule } } : {}),
628
810
  };
629
811
  }
812
+ /** The roles of each environment whose profile is made with credentials(). */
813
+ export function credentialsIn(profiles, envs) {
814
+ const out = {};
815
+ for (const env of envs) {
816
+ const c = credentialsOf(profiles[env]);
817
+ if (!c)
818
+ continue;
819
+ const needs = (role) => {
820
+ const cloud = cloudToken(c, role);
821
+ return { variables: roleVariables(c, role), ...(cloud ? { cloud } : {}) };
822
+ };
823
+ out[env] = { reader: needs("reader"), writer: needs("writer") };
824
+ }
825
+ return out;
826
+ }
630
827
  /** yodel.config.ts, checked; undefined when the project has none. */
631
828
  async function readConfig(dir) {
632
829
  return (await loadYodelConfig(dir))?.config;