@intentius/chant-lexicon-github 0.124.55 → 0.124.57
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/dist/components/generate-op-pipeline.d.ts +2 -2
- package/dist/components/generate-op-pipeline.d.ts.map +1 -1
- package/dist/components/generate-op-pipeline.js +73 -8
- package/dist/components/generate-op-waves-pipeline.d.ts +16 -3
- package/dist/components/generate-op-waves-pipeline.d.ts.map +1 -1
- package/dist/components/generate-op-waves-pipeline.js +82 -17
- package/dist/components/generate-pipeline.d.ts.map +1 -1
- package/dist/components/generate-pipeline.js +34 -6
- package/dist/integrity.json +2 -2
- package/dist/manifest.json +1 -1
- package/package.json +2 -2
- package/src/components/__golden__/op-waves-jobs.yml +31 -4
- package/src/components/generate-op-pipeline.cloud.test.ts +96 -0
- package/src/components/generate-op-pipeline.ts +89 -15
- package/src/components/generate-op-waves-pipeline.approval.test.ts +46 -0
- package/src/components/generate-op-waves-pipeline.guard.test.ts +57 -0
- package/src/components/generate-op-waves-pipeline.identity.test.ts +8 -8
- package/src/components/generate-op-waves-pipeline.test.ts +19 -3
- package/src/components/generate-op-waves-pipeline.ts +85 -15
- package/src/components/generate-pipeline.ts +38 -6
- package/src/components/generate-pr-pipeline.comment-targets.test.ts +24 -0
- package/src/components/generate-pr-pipeline.locks.test.ts +10 -0
- package/src/components/generate-pr-pipeline.respond.test.ts +47 -0
- package/src/components/generate-pr-pipeline.runners.test.ts +61 -0
- package/src/components/generate-pr-pipeline.stale-notes.test.ts +42 -0
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Op waves → GitHub Actions workflow (#3679).
|
|
3
3
|
*
|
|
4
|
-
* One workflow for the whole spec, on a push to its branches (and by hand)
|
|
5
|
-
*
|
|
4
|
+
* One workflow for the whole spec, on a push to its branches (and by hand).
|
|
5
|
+
* Each job is one of core's `opWaveJobs`: a wave, or a wide
|
|
6
6
|
* wave's deciding job and share jobs, chained by `needs:`. Re-running the
|
|
7
7
|
* failed jobs after `chant approve` carries on from that wave.
|
|
8
8
|
*
|
|
@@ -34,6 +34,8 @@
|
|
|
34
34
|
* `pull-requests: read`, so a wave can read the merged pull request's
|
|
35
35
|
* reviews, and a second workflow, `<name>-plans.yml`, runs `chant run wave
|
|
36
36
|
* --record-plans` on each pull request to record the digests its head plans.
|
|
37
|
+
* A `pull-request` wave (#4630) gets the plans workflow too, for the approval
|
|
38
|
+
* status before merge; it asks for no `pull-requests: read` on the waves.
|
|
37
39
|
*
|
|
38
40
|
* With `notify` on the spec (#3761), the workflow maps each variable it names
|
|
39
41
|
* from the repository secret of the same name, so a wave job can post to
|
|
@@ -62,15 +64,27 @@
|
|
|
62
64
|
* step holds the control token: the `tokenSecret` secret, mapped into
|
|
63
65
|
* `CHANT_CONTROL_TOKEN` and handed to curl on its stdin
|
|
64
66
|
* (`controlDispatchScript`, #4568).
|
|
67
|
+
*
|
|
68
|
+
* Every wave, deciding and share job starts with the apply-job guard
|
|
69
|
+
* (#4624, `@intentius/chant/ci-job-guard`), before the trade and the
|
|
70
|
+
* checkout: it stops the job unless `GITHUB_REF` is one of the spec's
|
|
71
|
+
* branches, the job runs in its forge environment (read from an OIDC
|
|
72
|
+
* token's claims, so only in a job that may request one), and, for a
|
|
73
|
+
* runner whose labels include `self-hosted`, on a self-hosted runner. The
|
|
74
|
+
* forgejo generator passes `forge: "forgejo"`, which checks the ref and
|
|
75
|
+
* runner only.
|
|
65
76
|
*/
|
|
66
77
|
|
|
67
78
|
import {
|
|
68
79
|
notifyVariables,
|
|
69
80
|
opWaveGroupOf,
|
|
70
81
|
opWaveJobs,
|
|
82
|
+
opWaveJobTimeout,
|
|
71
83
|
opWaveOutcomeDir,
|
|
72
84
|
opWavesControlTarget,
|
|
73
85
|
opWavesLastJobs,
|
|
86
|
+
opWavesApprovalStatusCommand,
|
|
87
|
+
opWavesPostsApprovalStatus,
|
|
74
88
|
opWavesOutcomeCommand,
|
|
75
89
|
opWavesRecordPlansCommand,
|
|
76
90
|
opWavesResumeCommand,
|
|
@@ -93,6 +107,8 @@ import {
|
|
|
93
107
|
setupStepDoc,
|
|
94
108
|
} from "@intentius/chant/ci-jobs";
|
|
95
109
|
import { OIDC_REQUEST_VARS, TRADE_SPEC_ENV, tradeLine, type CloudTradeSpec } from "@intentius/chant/ci-identity";
|
|
110
|
+
import { JOB_GUARD_ENV, jobGuardLine, jobGuardSpec, opWaveJobLabel } from "@intentius/chant/ci-job-guard";
|
|
111
|
+
import { resolveProductWords } from "@intentius/chant/ci-product-words";
|
|
96
112
|
import { checkoutStep, concurrency, DOWNLOAD_ARTIFACT, downloadArtifactStep, keepRecordStep, runSteps } from "./ci-jobs";
|
|
97
113
|
import { emitOpPipelineYAML, type GithubOpPipelineDoc } from "./generate-op-pipeline";
|
|
98
114
|
|
|
@@ -113,10 +129,15 @@ export function opWaveOutcomeArtifact(spec: Pick<OpWavesSpec, "name">, jobName:
|
|
|
113
129
|
export function buildGithubOpWavesDoc(
|
|
114
130
|
spec: OpWavesSpec,
|
|
115
131
|
options: OpWavesPipelineOptions,
|
|
132
|
+
dialect: { forge?: "github" | "forgejo" } = {},
|
|
116
133
|
): { doc: GithubOpPipelineDoc; jobs: OpWavesPipelineResult["jobs"]; resumeDoc?: GithubOpPipelineDoc; plansDoc?: GithubOpPipelineDoc } {
|
|
117
134
|
const jobs = opWaveJobs(spec, options.specFile, { bin: options.bin });
|
|
135
|
+
const forge = dialect.forge ?? "github";
|
|
136
|
+
const product = resolveProductWords(options.words ?? {}).product;
|
|
118
137
|
const image = options.image ?? OP_WAVES_IMAGE;
|
|
119
138
|
const byReview = spec.waves.some((w) => w.approval === "pr-review");
|
|
139
|
+
// #4630: a pr-review or pull-request wave gets the plans workflow and its approval status.
|
|
140
|
+
const statusBeforeMerge = opWavesPostsApprovalStatus(spec);
|
|
120
141
|
for (const wave of spec.waves) {
|
|
121
142
|
assertSetupSteps(`${spec.name}/${wave.name}`, wave.setup ?? []);
|
|
122
143
|
if (wave.environment) assertEnvironment(`${spec.name}/${wave.name}`, wave.environment);
|
|
@@ -126,6 +147,8 @@ export function buildGithubOpWavesDoc(
|
|
|
126
147
|
}
|
|
127
148
|
}
|
|
128
149
|
const permissions = { contents: "write", ...(byReview ? { "pull-requests": "read" } : {}) };
|
|
150
|
+
// The ruling for ci-products (2026-10-28): every wave job carries its timeout and the lock wait under it.
|
|
151
|
+
const timeout = opWaveJobTimeout(options.jobTimeoutMinutes);
|
|
129
152
|
|
|
130
153
|
const jobsDoc: Record<string, unknown> = {};
|
|
131
154
|
for (const job of jobs) {
|
|
@@ -134,7 +157,21 @@ export function buildGithubOpWavesDoc(
|
|
|
134
157
|
const group = opWaveGroupOf(spec, job);
|
|
135
158
|
// #4332: the job's cloud identities, traded before the checkout.
|
|
136
159
|
const cloud = group?.cloud ?? wave.cloud;
|
|
137
|
-
const
|
|
160
|
+
const environment = group?.environment ?? wave.environment;
|
|
161
|
+
const idTokens = group?.idTokens ?? wave.idTokens;
|
|
162
|
+
const idToken = idTokens !== undefined || cloud !== undefined;
|
|
163
|
+
const runner = group?.runner ?? wave.runner ?? spec.runner;
|
|
164
|
+
// #4624: the guard first, before the trade and the checkout.
|
|
165
|
+
const guard = guardStep({
|
|
166
|
+
forge,
|
|
167
|
+
job: opWaveJobLabel(spec.name, job),
|
|
168
|
+
product,
|
|
169
|
+
...(environment && forge === "github" ? { environment: environment.name } : {}),
|
|
170
|
+
branches: pushBranches(spec.branches),
|
|
171
|
+
...(runner !== undefined ? { runner } : {}),
|
|
172
|
+
idToken,
|
|
173
|
+
});
|
|
174
|
+
const steps: Array<Record<string, unknown>> = [guard, ...(cloud ? [tradeStep(cloud)] : []), checkoutStep(true)];
|
|
138
175
|
if (job.kind === "share") {
|
|
139
176
|
steps.push(downloadArtifactStep(`Download wave ${job.wave}'s decision`, opWaveRecordArtifact(spec, job.wave), dirnameOf(job.record!)));
|
|
140
177
|
}
|
|
@@ -151,11 +188,8 @@ export function buildGithubOpWavesDoc(
|
|
|
151
188
|
steps.push(keepRecordStep(`Keep wave ${job.wave}'s decision`, opWaveRecordArtifact(spec, job.wave), job.record!));
|
|
152
189
|
}
|
|
153
190
|
steps.push(keepRecordStep(`Keep the job's outcome`, opWaveOutcomeArtifact(spec, job.jobName), job.outcome));
|
|
154
|
-
const environment = group?.environment ?? wave.environment;
|
|
155
|
-
const idTokens = group?.idTokens ?? wave.idTokens;
|
|
156
|
-
const idToken = idTokens !== undefined || cloud !== undefined;
|
|
157
191
|
jobsDoc[job.jobName] = {
|
|
158
|
-
"runs-on": githubRunsOn(
|
|
192
|
+
"runs-on": githubRunsOn(runner),
|
|
159
193
|
...(job.needs.length > 0 ? { needs: job.needs } : {}),
|
|
160
194
|
// #4322: a wave's jobs run after a failure in the wave before; a share job runs only on its deciding job's success.
|
|
161
195
|
...(job.needs.length > 0 && job.kind !== "share" ? { if: "!cancelled()" } : {}),
|
|
@@ -163,7 +197,8 @@ export function buildGithubOpWavesDoc(
|
|
|
163
197
|
...(environment ? { environment: environmentDoc(environment) } : {}),
|
|
164
198
|
// #3914: a job's own permissions replace the workflow's, so they repeat them.
|
|
165
199
|
...(idToken ? { permissions: { ...permissions, "id-token": "write" } } : {}),
|
|
166
|
-
|
|
200
|
+
"timeout-minutes": timeout.minutes,
|
|
201
|
+
env: { ...timeout.env, ...(group || idTokens ? { ...wave.variables, ...group?.variables, ...idTokenAudienceEnv(idTokens) } : wave.variables) },
|
|
167
202
|
steps,
|
|
168
203
|
};
|
|
169
204
|
}
|
|
@@ -180,7 +215,8 @@ export function buildGithubOpWavesDoc(
|
|
|
180
215
|
name: spec.name,
|
|
181
216
|
on: { push: { branches: pushBranches(spec.branches) }, workflow_dispatch: {} },
|
|
182
217
|
...nonEmpty("env", env),
|
|
183
|
-
|
|
218
|
+
// The ruling for ci-products (2026-10-28): no workflow-level concurrency group. Runs on the target and repo grains wait for
|
|
219
|
+
// their rows on chant's ledger, oldest commit first; plunge stacks on the resource grain lease their resource rows and run.
|
|
184
220
|
permissions,
|
|
185
221
|
jobsDoc,
|
|
186
222
|
};
|
|
@@ -188,7 +224,7 @@ export function buildGithubOpWavesDoc(
|
|
|
188
224
|
doc,
|
|
189
225
|
jobs,
|
|
190
226
|
...(spec.resume ? { resumeDoc: buildResumeDoc(spec, options) } : {}),
|
|
191
|
-
...(
|
|
227
|
+
...(statusBeforeMerge ? { plansDoc: buildPlansDoc(spec, options, forge) } : {}),
|
|
192
228
|
};
|
|
193
229
|
}
|
|
194
230
|
|
|
@@ -256,21 +292,55 @@ function buildResumeDoc(spec: OpWavesSpec, options: OpWavesPipelineOptions): Git
|
|
|
256
292
|
};
|
|
257
293
|
}
|
|
258
294
|
|
|
259
|
-
/**
|
|
260
|
-
|
|
295
|
+
/**
|
|
296
|
+
* The pull request workflow a `pr-review` wave needs (#3684): record the
|
|
297
|
+
* digests each head plans. The `approval-status` job after it (#4630) posts
|
|
298
|
+
* `<product>/approval` on the head, and runs alone on each review submitted
|
|
299
|
+
* or dismissed. It holds a read token that may write statuses and nothing
|
|
300
|
+
* else. A `pull-request` wave gets the same workflow: the record keeps its
|
|
301
|
+
* change set, and the status reads the pull request's own approval of it.
|
|
302
|
+
*/
|
|
303
|
+
function buildPlansDoc(spec: OpWavesSpec, options: OpWavesPipelineOptions, forge: "github" | "forgejo"): GithubOpPipelineDoc {
|
|
261
304
|
const steps: Array<Record<string, unknown>> = [checkoutStep(true), ...(spec.waves[0]?.setup ?? []).map(setupStepDoc), ...runSteps(options.beforeScript ?? [])];
|
|
262
305
|
steps.push({ name: "Plan every wave at the head and record its digests", run: opWavesRecordPlansCommand(options.specFile, options.bin).join(" ") });
|
|
306
|
+
const statusSteps: Array<Record<string, unknown>> = [checkoutStep(true), ...runSteps(options.beforeScript ?? [])];
|
|
307
|
+
statusSteps.push({
|
|
308
|
+
name: "Post the approval status on the head",
|
|
309
|
+
run: opWavesApprovalStatusCommand(options.specFile, forge, options.bin).join(" "),
|
|
310
|
+
env: {
|
|
311
|
+
GITHUB_TOKEN: GITHUB_JOB_TOKEN,
|
|
312
|
+
PR_NUMBER: "${{ github.event.pull_request.number }}",
|
|
313
|
+
HEAD_SHA: "${{ github.event.pull_request.head.sha }}",
|
|
314
|
+
BASE_SHA: "${{ github.event.pull_request.base.sha }}",
|
|
315
|
+
},
|
|
316
|
+
});
|
|
263
317
|
return {
|
|
264
318
|
name: `${spec.name}-plans`,
|
|
265
|
-
on: { pull_request: { branches: pushBranches(spec.branches) } },
|
|
319
|
+
on: { pull_request: { branches: pushBranches(spec.branches) }, pull_request_review: { types: ["submitted", "dismissed"] } },
|
|
266
320
|
...nonEmpty("env", options.variables),
|
|
267
|
-
concurrency: concurrency(spec.name + "-plans-${{ github.event.number }}", true),
|
|
321
|
+
concurrency: concurrency(spec.name + "-plans-${{ github.event_name }}-${{ github.event.pull_request.number }}", true),
|
|
268
322
|
// contents: write pushes the record to chant/lifecycle.
|
|
269
323
|
permissions: { contents: "write" },
|
|
270
|
-
jobsDoc: {
|
|
324
|
+
jobsDoc: {
|
|
325
|
+
"record-plans": { if: "github.event_name == 'pull_request'", "runs-on": githubRunsOn(spec.runner), container: options.image ?? OP_WAVES_IMAGE, steps },
|
|
326
|
+
"approval-status": {
|
|
327
|
+
needs: ["record-plans"],
|
|
328
|
+
// On a review the record-plans job is skipped, and the status reads the plans an earlier push recorded.
|
|
329
|
+
if: "${{ always() && (needs.record-plans.result == 'success' || needs.record-plans.result == 'skipped') }}",
|
|
330
|
+
"runs-on": githubRunsOn(spec.runner),
|
|
331
|
+
container: options.image ?? OP_WAVES_IMAGE,
|
|
332
|
+
permissions: { contents: "read", "pull-requests": "read", statuses: "write" },
|
|
333
|
+
steps: statusSteps,
|
|
334
|
+
},
|
|
335
|
+
},
|
|
271
336
|
};
|
|
272
337
|
}
|
|
273
338
|
|
|
339
|
+
/** The apply-job guard step (#4624): the job's expectation in CHANT_JOB_GUARD, checked by `node -e` from `/`. */
|
|
340
|
+
function guardStep(input: Parameters<typeof jobGuardSpec>[0]): Record<string, unknown> {
|
|
341
|
+
return { name: "Check the job runs where the spec set it to run", env: { [JOB_GUARD_ENV]: JSON.stringify(jobGuardSpec(input)) }, run: jobGuardLine() };
|
|
342
|
+
}
|
|
343
|
+
|
|
274
344
|
/** The step that trades the job's OIDC token for `cloud` (#4332), writing the credentials to GITHUB_ENV. */
|
|
275
345
|
function tradeStep(cloud: CloudTradeSpec): Record<string, unknown> {
|
|
276
346
|
return { name: "Trade the job's OIDC token for its cloud identities", env: { [TRADE_SPEC_ENV]: JSON.stringify(cloud) }, run: tradeLine("$GITHUB_ENV", "github-env") };
|
|
@@ -59,7 +59,7 @@
|
|
|
59
59
|
import { resolveComponentGraph, type DriverComponent } from "@intentius/chant/components/driver";
|
|
60
60
|
import { hasPublishStep, promoteArchivePaths } from "@intentius/chant/components/promote";
|
|
61
61
|
import { GATED_WAVE_RECORD, gatedWaveJobs } from "@intentius/chant/components/gated-wave-pipeline";
|
|
62
|
-
import { CI_IMAGE, ciJobName, dirnameOf, fillCommand, GITHUB_JOB_TOKEN, GITHUB_RUNNER } from "@intentius/chant/ci-jobs";
|
|
62
|
+
import { CI_IMAGE, ciJobName, dirnameOf, fillCommand, GITHUB_JOB_TOKEN, GITHUB_RUNNER, githubRunsOn } from "@intentius/chant/ci-jobs";
|
|
63
63
|
import { CHECKOUT_TOKEN_ENV, checkoutScript, dropForgeTokens, handoverOutputs, handoverTrap, takeOverScript } from "@intentius/chant/ci-handover";
|
|
64
64
|
import { OIDC_REQUEST_VARS, TRADE_SPEC_ENV, tradeLine, type CloudTradeSpec } from "@intentius/chant/ci-identity";
|
|
65
65
|
import {
|
|
@@ -78,7 +78,7 @@ import {
|
|
|
78
78
|
PR_PLAN_NOTE_JOB,
|
|
79
79
|
PR_LOCK_JOB,
|
|
80
80
|
PR_UNLOCK_JOB,
|
|
81
|
-
|
|
81
|
+
prUnlockCommand,
|
|
82
82
|
prLockCommand,
|
|
83
83
|
prCommentLockJobs,
|
|
84
84
|
prCommentPreviewJob,
|
|
@@ -87,6 +87,10 @@ import {
|
|
|
87
87
|
prLoopWaveJobs,
|
|
88
88
|
prLoopWithWaves,
|
|
89
89
|
prEnvJobs,
|
|
90
|
+
prLoopJobStage,
|
|
91
|
+
prStaleJob,
|
|
92
|
+
PR_STALE_JOB,
|
|
93
|
+
prLoopRunner,
|
|
90
94
|
PR_ENV_REPORT_DIR,
|
|
91
95
|
PR_ENV_SWEEP_CRON,
|
|
92
96
|
PR_ENV_POST_JOB,
|
|
@@ -415,7 +419,25 @@ function prLoopGithubDoc(
|
|
|
415
419
|
},
|
|
416
420
|
]);
|
|
417
421
|
}
|
|
418
|
-
const post = prLoopPostJob(forgeName, env, member?.name, loop.words ? resolveProductWords(loop.words) : undefined);
|
|
422
|
+
const post = prLoopPostJob(forgeName, env, member?.name, loop.words ? resolveProductWords(loop.words) : undefined, { ...(loop.staleNotes ? { staleNotes: true } : {}), ...(loop.respond ? { respond: loop.respond } : {}), ...(loop.decide ? { decide: loop.decide } : {}) });
|
|
423
|
+
// #4628: on a push to the target branch, the open pull requests' notes posted against another commit are marked stale.
|
|
424
|
+
const stale = prStaleJob(forgeName, env, loop, member?.name);
|
|
425
|
+
const staleJobs: Record<string, unknown> = stale
|
|
426
|
+
? {
|
|
427
|
+
[PR_STALE_JOB]: {
|
|
428
|
+
if: "github.event_name == 'push' && startsWith(github.ref, 'refs/heads/')",
|
|
429
|
+
"runs-on": GITHUB_RUNNER,
|
|
430
|
+
container: image,
|
|
431
|
+
permissions: { contents: "read", "pull-requests": "write", statuses: "write" },
|
|
432
|
+
steps: [
|
|
433
|
+
// The pushed commit: the target branch's, so none of a pull request's code is here.
|
|
434
|
+
{ name: "Check out the target branch's commit", uses: CHECKOUT },
|
|
435
|
+
...runSteps(beforeScript),
|
|
436
|
+
{ name: "Mark the open pull requests' notes stale", ...(rooted ? {} : { "working-directory": "." }), env: stale.env, run: stale.command },
|
|
437
|
+
],
|
|
438
|
+
},
|
|
439
|
+
}
|
|
440
|
+
: {};
|
|
419
441
|
// #4619: with a control repo named, the note job reads its chant/lifecycle, read-only, so pr-post marks the cross-stack edges.
|
|
420
442
|
const controlRead = loop.control ? controlNoteCheckout(forgeName, loop.control.target, loop.words ?? {}) : undefined;
|
|
421
443
|
const noteSteps: Array<Record<string, unknown>> = [
|
|
@@ -431,6 +453,8 @@ function prLoopGithubDoc(
|
|
|
431
453
|
const prEnv = loop.prEnvironment ? prEnvGithubJobs(loop.forge ?? "github", image, beforeScript, extraScript, loop, member) : undefined;
|
|
432
454
|
// #4327: with ci.locks: plan the pull_request trigger fires on close too, for the unlock job, and the plan skips that event.
|
|
433
455
|
const locks = loop.locks === "plan";
|
|
456
|
+
// plywood#22: the lock lines run through the product's bin, as its other job lines do.
|
|
457
|
+
const lockBin = resolveProductWords(loop.words).bin;
|
|
434
458
|
const closes = Boolean(prEnv) || locks;
|
|
435
459
|
const lockJobs: Record<string, unknown> = locks
|
|
436
460
|
? {
|
|
@@ -449,7 +473,7 @@ function prLoopGithubDoc(
|
|
|
449
473
|
{
|
|
450
474
|
name: "Lock the components the plan selected",
|
|
451
475
|
env: { PR_NUMBER: "${{ github.event.pull_request.number }}", HEAD_SHA: "${{ github.event.pull_request.head.sha }}", GITHUB_TOKEN: GITHUB_JOB_TOKEN },
|
|
452
|
-
run: prLockCommand(forgeName),
|
|
476
|
+
run: prLockCommand(forgeName, lockBin),
|
|
453
477
|
},
|
|
454
478
|
],
|
|
455
479
|
},
|
|
@@ -461,7 +485,7 @@ function prLoopGithubDoc(
|
|
|
461
485
|
steps: [
|
|
462
486
|
{ name: "Check out the target branch's commit", uses: CHECKOUT, with: { ref: "${{ github.event.pull_request.base.sha }}", "fetch-depth": 0 } },
|
|
463
487
|
...runSteps(beforeScript),
|
|
464
|
-
{ name: "Release the pull request's locks", env: { PR_NUMBER: "${{ github.event.pull_request.number }}" }, run:
|
|
488
|
+
{ name: "Release the pull request's locks", env: { PR_NUMBER: "${{ github.event.pull_request.number }}" }, run: prUnlockCommand(lockBin) },
|
|
465
489
|
],
|
|
466
490
|
},
|
|
467
491
|
}
|
|
@@ -563,6 +587,7 @@ function prLoopGithubDoc(
|
|
|
563
587
|
...lockJobs,
|
|
564
588
|
...commentLockJobs,
|
|
565
589
|
...previewJobs,
|
|
590
|
+
...staleJobs,
|
|
566
591
|
...Object.fromEntries(applyJobs),
|
|
567
592
|
...Object.fromEntries(controlJobs),
|
|
568
593
|
...(prEnv ? prEnv.jobs : {}),
|
|
@@ -580,6 +605,12 @@ function prLoopGithubDoc(
|
|
|
580
605
|
}
|
|
581
606
|
// #4301: a /chant apply comment starts the apply before merge. #4588: and a lock or unlock comment its job.
|
|
582
607
|
if (loop.applyBeforeMerge || commentLocks.length > 0 || preview) on.issue_comment = { types: ["created"] };
|
|
608
|
+
// #4625: each job on its stage's runner, the plan side's, the apply side's, or the default.
|
|
609
|
+
if (loop.runners) {
|
|
610
|
+
for (const [name, job] of Object.entries(jobsDoc) as Array<[string, Record<string, unknown>]>) {
|
|
611
|
+
job["runs-on"] = githubRunsOn(prLoopRunner(loop, prLoopJobStage(name)));
|
|
612
|
+
}
|
|
613
|
+
}
|
|
583
614
|
return {
|
|
584
615
|
name: member ? `chant-pr.${member.name}.${env}` : `chant-pr-${env}`,
|
|
585
616
|
environment: env,
|
|
@@ -587,10 +618,11 @@ function prLoopGithubDoc(
|
|
|
587
618
|
...(rooted ? {} : { defaults: { run: { "working-directory": member!.dir } } }),
|
|
588
619
|
env: { ...options.variables, CHANT_ENV: env },
|
|
589
620
|
jobsDoc,
|
|
590
|
-
stages: ["plan", PR_PLAN_NOTE_JOB, ...(locks ? [PR_LOCK_JOB, PR_UNLOCK_JOB] : []), ...commentLocks.map((j) => j.jobName), ...(preview ? [preview.jobName, preview.noteJobName] : []), ...applyNames, ...controlJobs.map(([name]) => name), ...(prEnv ? ["env-up", PR_ENV_POST_JOB, "env-down", "env-sweep"] : [])],
|
|
621
|
+
stages: ["plan", PR_PLAN_NOTE_JOB, ...(locks ? [PR_LOCK_JOB, PR_UNLOCK_JOB] : []), ...commentLocks.map((j) => j.jobName), ...(preview ? [preview.jobName, preview.noteJobName] : []), ...(stale ? [PR_STALE_JOB] : []), ...applyNames, ...controlJobs.map(([name]) => name), ...(prEnv ? ["env-up", PR_ENV_POST_JOB, "env-down", "env-sweep"] : [])],
|
|
591
622
|
jobs: [
|
|
592
623
|
{ jobName: "plan", component: "pull request plan", stage: "plan", needs: [] },
|
|
593
624
|
{ jobName: PR_PLAN_NOTE_JOB, component: "pull request plan note", stage: PR_PLAN_NOTE_JOB, needs: ["plan"] },
|
|
625
|
+
...(stale ? [{ jobName: PR_STALE_JOB, component: "pull request stale notes", stage: PR_STALE_JOB, needs: [] }] : []),
|
|
594
626
|
...(locks
|
|
595
627
|
? [
|
|
596
628
|
{ jobName: PR_LOCK_JOB, component: "pull request lock", stage: PR_LOCK_JOB, needs: ["plan"] },
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The preview comment's --target on GitHub (#4626): the preview step runs
|
|
3
|
+
* `pr-plan --comment-targets` with the comment body in CHANT_PREVIEW_COMMENT,
|
|
4
|
+
* and the body never appears in the shell line. Written for the
|
|
5
|
+
* ci-products post-merge pass.
|
|
6
|
+
*/
|
|
7
|
+
import { expect, test } from "vitest";
|
|
8
|
+
import { parseYAML } from "@intentius/chant/yaml";
|
|
9
|
+
import type { DriverComponent } from "@intentius/chant/components/driver";
|
|
10
|
+
import { generateGithubPipeline } from "./generate-pipeline";
|
|
11
|
+
|
|
12
|
+
const ESTATE: DriverComponent[] = [{ name: "net", dependsOn: [], deploy: [{ phase: "Apply", steps: [{ kind: "terraform-apply", root: "net" }] }] }];
|
|
13
|
+
const WORDS = { product: "plywood", stages: { plan: "preview", apply: "up" }, commands: { preview: "plan" as const }, bin: "npx --no-install plywood" };
|
|
14
|
+
|
|
15
|
+
test("the preview step reads the comment from a variable (#4626)", () => {
|
|
16
|
+
const doc = parseYAML(generateGithubPipeline(ESTATE, { env: "prod", prLoop: { previewComment: true, words: WORDS } }).yaml) as { jobs: Record<string, { steps: Array<{ name?: string; run?: string; env?: Record<string, string> }> }> };
|
|
17
|
+
const step = doc.jobs["comment-preview"]!.steps.find((s) => s.name === "Preview the members this pull request reaches")!;
|
|
18
|
+
expect(step.run).toContain("npx --no-install plywood ci run plan");
|
|
19
|
+
expect(step.run).toContain("--comment-targets");
|
|
20
|
+
expect(step.env!.CHANT_PREVIEW_COMMENT).toBe("${{ github.event.comment.body }}");
|
|
21
|
+
expect(step.run).not.toContain("comment.body");
|
|
22
|
+
const plan = doc.jobs.plan!.steps.find((s) => s.name === "Plan the members this pull request reaches")!;
|
|
23
|
+
expect(plan.run).not.toContain("--comment-targets");
|
|
24
|
+
});
|
|
@@ -56,6 +56,16 @@ describe("the GitHub workflow with ci.locks: plan", () => {
|
|
|
56
56
|
});
|
|
57
57
|
});
|
|
58
58
|
|
|
59
|
+
describe("a product's words (plywood#22)", () => {
|
|
60
|
+
test("the lock and unlock lines run through the product's bin, so its job image needs no chant on PATH", () => {
|
|
61
|
+
const doc = gen({ locks: "plan", words: { product: "plywood", bin: "npx --no-install plywood" } });
|
|
62
|
+
const lock = doc.jobs.lock!.steps.find((s) => s.run?.includes(" lock --from-report"))!;
|
|
63
|
+
expect(lock.run).toBe('npx --no-install plywood lock --from-report .chant/pr/pr-plan.json --pr "$PR_NUMBER" --head "$HEAD_SHA" --stage plan --forge github');
|
|
64
|
+
const unlock = doc.jobs.unlock!.steps.find((s) => s.run?.includes(" unlock --pr"))!;
|
|
65
|
+
expect(unlock.run).toBe('npx --no-install plywood unlock --pr "$PR_NUMBER"');
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
59
69
|
describe("with a pull request environment too", () => {
|
|
60
70
|
test("the pull_request types and the plan condition are the same as with the environment alone", () => {
|
|
61
71
|
const both = gen({ locks: "plan", prEnvironment: true });
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The pull request loop's responses, description check and merge on GitHub
|
|
3
|
+
* (#4629, #4636), as `chant build --pr-loop --respond --decide-backend
|
|
4
|
+
* --decide-model --merge auto` sets them: the note job asks the decide
|
|
5
|
+
* service, and the apply job carries CHANT_RESPOND and the merge.
|
|
6
|
+
*/
|
|
7
|
+
import { describe, expect, test } from "vitest";
|
|
8
|
+
import { parseYAML } from "@intentius/chant/yaml";
|
|
9
|
+
import type { DriverComponent } from "@intentius/chant/components/driver";
|
|
10
|
+
import { generateGithubPipeline } from "./generate-pipeline";
|
|
11
|
+
|
|
12
|
+
const ESTATE: DriverComponent[] = [{ name: "net", dependsOn: [], deploy: [{ phase: "Apply", steps: [{ kind: "terraform-apply", root: "net" }] }] }];
|
|
13
|
+
|
|
14
|
+
type Step = { name?: string; run?: string; env?: Record<string, string> };
|
|
15
|
+
type Doc = { jobs: Record<string, { steps: Step[] }> };
|
|
16
|
+
const gen = (prLoop: Record<string, unknown>) => parseYAML(generateGithubPipeline(ESTATE, { env: "prod", prLoop }).yaml) as Doc;
|
|
17
|
+
|
|
18
|
+
describe("responses and the merge on GitHub (#4629, #4636)", () => {
|
|
19
|
+
const doc = gen({
|
|
20
|
+
applyBeforeMerge: true,
|
|
21
|
+
applyMerge: { tokenEnv: "CHANT_MERGE_TOKEN", method: "squash" },
|
|
22
|
+
respond: { "wave-refused": "off", description: "check" },
|
|
23
|
+
decide: { backend: "review", model: "m-1" },
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("the note job carries the responses and asks the decide service", () => {
|
|
27
|
+
const post = doc.jobs["plan-note"]!.steps.at(-1)!;
|
|
28
|
+
expect(post.run).toMatch(/--decide-backend "review" --decide-model "m-1"$/);
|
|
29
|
+
expect(post.env!.CHANT_RESPOND).toBe("wave-refused=off,description=check");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("the apply job carries the responses and the merge", () => {
|
|
33
|
+
const steps = Object.values(doc.jobs).flatMap((j) => j.steps);
|
|
34
|
+
const apply = steps.find((s) => s.run?.includes("--apply-before-merge"))!;
|
|
35
|
+
expect(apply.run).toContain("--merge auto --merge-token-env CHANT_MERGE_TOKEN --merge-method squash");
|
|
36
|
+
expect(apply.env!.CHANT_RESPOND).toBe("wave-refused=off,description=check");
|
|
37
|
+
expect(apply.env!.CHANT_MERGE_TOKEN).toBe("${{ secrets.CHANT_MERGE_TOKEN }}");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("without them, neither job changes", () => {
|
|
41
|
+
const plain = gen({ applyBeforeMerge: true });
|
|
42
|
+
const post = plain.jobs["plan-note"]!.steps.at(-1)!;
|
|
43
|
+
expect(post.run).not.toContain("--decide-backend");
|
|
44
|
+
expect(post.env!.CHANT_RESPOND).toBeUndefined();
|
|
45
|
+
expect(JSON.stringify(plain)).not.toContain("--merge auto");
|
|
46
|
+
});
|
|
47
|
+
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The pull request loop's runners, variables and passed secrets on GitHub
|
|
3
|
+
* (#4625): every job on its stage's runner, and the jobs that plan or apply
|
|
4
|
+
* with the variables and the secrets mapped from the repository's. Written
|
|
5
|
+
* for the ci-products post-merge pass.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, expect, test } from "vitest";
|
|
9
|
+
import { parseYAML } from "@intentius/chant/yaml";
|
|
10
|
+
import type { DriverComponent } from "@intentius/chant/components/driver";
|
|
11
|
+
import { generateGithubPipeline } from "./generate-pipeline";
|
|
12
|
+
|
|
13
|
+
const ESTATE: DriverComponent[] = [{ name: "net", dependsOn: [], deploy: [{ phase: "Apply", steps: [{ kind: "terraform-apply", root: "net" }] }] }];
|
|
14
|
+
const WORDS = { product: "plywood", stages: { plan: "preview", apply: "up" }, commands: { preview: "plan" as const, lock: "lock" as const, unlock: "unlock" as const } };
|
|
15
|
+
|
|
16
|
+
type Step = { name?: string; run?: string; env?: Record<string, string> };
|
|
17
|
+
type Doc = { jobs: Record<string, { "runs-on": unknown; steps: Step[] }> };
|
|
18
|
+
|
|
19
|
+
const doc = parseYAML(
|
|
20
|
+
generateGithubPipeline(ESTATE, {
|
|
21
|
+
env: "prod",
|
|
22
|
+
prLoop: {
|
|
23
|
+
words: WORDS,
|
|
24
|
+
previewComment: true,
|
|
25
|
+
locks: "plan",
|
|
26
|
+
applyOnMerge: true,
|
|
27
|
+
runners: { plan: ["self-hosted", "big"], apply: "deployer", default: "small" },
|
|
28
|
+
variables: { REGION: "eu-west-1" },
|
|
29
|
+
secrets: { DB_PASSWORD: "PROD_DB_PASSWORD" },
|
|
30
|
+
parallelism: 2,
|
|
31
|
+
},
|
|
32
|
+
}).yaml,
|
|
33
|
+
) as Doc;
|
|
34
|
+
|
|
35
|
+
describe("the GitHub pull request loop's runners and variables (#4625)", () => {
|
|
36
|
+
test("every job runs on its stage's runner", () => {
|
|
37
|
+
expect(doc.jobs.plan!["runs-on"]).toEqual(["self-hosted", "big"]);
|
|
38
|
+
expect(doc.jobs["comment-preview"]!["runs-on"]).toEqual(["self-hosted", "big"]);
|
|
39
|
+
expect(doc.jobs.apply!["runs-on"]).toBe("deployer");
|
|
40
|
+
expect(doc.jobs.record!["runs-on"]).toBe("deployer");
|
|
41
|
+
for (const name of ["plan-note", "comment-preview-note", "lock", "unlock", "comment-lock", "comment-unlock"]) expect(doc.jobs[name]!["runs-on"], name).toBe("small");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("the plan step gets the variable and the secret, and still drops the forge tokens first", () => {
|
|
45
|
+
const step = doc.jobs.plan!.steps.find((s) => s.name === "Plan the members this pull request reaches")!;
|
|
46
|
+
expect(step.env).toMatchObject({ REGION: "eu-west-1", DB_PASSWORD: "${{ secrets.PROD_DB_PASSWORD }}" });
|
|
47
|
+
expect(step.env).not.toHaveProperty("GITHUB_TOKEN");
|
|
48
|
+
expect(step.run!.split("\n")[0]).toContain("unset");
|
|
49
|
+
expect(step.run).toContain("--parallelism 2");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("the apply step gets them too", () => {
|
|
53
|
+
const step = doc.jobs.apply!.steps.find((s) => s.run?.includes("ci run apply"))!;
|
|
54
|
+
expect(step.env).toMatchObject({ REGION: "eu-west-1", DB_PASSWORD: "${{ secrets.PROD_DB_PASSWORD }}" });
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("without runners every job keeps the forge's default", () => {
|
|
58
|
+
const plain = parseYAML(generateGithubPipeline(ESTATE, { env: "prod", prLoop: {} }).yaml) as Doc;
|
|
59
|
+
expect(plain.jobs.plan!["runs-on"]).toBe("ubuntu-latest");
|
|
60
|
+
});
|
|
61
|
+
});
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stale marking on GitHub (#4628): with staleNotes the note job passes the
|
|
3
|
+
* target branch's commit, and a stale-notes job on a push to the branch
|
|
4
|
+
* runs `pr-post --stale` at the pushed commit with a token that may edit the
|
|
5
|
+
* notes and set the statuses. Written for the ci-products post-merge pass.
|
|
6
|
+
*/
|
|
7
|
+
import { describe, expect, test } from "vitest";
|
|
8
|
+
import { parseYAML } from "@intentius/chant/yaml";
|
|
9
|
+
import type { DriverComponent } from "@intentius/chant/components/driver";
|
|
10
|
+
import { generateGithubPipeline } from "./generate-pipeline";
|
|
11
|
+
|
|
12
|
+
const ESTATE: DriverComponent[] = [{ name: "net", dependsOn: [], deploy: [{ phase: "Apply", steps: [{ kind: "terraform-apply", root: "net" }] }] }];
|
|
13
|
+
const WORDS = { product: "plywood", stages: { plan: "preview", apply: "up" }, commands: { preview: "plan" as const }, bin: "npx --no-install plywood" };
|
|
14
|
+
|
|
15
|
+
type Step = { name?: string; run?: string; env?: Record<string, string>; uses?: string };
|
|
16
|
+
type Doc = { jobs: Record<string, { if?: string; permissions?: Record<string, string>; steps: Step[] }> };
|
|
17
|
+
const gen = (prLoop: Record<string, unknown>) => parseYAML(generateGithubPipeline(ESTATE, { env: "prod", prLoop }).yaml) as Doc;
|
|
18
|
+
|
|
19
|
+
describe("stale notes on GitHub (#4628)", () => {
|
|
20
|
+
const doc = gen({ words: WORDS, staleNotes: true, previewComment: true });
|
|
21
|
+
|
|
22
|
+
test("the note job passes the target branch's commit", () => {
|
|
23
|
+
const post = doc.jobs["plan-note"]!.steps.at(-1)!;
|
|
24
|
+
expect(post.run).toContain('--tip "$TIP_SHA"');
|
|
25
|
+
expect(post.env!.TIP_SHA).toBe("${{ github.event.pull_request.base.sha }}");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("a stale-notes job on a push marks the open notes stale, naming the preview comment", () => {
|
|
29
|
+
const job = doc.jobs["stale-notes"]!;
|
|
30
|
+
expect(job.if).toBe("github.event_name == 'push' && startsWith(github.ref, 'refs/heads/')");
|
|
31
|
+
expect(job.permissions).toEqual({ contents: "read", "pull-requests": "write", statuses: "write" });
|
|
32
|
+
const step = job.steps.at(-1)!;
|
|
33
|
+
expect(step.run).toBe('npx --no-install plywood components pr-post --stale --tip "$TIP_SHA" --env prod --forge github --preview-comment');
|
|
34
|
+
expect(step.env).toEqual({ TIP_SHA: "${{ github.sha }}", GITHUB_TOKEN: "${{ github.token }}" });
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("without staleNotes, no job and no tip", () => {
|
|
38
|
+
const plain = gen({ words: WORDS });
|
|
39
|
+
expect(plain.jobs["stale-notes"]).toBeUndefined();
|
|
40
|
+
expect(plain.jobs["plan-note"]!.steps.at(-1)!.run).not.toContain("--tip");
|
|
41
|
+
});
|
|
42
|
+
});
|