@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.
- package/README.md +2 -2
- package/dist/apply-steps/index.d.ts +65 -0
- package/dist/apply-steps/index.js +106 -0
- package/dist/apply-steps/index.js.map +1 -0
- package/dist/approval-mode/index.d.ts +78 -0
- package/dist/approval-mode/index.js +105 -0
- package/dist/approval-mode/index.js.map +1 -0
- package/dist/chant/access.d.ts +84 -0
- package/dist/chant/access.js +155 -0
- package/dist/chant/access.js.map +1 -0
- package/dist/chant/drift.d.ts +17 -1
- package/dist/chant/drift.js +55 -2
- package/dist/chant/drift.js.map +1 -1
- package/dist/chant/plan.d.ts +18 -2
- package/dist/chant/plan.js +21 -3
- package/dist/chant/plan.js.map +1 -1
- package/dist/ci/login.d.ts +78 -0
- package/dist/ci/login.js +199 -0
- package/dist/ci/login.js.map +1 -0
- package/dist/ci/render.d.ts +64 -10
- package/dist/ci/render.js +385 -88
- package/dist/ci/render.js.map +1 -1
- package/dist/cleanup/index.d.ts +92 -0
- package/dist/cleanup/index.js +213 -0
- package/dist/cleanup/index.js.map +1 -0
- package/dist/commands/apply.js +178 -20
- package/dist/commands/apply.js.map +1 -1
- package/dist/commands/approve.d.ts +13 -0
- package/dist/commands/approve.js +206 -24
- package/dist/commands/approve.js.map +1 -1
- package/dist/commands/ci.js +12 -0
- package/dist/commands/ci.js.map +1 -1
- package/dist/commands/cleanup.d.ts +13 -0
- package/dist/commands/cleanup.js +158 -0
- package/dist/commands/cleanup.js.map +1 -0
- package/dist/commands/config.d.ts +3 -0
- package/dist/commands/config.js +136 -0
- package/dist/commands/config.js.map +1 -0
- package/dist/commands/drift.js +33 -3
- package/dist/commands/drift.js.map +1 -1
- package/dist/commands/index.js +6 -0
- package/dist/commands/index.js.map +1 -1
- package/dist/commands/init.d.ts +11 -0
- package/dist/commands/init.js +98 -10
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/lint.js +67 -10
- package/dist/commands/lint.js.map +1 -1
- package/dist/commands/new.js +135 -54
- package/dist/commands/new.js.map +1 -1
- package/dist/commands/plan.js +39 -5
- package/dist/commands/plan.js.map +1 -1
- package/dist/commands/test.d.ts +3 -0
- package/dist/commands/test.js +164 -0
- package/dist/commands/test.js.map +1 -0
- package/dist/config/define.d.ts +175 -2
- package/dist/config/define.js +2 -0
- package/dist/config/define.js.map +1 -1
- package/dist/config/load.js +78 -10
- package/dist/config/load.js.map +1 -1
- package/dist/config/settings.d.ts +20 -0
- package/dist/config/settings.js +28 -0
- package/dist/config/settings.js.map +1 -1
- package/dist/credentials.d.ts +119 -0
- package/dist/credentials.js +119 -0
- package/dist/credentials.js.map +1 -0
- package/dist/exit.js +1 -1
- package/dist/exit.js.map +1 -1
- package/dist/index.d.ts +5 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/init/adopt.js +1 -1
- package/dist/init/adopt.js.map +1 -1
- package/dist/init/clickhouse-access.d.ts +64 -0
- package/dist/init/clickhouse-access.js +211 -0
- package/dist/init/clickhouse-access.js.map +1 -0
- package/dist/init/index.d.ts +2 -0
- package/dist/init/index.js +2 -0
- package/dist/init/index.js.map +1 -1
- package/dist/init/live.d.ts +4 -0
- package/dist/init/live.js +16 -0
- package/dist/init/live.js.map +1 -1
- package/dist/init/second.d.ts +70 -0
- package/dist/init/second.js +149 -0
- package/dist/init/second.js.map +1 -0
- package/dist/lint/access.d.ts +18 -0
- package/dist/lint/access.js +34 -0
- package/dist/lint/access.js.map +1 -0
- package/dist/lint/annotations.d.ts +49 -0
- package/dist/lint/annotations.js +82 -0
- package/dist/lint/annotations.js.map +1 -0
- package/dist/lint/check.js +25 -7
- package/dist/lint/check.js.map +1 -1
- package/dist/lint/config.d.ts +7 -1
- package/dist/lint/config.js +11 -5
- package/dist/lint/config.js.map +1 -1
- package/dist/lint/index.d.ts +4 -0
- package/dist/lint/index.js +4 -0
- package/dist/lint/index.js.map +1 -1
- package/dist/lint/naming.d.ts +62 -0
- package/dist/lint/naming.js +138 -0
- package/dist/lint/naming.js.map +1 -0
- package/dist/lint/rules.d.ts +37 -4
- package/dist/lint/rules.js +16 -4
- package/dist/lint/rules.js.map +1 -1
- package/dist/lint/schema-checks.d.ts +63 -0
- package/dist/lint/schema-checks.js +128 -0
- package/dist/lint/schema-checks.js.map +1 -0
- package/dist/lint/statements.d.ts +22 -1
- package/dist/lint/statements.js +40 -1
- package/dist/lint/statements.js.map +1 -1
- package/dist/mcp/tools.d.ts +1 -1
- package/dist/mcp/tools.js +1 -1
- package/dist/mcp/tools.js.map +1 -1
- package/dist/migrations/checkpoint.js +0 -2
- package/dist/migrations/checkpoint.js.map +1 -1
- package/dist/migrations/checks.d.ts +19 -1
- package/dist/migrations/checks.js +15 -0
- package/dist/migrations/checks.js.map +1 -1
- package/dist/migrations/create.d.ts +13 -4
- package/dist/migrations/create.js +24 -9
- package/dist/migrations/create.js.map +1 -1
- package/dist/migrations/routines.d.ts +5 -15
- package/dist/migrations/routines.js +5 -30
- package/dist/migrations/routines.js.map +1 -1
- package/dist/pr/model.d.ts +4 -1
- package/dist/pr/model.js +13 -5
- package/dist/pr/model.js.map +1 -1
- package/dist/pr/render.d.ts +4 -1
- package/dist/pr/render.js +22 -8
- package/dist/pr/render.js.map +1 -1
- package/dist/pr/tenants.d.ts +37 -0
- package/dist/pr/tenants.js +138 -0
- package/dist/pr/tenants.js.map +1 -0
- package/dist/probe.d.ts +27 -0
- package/dist/probe.js +106 -0
- package/dist/probe.js.map +1 -0
- package/dist/project.d.ts +16 -0
- package/dist/project.js +29 -1
- package/dist/project.js.map +1 -1
- package/dist/replay/clickhouse.d.ts +31 -0
- package/dist/replay/clickhouse.js +103 -6
- package/dist/replay/clickhouse.js.map +1 -1
- package/dist/replay/replay.d.ts +5 -2
- package/dist/replay/replay.js.map +1 -1
- package/dist/report/audit.d.ts +1 -1
- package/dist/report/audit.js +20 -3
- package/dist/report/audit.js.map +1 -1
- package/dist/schemas.d.ts +3 -1
- package/dist/schemas.js +2 -0
- package/dist/schemas.js.map +1 -1
- package/dist/sources/ddl.d.ts +15 -20
- package/dist/sources/ddl.js +34 -328
- package/dist/sources/ddl.js.map +1 -1
- package/dist/sources/index.js +2 -2
- package/dist/sources/index.js.map +1 -1
- package/dist/steps/backfill.d.ts +40 -5
- package/dist/steps/backfill.js +128 -8
- package/dist/steps/backfill.js.map +1 -1
- package/dist/steps/index.d.ts +19 -1
- package/dist/steps/index.js +34 -7
- package/dist/steps/index.js.map +1 -1
- package/dist/steps/kinds.d.ts +42 -4
- package/dist/steps/kinds.js +88 -14
- package/dist/steps/kinds.js.map +1 -1
- package/dist/steps/postgres-migration.d.ts +3 -0
- package/dist/steps/postgres-migration.js +7 -3
- package/dist/steps/postgres-migration.js.map +1 -1
- package/dist/steps/rebuild.d.ts +15 -6
- package/dist/steps/rebuild.js +12 -9
- package/dist/steps/rebuild.js.map +1 -1
- package/dist/tenants/index.d.ts +2 -1
- package/dist/tenants/index.js +3 -4
- package/dist/tenants/index.js.map +1 -1
- package/dist/tenants/wave.d.ts +128 -0
- package/dist/tenants/wave.js +254 -0
- package/dist/tenants/wave.js.map +1 -0
- package/dist/testing/define.d.ts +62 -0
- package/dist/testing/define.js +32 -0
- package/dist/testing/define.js.map +1 -0
- package/dist/testing/files.d.ts +5 -0
- package/dist/testing/files.js +18 -0
- package/dist/testing/files.js.map +1 -0
- package/dist/testing/report.d.ts +14 -0
- package/dist/testing/report.js +83 -0
- package/dist/testing/report.js.map +1 -0
- package/dist/testing/run.d.ts +104 -0
- package/dist/testing/run.js +328 -0
- package/dist/testing/run.js.map +1 -0
- package/dist/versioned/approval.d.ts +6 -0
- package/dist/versioned/approval.js +11 -5
- package/dist/versioned/approval.js.map +1 -1
- package/dist/versioned/checks.d.ts +8 -2
- package/dist/versioned/checks.js +34 -13
- package/dist/versioned/checks.js.map +1 -1
- package/dist/versioned/driver.d.ts +2 -2
- package/dist/versioned/engine.d.ts +10 -0
- package/dist/versioned/engine.js +8 -4
- package/dist/versioned/engine.js.map +1 -1
- package/dist/versioned/explain.d.ts +2 -0
- package/dist/versioned/explain.js +3 -1
- package/dist/versioned/explain.js.map +1 -1
- package/dist/versioned/op.d.ts +4 -0
- package/dist/versioned/op.js +5 -2
- package/dist/versioned/op.js.map +1 -1
- package/dist/versioned/state.js +1 -1
- package/dist/versioned/state.js.map +1 -1
- package/dist/waves/index.d.ts +58 -13
- package/dist/waves/index.js +109 -24
- package/dist/waves/index.js.map +1 -1
- package/dist/waves/record.d.ts +10 -0
- package/dist/waves/record.js.map +1 -1
- package/package.json +8 -7
- package/schemas/apply.schema.json +1 -1
- package/schemas/cleanup.schema.json +40 -0
- package/schemas/init.schema.json +51 -22
- package/schemas/lint-rules.schema.json +4 -0
- package/schemas/new.schema.json +41 -3
- package/schemas/plan.schema.json +39 -0
- package/schemas/report.schema.json +1 -1
- 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
|
|
184
|
-
*
|
|
185
|
-
*
|
|
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
|
-
|
|
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":
|
|
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:
|
|
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([...
|
|
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
|
|
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:
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
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
|
-
|
|
384
|
-
|
|
385
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
: {
|
|
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
|
-
"#
|
|
450
|
-
"#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
501
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
535
|
-
* tenant
|
|
536
|
-
*
|
|
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 {
|
|
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
|
|
545
|
-
|
|
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
|
-
|
|
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;
|