@intentius/sql-yodeler 0.2.0 → 0.3.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 +3 -3
- package/dist/chant/build.js +7 -0
- package/dist/chant/build.js.map +1 -1
- package/dist/ci/render.d.ts +289 -0
- package/dist/ci/render.js +688 -0
- package/dist/ci/render.js.map +1 -0
- package/dist/ci/replay.d.ts +17 -0
- package/dist/ci/replay.js +69 -0
- package/dist/ci/replay.js.map +1 -0
- package/dist/commands/apply.d.ts +113 -0
- package/dist/commands/apply.js +228 -21
- package/dist/commands/apply.js.map +1 -1
- package/dist/commands/approve.js +44 -0
- package/dist/commands/approve.js.map +1 -1
- package/dist/commands/checkpoint.d.ts +3 -0
- package/dist/commands/checkpoint.js +78 -0
- package/dist/commands/checkpoint.js.map +1 -0
- package/dist/commands/ci.d.ts +3 -0
- package/dist/commands/ci.js +118 -0
- package/dist/commands/ci.js.map +1 -0
- package/dist/commands/docs.d.ts +11 -0
- package/dist/commands/docs.js +108 -0
- package/dist/commands/docs.js.map +1 -0
- package/dist/commands/drift.d.ts +13 -1
- package/dist/commands/drift.js +18 -2
- package/dist/commands/drift.js.map +1 -1
- package/dist/commands/index.js +10 -0
- package/dist/commands/index.js.map +1 -1
- package/dist/commands/init.js +1 -1
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/lint.js +11 -3
- package/dist/commands/lint.js.map +1 -1
- package/dist/commands/override.js +48 -16
- package/dist/commands/override.js.map +1 -1
- package/dist/commands/plan.js +5 -1
- package/dist/commands/plan.js.map +1 -1
- package/dist/commands/report.d.ts +13 -0
- package/dist/commands/report.js +121 -0
- package/dist/commands/report.js.map +1 -0
- package/dist/commands/revert.d.ts +20 -0
- package/dist/commands/revert.js +246 -0
- package/dist/commands/revert.js.map +1 -0
- package/dist/commands/status.js +32 -5
- package/dist/commands/status.js.map +1 -1
- package/dist/config/base.d.ts +69 -0
- package/dist/config/base.js +121 -0
- package/dist/config/base.js.map +1 -0
- package/dist/config/define.d.ts +123 -1
- package/dist/config/define.js +2 -0
- package/dist/config/define.js.map +1 -1
- package/dist/config/index.d.ts +2 -0
- package/dist/config/index.js +2 -0
- package/dist/config/index.js.map +1 -1
- package/dist/config/load.d.ts +1 -1
- package/dist/config/load.js +90 -5
- package/dist/config/load.js.map +1 -1
- package/dist/config/settings.d.ts +4 -1
- package/dist/config/settings.js +8 -2
- package/dist/config/settings.js.map +1 -1
- package/dist/exit.js +1 -1
- package/dist/exit.js.map +1 -1
- package/dist/history/postgres/ddl.d.ts +18 -0
- package/dist/history/postgres/ddl.js +27 -2
- package/dist/history/postgres/ddl.js.map +1 -1
- package/dist/history/postgres/store.d.ts +2 -0
- package/dist/history/postgres/store.js +7 -1
- package/dist/history/postgres/store.js.map +1 -1
- package/dist/history/rows.d.ts +16 -3
- package/dist/history/rows.js +30 -1
- package/dist/history/rows.js.map +1 -1
- package/dist/history/store.d.ts +2 -0
- package/dist/history/store.js +2 -2
- package/dist/history/store.js.map +1 -1
- package/dist/index.d.ts +4 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/lint/check.js +17 -2
- package/dist/lint/check.js.map +1 -1
- package/dist/lint/report.js +1 -1
- package/dist/lint/report.js.map +1 -1
- package/dist/lint/routines.d.ts +26 -0
- package/dist/lint/routines.js +65 -0
- package/dist/lint/routines.js.map +1 -0
- package/dist/lint/rules.d.ts +16 -4
- package/dist/lint/rules.js +9 -4
- package/dist/lint/rules.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.d.ts +45 -0
- package/dist/migrations/checkpoint.js +110 -0
- package/dist/migrations/checkpoint.js.map +1 -0
- package/dist/migrations/create.js +2 -0
- package/dist/migrations/create.js.map +1 -1
- package/dist/migrations/document.d.ts +10 -0
- package/dist/migrations/document.js +1 -0
- package/dist/migrations/document.js.map +1 -1
- package/dist/migrations/index.d.ts +2 -0
- package/dist/migrations/index.js +2 -0
- package/dist/migrations/index.js.map +1 -1
- package/dist/migrations/routines.d.ts +31 -0
- package/dist/migrations/routines.js +55 -0
- package/dist/migrations/routines.js.map +1 -0
- package/dist/notify/index.d.ts +124 -0
- package/dist/notify/index.js +210 -0
- package/dist/notify/index.js.map +1 -0
- package/dist/notify/verify.d.ts +53 -0
- package/dist/notify/verify.js +71 -0
- package/dist/notify/verify.js.map +1 -0
- package/dist/pr/model.d.ts +3 -0
- package/dist/pr/model.js +16 -2
- package/dist/pr/model.js.map +1 -1
- package/dist/pr/render.js +27 -1
- package/dist/pr/render.js.map +1 -1
- package/dist/project.d.ts +19 -2
- package/dist/project.js +33 -1
- package/dist/project.js.map +1 -1
- package/dist/replay/replay.d.ts +9 -1
- package/dist/replay/replay.js +62 -31
- package/dist/replay/replay.js.map +1 -1
- package/dist/replay/report.js +1 -1
- package/dist/replay/report.js.map +1 -1
- package/dist/report/audit.d.ts +138 -0
- package/dist/report/audit.js +202 -0
- package/dist/report/audit.js.map +1 -0
- package/dist/report/jsonl.d.ts +21 -0
- package/dist/report/jsonl.js +52 -0
- package/dist/report/jsonl.js.map +1 -0
- package/dist/report/model.d.ts +68 -0
- package/dist/report/model.js +127 -0
- package/dist/report/model.js.map +1 -0
- package/dist/report/render.d.ts +18 -0
- package/dist/report/render.js +127 -0
- package/dist/report/render.js.map +1 -0
- package/dist/schema-docs/render.d.ts +49 -0
- package/dist/schema-docs/render.js +359 -0
- package/dist/schema-docs/render.js.map +1 -0
- package/dist/schemas.d.ts +5 -2
- package/dist/schemas.js +5 -2
- package/dist/schemas.js.map +1 -1
- package/dist/sources/ddl.d.ts +53 -0
- package/dist/sources/ddl.js +347 -0
- package/dist/sources/ddl.js.map +1 -0
- package/dist/sources/index.d.ts +69 -0
- package/dist/sources/index.js +170 -0
- package/dist/sources/index.js.map +1 -0
- package/dist/steps/postgres-migration.d.ts +35 -25
- package/dist/steps/postgres-migration.js +42 -29
- package/dist/steps/postgres-migration.js.map +1 -1
- package/dist/tenants/index.d.ts +40 -0
- package/dist/tenants/index.js +122 -0
- package/dist/tenants/index.js.map +1 -0
- package/dist/tenants/target.d.ts +23 -0
- package/dist/tenants/target.js +29 -0
- package/dist/tenants/target.js.map +1 -0
- package/dist/versioned/engine.d.ts +6 -0
- package/dist/versioned/engine.js +12 -2
- package/dist/versioned/engine.js.map +1 -1
- package/dist/versioned/index.d.ts +1 -0
- package/dist/versioned/index.js +1 -0
- package/dist/versioned/index.js.map +1 -1
- package/dist/versioned/op.d.ts +6 -1
- package/dist/versioned/op.js +7 -2
- package/dist/versioned/op.js.map +1 -1
- package/dist/versioned/policy.d.ts +33 -2
- package/dist/versioned/policy.js +59 -8
- package/dist/versioned/policy.js.map +1 -1
- package/dist/versioned/report.js +9 -0
- package/dist/versioned/report.js.map +1 -1
- package/dist/versioned/revert.d.ts +128 -0
- package/dist/versioned/revert.js +251 -0
- package/dist/versioned/revert.js.map +1 -0
- package/dist/versioned/state.d.ts +25 -0
- package/dist/versioned/state.js +36 -3
- package/dist/versioned/state.js.map +1 -1
- package/dist/waves/index.d.ts +179 -0
- package/dist/waves/index.js +268 -0
- package/dist/waves/index.js.map +1 -0
- package/dist/waves/promotion.d.ts +56 -0
- package/dist/waves/promotion.js +108 -0
- package/dist/waves/promotion.js.map +1 -0
- package/dist/waves/record.d.ts +24 -0
- package/dist/waves/record.js +20 -0
- package/dist/waves/record.js.map +1 -0
- package/package.json +11 -7
- package/schemas/apply.schema.json +162 -3
- package/schemas/checkpoint.schema.json +19 -0
- package/schemas/drift.schema.json +26 -1
- package/schemas/event.schema.json +36 -0
- package/schemas/lint.schema.json +4 -0
- package/schemas/plan.schema.json +118 -0
- package/schemas/report.schema.json +114 -0
- package/schemas/revert.schema.json +69 -0
- package/schemas/status.schema.json +64 -3
|
@@ -0,0 +1,688 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `yodel ci` (#65): a project's CI for GitHub Actions, GitLab CI and Forgejo
|
|
3
|
+
* Actions, rendered by the installed package, so a fix to the pipelines
|
|
4
|
+
* reaches a project when it upgrades yodel and renders again. Until #65 the
|
|
5
|
+
* templates carried this as scripts/ci.ts.
|
|
6
|
+
*
|
|
7
|
+
* yodel ci write the files
|
|
8
|
+
* yodel ci --check exit 1 when a file differs from what this renders
|
|
9
|
+
* yodel ci --replay the lint job's replay check on this machine (./replay.ts)
|
|
10
|
+
*
|
|
11
|
+
* yodel.config.ts `ci` picks the forges (default all three) and names the
|
|
12
|
+
* module that declares the project's own jobs, which are merged after
|
|
13
|
+
* yodel's into the pull request pipeline and kept on every render
|
|
14
|
+
* ({@link ProjectJobs}).
|
|
15
|
+
*
|
|
16
|
+
* Environments are the keys of `sql.profiles` in chant.config.ts, in order,
|
|
17
|
+
* but `replay`, which is the lint job's throwaway server.
|
|
18
|
+
* Each one needs ops/migrate-<env>.op.ts and ops/watch-<env>.op.ts.
|
|
19
|
+
* The apply pipeline's waves are yodel.config.ts `waves`, in order; without
|
|
20
|
+
* it, one wave per environment in profile order, each with gate `always`.
|
|
21
|
+
*
|
|
22
|
+
* Three pipelines per forge, each declared with chant: yodel-pr with the
|
|
23
|
+
* github lexicon's Workflow, Job and Step (Forgejo renders the same
|
|
24
|
+
* declaration through the forgejo lexicon's dialect) and the gitlab
|
|
25
|
+
* lexicon's Workflow and Job; yodel-apply with chant's
|
|
26
|
+
* generateOpWavesPipeline, from the waves spec this writes
|
|
27
|
+
* (yodel-waves.json); the watch with generateOpsPipeline.
|
|
28
|
+
*
|
|
29
|
+
* - yodel-pr: on a pull (merge) request, `yodel ci --check` (these files are
|
|
30
|
+
* what yodel ci renders), `yodel lint`, and the replay check into a
|
|
31
|
+
* throwaway server the job starts (the `replay` profile), then for each
|
|
32
|
+
* environment `yodel plan <env> --comment`, which posts the pending
|
|
33
|
+
* migrations and the digest to approve. One job per environment, holding
|
|
34
|
+
* that environment's reader credentials and nothing else.
|
|
35
|
+
* - yodel-apply: on a push to main, one job per wave, each needing the one
|
|
36
|
+
* before, running `chant run wave`: it plans the environment (`yodel apply
|
|
37
|
+
* <env> --wave-plan`), reads the wave's gate policy from yodel-waves.json
|
|
38
|
+
* as the commit before the push has it, waits for an approval when that
|
|
39
|
+
* policy asks for one (exit 3, and the waves after it do not run), and
|
|
40
|
+
* applies (`yodel apply <env> --wave-apply`). The only jobs that hold a
|
|
41
|
+
* writer's credentials, one environment's each, with
|
|
42
|
+
* YODEL_CREDENTIALS=writer. Two merges in quick succession do not fail the
|
|
43
|
+
* second apply: it waits for the first's lock (YODEL_LOCK_WAIT), and an
|
|
44
|
+
* apply whose commit is no longer main's tip stands down
|
|
45
|
+
* (YODEL_STAND_DOWN), since the newer commit's waves apply the same
|
|
46
|
+
* environments.
|
|
47
|
+
* - watch-<env>: the environment's WatchOp on its own schedule, rendered by
|
|
48
|
+
* chant's generateOpsPipeline, with the reader's credentials. After the
|
|
49
|
+
* Op, `yodel drift <env> --issue` compares the server with the schema the
|
|
50
|
+
* newest applied migration records (so a migration waiting at its gate is
|
|
51
|
+
* not drift), keeps one tracking issue for the environment (opened on
|
|
52
|
+
* drift, updated while it changes, closed when it is gone), and fails the
|
|
53
|
+
* job on drift. The issue is written with the job's token on GitHub
|
|
54
|
+
* (issues: write) and Forgejo, and with GITLAB_TOKEN on GitLab.
|
|
55
|
+
*
|
|
56
|
+
* The scheduled watch never pushes. Its first phase (chant lifecycle
|
|
57
|
+
* snapshot) commits a snapshot to chant/lifecycle and tries to push it; the
|
|
58
|
+
* drift it reports comes from `chant lifecycle diff --live`, declared against
|
|
59
|
+
* live, which needs no snapshot on the remote. So the job sets a push URL
|
|
60
|
+
* that cannot be pushed to: the push fails, chant carries on, and the job
|
|
61
|
+
* works the same with a read-only token (GitHub `contents: read`, GitLab's
|
|
62
|
+
* CI_JOB_TOKEN) as with Forgejo's token, which can write. A push that could
|
|
63
|
+
* land would race the approvals people push to chant/lifecycle.
|
|
64
|
+
*
|
|
65
|
+
* Every job installs @intentius/sql-yodeler from npm with `npm ci`, which
|
|
66
|
+
* needs no token, in IMAGE. With yodel.config.ts `ci.image` (#66), the CI
|
|
67
|
+
* image ghcr.io/intentius/sql-yodeler pinned by its digest, every job runs
|
|
68
|
+
* in that image instead, which carries node, yodel, chant and the lexicons
|
|
69
|
+
* on PATH, and no job runs `npm ci` ({@link Toolchain}).
|
|
70
|
+
*/
|
|
71
|
+
import { existsSync } from "node:fs";
|
|
72
|
+
import { join, resolve } from "node:path";
|
|
73
|
+
import { pathToFileURL } from "node:url";
|
|
74
|
+
import { loadYodelConfig } from "../config/load.js";
|
|
75
|
+
import { emitYAMLEntry, parseYAMLDocument } from "@intentius/chant/yaml";
|
|
76
|
+
import { Concurrency, Job as ActionsJob, Permissions, Service as ActionsService, Step, Workflow as ActionsWorkflow, } from "@intentius/chant-lexicon-github";
|
|
77
|
+
import { githubSerializer } from "@intentius/chant-lexicon-github/serializer";
|
|
78
|
+
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";
|
|
80
|
+
import { gitlabSerializer } from "@intentius/chant-lexicon-gitlab/serializer";
|
|
81
|
+
export const FORGES = ["github", "gitlab", "forgejo"];
|
|
82
|
+
/** The image every job runs in, where the forge runs jobs in a container. It needs git, so not -slim. */
|
|
83
|
+
export const IMAGE = "node:22-bookworm";
|
|
84
|
+
/** The CI image (#66): yodel, chant and the sql lexicon, published for each release. */
|
|
85
|
+
export const YODEL_IMAGE = "ghcr.io/intentius/sql-yodeler";
|
|
86
|
+
export const toolchainOf = (envs) => (envs.image ? { image: envs.image, yodelImage: true } : { image: IMAGE, yodelImage: false });
|
|
87
|
+
/** The steps that install the project's packages and put their bins on PATH, as each forge writes them. */
|
|
88
|
+
const installLines = (tc, forge) => tc.yodelImage ? [] : ["npm ci", forge === "gitlab" ? 'export PATH="$PWD/node_modules/.bin:$PATH"' : 'echo "$PWD/node_modules/.bin" >> "$GITHUB_PATH"'];
|
|
89
|
+
export const MAIN = "main";
|
|
90
|
+
/** The forgejo lexicon rewrites it to https://code.forgejo.org/actions/checkout@v7 on Forgejo. */
|
|
91
|
+
const CHECKOUT = "actions/checkout@v7";
|
|
92
|
+
const SETUP_NODE = "actions/setup-node@v7";
|
|
93
|
+
/** DEV for dev: the prefix of an environment's variables. */
|
|
94
|
+
export const prefixOf = (env) => env.toUpperCase().replace(/[^A-Z0-9]/g, "_");
|
|
95
|
+
/** DEV_CLICKHOUSE for dev on ClickHouse: what an environment's variables start with. */
|
|
96
|
+
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) => {
|
|
99
|
+
const p = variablePrefix(env, dialect);
|
|
100
|
+
return [`${p}_URL`, `${p}_READER_USER`, `${p}_READER_PASSWORD`];
|
|
101
|
+
};
|
|
102
|
+
/** The variables the apply job for `env` holds: the URL and the writer. */
|
|
103
|
+
export const writerVariables = (env, dialect) => {
|
|
104
|
+
const p = variablePrefix(env, dialect);
|
|
105
|
+
return [`${p}_URL`, `${p}_WRITER_USER`, `${p}_WRITER_PASSWORD`];
|
|
106
|
+
};
|
|
107
|
+
const secret = (name) => `\${{ secrets.${name} }}`;
|
|
108
|
+
const secrets = (names) => Object.fromEntries(names.map((n) => [n, secret(n)]));
|
|
109
|
+
/**
|
|
110
|
+
* The approvals: `chant approve` records them on the chant/lifecycle branch,
|
|
111
|
+
* and approvers push it. A CI checkout fetches one ref, so fetch this one too.
|
|
112
|
+
* The remote-tracking ref is what chant's push lease reads.
|
|
113
|
+
*/
|
|
114
|
+
export const FETCH_APPROVALS = "git fetch --no-tags origin '+refs/heads/chant/lifecycle:refs/remotes/origin/chant/lifecycle' && git branch -f chant/lifecycle origin/chant/lifecycle || echo 'no chant/lifecycle branch yet, so nothing is approved'";
|
|
115
|
+
/** The apply jobs' lock settings: wait for another apply's lock, and stand down for a newer commit on main. */
|
|
116
|
+
export const APPLY_LOCK = { YODEL_LOCK_WAIT: "10m", YODEL_STAND_DOWN: "1" };
|
|
117
|
+
/** The watch reads; it never pushes. See the comment at the top. */
|
|
118
|
+
export const NO_PUSH = "git remote set-url --push origin no-push://the-drift-watch-only-reads";
|
|
119
|
+
/** After the watch's `chant run`: drift against the history, the environment's tracking issue, and exit 2 on drift. */
|
|
120
|
+
export const watchDrift = (env) => `yodel drift ${env} --issue`;
|
|
121
|
+
/**
|
|
122
|
+
* The replay check: `yodel lint --replay replay --reset` replays every
|
|
123
|
+
* migration, in order, into a fresh database on the `replay` profile's
|
|
124
|
+
* server and fails when one does not give the schema it recorded. That
|
|
125
|
+
* server is a throwaway container the lint job starts as a service, the
|
|
126
|
+
* image chant's emulator runs, which the job (in IMAGE, on every forge)
|
|
127
|
+
* reaches by the service's name; never a server whose data matters, since
|
|
128
|
+
* --reset drops what the history names there first.
|
|
129
|
+
*/
|
|
130
|
+
export const REPLAY_PROFILE = "replay";
|
|
131
|
+
export const REPLAY_SERVICE = {
|
|
132
|
+
clickhouse: {
|
|
133
|
+
name: "replay-clickhouse",
|
|
134
|
+
image: "clickhouse/clickhouse-server:26.8.15.10",
|
|
135
|
+
port: 8123,
|
|
136
|
+
// The default user, with no password, from outside the container.
|
|
137
|
+
env: { CLICKHOUSE_SKIP_USER_SETUP: "1" },
|
|
138
|
+
health: "clickhouse-client --query 'SELECT 1'",
|
|
139
|
+
url: (host, port = 8123) => `http://${host}:${String(port)}`,
|
|
140
|
+
jobEnv: {},
|
|
141
|
+
},
|
|
142
|
+
postgres: {
|
|
143
|
+
name: "replay-postgres",
|
|
144
|
+
image: "postgres:18.6",
|
|
145
|
+
port: 5432,
|
|
146
|
+
env: { POSTGRES_PASSWORD: "replay" },
|
|
147
|
+
// Over TCP: during its first start the image runs a server on the socket only, then restarts.
|
|
148
|
+
health: "pg_isready -h 127.0.0.1 -U postgres",
|
|
149
|
+
url: (host, port = 5432) => `postgres://${host}:${String(port)}/postgres`,
|
|
150
|
+
jobEnv: { REPLAY_POSTGRES_USER: "postgres", REPLAY_POSTGRES_PASSWORD: "replay" },
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
/** Waits up to 90 s for the replay server's port; a shell-free one-liner, so every forge runs it the same. */
|
|
154
|
+
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
|
+
export const LINT = "yodel lint";
|
|
156
|
+
export const REPLAY = `yodel lint --replay ${REPLAY_PROFILE} --reset`;
|
|
157
|
+
/** Fails a pull request whose pipelines differ from what yodel ci renders. */
|
|
158
|
+
export const CI_CHECK = "yodel ci --check";
|
|
159
|
+
/**
|
|
160
|
+
* Webhook events (yodel.config.ts `notify`): the variables the apply, plan
|
|
161
|
+
* and watch jobs pass from the forge's secrets. yodel sends nothing while
|
|
162
|
+
* they are unset (empty), so an unconfigured repository needs neither.
|
|
163
|
+
* GitLab jobs have the project's variables already.
|
|
164
|
+
*/
|
|
165
|
+
export const WEBHOOK_VARIABLES = ["YODEL_WEBHOOK_URL", "YODEL_WEBHOOK_KEY"];
|
|
166
|
+
/** Where the plan and apply jobs write `yodel report`, kept as the job's artifact. */
|
|
167
|
+
export const REPORT_DIR = "yodel-report";
|
|
168
|
+
/**
|
|
169
|
+
* `yodel report <env>` as JSON and as the HTML run view, into REPORT_DIR.
|
|
170
|
+
* It runs whatever the job's outcome, and a gap it reports (exit 4) does not
|
|
171
|
+
* fail the job: the report is for reading. `yodel` is the project's, named
|
|
172
|
+
* by path, since GitLab's after_script does not keep before_script's PATH;
|
|
173
|
+
* in the yodel image, the image's, which is on PATH.
|
|
174
|
+
*/
|
|
175
|
+
export const reportCommand = (env, tc = { image: IMAGE, yodelImage: false }) => {
|
|
176
|
+
const yodel = tc.yodelImage ? "yodel" : "./node_modules/.bin/yodel";
|
|
177
|
+
return `mkdir -p ${REPORT_DIR}; ${yodel} report ${env} --json > ${REPORT_DIR}/${env}.json || true; ${yodel} report ${env} --html ${REPORT_DIR}/${env}.html || true`;
|
|
178
|
+
};
|
|
179
|
+
/** The artifact a job keeps its report in. */
|
|
180
|
+
export const reportArtifact = (job) => `yodel-report-${job}`;
|
|
181
|
+
const UPLOAD_ARTIFACT = "actions/upload-artifact@v4";
|
|
182
|
+
/**
|
|
183
|
+
* The comment at the top of every file. Added here, not by chant: neither the
|
|
184
|
+
* github nor the gitlab lexicon's serializer writes YAML comments
|
|
185
|
+
* (INTENTIUS/chant#3667, INTENTIUS/chant#3669).
|
|
186
|
+
*/
|
|
187
|
+
const HEADER = [
|
|
188
|
+
"# Rendered by yodel ci from chant.config.ts, yodel.config.ts and ops/.",
|
|
189
|
+
"# Edit those and render again; yodel ci --check says when this file is stale.",
|
|
190
|
+
];
|
|
191
|
+
const withHeader = (header, yaml) => `${[...header, ""].join("\n")}${yaml}`;
|
|
192
|
+
/** A serializer's output for one file: its YAML, refusing anything else (a second file, a warning). */
|
|
193
|
+
function oneFile(forge, out) {
|
|
194
|
+
if (typeof out === "string")
|
|
195
|
+
return out;
|
|
196
|
+
const extra = [...Object.keys(out.files ?? {}), ...(out.warnings ?? [])];
|
|
197
|
+
if (extra.length > 0)
|
|
198
|
+
throw new Error(`${forge}: expected one file and no warnings, got ${extra.join("; ")}`);
|
|
199
|
+
return out.primary;
|
|
200
|
+
}
|
|
201
|
+
function renderActions(forge, workflow) {
|
|
202
|
+
const serializer = forge === "github" ? githubSerializer : forgejoSerializer;
|
|
203
|
+
return oneFile(forge, serializer.serialize(new Map([["workflow", workflow]])));
|
|
204
|
+
}
|
|
205
|
+
/** The report steps of a job: `yodel report` into REPORT_DIR and the artifact, whatever the job's outcome. */
|
|
206
|
+
function reportSteps(env, job, tc) {
|
|
207
|
+
return [
|
|
208
|
+
new Step({ name: `yodel report ${env}`, if: "always()", run: reportCommand(env, tc) }),
|
|
209
|
+
new Step({ name: "Keep the report", if: "always()", uses: UPLOAD_ARTIFACT, with: { name: reportArtifact(job), path: REPORT_DIR, "if-no-files-found": "ignore" } }),
|
|
210
|
+
];
|
|
211
|
+
}
|
|
212
|
+
/** `inContainer`: the job runs in the toolchain's image, which has node, so it needs no setup-node. */
|
|
213
|
+
function setupSteps(inContainer, tc) {
|
|
214
|
+
// Step inputs are strings in the lexicon's schema (INTENTIUS/chant#3670); the actions read them as strings either way.
|
|
215
|
+
const steps = [new Step({ uses: CHECKOUT, with: { "fetch-depth": "0" } })];
|
|
216
|
+
if (!inContainer)
|
|
217
|
+
steps.push(new Step({ uses: SETUP_NODE, with: { "node-version": "22", cache: "npm" } }));
|
|
218
|
+
steps.push(...installLines(tc, "github").map((run) => new Step({ run })), new Step({ name: "Fetch the approvals (chant/lifecycle)", run: FETCH_APPROVALS }));
|
|
219
|
+
return steps;
|
|
220
|
+
}
|
|
221
|
+
/** Forgejo runs every job in the image; GitHub on the runner's VM, unless the job asks for the container or the image is yodel's. */
|
|
222
|
+
const inContainer = (forge, tc) => forge === "forgejo" || tc.yodelImage;
|
|
223
|
+
const runner = (container, tc) => ({ "runs-on": "ubuntu-latest", ...(container ? { container: tc.image } : {}) });
|
|
224
|
+
/** The token `yodel plan --comment` posts with: the job's own. */
|
|
225
|
+
function commentToken(forge) {
|
|
226
|
+
return forge === "github" ? { GITHUB_TOKEN: "${{ github.token }}" } : { FORGEJO_TOKEN: "${{ github.token }}" };
|
|
227
|
+
}
|
|
228
|
+
function lintJob(forge, dialect, replay, tc) {
|
|
229
|
+
// With the replay check, the job runs in IMAGE on every forge and reaches
|
|
230
|
+
// its replay server by the service's name on the job's own network. On
|
|
231
|
+
// GitHub's VM the service would be on localhost through a published port,
|
|
232
|
+
// which collides with any database already on that port (act, a
|
|
233
|
+
// self-hosted runner).
|
|
234
|
+
const container = replay || inContainer(forge, tc);
|
|
235
|
+
const steps = [
|
|
236
|
+
...setupSteps(container, tc),
|
|
237
|
+
new Step({ name: "The pipelines are up to date", run: CI_CHECK }),
|
|
238
|
+
new Step({ name: "yodel lint", run: LINT }),
|
|
239
|
+
];
|
|
240
|
+
if (!replay)
|
|
241
|
+
return new ActionsJob({ ...runner(container, tc), steps });
|
|
242
|
+
const svc = REPLAY_SERVICE[dialect];
|
|
243
|
+
return new ActionsJob({
|
|
244
|
+
...runner(container, tc),
|
|
245
|
+
services: {
|
|
246
|
+
[svc.name]: new ActionsService({
|
|
247
|
+
image: svc.image,
|
|
248
|
+
env: svc.env,
|
|
249
|
+
options: `--health-cmd "${svc.health}" --health-interval 2s --health-timeout 5s --health-retries 45`,
|
|
250
|
+
}),
|
|
251
|
+
},
|
|
252
|
+
env: { [`${variablePrefix(REPLAY_PROFILE, dialect)}_URL`]: svc.url(svc.name), ...svc.jobEnv },
|
|
253
|
+
steps: [
|
|
254
|
+
...steps,
|
|
255
|
+
new Step({ name: `Wait for the replay server (${svc.name})`, run: waitFor(svc.name, svc.port) }),
|
|
256
|
+
new Step({ name: "yodel lint --replay", run: REPLAY }),
|
|
257
|
+
],
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
export function actionsReview(forge, envs_) {
|
|
261
|
+
const { dialect, names: envs, replay, waves, jobs: own } = envs_;
|
|
262
|
+
const tc = toolchainOf(envs_);
|
|
263
|
+
const jobs = { lint: lintJob(forge, dialect, replay, tc) };
|
|
264
|
+
for (const env of envs) {
|
|
265
|
+
jobs[`plan-${env}`] = new ActionsJob({
|
|
266
|
+
...runner(inContainer(forge, tc), tc),
|
|
267
|
+
// 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]),
|
|
269
|
+
steps: [
|
|
270
|
+
// The plan explains what moved since an approval from git's history of migrations/.
|
|
271
|
+
...setupSteps(inContainer(forge, tc), tc),
|
|
272
|
+
new Step({ name: `yodel plan ${env} --comment`, run: `yodel plan ${env} --comment`, env: commentToken(forge) }),
|
|
273
|
+
...reportSteps(env, `plan-${env}`, tc),
|
|
274
|
+
],
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
Object.assign(jobs, ownJobs(own?.actions, jobs, "actions"));
|
|
278
|
+
const workflow = new ActionsWorkflow({
|
|
279
|
+
name: "yodel-pr",
|
|
280
|
+
on: { pull_request: { branches: [MAIN] } },
|
|
281
|
+
permissions: new Permissions({ contents: "read", "pull-requests": "write" }),
|
|
282
|
+
concurrency: new Concurrency({ group: "yodel-pr-${{ github.event.pull_request.number }}", "cancel-in-progress": true }),
|
|
283
|
+
jobs,
|
|
284
|
+
});
|
|
285
|
+
const header = [
|
|
286
|
+
...HEADER,
|
|
287
|
+
"#",
|
|
288
|
+
"# Pull requests: yodel lint and the replay check (into a throwaway server the",
|
|
289
|
+
"# lint job starts), and the plan comment per environment with the",
|
|
290
|
+
"# environment's reader credentials. No job here holds a writer.",
|
|
291
|
+
...(forge === "forgejo"
|
|
292
|
+
? ["# The comment is posted with the job's own token (Forgejo gives a same-repository", "# pull request one that can comment)."]
|
|
293
|
+
: []),
|
|
294
|
+
];
|
|
295
|
+
return {
|
|
296
|
+
path: `${forge === "github" ? ".github" : ".forgejo"}/workflows/yodel-pr.yml`,
|
|
297
|
+
content: withHeader(header, renderActions(forge, workflow)),
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
// ── GitLab CI ───────────────────────────────────────────────────────────────
|
|
301
|
+
export const GITLAB_WATCH_FILE = ".gitlab/yodel-watch.gitlab-ci.yml";
|
|
302
|
+
export const GITLAB_APPLY_FILE = ".gitlab/yodel-apply.gitlab-ci.yml";
|
|
303
|
+
/** The stage of each wave's job in GITLAB_APPLY_FILE, as chant names it. */
|
|
304
|
+
const waveStages = (waves) => waves.map((w, i) => `wave-${String(i + 1)}-${w.env}`);
|
|
305
|
+
export function gitlabPipeline(envs_) {
|
|
306
|
+
const { dialect, names: envs, replay, waves, jobs: own } = envs_;
|
|
307
|
+
const tc = toolchainOf(envs_);
|
|
308
|
+
const mr = () => [new Rule({ if: '$CI_PIPELINE_SOURCE == "merge_request_event"' })];
|
|
309
|
+
const workflow = new GitLabWorkflow({
|
|
310
|
+
rules: [
|
|
311
|
+
new WorkflowRule({ if: '$CI_PIPELINE_SOURCE == "merge_request_event"' }),
|
|
312
|
+
new WorkflowRule({ if: "$CI_COMMIT_BRANCH && $CI_OPEN_MERGE_REQUESTS", when: "never" }),
|
|
313
|
+
new WorkflowRule({ if: "$CI_COMMIT_BRANCH" }),
|
|
314
|
+
],
|
|
315
|
+
});
|
|
316
|
+
const jobs = new Map();
|
|
317
|
+
jobs.set(".yodel", new GitLabJob({
|
|
318
|
+
image: new Image({ name: tc.image }),
|
|
319
|
+
before_script: [...installLines(tc, "gitlab"), FETCH_APPROVALS],
|
|
320
|
+
}));
|
|
321
|
+
const svc = REPLAY_SERVICE[dialect];
|
|
322
|
+
jobs.set("lint", new GitLabJob({
|
|
323
|
+
extends: ".yodel",
|
|
324
|
+
stage: "review",
|
|
325
|
+
rules: mr(),
|
|
326
|
+
...(replay
|
|
327
|
+
? {
|
|
328
|
+
script: [CI_CHECK, LINT, waitFor(svc.name, svc.port), REPLAY],
|
|
329
|
+
services: [new GitLabService({ name: svc.image, alias: svc.name, variables: svc.env })],
|
|
330
|
+
variables: { [`${variablePrefix(REPLAY_PROFILE, dialect)}_URL`]: svc.url(svc.name), ...svc.jobEnv },
|
|
331
|
+
}
|
|
332
|
+
: { script: [CI_CHECK, LINT] }),
|
|
333
|
+
}));
|
|
334
|
+
for (const env of envs) {
|
|
335
|
+
jobs.set(`plan-${env}`, new GitLabJob({
|
|
336
|
+
extends: ".yodel",
|
|
337
|
+
stage: "review",
|
|
338
|
+
rules: mr(),
|
|
339
|
+
script: [`yodel plan ${env} --comment`],
|
|
340
|
+
after_script: [reportCommand(env, tc)],
|
|
341
|
+
artifacts: new GitLabArtifacts({ name: reportArtifact(`plan-${env}`), when: "always", paths: [`${REPORT_DIR}/`] }),
|
|
342
|
+
}));
|
|
343
|
+
}
|
|
344
|
+
for (const [name, job] of Object.entries(ownJobs(own?.gitlab, Object.fromEntries(jobs), "gitlab")))
|
|
345
|
+
jobs.set(name, job);
|
|
346
|
+
const serialize = (entities) => oneFile("gitlab", gitlabSerializer.serialize(entities));
|
|
347
|
+
// Exception (INTENTIUS/chant#3669): the gitlab serializer writes no
|
|
348
|
+
// top-level include or variables, and its stages are only the ones the
|
|
349
|
+
// declared jobs name, so not the watch file's ops. It writes those stages
|
|
350
|
+
// first; they are replaced by GITLAB_STAGES, and include and variables are
|
|
351
|
+
// written beside them.
|
|
352
|
+
const jobsYaml = serialize(jobs);
|
|
353
|
+
const end = jobsYaml.indexOf("\n\n");
|
|
354
|
+
const derivedStages = parseYAMLDocument(jobsYaml.slice(0, end + 1)).stages;
|
|
355
|
+
if (!jobsYaml.startsWith("stages:\n") || !Array.isArray(derivedStages) || derivedStages[0] !== "review")
|
|
356
|
+
throw new Error("gitlab: expected the jobs' YAML to start with their stages, review first");
|
|
357
|
+
const derived = jobsYaml.slice(0, end + 2);
|
|
358
|
+
// The stages this file's jobs use (review, then any the project's own jobs add), then the apply file's waves and the watch file's ops.
|
|
359
|
+
const stages = [...new Set([...derivedStages.map(String), ...waveStages(waves), "ops"])];
|
|
360
|
+
const topLevel = [
|
|
361
|
+
emitYAMLEntry("include", [{ local: GITLAB_APPLY_FILE }, { local: GITLAB_WATCH_FILE }]),
|
|
362
|
+
emitYAMLEntry("stages", stages),
|
|
363
|
+
emitYAMLEntry("variables", { GIT_DEPTH: "0" }),
|
|
364
|
+
];
|
|
365
|
+
const yaml = `${serialize(new Map([["workflow", workflow]]))}\n${topLevel.join("\n\n")}\n\n${jobsYaml.slice(derived.length)}`;
|
|
366
|
+
const variables = envs.flatMap((env) => [
|
|
367
|
+
`# ${readerVariables(env, dialect).join(", ")}: not protected (merge request`,
|
|
368
|
+
"# pipelines run on unprotected branches), masked; no environment scope.",
|
|
369
|
+
`# ${writerVariables(env, dialect).slice(1).join(", ")}: protected, masked,`,
|
|
370
|
+
`# environment scope "${env}": only ${env}'s wave job declares that environment.`,
|
|
371
|
+
]);
|
|
372
|
+
const header = [
|
|
373
|
+
...HEADER,
|
|
374
|
+
"#",
|
|
375
|
+
"# Merge requests: lint and the plan comment (stage review). A push to the",
|
|
376
|
+
`# default branch: the apply waves, one stage each, in ${GITLAB_APPLY_FILE}.`,
|
|
377
|
+
`# The drift watches are in ${GITLAB_WATCH_FILE} (stage ops).`,
|
|
378
|
+
"#",
|
|
379
|
+
"# GitLab gives every job the project's CI/CD variables unless a variable is",
|
|
380
|
+
"# protected or scoped to an environment, so the split between reader and",
|
|
381
|
+
"# writer is in the variables' settings (Settings > CI/CD > Variables):",
|
|
382
|
+
...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.",
|
|
386
|
+
"# The jobs that hold no writer read with the reader: chant.config.ts picks an",
|
|
387
|
+
"# environment's writer only under YODEL_CREDENTIALS=writer:<env>, which only",
|
|
388
|
+
"# that environment's wave job sets.",
|
|
389
|
+
];
|
|
390
|
+
return { path: ".gitlab-ci.yml", content: withHeader(header, yaml) };
|
|
391
|
+
}
|
|
392
|
+
const WAVE_GATES = ["always", "on-destructive", "never"];
|
|
393
|
+
/** The environment `env`'s wave requires, as a list. */
|
|
394
|
+
const requiredOf = (waves, env) => {
|
|
395
|
+
const r = waves.find((w) => w.env === env)?.requires;
|
|
396
|
+
return r ? [r] : [];
|
|
397
|
+
};
|
|
398
|
+
/** The waves spec: committed, and read by every wave job (its gate policy at the commit before the push). */
|
|
399
|
+
export const WAVES_SPEC = "yodel-waves.json";
|
|
400
|
+
export const APPLY_PIPELINE = "yodel-apply";
|
|
401
|
+
/**
|
|
402
|
+
* The waves spec. What the file holds is the same for every forge; each
|
|
403
|
+
* forge's jobs get their own variables on top (`variables`).
|
|
404
|
+
*/
|
|
405
|
+
export function wavesSpec(waves, variables) {
|
|
406
|
+
const first = waves[0];
|
|
407
|
+
if (!first)
|
|
408
|
+
throw new Error("no waves to render");
|
|
409
|
+
return {
|
|
410
|
+
name: APPLY_PIPELINE,
|
|
411
|
+
op: `migrate-${first.env}`,
|
|
412
|
+
plan: ["yodel", "apply", "{target}", "--wave-plan", "{plan}", "--spec", WAVES_SPEC],
|
|
413
|
+
apply: ["yodel", "apply", "{target}", "--wave-apply", "{plan}", "--spec", WAVES_SPEC],
|
|
414
|
+
branches: [MAIN],
|
|
415
|
+
waves: waves.map((w) => ({
|
|
416
|
+
name: w.env,
|
|
417
|
+
runs: (w.targets ?? [w.env]).map((target) => ({ target, op: `migrate-${w.env}` })),
|
|
418
|
+
gate: w.gate,
|
|
419
|
+
...(w.shares !== undefined && w.shares > 1 ? { shares: w.shares } : {}),
|
|
420
|
+
...(variables ? { environment: { name: w.env }, variables: variables(w.env) } : {}),
|
|
421
|
+
})),
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
/** The waves spec file and the apply pipeline on each forge. */
|
|
425
|
+
export async function applyPipelines(dir, envs) {
|
|
426
|
+
const { dialect, waves } = envs;
|
|
427
|
+
const tc = toolchainOf(envs);
|
|
428
|
+
const { generateOpWavesPipeline } = (await import("@intentius/chant/op"));
|
|
429
|
+
const out = [{ path: WAVES_SPEC, content: `${JSON.stringify(wavesSpec(waves), null, 2)}\n` }];
|
|
430
|
+
// A GitLab job already has the project's variables, a writer's only under its environment.
|
|
431
|
+
const variables = (forge) => (env) => {
|
|
432
|
+
const required = waves.find((w) => w.env === env)?.requires;
|
|
433
|
+
return {
|
|
434
|
+
YODEL_CREDENTIALS: `writer:${env}`,
|
|
435
|
+
...APPLY_LOCK,
|
|
436
|
+
...(forge === "gitlab"
|
|
437
|
+
? {}
|
|
438
|
+
: { ...secrets([...writerVariables(env, dialect), ...WEBHOOK_VARIABLES]), ...(required ? secrets(readerVariables(required, dialect)) : {}) }),
|
|
439
|
+
};
|
|
440
|
+
};
|
|
441
|
+
const header = [
|
|
442
|
+
...HEADER,
|
|
443
|
+
"#",
|
|
444
|
+
"# A push to main: one job per wave, each needing the one before. Each runs",
|
|
445
|
+
"# chant run wave: yodel apply <env> --wave-plan plans the environment, the",
|
|
446
|
+
`# wave's gate policy is read from ${WAVES_SPEC} as the commit before the push`,
|
|
447
|
+
"# has it (yodel.config.ts waves, rendered), and a wave that needs an approval",
|
|
448
|
+
"# 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",
|
|
451
|
+
"# the reader of the environment before (waves[].requires): yodel apply refuses",
|
|
452
|
+
"# a migration that environment's history does not record as applied.",
|
|
453
|
+
];
|
|
454
|
+
for (const forge of FORGES) {
|
|
455
|
+
const result = await generateOpWavesPipeline(wavesSpec(waves, variables(forge)), forge, {
|
|
456
|
+
specFile: WAVES_SPEC,
|
|
457
|
+
image: tc.image,
|
|
458
|
+
beforeScript: [...installLines(tc, forge), FETCH_APPROVALS],
|
|
459
|
+
...(forge === "gitlab" ? { opsFileName: GITLAB_APPLY_FILE } : {}),
|
|
460
|
+
}, dir);
|
|
461
|
+
if (!result.success || !result.files)
|
|
462
|
+
throw new Error(`${forge}: ${result.error ?? "generateOpWavesPipeline rendered nothing"}`);
|
|
463
|
+
for (const file of result.files) {
|
|
464
|
+
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)) });
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
return out;
|
|
469
|
+
}
|
|
470
|
+
/**
|
|
471
|
+
* The wave jobs' report: chant's generateOpWavesPipeline takes run lines
|
|
472
|
+
* after the wave's command but no step that runs when it fails (a wave that
|
|
473
|
+
* waits exits 3), and no artifact, so they are added to its output here.
|
|
474
|
+
* The output is parsed and emitted again; the emitter is checked to give
|
|
475
|
+
* back chant's text unchanged before anything is added, so a change in
|
|
476
|
+
* chant's emitter fails here instead of rendering something else.
|
|
477
|
+
*/
|
|
478
|
+
function withWaveReports(forge, yaml, waves, tc) {
|
|
479
|
+
// A wide wave's jobs are wave-<k>-<env>-decide and -share-<i>.
|
|
480
|
+
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
|
+
const body = yaml.search(/^[^#\n]/m);
|
|
482
|
+
const header = yaml.slice(0, body);
|
|
483
|
+
const doc = parseYAMLDocument(yaml);
|
|
484
|
+
if (forge === "gitlab") {
|
|
485
|
+
const emit = (d) => `${header}${Object.entries(d).map(([k, v]) => emitYAMLEntry(k, v)).join("\n\n")}\n`;
|
|
486
|
+
if (emit(doc) !== yaml)
|
|
487
|
+
throw new Error("gitlab: the apply waves do not emit back to chant's text");
|
|
488
|
+
for (const [name, job] of Object.entries(doc)) {
|
|
489
|
+
const env = envOf(name);
|
|
490
|
+
if (!env || typeof job !== "object" || job === null)
|
|
491
|
+
continue;
|
|
492
|
+
Object.assign(job, { after_script: [reportCommand(env, tc)], artifacts: { name: reportArtifact(name), when: "always", paths: [`${REPORT_DIR}/`] } });
|
|
493
|
+
}
|
|
494
|
+
return emit(doc);
|
|
495
|
+
}
|
|
496
|
+
const at = yaml.indexOf("\njobs:\n");
|
|
497
|
+
if (at < 0)
|
|
498
|
+
throw new Error(`${forge}: the apply waves have no jobs`);
|
|
499
|
+
const jobs = doc.jobs;
|
|
500
|
+
const emit = () => `${yaml.slice(0, at + 1)}${emitYAMLEntry("jobs", jobs)}\n`;
|
|
501
|
+
if (emit() !== yaml)
|
|
502
|
+
throw new Error(`${forge}: the apply waves do not emit back to chant's text`);
|
|
503
|
+
for (const [name, job] of Object.entries(jobs)) {
|
|
504
|
+
const env = envOf(name);
|
|
505
|
+
if (!env)
|
|
506
|
+
continue;
|
|
507
|
+
job.steps.push({ name: `yodel report ${env}`, if: "always()", run: reportCommand(env, tc) },
|
|
508
|
+
// chant's forgejo output is already in Forgejo's dialect, so the action is named as the forgejo lexicon names it.
|
|
509
|
+
{ 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
|
+
}
|
|
511
|
+
return emit();
|
|
512
|
+
}
|
|
513
|
+
/** yodel.config.ts `waves`, checked against the environments; one wave per environment, gate `always`, without it. */
|
|
514
|
+
export async function readWaves(dir, envs) {
|
|
515
|
+
const declared = (await readConfig(dir))?.waves;
|
|
516
|
+
if (declared === undefined)
|
|
517
|
+
return envs.map((env, i) => ({ env, gate: "always", ...(i > 0 ? { requires: envs[i - 1] } : {}) }));
|
|
518
|
+
if (!Array.isArray(declared) || declared.length === 0)
|
|
519
|
+
throw new Error("yodel.config.ts: waves must list at least one { env, gate }");
|
|
520
|
+
return declared.map((w, i, all) => {
|
|
521
|
+
const env = String(w.env);
|
|
522
|
+
if (!envs.includes(env))
|
|
523
|
+
throw new Error(`yodel.config.ts: waves[${String(i)}].env ${env} is not an environment (${envs.join(", ")})`);
|
|
524
|
+
const gate = (w.gate ?? "always");
|
|
525
|
+
if (!WAVE_GATES.includes(gate))
|
|
526
|
+
throw new Error(`yodel.config.ts: waves[${String(i)}].gate ${String(w.gate)} is not ${WAVE_GATES.join(", ")}`);
|
|
527
|
+
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
|
+
if (requires !== undefined && !envs.includes(requires))
|
|
529
|
+
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 } : {}) };
|
|
531
|
+
});
|
|
532
|
+
}
|
|
533
|
+
/**
|
|
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.
|
|
537
|
+
*/
|
|
538
|
+
export async function withTenants(dir, waves) {
|
|
539
|
+
const { loadProject } = await import("../project.js");
|
|
540
|
+
const { listTenants, tenantTarget } = await import("../tenants/index.js");
|
|
541
|
+
const project = await loadProject(dir);
|
|
542
|
+
const out = [];
|
|
543
|
+
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)) });
|
|
552
|
+
}
|
|
553
|
+
return out;
|
|
554
|
+
}
|
|
555
|
+
/**
|
|
556
|
+
* The watch's spec for one environment: the Op, and on GitHub and Forgejo the
|
|
557
|
+
* reader's secrets. A GitLab job already has the project's variables (a
|
|
558
|
+
* writer's only under its environment, which the watch does not declare), and
|
|
559
|
+
* `NAME: $NAME` there would refer to itself.
|
|
560
|
+
*/
|
|
561
|
+
export function watchSpec(env, dialect, forge) {
|
|
562
|
+
if (forge === "gitlab")
|
|
563
|
+
return { name: `watch-${env}` };
|
|
564
|
+
// The job's token, for the tracking issue: GitHub grants it issues: write here, Forgejo's can write already.
|
|
565
|
+
return {
|
|
566
|
+
name: `watch-${env}`,
|
|
567
|
+
variables: { ...secrets([...readerVariables(env, dialect), ...WEBHOOK_VARIABLES]), GITHUB_TOKEN: "${{ github.token }}" },
|
|
568
|
+
...(forge === "github" ? { permissions: { issues: "write" } } : {}),
|
|
569
|
+
};
|
|
570
|
+
}
|
|
571
|
+
export async function watchPipelines(dir, envs_) {
|
|
572
|
+
const { dialect, names: envs } = envs_;
|
|
573
|
+
const tc = toolchainOf(envs_);
|
|
574
|
+
const { generateOpsPipeline } = (await import("@intentius/chant/op"));
|
|
575
|
+
const out = [];
|
|
576
|
+
const emit = async (forge, specs, extraScript) => {
|
|
577
|
+
const result = await generateOpsPipeline(specs, forge, {
|
|
578
|
+
image: tc.image,
|
|
579
|
+
beforeScript: [...installLines(tc, forge), FETCH_APPROVALS, NO_PUSH],
|
|
580
|
+
extraScript,
|
|
581
|
+
...(forge === "gitlab" ? { opsStage: "ops", opsFileName: GITLAB_WATCH_FILE } : {}),
|
|
582
|
+
}, dir);
|
|
583
|
+
if (!result.success || !result.files)
|
|
584
|
+
throw new Error(`${forge}: ${result.error ?? "generateOpsPipeline rendered nothing"}`);
|
|
585
|
+
for (const file of result.files) {
|
|
586
|
+
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`}` });
|
|
588
|
+
}
|
|
589
|
+
};
|
|
590
|
+
// `chant run` exits 0 when a watch finds drift; the last line, yodel drift, fails the job then.
|
|
591
|
+
for (const forge of ["github", "forgejo"]) {
|
|
592
|
+
for (const env of envs)
|
|
593
|
+
await emit(forge, [watchSpec(env, dialect, forge)], [watchDrift(env)]);
|
|
594
|
+
}
|
|
595
|
+
// GitLab: one file for every watch, so the last line takes the environment from the job's own name.
|
|
596
|
+
await emit("gitlab", envs.map((env) => watchSpec(env, dialect, "gitlab")), [watchDrift('"${CI_JOB_NAME#watch-}"')]);
|
|
597
|
+
return out;
|
|
598
|
+
}
|
|
599
|
+
// ── The project ─────────────────────────────────────────────────────────────
|
|
600
|
+
/** The dialect and the environments chant.config.ts declares, in order. */
|
|
601
|
+
export async function environments(dir) {
|
|
602
|
+
const { loadChantConfig } = await import("@intentius/chant/config");
|
|
603
|
+
const { config } = await loadChantConfig(dir);
|
|
604
|
+
const sql = config.sql;
|
|
605
|
+
const dialect = sql?.dialect;
|
|
606
|
+
if (dialect !== "clickhouse" && dialect !== "postgres")
|
|
607
|
+
throw new Error(`chant.config.ts: sql.dialect must be clickhouse or postgres, got ${String(dialect)}`);
|
|
608
|
+
const all = Object.keys(sql?.profiles ?? {});
|
|
609
|
+
const replay = all.includes(REPLAY_PROFILE);
|
|
610
|
+
const envs = all.filter((name) => name !== REPLAY_PROFILE);
|
|
611
|
+
if (envs.length === 0)
|
|
612
|
+
throw new Error("chant.config.ts declares no sql.profiles");
|
|
613
|
+
for (const env of envs) {
|
|
614
|
+
for (const op of [`migrate-${env}`, `watch-${env}`]) {
|
|
615
|
+
if (!existsSync(join(dir, "ops", `${op}.op.ts`)))
|
|
616
|
+
throw new Error(`sql.profiles.${env} has no ops/${op}.op.ts`);
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
const ci = (await readConfig(dir))?.ci;
|
|
620
|
+
return {
|
|
621
|
+
dialect,
|
|
622
|
+
names: envs,
|
|
623
|
+
replay,
|
|
624
|
+
waves: await withTenants(dir, await readWaves(dir, envs)),
|
|
625
|
+
forges: ci?.forges ?? FORGES,
|
|
626
|
+
...(ci?.jobs ? { jobs: await loadProjectJobs(dir, ci.jobs) } : {}),
|
|
627
|
+
...(ci?.image ? { image: ci.image } : {}),
|
|
628
|
+
};
|
|
629
|
+
}
|
|
630
|
+
/** yodel.config.ts, checked; undefined when the project has none. */
|
|
631
|
+
async function readConfig(dir) {
|
|
632
|
+
return (await loadYodelConfig(dir))?.config;
|
|
633
|
+
}
|
|
634
|
+
/** Loads the module `ci.jobs` names. */
|
|
635
|
+
export async function loadProjectJobs(dir, file) {
|
|
636
|
+
const path = resolve(dir, file);
|
|
637
|
+
if (!existsSync(path))
|
|
638
|
+
throw new Error(`yodel.config.ts: ci.jobs names ${file}, which is not there`);
|
|
639
|
+
const mod = (await import(pathToFileURL(path).href));
|
|
640
|
+
const out = {};
|
|
641
|
+
for (const key of Object.keys(mod)) {
|
|
642
|
+
if (key !== "actions" && key !== "gitlab" && key !== "default")
|
|
643
|
+
throw new Error(`${file}: export ${key} is not actions or gitlab`);
|
|
644
|
+
}
|
|
645
|
+
for (const key of ["actions", "gitlab"]) {
|
|
646
|
+
const v = mod[key];
|
|
647
|
+
if (v === undefined)
|
|
648
|
+
continue;
|
|
649
|
+
if (typeof v !== "object" || v === null || Array.isArray(v))
|
|
650
|
+
throw new Error(`${file}: ${key} must be an object of jobs, keyed by the job's name`);
|
|
651
|
+
out[key] = v;
|
|
652
|
+
}
|
|
653
|
+
return out;
|
|
654
|
+
}
|
|
655
|
+
/** The project's jobs for one pipeline, refusing a name yodel's jobs already use. */
|
|
656
|
+
function ownJobs(own, yodels, what) {
|
|
657
|
+
if (!own)
|
|
658
|
+
return {};
|
|
659
|
+
for (const name of Object.keys(own)) {
|
|
660
|
+
if (name in yodels)
|
|
661
|
+
throw new Error(`ci.jobs: ${what} job ${name} has the name of one of yodel's jobs; name it something else`);
|
|
662
|
+
}
|
|
663
|
+
return own;
|
|
664
|
+
}
|
|
665
|
+
/** The pipelines that need no Op discovery: pull requests, and GitLab's main file. */
|
|
666
|
+
export function review(envs) {
|
|
667
|
+
return [actionsReview("github", envs), actionsReview("forgejo", envs), gitlabPipeline(envs)];
|
|
668
|
+
}
|
|
669
|
+
/** The forge a rendered file is for; undefined for the waves spec, which every forge's apply reads. */
|
|
670
|
+
export function forgeOf(path) {
|
|
671
|
+
if (path.startsWith(".github/"))
|
|
672
|
+
return "github";
|
|
673
|
+
if (path.startsWith(".forgejo/"))
|
|
674
|
+
return "forgejo";
|
|
675
|
+
if (path.startsWith(".gitlab"))
|
|
676
|
+
return "gitlab";
|
|
677
|
+
return undefined;
|
|
678
|
+
}
|
|
679
|
+
export async function render(dir) {
|
|
680
|
+
const envs = await environments(dir);
|
|
681
|
+
const forges = envs.forges ?? FORGES;
|
|
682
|
+
const files = [...review(envs), ...(await applyPipelines(dir, envs)), ...(await watchPipelines(dir, envs))];
|
|
683
|
+
return files.filter((f) => {
|
|
684
|
+
const forge = forgeOf(f.path);
|
|
685
|
+
return forge === undefined || forges.includes(forge);
|
|
686
|
+
});
|
|
687
|
+
}
|
|
688
|
+
//# sourceMappingURL=render.js.map
|