@intentius/chant-lexicon-github 0.124.54 → 0.124.56
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/agent-jobs.d.ts +67 -0
- package/dist/components/agent-jobs.d.ts.map +1 -0
- package/dist/components/agent-jobs.js +139 -0
- package/dist/components/control-pipeline.d.ts +34 -0
- package/dist/components/control-pipeline.d.ts.map +1 -0
- package/dist/components/control-pipeline.js +45 -0
- package/dist/components/generate-op-pipeline.d.ts +8 -1
- package/dist/components/generate-op-pipeline.d.ts.map +1 -1
- package/dist/components/generate-op-pipeline.js +144 -16
- package/dist/components/generate-op-waves-pipeline.d.ts +27 -1
- package/dist/components/generate-op-waves-pipeline.d.ts.map +1 -1
- package/dist/components/generate-op-waves-pipeline.js +135 -16
- package/dist/components/generate-pipeline.d.ts.map +1 -1
- package/dist/components/generate-pipeline.js +160 -15
- 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 +20 -0
- package/src/components/agent-jobs.ts +177 -0
- package/src/components/control-pipeline.ts +65 -0
- package/src/components/generate-op-pipeline.agent.test.ts +147 -0
- package/src/components/generate-op-pipeline.cloud.test.ts +96 -0
- package/src/components/generate-op-pipeline.notice.test.ts +2 -1
- package/src/components/generate-op-pipeline.ts +165 -20
- package/src/components/generate-op-waves-pipeline.approval.test.ts +46 -0
- package/src/components/generate-op-waves-pipeline.control.test.ts +64 -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 +17 -0
- package/src/components/generate-op-waves-pipeline.ts +157 -15
- package/src/components/generate-pipeline.ts +170 -15
- package/src/components/generate-pr-pipeline.comment-locks.test.ts +53 -0
- package/src/components/generate-pr-pipeline.comment-preview.test.ts +64 -0
- package/src/components/generate-pr-pipeline.comment-targets.test.ts +24 -0
- package/src/components/generate-pr-pipeline.control.test.ts +94 -0
- package/src/components/generate-pr-pipeline.pulumi-identity.test.ts +43 -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
|
@@ -22,6 +22,9 @@
|
|
|
22
22
|
* downloads it. The forgejo generator reuses {@link buildGithubOpWavesDoc}
|
|
23
23
|
* and applies its dialect.
|
|
24
24
|
*
|
|
25
|
+
* `options.bin` (#4607) is a product's command in place of `chant` in every
|
|
26
|
+
* job line: the wave jobs, the resume job and the plans job.
|
|
27
|
+
*
|
|
25
28
|
* With `resume` on the spec (#3683), a second workflow, `<name>-resume.yml`,
|
|
26
29
|
* runs `chant run resume --op <name>` on that schedule: it re-runs the failed
|
|
27
30
|
* jobs of a run whose waiting wave has been approved since, with the job's
|
|
@@ -31,6 +34,8 @@
|
|
|
31
34
|
* `pull-requests: read`, so a wave can read the merged pull request's
|
|
32
35
|
* reviews, and a second workflow, `<name>-plans.yml`, runs `chant run wave
|
|
33
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.
|
|
34
39
|
*
|
|
35
40
|
* With `notify` on the spec (#3761), the workflow maps each variable it names
|
|
36
41
|
* from the repository secret of the same name, so a wave job can post to
|
|
@@ -49,9 +54,42 @@
|
|
|
49
54
|
* checkout. Every later run step starts with an unset of the variables a
|
|
50
55
|
* token is requested with, so the code the job runs holds no OIDC token
|
|
51
56
|
* and cannot request one for another group's identity.
|
|
57
|
+
*
|
|
58
|
+
* With `control` on the spec (#4617), a `control-dispatch` job needs the
|
|
59
|
+
* last wave's jobs and runs after them unless the run was cancelled, so it
|
|
60
|
+
* runs after a failure too. It downloads every job's outcome file, adds
|
|
61
|
+
* them up with `chant run wave --waves-outcome` (through the product's
|
|
62
|
+
* bin) into `CHANT_CONTROL_OUTCOME`, and sends the control repo one
|
|
63
|
+
* dispatch carrying that outcome and the pushed commit. Only the dispatch
|
|
64
|
+
* step holds the control token: the `tokenSecret` secret, mapped into
|
|
65
|
+
* `CHANT_CONTROL_TOKEN` and handed to curl on its stdin
|
|
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.
|
|
52
76
|
*/
|
|
53
77
|
|
|
54
|
-
import {
|
|
78
|
+
import {
|
|
79
|
+
notifyVariables,
|
|
80
|
+
opWaveGroupOf,
|
|
81
|
+
opWaveJobs,
|
|
82
|
+
opWaveOutcomeDir,
|
|
83
|
+
opWavesControlTarget,
|
|
84
|
+
opWavesLastJobs,
|
|
85
|
+
opWavesApprovalStatusCommand,
|
|
86
|
+
opWavesPostsApprovalStatus,
|
|
87
|
+
opWavesOutcomeCommand,
|
|
88
|
+
opWavesRecordPlansCommand,
|
|
89
|
+
opWavesResumeCommand,
|
|
90
|
+
type OpWavesSpec,
|
|
91
|
+
} from "@intentius/chant/op/op-waves";
|
|
92
|
+
import { CONTROL_OUTCOME_VAR, controlDispatchJob } from "@intentius/chant/control/dispatch";
|
|
55
93
|
import type { OpWavesPipelineOptions, OpWavesPipelineResult } from "@intentius/chant/lexicon";
|
|
56
94
|
import {
|
|
57
95
|
assertEnvironment,
|
|
@@ -68,6 +106,8 @@ import {
|
|
|
68
106
|
setupStepDoc,
|
|
69
107
|
} from "@intentius/chant/ci-jobs";
|
|
70
108
|
import { OIDC_REQUEST_VARS, TRADE_SPEC_ENV, tradeLine, type CloudTradeSpec } from "@intentius/chant/ci-identity";
|
|
109
|
+
import { JOB_GUARD_ENV, jobGuardLine, jobGuardSpec, opWaveJobLabel } from "@intentius/chant/ci-job-guard";
|
|
110
|
+
import { resolveProductWords } from "@intentius/chant/ci-product-words";
|
|
71
111
|
import { checkoutStep, concurrency, DOWNLOAD_ARTIFACT, downloadArtifactStep, keepRecordStep, runSteps } from "./ci-jobs";
|
|
72
112
|
import { emitOpPipelineYAML, type GithubOpPipelineDoc } from "./generate-op-pipeline";
|
|
73
113
|
|
|
@@ -88,10 +128,15 @@ export function opWaveOutcomeArtifact(spec: Pick<OpWavesSpec, "name">, jobName:
|
|
|
88
128
|
export function buildGithubOpWavesDoc(
|
|
89
129
|
spec: OpWavesSpec,
|
|
90
130
|
options: OpWavesPipelineOptions,
|
|
131
|
+
dialect: { forge?: "github" | "forgejo" } = {},
|
|
91
132
|
): { doc: GithubOpPipelineDoc; jobs: OpWavesPipelineResult["jobs"]; resumeDoc?: GithubOpPipelineDoc; plansDoc?: GithubOpPipelineDoc } {
|
|
92
|
-
const jobs = opWaveJobs(spec, options.specFile);
|
|
133
|
+
const jobs = opWaveJobs(spec, options.specFile, { bin: options.bin });
|
|
134
|
+
const forge = dialect.forge ?? "github";
|
|
135
|
+
const product = resolveProductWords(options.words ?? {}).product;
|
|
93
136
|
const image = options.image ?? OP_WAVES_IMAGE;
|
|
94
137
|
const byReview = spec.waves.some((w) => w.approval === "pr-review");
|
|
138
|
+
// #4630: a pr-review or pull-request wave gets the plans workflow and its approval status.
|
|
139
|
+
const statusBeforeMerge = opWavesPostsApprovalStatus(spec);
|
|
95
140
|
for (const wave of spec.waves) {
|
|
96
141
|
assertSetupSteps(`${spec.name}/${wave.name}`, wave.setup ?? []);
|
|
97
142
|
if (wave.environment) assertEnvironment(`${spec.name}/${wave.name}`, wave.environment);
|
|
@@ -109,7 +154,21 @@ export function buildGithubOpWavesDoc(
|
|
|
109
154
|
const group = opWaveGroupOf(spec, job);
|
|
110
155
|
// #4332: the job's cloud identities, traded before the checkout.
|
|
111
156
|
const cloud = group?.cloud ?? wave.cloud;
|
|
112
|
-
const
|
|
157
|
+
const environment = group?.environment ?? wave.environment;
|
|
158
|
+
const idTokens = group?.idTokens ?? wave.idTokens;
|
|
159
|
+
const idToken = idTokens !== undefined || cloud !== undefined;
|
|
160
|
+
const runner = group?.runner ?? wave.runner ?? spec.runner;
|
|
161
|
+
// #4624: the guard first, before the trade and the checkout.
|
|
162
|
+
const guard = guardStep({
|
|
163
|
+
forge,
|
|
164
|
+
job: opWaveJobLabel(spec.name, job),
|
|
165
|
+
product,
|
|
166
|
+
...(environment && forge === "github" ? { environment: environment.name } : {}),
|
|
167
|
+
branches: pushBranches(spec.branches),
|
|
168
|
+
...(runner !== undefined ? { runner } : {}),
|
|
169
|
+
idToken,
|
|
170
|
+
});
|
|
171
|
+
const steps: Array<Record<string, unknown>> = [guard, ...(cloud ? [tradeStep(cloud)] : []), checkoutStep(true)];
|
|
113
172
|
if (job.kind === "share") {
|
|
114
173
|
steps.push(downloadArtifactStep(`Download wave ${job.wave}'s decision`, opWaveRecordArtifact(spec, job.wave), dirnameOf(job.record!)));
|
|
115
174
|
}
|
|
@@ -126,11 +185,8 @@ export function buildGithubOpWavesDoc(
|
|
|
126
185
|
steps.push(keepRecordStep(`Keep wave ${job.wave}'s decision`, opWaveRecordArtifact(spec, job.wave), job.record!));
|
|
127
186
|
}
|
|
128
187
|
steps.push(keepRecordStep(`Keep the job's outcome`, opWaveOutcomeArtifact(spec, job.jobName), job.outcome));
|
|
129
|
-
const environment = group?.environment ?? wave.environment;
|
|
130
|
-
const idTokens = group?.idTokens ?? wave.idTokens;
|
|
131
|
-
const idToken = idTokens !== undefined || cloud !== undefined;
|
|
132
188
|
jobsDoc[job.jobName] = {
|
|
133
|
-
"runs-on": githubRunsOn(
|
|
189
|
+
"runs-on": githubRunsOn(runner),
|
|
134
190
|
...(job.needs.length > 0 ? { needs: job.needs } : {}),
|
|
135
191
|
// #4322: a wave's jobs run after a failure in the wave before; a share job runs only on its deciding job's success.
|
|
136
192
|
...(job.needs.length > 0 && job.kind !== "share" ? { if: "!cancelled()" } : {}),
|
|
@@ -143,6 +199,12 @@ export function buildGithubOpWavesDoc(
|
|
|
143
199
|
};
|
|
144
200
|
}
|
|
145
201
|
|
|
202
|
+
// #4617: once the last wave ran, one job tells the control repo how the waves ended.
|
|
203
|
+
if (spec.control) {
|
|
204
|
+
const [name, job] = controlJob(spec, options, jobs, image, permissions);
|
|
205
|
+
jobsDoc[name] = job;
|
|
206
|
+
}
|
|
207
|
+
|
|
146
208
|
// #3761: the notify variables come from the secrets of the same name.
|
|
147
209
|
const env = { ...githubSecretEnv(notifyVariables(spec.notify)), ...(options.variables ?? {}) };
|
|
148
210
|
const doc: GithubOpPipelineDoc = {
|
|
@@ -157,16 +219,62 @@ export function buildGithubOpWavesDoc(
|
|
|
157
219
|
doc,
|
|
158
220
|
jobs,
|
|
159
221
|
...(spec.resume ? { resumeDoc: buildResumeDoc(spec, options) } : {}),
|
|
160
|
-
...(
|
|
222
|
+
...(statusBeforeMerge ? { plansDoc: buildPlansDoc(spec, options, forge) } : {}),
|
|
161
223
|
};
|
|
162
224
|
}
|
|
163
225
|
|
|
226
|
+
/**
|
|
227
|
+
* The control dispatch job (#4617): after the last wave, whatever it did,
|
|
228
|
+
* the waves' outcome read from every job's outcome file, then one dispatch
|
|
229
|
+
* carrying it. The pushed commit is the one every wave ran at.
|
|
230
|
+
*/
|
|
231
|
+
function controlJob(
|
|
232
|
+
spec: OpWavesSpec,
|
|
233
|
+
options: OpWavesPipelineOptions,
|
|
234
|
+
jobs: OpWavesPipelineResult["jobs"],
|
|
235
|
+
image: string,
|
|
236
|
+
permissions: Record<string, unknown>,
|
|
237
|
+
): [string, Record<string, unknown>] {
|
|
238
|
+
const control = spec.control!;
|
|
239
|
+
const dispatch = controlDispatchJob("github", opWavesControlTarget(control), "${{ github.sha }}", {
|
|
240
|
+
...(options.words ? { words: options.words } : {}),
|
|
241
|
+
...(control.sentCheck ? { sentCheck: control.sentCheck } : {}),
|
|
242
|
+
...(control.markSent ? { markSent: control.markSent } : {}),
|
|
243
|
+
outcome: true,
|
|
244
|
+
});
|
|
245
|
+
const marks = control.sentCheck !== undefined || control.markSent !== undefined;
|
|
246
|
+
const steps: Array<Record<string, unknown>> = [
|
|
247
|
+
checkoutStep(true),
|
|
248
|
+
{ name: "Download the waves' outcomes", uses: DOWNLOAD_ARTIFACT, with: { path: opWaveOutcomeDir(spec) } },
|
|
249
|
+
...(spec.waves[0]?.setup ?? []).map(setupStepDoc),
|
|
250
|
+
...runSteps(options.beforeScript ?? []),
|
|
251
|
+
{
|
|
252
|
+
name: "Add up the waves' outcomes",
|
|
253
|
+
run: `outcome="$(${opWavesOutcomeCommand(options.specFile, options.bin).join(" ")})" || exit 1\necho "${CONTROL_OUTCOME_VAR}=$outcome" >> "$GITHUB_ENV"`,
|
|
254
|
+
},
|
|
255
|
+
{ name: `Tell the control repo ${control.repo} how ${control.member}'s waves ended`, env: { ...dispatch.env, ...(marks ? { GITHUB_TOKEN: GITHUB_JOB_TOKEN } : {}) }, run: dispatch.script },
|
|
256
|
+
];
|
|
257
|
+
return [
|
|
258
|
+
dispatch.name,
|
|
259
|
+
{
|
|
260
|
+
"runs-on": githubRunsOn(spec.runner),
|
|
261
|
+
needs: opWavesLastJobs(jobs),
|
|
262
|
+
// The waves ran, whatever they did: a failed run is an outcome the control repo is told about.
|
|
263
|
+
if: "!cancelled()",
|
|
264
|
+
container: image,
|
|
265
|
+
// markSent sets a commit status; the rest reads the repository and the run's artifacts.
|
|
266
|
+
permissions: marks ? { ...permissions, statuses: "write" } : permissions,
|
|
267
|
+
steps,
|
|
268
|
+
},
|
|
269
|
+
];
|
|
270
|
+
}
|
|
271
|
+
|
|
164
272
|
/** The scheduled resume workflow (#3683). */
|
|
165
273
|
function buildResumeDoc(spec: OpWavesSpec, options: OpWavesPipelineOptions): GithubOpPipelineDoc {
|
|
166
274
|
const steps: Array<Record<string, unknown>> = [checkoutStep(), ...runSteps(options.beforeScript ?? [])];
|
|
167
275
|
steps.push({
|
|
168
276
|
name: `Resume the waves of ${spec.name} whose approval arrived`,
|
|
169
|
-
run: opWavesResumeCommand(spec).join(" "),
|
|
277
|
+
run: opWavesResumeCommand(spec, options.bin).join(" "),
|
|
170
278
|
env: { GITHUB_TOKEN: GITHUB_JOB_TOKEN },
|
|
171
279
|
});
|
|
172
280
|
return {
|
|
@@ -179,21 +287,55 @@ function buildResumeDoc(spec: OpWavesSpec, options: OpWavesPipelineOptions): Git
|
|
|
179
287
|
};
|
|
180
288
|
}
|
|
181
289
|
|
|
182
|
-
/**
|
|
183
|
-
|
|
290
|
+
/**
|
|
291
|
+
* The pull request workflow a `pr-review` wave needs (#3684): record the
|
|
292
|
+
* digests each head plans. The `approval-status` job after it (#4630) posts
|
|
293
|
+
* `<product>/approval` on the head, and runs alone on each review submitted
|
|
294
|
+
* or dismissed. It holds a read token that may write statuses and nothing
|
|
295
|
+
* else. A `pull-request` wave gets the same workflow: the record keeps its
|
|
296
|
+
* change set, and the status reads the pull request's own approval of it.
|
|
297
|
+
*/
|
|
298
|
+
function buildPlansDoc(spec: OpWavesSpec, options: OpWavesPipelineOptions, forge: "github" | "forgejo"): GithubOpPipelineDoc {
|
|
184
299
|
const steps: Array<Record<string, unknown>> = [checkoutStep(true), ...(spec.waves[0]?.setup ?? []).map(setupStepDoc), ...runSteps(options.beforeScript ?? [])];
|
|
185
|
-
steps.push({ name: "Plan every wave at the head and record its digests", run:
|
|
300
|
+
steps.push({ name: "Plan every wave at the head and record its digests", run: opWavesRecordPlansCommand(options.specFile, options.bin).join(" ") });
|
|
301
|
+
const statusSteps: Array<Record<string, unknown>> = [checkoutStep(true), ...runSteps(options.beforeScript ?? [])];
|
|
302
|
+
statusSteps.push({
|
|
303
|
+
name: "Post the approval status on the head",
|
|
304
|
+
run: opWavesApprovalStatusCommand(options.specFile, forge, options.bin).join(" "),
|
|
305
|
+
env: {
|
|
306
|
+
GITHUB_TOKEN: GITHUB_JOB_TOKEN,
|
|
307
|
+
PR_NUMBER: "${{ github.event.pull_request.number }}",
|
|
308
|
+
HEAD_SHA: "${{ github.event.pull_request.head.sha }}",
|
|
309
|
+
BASE_SHA: "${{ github.event.pull_request.base.sha }}",
|
|
310
|
+
},
|
|
311
|
+
});
|
|
186
312
|
return {
|
|
187
313
|
name: `${spec.name}-plans`,
|
|
188
|
-
on: { pull_request: { branches: pushBranches(spec.branches) } },
|
|
314
|
+
on: { pull_request: { branches: pushBranches(spec.branches) }, pull_request_review: { types: ["submitted", "dismissed"] } },
|
|
189
315
|
...nonEmpty("env", options.variables),
|
|
190
|
-
concurrency: concurrency(spec.name + "-plans-${{ github.event.number }}", true),
|
|
316
|
+
concurrency: concurrency(spec.name + "-plans-${{ github.event_name }}-${{ github.event.pull_request.number }}", true),
|
|
191
317
|
// contents: write pushes the record to chant/lifecycle.
|
|
192
318
|
permissions: { contents: "write" },
|
|
193
|
-
jobsDoc: {
|
|
319
|
+
jobsDoc: {
|
|
320
|
+
"record-plans": { if: "github.event_name == 'pull_request'", "runs-on": githubRunsOn(spec.runner), container: options.image ?? OP_WAVES_IMAGE, steps },
|
|
321
|
+
"approval-status": {
|
|
322
|
+
needs: ["record-plans"],
|
|
323
|
+
// On a review the record-plans job is skipped, and the status reads the plans an earlier push recorded.
|
|
324
|
+
if: "${{ always() && (needs.record-plans.result == 'success' || needs.record-plans.result == 'skipped') }}",
|
|
325
|
+
"runs-on": githubRunsOn(spec.runner),
|
|
326
|
+
container: options.image ?? OP_WAVES_IMAGE,
|
|
327
|
+
permissions: { contents: "read", "pull-requests": "read", statuses: "write" },
|
|
328
|
+
steps: statusSteps,
|
|
329
|
+
},
|
|
330
|
+
},
|
|
194
331
|
};
|
|
195
332
|
}
|
|
196
333
|
|
|
334
|
+
/** The apply-job guard step (#4624): the job's expectation in CHANT_JOB_GUARD, checked by `node -e` from `/`. */
|
|
335
|
+
function guardStep(input: Parameters<typeof jobGuardSpec>[0]): Record<string, unknown> {
|
|
336
|
+
return { name: "Check the job runs where the spec set it to run", env: { [JOB_GUARD_ENV]: JSON.stringify(jobGuardSpec(input)) }, run: jobGuardLine() };
|
|
337
|
+
}
|
|
338
|
+
|
|
197
339
|
/** The step that trades the job's OIDC token for `cloud` (#4332), writing the credentials to GITHUB_ENV. */
|
|
198
340
|
function tradeStep(cloud: CloudTradeSpec): Record<string, unknown> {
|
|
199
341
|
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,11 +59,11 @@
|
|
|
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 {
|
|
66
|
-
|
|
66
|
+
prApplyComment,
|
|
67
67
|
GREEN_TAG_PATTERN,
|
|
68
68
|
GREEN_TICK_WORKFLOW,
|
|
69
69
|
PR_APPLY_RECORD,
|
|
@@ -80,14 +80,25 @@ import {
|
|
|
80
80
|
PR_UNLOCK_JOB,
|
|
81
81
|
PR_UNLOCK_COMMAND,
|
|
82
82
|
prLockCommand,
|
|
83
|
+
prCommentLockJobs,
|
|
84
|
+
prCommentPreviewJob,
|
|
85
|
+
PREVIEW_REF_ENV,
|
|
86
|
+
PREVIEW_HEAD_OUTPUT,
|
|
83
87
|
prLoopWaveJobs,
|
|
84
88
|
prLoopWithWaves,
|
|
85
89
|
prEnvJobs,
|
|
90
|
+
prLoopJobStage,
|
|
91
|
+
prStaleJob,
|
|
92
|
+
PR_STALE_JOB,
|
|
93
|
+
prLoopRunner,
|
|
86
94
|
PR_ENV_REPORT_DIR,
|
|
87
95
|
PR_ENV_SWEEP_CRON,
|
|
88
96
|
PR_ENV_POST_JOB,
|
|
89
97
|
} from "@intentius/chant/components/pr-pipeline";
|
|
98
|
+
import { forgejoRepoLockLines, REPO_APPLY_GROUP } from "@intentius/chant/components/repo-lock";
|
|
90
99
|
import { memberRepoPath } from "@intentius/chant/lexicon";
|
|
100
|
+
import { productStatusContext, resolveProductWords } from "@intentius/chant/ci-product-words";
|
|
101
|
+
import { controlDispatchJob, controlNoteCheckout } from "@intentius/chant/control/dispatch";
|
|
91
102
|
import type {
|
|
92
103
|
ComponentPipelineJob as GeneratedJob,
|
|
93
104
|
ComponentPipelineOptions as GenerateGithubOptions,
|
|
@@ -137,6 +148,8 @@ export interface GithubPipelineDoc {
|
|
|
137
148
|
|
|
138
149
|
/** The plan job's last step, whose outputs carry its report to the plan-note job (#4295). */
|
|
139
150
|
const PLAN_STEP_ID = "plan";
|
|
151
|
+
/** The comment preview's checkout step (#4620), whose output names the head it checked out. */
|
|
152
|
+
const PREVIEW_HEAD_STEP_ID = "head";
|
|
140
153
|
|
|
141
154
|
/** The output a gated apply wave hands the green commit it applies through, and the step that sets it (#4221). */
|
|
142
155
|
const WAVE_GREEN_OUTPUT = "green-sha";
|
|
@@ -292,10 +305,12 @@ function prLoopGithubDoc(
|
|
|
292
305
|
});
|
|
293
306
|
const withIdToken = (permissions: Record<string, string>): Record<string, string> => (identity ? { ...permissions, "id-token": "write" } : permissions);
|
|
294
307
|
const reportName = (job: typeof plan) => `chant-pr-${job.wave ? prApplyWaveJobName(job.wave) : job.jobName}`;
|
|
308
|
+
// #4552: on the repo grain a Forgejo apply takes the lock tag in its own step, before the command, since the runner does not queue on a concurrency group.
|
|
309
|
+
const repoLock = (job: typeof plan): string[] => (loop.lockGrain === "repo" && loop.forge === "forgejo" && job.jobName !== "record" ? forgejoRepoLockLines() : []);
|
|
295
310
|
const steps = (job: typeof plan, name: string): Array<Record<string, unknown>> => [
|
|
296
311
|
...(identity ? [tradeStep(job.jobName === "record" ? identity.plan : identity.apply, job.jobName === "record" ? "plan" : "apply")] : []),
|
|
297
312
|
checkoutStep(true),
|
|
298
|
-
...runSteps(prLoopSetup(image)),
|
|
313
|
+
...runSteps(prLoopSetup(image, loop.engineSetup)),
|
|
299
314
|
...runSteps(beforeScript),
|
|
300
315
|
...(job.build ? [{ name: "Build the templates", run: job.build[0] }, { name: "Lint the project", run: job.build[1] }] : []),
|
|
301
316
|
...(job.jobName === "apply" ? [restoreRecord(job.wave)] : []),
|
|
@@ -307,9 +322,9 @@ function prLoopGithubDoc(
|
|
|
307
322
|
name,
|
|
308
323
|
id: WAVE_STEP_ID,
|
|
309
324
|
env: { ...job.run.env, ...(job.wave > 1 ? { GREEN_SHA: `\${{ needs.${prApplyWaveJobName(job.wave - 1)}.outputs.${WAVE_GREEN_OUTPUT} }}` } : {}) },
|
|
310
|
-
run: [...(job.checkout ?? []), `echo "${WAVE_GREEN_OUTPUT}=$GREEN_SHA" >> "$GITHUB_OUTPUT"`, ...(job.setup ?? []), job.run.command].join("\n"),
|
|
325
|
+
run: [...(job.checkout ?? []), `echo "${WAVE_GREEN_OUTPUT}=$GREEN_SHA" >> "$GITHUB_OUTPUT"`, ...(job.setup ?? []), ...repoLock(job), job.run.command].join("\n"),
|
|
311
326
|
}
|
|
312
|
-
: { name, env: job.run.env, run: [...(job.checkout ?? []), ...(job.setup ?? []), job.run.command].join("\n") },
|
|
327
|
+
: { name, env: job.run.env, run: [...(job.checkout ?? []), ...(job.setup ?? []), ...repoLock(job), job.run.command].join("\n") },
|
|
313
328
|
...runSteps(extraScript),
|
|
314
329
|
...(job.jobName === "apply" ? [saveRecord(job.wave)] : []),
|
|
315
330
|
{
|
|
@@ -328,7 +343,7 @@ function prLoopGithubDoc(
|
|
|
328
343
|
"github.event_name == 'issue_comment'",
|
|
329
344
|
// Forgejo's payload says so with is_pull (terragucci comment-apply.ts:129 at e6cb431).
|
|
330
345
|
forgejo ? "(github.event.issue.pull_request || github.event.is_pull)" : "github.event.issue.pull_request",
|
|
331
|
-
`startsWith(github.event.comment.body, '${
|
|
346
|
+
`startsWith(github.event.comment.body, '${prApplyComment(loop)}')`,
|
|
332
347
|
...(forgejo ? [] : ["contains(fromJSON('[\"OWNER\",\"MEMBER\",\"COLLABORATOR\"]'), github.event.comment.author_association)"]),
|
|
333
348
|
].join(" && ");
|
|
334
349
|
const applyJob = (job: typeof plan, needs: string[], name: string) => ({
|
|
@@ -337,7 +352,8 @@ function prLoopGithubDoc(
|
|
|
337
352
|
"runs-on": GITHUB_RUNNER,
|
|
338
353
|
...(needs.length > 0 ? { needs } : {}),
|
|
339
354
|
container: image,
|
|
340
|
-
|
|
355
|
+
// #4552: on the repo grain every apply of the repository shares one group.
|
|
356
|
+
concurrency: concurrency(loop.lockGrain === "repo" ? REPO_APPLY_GROUP : prApplyGroup(env, member?.name)),
|
|
341
357
|
permissions: withIdToken({ contents: "write", "pull-requests": "write", statuses: "write" }),
|
|
342
358
|
...(job.wave ? { outputs: { [WAVE_GREEN_OUTPUT]: `\${{ steps.${WAVE_STEP_ID}.outputs.${WAVE_GREEN_OUTPUT} }}` } } : {}),
|
|
343
359
|
steps: steps(job, name),
|
|
@@ -370,7 +386,7 @@ function prLoopGithubDoc(
|
|
|
370
386
|
env: { [CHECKOUT_TOKEN_ENV]: GITHUB_JOB_TOKEN },
|
|
371
387
|
run: checkoutScript(true, true),
|
|
372
388
|
},
|
|
373
|
-
...[...prLoopSetup(image), ...beforeScript].map((line) => headStep(line)),
|
|
389
|
+
...[...prLoopSetup(image, loop.engineSetup), ...beforeScript].map((line) => headStep(line)),
|
|
374
390
|
...(plan.build ? [headStep(plan.build[0]!, "Build the templates"), headStep(plan.build[1]!, "Lint the project")] : []),
|
|
375
391
|
// The job's last step: a step after it would start beside whatever the change's code left running.
|
|
376
392
|
{
|
|
@@ -380,14 +396,58 @@ function prLoopGithubDoc(
|
|
|
380
396
|
run: [drop, ...handoverTrap(PR_LOOP_REPORT_DIR, forgeName), ...(plan.checkout ?? []), ...(plan.setup ?? []), plan.run.command, ...extraScript].join("\n"),
|
|
381
397
|
},
|
|
382
398
|
];
|
|
383
|
-
|
|
399
|
+
// #4568: once the last apply on merge succeeded, one job tells the control repo this member applied.
|
|
400
|
+
const lastGreen = loop.control && !loop.applyBeforeMerge && greenJobs.length > 0 ? greenJobs[greenJobs.length - 1]![0] : undefined;
|
|
401
|
+
const controlJobs: Array<[string, Record<string, unknown>]> = [];
|
|
402
|
+
if (loop.control && lastGreen) {
|
|
403
|
+
// A wave job hands on the commit it applied; a single apply job applies the green commit its trigger names.
|
|
404
|
+
const sha = loop.waveGate ? `\${{ needs.${lastGreen}.outputs.${WAVE_GREEN_OUTPUT} }}` : forgejo ? "${{ github.sha }}" : "${{ github.event.workflow_run.head_sha || github.sha }}";
|
|
405
|
+
const dispatch = controlDispatchJob(forgeName, loop.control.target, sha, {
|
|
406
|
+
...(loop.words ? { words: loop.words } : {}),
|
|
407
|
+
...(loop.control.sentCheck ? { sentCheck: loop.control.sentCheck } : {}),
|
|
408
|
+
...(loop.control.markSent ? { markSent: loop.control.markSent } : {}),
|
|
409
|
+
});
|
|
410
|
+
controlJobs.push([
|
|
411
|
+
dispatch.name,
|
|
412
|
+
{
|
|
413
|
+
needs: [lastGreen],
|
|
414
|
+
if: `success() && (${onGreen})`,
|
|
415
|
+
"runs-on": GITHUB_RUNNER,
|
|
416
|
+
container: image,
|
|
417
|
+
permissions: loop.control.markSent ? { contents: "read", statuses: "write" } : { contents: "read" },
|
|
418
|
+
steps: [{ name: dispatch.stepName, ...(rooted ? {} : { "working-directory": "." }), env: { ...dispatch.env, ...(loop.control.sentCheck || loop.control.markSent ? { GITHUB_TOKEN: GITHUB_JOB_TOKEN } : {}) }, run: dispatch.script }],
|
|
419
|
+
},
|
|
420
|
+
]);
|
|
421
|
+
}
|
|
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
|
+
: {};
|
|
441
|
+
// #4619: with a control repo named, the note job reads its chant/lifecycle, read-only, so pr-post marks the cross-stack edges.
|
|
442
|
+
const controlRead = loop.control ? controlNoteCheckout(forgeName, loop.control.target, loop.words ?? {}) : undefined;
|
|
384
443
|
const noteSteps: Array<Record<string, unknown>> = [
|
|
385
444
|
// The target branch's commit: the chant this job runs is the target branch's, and none of the change's code is here.
|
|
386
445
|
{ name: "Check out the target branch's commit", uses: CHECKOUT, with: { ref: "${{ github.event.pull_request.base.sha }}" } },
|
|
387
446
|
...runSteps(beforeScript),
|
|
388
447
|
{ name: "Take the plan report from the plan job", run: takeOverScript("plan", PR_LOOP_REPORT_DIR, forgeName) },
|
|
389
448
|
{ name: "Keep the plan report", if: "always()", uses: UPLOAD_ARTIFACT, with: { name: reportName(plan), path: reportPath, "if-no-files-found": "ignore", "include-hidden-files": true } },
|
|
390
|
-
{ name:
|
|
449
|
+
...(controlRead ? [{ name: controlRead.stepName, if: "always()", env: controlRead.env, run: controlRead.script }] : []),
|
|
450
|
+
{ name: `Post the plan note and ${productStatusContext(resolveProductWords(loop.words), "plan")}`, if: "always()", env: post.env, run: post.command },
|
|
391
451
|
];
|
|
392
452
|
// #4298: with a pull request environment the pull_request trigger fires on close too, and the plan skips that event.
|
|
393
453
|
const prEnv = loop.prEnvironment ? prEnvGithubJobs(loop.forge ?? "github", image, beforeScript, extraScript, loop, member) : undefined;
|
|
@@ -428,6 +488,82 @@ function prLoopGithubDoc(
|
|
|
428
488
|
},
|
|
429
489
|
}
|
|
430
490
|
: {};
|
|
491
|
+
// #4588: `/<product> lock` and `/<product> unlock` comments, at the target branch's commit, so none of the change's code runs.
|
|
492
|
+
const commentLocks = prCommentLockJobs(forgeName, loop);
|
|
493
|
+
const commentLockJobs: Record<string, unknown> = Object.fromEntries(
|
|
494
|
+
commentLocks.map((job) => [
|
|
495
|
+
job.jobName,
|
|
496
|
+
{
|
|
497
|
+
if: [
|
|
498
|
+
"github.event_name == 'issue_comment'",
|
|
499
|
+
forgejo ? "(github.event.issue.pull_request || github.event.is_pull)" : "github.event.issue.pull_request",
|
|
500
|
+
`startsWith(github.event.comment.body, '${job.comment}')`,
|
|
501
|
+
...(forgejo ? [] : ["contains(fromJSON('[\"OWNER\",\"MEMBER\",\"COLLABORATOR\"]'), github.event.comment.author_association)"]),
|
|
502
|
+
].join(" && "),
|
|
503
|
+
"runs-on": GITHUB_RUNNER,
|
|
504
|
+
container: image,
|
|
505
|
+
permissions: { contents: "write", "pull-requests": "read", statuses: "write" },
|
|
506
|
+
steps: [
|
|
507
|
+
{ name: "Check out the target branch's commit", uses: CHECKOUT, with: { "fetch-depth": 0 } },
|
|
508
|
+
...runSteps(beforeScript),
|
|
509
|
+
{ name: job.command === "lock" ? "Keep the locks the pull request's plan took" : "Release the pull request's locks", env: job.env, run: job.line },
|
|
510
|
+
],
|
|
511
|
+
},
|
|
512
|
+
]),
|
|
513
|
+
);
|
|
514
|
+
// #4620: `/<product> <plan word>` plans the pull request's head again with a read token; the job after it posts the note.
|
|
515
|
+
const preview = prCommentPreviewJob(forgeName, env, loop, member?.name);
|
|
516
|
+
const previewJobs: Record<string, unknown> = preview
|
|
517
|
+
? {
|
|
518
|
+
[preview.jobName]: {
|
|
519
|
+
if: [
|
|
520
|
+
"github.event_name == 'issue_comment'",
|
|
521
|
+
forgejo ? "(github.event.issue.pull_request || github.event.is_pull)" : "github.event.issue.pull_request",
|
|
522
|
+
`startsWith(github.event.comment.body, '${preview.comment}')`,
|
|
523
|
+
...(forgejo ? [] : ["contains(fromJSON('[\"OWNER\",\"MEMBER\",\"COLLABORATOR\"]'), github.event.comment.author_association)"]),
|
|
524
|
+
].join(" && "),
|
|
525
|
+
"runs-on": GITHUB_RUNNER,
|
|
526
|
+
container: image,
|
|
527
|
+
permissions: withIdToken({ contents: "read" }),
|
|
528
|
+
outputs: { ...handoverOutputs(PLAN_STEP_ID, forgeName), [PREVIEW_HEAD_OUTPUT]: `\${{ steps.${PREVIEW_HEAD_STEP_ID}.outputs.${PREVIEW_HEAD_OUTPUT} }}` },
|
|
529
|
+
steps: [
|
|
530
|
+
...(identity ? [tradeStep(identity.plan, "plan")] : []),
|
|
531
|
+
// The head is read here, before any of the change's code runs, so the post job names the commit this job planned.
|
|
532
|
+
{
|
|
533
|
+
name: "Check out the pull request's head",
|
|
534
|
+
id: PREVIEW_HEAD_STEP_ID,
|
|
535
|
+
...(rooted ? {} : { "working-directory": "." }),
|
|
536
|
+
env: { [CHECKOUT_TOKEN_ENV]: GITHUB_JOB_TOKEN, [PREVIEW_REF_ENV]: "ref" in preview.head ? preview.head.ref : "" },
|
|
537
|
+
run: [checkoutScript(true, true, { refEnv: PREVIEW_REF_ENV }), `echo "${PREVIEW_HEAD_OUTPUT}=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"`].join("\n"),
|
|
538
|
+
},
|
|
539
|
+
...[...prLoopSetup(image, loop.engineSetup), ...beforeScript].map((line) => headStep(line)),
|
|
540
|
+
...(preview.plan.build ? [headStep(preview.plan.build[0]!, "Build the templates"), headStep(preview.plan.build[1]!, "Lint the project")] : []),
|
|
541
|
+
{
|
|
542
|
+
name: "Preview the members this pull request reaches",
|
|
543
|
+
id: PLAN_STEP_ID,
|
|
544
|
+
env: preview.plan.run.env,
|
|
545
|
+
run: [drop, ...handoverTrap(PR_LOOP_REPORT_DIR, forgeName), preview.base, ...(preview.plan.setup ?? []), preview.plan.run.command, ...extraScript].join("\n"),
|
|
546
|
+
},
|
|
547
|
+
],
|
|
548
|
+
},
|
|
549
|
+
[preview.noteJobName]: {
|
|
550
|
+
needs: [preview.jobName],
|
|
551
|
+
if: `always() && github.event_name == 'issue_comment' && (needs.${preview.jobName}.result == 'success' || needs.${preview.jobName}.result == 'failure')`,
|
|
552
|
+
"runs-on": GITHUB_RUNNER,
|
|
553
|
+
container: image,
|
|
554
|
+
permissions: { actions: "read", contents: "read", "pull-requests": "write", statuses: "write" },
|
|
555
|
+
steps: [
|
|
556
|
+
// The target branch's commit, which an issue_comment run checks out: none of the change's code is here.
|
|
557
|
+
{ name: "Check out the target branch's commit", uses: CHECKOUT },
|
|
558
|
+
...runSteps(beforeScript),
|
|
559
|
+
{ name: "Take the preview report from the preview job", run: takeOverScript(preview.jobName, PR_LOOP_REPORT_DIR, forgeName) },
|
|
560
|
+
{ name: "Keep the preview report", if: "always()", uses: UPLOAD_ARTIFACT, with: { name: `chant-pr-${preview.jobName}`, path: reportPath, "if-no-files-found": "ignore", "include-hidden-files": true } },
|
|
561
|
+
...(controlRead ? [{ name: controlRead.stepName, if: "always()", env: controlRead.env, run: controlRead.script }] : []),
|
|
562
|
+
{ name: `Post the preview note and ${productStatusContext(resolveProductWords(loop.words), "plan")}`, if: "always()", env: preview.post.env, run: preview.post.command },
|
|
563
|
+
],
|
|
564
|
+
},
|
|
565
|
+
}
|
|
566
|
+
: {};
|
|
431
567
|
const jobsDoc: Record<string, unknown> = {
|
|
432
568
|
plan: {
|
|
433
569
|
if: closes ? "github.event_name == 'pull_request' && github.event.action != 'closed'" : "github.event_name == 'pull_request'",
|
|
@@ -447,7 +583,11 @@ function prLoopGithubDoc(
|
|
|
447
583
|
steps: noteSteps,
|
|
448
584
|
},
|
|
449
585
|
...lockJobs,
|
|
586
|
+
...commentLockJobs,
|
|
587
|
+
...previewJobs,
|
|
588
|
+
...staleJobs,
|
|
450
589
|
...Object.fromEntries(applyJobs),
|
|
590
|
+
...Object.fromEntries(controlJobs),
|
|
451
591
|
...(prEnv ? prEnv.jobs : {}),
|
|
452
592
|
};
|
|
453
593
|
const applyNames = applyJobs.map(([name]) => name);
|
|
@@ -461,8 +601,14 @@ function prLoopGithubDoc(
|
|
|
461
601
|
on.pull_request = { branches: [branch], types: ["opened", "synchronize", "reopened", "closed"] };
|
|
462
602
|
on.schedule = [{ cron: PR_ENV_SWEEP_CRON }];
|
|
463
603
|
}
|
|
464
|
-
// #4301: a /chant apply comment starts the apply before merge.
|
|
465
|
-
if (loop.applyBeforeMerge) on.issue_comment = { types: ["created"] };
|
|
604
|
+
// #4301: a /chant apply comment starts the apply before merge. #4588: and a lock or unlock comment its job.
|
|
605
|
+
if (loop.applyBeforeMerge || commentLocks.length > 0 || preview) on.issue_comment = { types: ["created"] };
|
|
606
|
+
// #4625: each job on its stage's runner, the plan side's, the apply side's, or the default.
|
|
607
|
+
if (loop.runners) {
|
|
608
|
+
for (const [name, job] of Object.entries(jobsDoc) as Array<[string, Record<string, unknown>]>) {
|
|
609
|
+
job["runs-on"] = githubRunsOn(prLoopRunner(loop, prLoopJobStage(name)));
|
|
610
|
+
}
|
|
611
|
+
}
|
|
466
612
|
return {
|
|
467
613
|
name: member ? `chant-pr.${member.name}.${env}` : `chant-pr-${env}`,
|
|
468
614
|
environment: env,
|
|
@@ -470,16 +616,24 @@ function prLoopGithubDoc(
|
|
|
470
616
|
...(rooted ? {} : { defaults: { run: { "working-directory": member!.dir } } }),
|
|
471
617
|
env: { ...options.variables, CHANT_ENV: env },
|
|
472
618
|
jobsDoc,
|
|
473
|
-
stages: ["plan", PR_PLAN_NOTE_JOB, ...(locks ? [PR_LOCK_JOB, PR_UNLOCK_JOB] : []), ...applyNames, ...(prEnv ? ["env-up", PR_ENV_POST_JOB, "env-down", "env-sweep"] : [])],
|
|
619
|
+
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"] : [])],
|
|
474
620
|
jobs: [
|
|
475
621
|
{ jobName: "plan", component: "pull request plan", stage: "plan", needs: [] },
|
|
476
622
|
{ jobName: PR_PLAN_NOTE_JOB, component: "pull request plan note", stage: PR_PLAN_NOTE_JOB, needs: ["plan"] },
|
|
623
|
+
...(stale ? [{ jobName: PR_STALE_JOB, component: "pull request stale notes", stage: PR_STALE_JOB, needs: [] }] : []),
|
|
477
624
|
...(locks
|
|
478
625
|
? [
|
|
479
626
|
{ jobName: PR_LOCK_JOB, component: "pull request lock", stage: PR_LOCK_JOB, needs: ["plan"] },
|
|
480
627
|
{ jobName: PR_UNLOCK_JOB, component: "pull request unlock", stage: PR_UNLOCK_JOB, needs: [] },
|
|
481
628
|
]
|
|
482
629
|
: []),
|
|
630
|
+
...commentLocks.map((j) => ({ jobName: j.jobName, component: `pull request ${j.command} comment`, stage: j.jobName, needs: [] })),
|
|
631
|
+
...(preview
|
|
632
|
+
? [
|
|
633
|
+
{ jobName: preview.jobName, component: "pull request preview comment", stage: preview.jobName, needs: [] },
|
|
634
|
+
{ jobName: preview.noteJobName, component: "pull request preview comment note", stage: preview.noteJobName, needs: [preview.jobName] },
|
|
635
|
+
]
|
|
636
|
+
: []),
|
|
483
637
|
...applyNames.map((name, i) => ({
|
|
484
638
|
jobName: name,
|
|
485
639
|
component: name === "record" ? "merge record" : loop.applyBeforeMerge ? "apply before merge" : loop.waveGate ? `green apply, wave ${i}` : "green apply",
|
|
@@ -487,6 +641,7 @@ function prLoopGithubDoc(
|
|
|
487
641
|
// The record runs on a push and the apply on a green commit, so only the waves need each other.
|
|
488
642
|
needs: i > 1 ? [applyNames[i - 1]] : [],
|
|
489
643
|
})),
|
|
644
|
+
...controlJobs.map(([name]) => ({ jobName: name, component: "control repo dispatch", stage: name, needs: [lastGreen!] })),
|
|
490
645
|
...(prEnv ? prEnv.listed : []),
|
|
491
646
|
],
|
|
492
647
|
};
|
|
@@ -547,7 +702,7 @@ function prEnvGithubJobs(
|
|
|
547
702
|
env: { [CHECKOUT_TOKEN_ENV]: GITHUB_JOB_TOKEN },
|
|
548
703
|
run: checkoutScript(true, true),
|
|
549
704
|
},
|
|
550
|
-
...[...prLoopSetup(image), ...beforeScript].map((line) => headStep(line)),
|
|
705
|
+
...[...prLoopSetup(image, loop.engineSetup), ...beforeScript].map((line) => headStep(line)),
|
|
551
706
|
...(up.build ? [headStep(up.build[0]!, "Build the templates", { CHANT_ENV: up.env.CHANT_ENV! }), headStep(up.build[1]!, "Lint the project")] : []),
|
|
552
707
|
// The job's last step: a step after it would start beside whatever the change's code left running.
|
|
553
708
|
{
|
|
@@ -583,7 +738,7 @@ function prEnvGithubJobs(
|
|
|
583
738
|
steps: [
|
|
584
739
|
...tradeStep,
|
|
585
740
|
{ uses: CHECKOUT, with: { "fetch-depth": 0, ...(ref ? { ref } : {}) } },
|
|
586
|
-
...runSteps(prLoopSetup(image)),
|
|
741
|
+
...runSteps(prLoopSetup(image, loop.engineSetup)),
|
|
587
742
|
...runSteps(beforeScript),
|
|
588
743
|
{ name, env: j.env, run: j.command },
|
|
589
744
|
...runSteps(extraScript),
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `/<product> lock` and `/<product> unlock` (#4588): under `ci.locks: plan`,
|
|
3
|
+
* when the words name the commands, a job answers each comment at the
|
|
4
|
+
* target branch's commit. The lock job keeps the locks the plan took; the
|
|
5
|
+
* unlock job releases them.
|
|
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[] = [
|
|
14
|
+
{ name: "net", dependsOn: [], deploy: [{ phase: "Apply", steps: [{ kind: "terraform-apply", root: "net" }] }] },
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
const WORDS = { product: "plywood", commands: { lock: "lock" as const, unlock: "unlock" as const }, bin: "npx --no-install plywood" };
|
|
18
|
+
|
|
19
|
+
interface Step { name?: string; uses?: string; run?: string; env?: Record<string, string>; with?: Record<string, unknown> }
|
|
20
|
+
interface Job { if?: string; needs?: string[]; permissions: Record<string, string>; steps: Step[] }
|
|
21
|
+
type Doc = { on: Record<string, unknown>; jobs: Record<string, Job> };
|
|
22
|
+
|
|
23
|
+
const gen = (prLoop: Record<string, unknown>) => parseYAML(generateGithubPipeline(ESTATE, { env: "prod", prLoop }).yaml) as Doc;
|
|
24
|
+
|
|
25
|
+
describe("the GitHub workflow's lock and unlock comments", () => {
|
|
26
|
+
const doc = gen({ locks: "plan", words: WORDS });
|
|
27
|
+
|
|
28
|
+
test("a job per comment, started by issue_comment from a commenter who can write", () => {
|
|
29
|
+
expect(Object.keys(doc.jobs)).toEqual(expect.arrayContaining(["comment-lock", "comment-unlock"]));
|
|
30
|
+
expect(doc.on.issue_comment).toEqual({ types: ["created"] });
|
|
31
|
+
const lock = doc.jobs["comment-lock"]!;
|
|
32
|
+
expect(lock.if).toContain("github.event_name == 'issue_comment'");
|
|
33
|
+
expect(lock.if).toContain("startsWith(github.event.comment.body, '/plywood lock')");
|
|
34
|
+
expect(lock.if).toContain("github.event.comment.author_association");
|
|
35
|
+
expect(doc.jobs["comment-unlock"]!.if).toContain("startsWith(github.event.comment.body, '/plywood unlock')");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("the lock keeps the plan's locks through the product's bin, the unlock releases them, at the target branch's commit", () => {
|
|
39
|
+
const lock = doc.jobs["comment-lock"]!;
|
|
40
|
+
expect(lock.steps[0]!.with).toEqual({ "fetch-depth": 0 });
|
|
41
|
+
expect(lock.steps.at(-1)!.run).toBe('npx --no-install plywood lock --keep --pr "$PR_NUMBER" --forge github');
|
|
42
|
+
expect(lock.steps.at(-1)!.env!.PR_NUMBER).toBe("${{ github.event.issue.number }}");
|
|
43
|
+
expect(doc.jobs["comment-unlock"]!.steps.at(-1)!.run).toBe('npx --no-install plywood unlock --pr "$PR_NUMBER"');
|
|
44
|
+
expect(lock.permissions).toEqual({ contents: "write", "pull-requests": "read", statuses: "write" });
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("neither job without ci.locks, nor without the words for the commands", () => {
|
|
48
|
+
expect(Object.keys(gen({ words: WORDS }).jobs)).not.toContain("comment-lock");
|
|
49
|
+
const chant = gen({ locks: "plan" });
|
|
50
|
+
expect(Object.keys(chant.jobs)).not.toContain("comment-lock");
|
|
51
|
+
expect(chant.on.issue_comment).toBeUndefined();
|
|
52
|
+
});
|
|
53
|
+
});
|