@intentius/chant-lexicon-github 0.124.56 → 0.124.58
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 +2 -2
- package/dist/components/generate-op-waves-pipeline.d.ts +2 -2
- package/dist/components/generate-op-waves-pipeline.d.ts.map +1 -1
- package/dist/components/generate-op-waves-pipeline.js +9 -5
- package/dist/components/generate-pipeline.d.ts.map +1 -1
- package/dist/components/generate-pipeline.js +51 -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 +11 -4
- package/src/components/__golden__/pr-loop-action.github.yml +228 -0
- package/src/components/generate-op-pipeline.ts +4 -4
- package/src/components/generate-op-waves-pipeline.test.ts +19 -3
- package/src/components/generate-op-waves-pipeline.ts +9 -4
- package/src/components/generate-pipeline.ts +53 -6
- package/src/components/generate-pr-pipeline.action.test.ts +67 -0
- package/src/components/generate-pr-pipeline.locks.test.ts +10 -0
|
@@ -104,8 +104,8 @@ export interface GithubOpPipelineDoc {
|
|
|
104
104
|
on: Record<string, unknown>;
|
|
105
105
|
/** The `env:` mapping, when `options.variables` is set. */
|
|
106
106
|
env?: Record<string, unknown>;
|
|
107
|
-
/** The `concurrency:` mapping — one run at a time per Op. */
|
|
108
|
-
concurrency
|
|
107
|
+
/** The `concurrency:` mapping — one run at a time per Op. Absent on a waves file (ruling for ci-products 2026-10-28). */
|
|
108
|
+
concurrency?: Record<string, unknown>;
|
|
109
109
|
/**
|
|
110
110
|
* The `permissions:` mapping for this Op's finding-mode. GitHub-only:
|
|
111
111
|
* Forgejo Actions ignores `permissions:` entirely, so the forgejo dialect
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"generate-op-pipeline.d.ts","sourceRoot":"","sources":["../../src/components/generate-op-pipeline.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAsEG;AAGH,OAAO,EACL,iBAAiB,EACjB,gBAAgB,EAOhB,gBAAgB,EAIjB,MAAM,0BAA0B,CAAC;AAElC,OAAO,KAAK,EACV,wBAAwB,IAAI,uBAAuB,EAEnD,aAAa,EACb,gBAAgB,IAAI,sBAAsB,EAG1C,eAAe,EAChB,MAAM,0BAA0B,CAAC;AAUlC,YAAY,EAAE,uBAAuB,EAAE,sBAAsB,EAAE,CAAC;AAIhE,OAAO,EAAE,iBAAiB,EAAE,gBAAgB,EAAE,gBAAgB,EAAE,CAAC;AAEjE;;;;;;GAMG;AACH,MAAM,WAAW,mBAAmB;IAClC;;;;;;;OAOG;IACH,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;IAClB;;;;OAIG;IACH,IAAI,CAAC,EAAE,MAAM,CAAC;IACd;;;;OAIG;IACH,EAAE,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC5B,2DAA2D;IAC3D,GAAG,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC9B,
|
|
1
|
+
{"version":3,"file":"generate-op-pipeline.d.ts","sourceRoot":"","sources":["../../src/components/generate-op-pipeline.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAsEG;AAGH,OAAO,EACL,iBAAiB,EACjB,gBAAgB,EAOhB,gBAAgB,EAIjB,MAAM,0BAA0B,CAAC;AAElC,OAAO,KAAK,EACV,wBAAwB,IAAI,uBAAuB,EAEnD,aAAa,EACb,gBAAgB,IAAI,sBAAsB,EAG1C,eAAe,EAChB,MAAM,0BAA0B,CAAC;AAUlC,YAAY,EAAE,uBAAuB,EAAE,sBAAsB,EAAE,CAAC;AAIhE,OAAO,EAAE,iBAAiB,EAAE,gBAAgB,EAAE,gBAAgB,EAAE,CAAC;AAEjE;;;;;;GAMG;AACH,MAAM,WAAW,mBAAmB;IAClC;;;;;;;OAOG;IACH,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;IAClB;;;;OAIG;IACH,IAAI,CAAC,EAAE,MAAM,CAAC;IACd;;;;OAIG;IACH,EAAE,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC5B,2DAA2D;IAC3D,GAAG,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC9B,yHAAyH;IACzH,WAAW,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACtC;;;;OAIG;IACH,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACrC,8DAA8D;IAC9D,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACjC;;;;;;;;OAQG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACzC;;;;;OAKG;IACH,aAAa,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACxC;;;;;OAKG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACpC;AAED,qFAAqF;AACrF,MAAM,WAAW,oBAAoB;IACnC,4FAA4F;IAC5F,IAAI,EAAE,MAAM,CAAC;IACb,GAAG,EAAE,mBAAmB,CAAC;CAC1B;AAuOD;;;;;;;;;;;GAWG;AACH,wBAAgB,yBAAyB,CACvC,GAAG,EAAE,eAAe,EAAE,EACtB,OAAO,GAAE,uBAA4B,EACrC,KAAK,GAAE,QAAQ,GAAG,SAAoB,GACrC;IAAE,KAAK,EAAE,oBAAoB,EAAE,CAAC;IAAC,IAAI,EAAE,aAAa,EAAE,CAAA;CAAE,CAuM1D;AAwCD;;;;GAIG;AACH,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,mBAAmB,GAAG,MAAM,CAMnE;AAED;;;;;GAKG;AACH,wBAAgB,wBAAwB,CACtC,GAAG,EAAE,eAAe,EAAE,EACtB,OAAO,GAAE,uBAA4B,GACpC,sBAAsB,CAMxB"}
|
|
@@ -513,14 +513,14 @@ function scopeOpFileToMember(file, member) {
|
|
|
513
513
|
const { steps, ...rest } = props;
|
|
514
514
|
return [job, { ...rest, defaults: { run: { "working-directory": member.dir } }, steps }];
|
|
515
515
|
}));
|
|
516
|
-
const group = file.doc.concurrency
|
|
516
|
+
const group = file.doc.concurrency?.group;
|
|
517
517
|
return {
|
|
518
518
|
name,
|
|
519
519
|
doc: {
|
|
520
520
|
...file.doc,
|
|
521
521
|
...(file.doc.name ? { name: `${member.name}/${file.doc.name}` } : {}),
|
|
522
522
|
on,
|
|
523
|
-
concurrency: { ...file.doc.concurrency, ...(typeof group === "string" ? { group: `${member.name}-${group}` } : {}) },
|
|
523
|
+
...(file.doc.concurrency ? { concurrency: { ...file.doc.concurrency, ...(typeof group === "string" ? { group: `${member.name}-${group}` } : {}) } } : {}),
|
|
524
524
|
jobsDoc,
|
|
525
525
|
},
|
|
526
526
|
};
|
|
@@ -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
|
*
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"generate-op-waves-pipeline.d.ts","sourceRoot":"","sources":["../../src/components/generate-op-waves-pipeline.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2EG;AAEH,OAAO,
|
|
1
|
+
{"version":3,"file":"generate-op-waves-pipeline.d.ts","sourceRoot":"","sources":["../../src/components/generate-op-waves-pipeline.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2EG;AAEH,OAAO,EAaL,KAAK,WAAW,EACjB,MAAM,8BAA8B,CAAC;AAEtC,OAAO,KAAK,EAAE,sBAAsB,EAAE,qBAAqB,EAAE,MAAM,0BAA0B,CAAC;AAmB9F,OAAO,EAAsB,KAAK,mBAAmB,EAAE,MAAM,wBAAwB,CAAC;AAEtF,yFAAyF;AACzF,eAAO,MAAM,cAAc,YAAW,CAAC;AAEvC,oEAAoE;AACpE,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,IAAI,CAAC,WAAW,EAAE,MAAM,CAAC,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,CAE1F;AAED,4DAA4D;AAC5D,wBAAgB,qBAAqB,CAAC,IAAI,EAAE,IAAI,CAAC,WAAW,EAAE,MAAM,CAAC,EAAE,OAAO,EAAE,MAAM,GAAG,MAAM,CAE9F;AAED,qEAAqE;AACrE,wBAAgB,qBAAqB,CACnC,IAAI,EAAE,WAAW,EACjB,OAAO,EAAE,sBAAsB,EAC/B,OAAO,GAAE;IAAE,KAAK,CAAC,EAAE,QAAQ,GAAG,SAAS,CAAA;CAAO,GAC7C;IAAE,GAAG,EAAE,mBAAmB,CAAC;IAAC,IAAI,EAAE,qBAAqB,CAAC,MAAM,CAAC,CAAC;IAAC,SAAS,CAAC,EAAE,mBAAmB,CAAC;IAAC,QAAQ,CAAC,EAAE,mBAAmB,CAAA;CAAE,CAgGpI;AAoID,4EAA4E;AAC5E,wBAAgB,6BAA6B,CAAC,IAAI,EAAE,WAAW,EAAE,OAAO,EAAE,sBAAsB,GAAG,qBAAqB,CAMvH"}
|
|
@@ -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
|
*
|
|
@@ -74,7 +74,7 @@
|
|
|
74
74
|
* forgejo generator passes `forge: "forgejo"`, which checks the ref and
|
|
75
75
|
* runner only.
|
|
76
76
|
*/
|
|
77
|
-
import { notifyVariables, opWaveGroupOf, opWaveJobs, opWaveOutcomeDir, opWavesControlTarget, opWavesLastJobs, opWavesApprovalStatusCommand, opWavesPostsApprovalStatus, opWavesOutcomeCommand, opWavesRecordPlansCommand, opWavesResumeCommand, } from "@intentius/chant/op/op-waves";
|
|
77
|
+
import { notifyVariables, opWaveGroupOf, opWaveJobs, opWaveJobTimeout, opWaveOutcomeDir, opWavesControlTarget, opWavesLastJobs, opWavesApprovalStatusCommand, opWavesPostsApprovalStatus, opWavesOutcomeCommand, opWavesRecordPlansCommand, opWavesResumeCommand, } from "@intentius/chant/op/op-waves";
|
|
78
78
|
import { CONTROL_OUTCOME_VAR, controlDispatchJob } from "@intentius/chant/control/dispatch";
|
|
79
79
|
import { assertEnvironment, assertSetupSteps, CI_IMAGE, dirnameOf, environmentDoc, GITHUB_JOB_TOKEN, githubRunsOn, githubSecretEnv, idTokenAudienceEnv, nonEmpty, pushBranches, setupStepDoc, } from "@intentius/chant/ci-jobs";
|
|
80
80
|
import { OIDC_REQUEST_VARS, TRADE_SPEC_ENV, tradeLine } from "@intentius/chant/ci-identity";
|
|
@@ -112,6 +112,8 @@ export function buildGithubOpWavesDoc(spec, options, dialect = {}) {
|
|
|
112
112
|
}
|
|
113
113
|
}
|
|
114
114
|
const permissions = { contents: "write", ...(byReview ? { "pull-requests": "read" } : {}) };
|
|
115
|
+
// The ruling for ci-products (2026-10-28): every wave job carries its timeout and the lock wait under it.
|
|
116
|
+
const timeout = opWaveJobTimeout(options.jobTimeoutMinutes);
|
|
115
117
|
const jobsDoc = {};
|
|
116
118
|
for (const job of jobs) {
|
|
117
119
|
const wave = spec.waves[job.wave - 1];
|
|
@@ -159,7 +161,8 @@ export function buildGithubOpWavesDoc(spec, options, dialect = {}) {
|
|
|
159
161
|
...(environment ? { environment: environmentDoc(environment) } : {}),
|
|
160
162
|
// #3914: a job's own permissions replace the workflow's, so they repeat them.
|
|
161
163
|
...(idToken ? { permissions: { ...permissions, "id-token": "write" } } : {}),
|
|
162
|
-
|
|
164
|
+
"timeout-minutes": timeout.minutes,
|
|
165
|
+
env: { ...timeout.env, ...(group || idTokens ? { ...wave.variables, ...group?.variables, ...idTokenAudienceEnv(idTokens) } : wave.variables) },
|
|
163
166
|
steps,
|
|
164
167
|
};
|
|
165
168
|
}
|
|
@@ -174,7 +177,8 @@ export function buildGithubOpWavesDoc(spec, options, dialect = {}) {
|
|
|
174
177
|
name: spec.name,
|
|
175
178
|
on: { push: { branches: pushBranches(spec.branches) }, workflow_dispatch: {} },
|
|
176
179
|
...nonEmpty("env", env),
|
|
177
|
-
|
|
180
|
+
// The ruling for ci-products (2026-10-28): no workflow-level concurrency group. Runs on the target and repo grains wait for
|
|
181
|
+
// their rows on chant's ledger, oldest commit first; plunge stacks on the resource grain lease their resource rows and run.
|
|
178
182
|
permissions,
|
|
179
183
|
jobsDoc,
|
|
180
184
|
};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"generate-pipeline.d.ts","sourceRoot":"","sources":["../../src/components/generate-pipeline.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwDG;AAEH,OAAO,EAAyB,KAAK,eAAe,EAAE,MAAM,oCAAoC,CAAC;
|
|
1
|
+
{"version":3,"file":"generate-pipeline.d.ts","sourceRoot":"","sources":["../../src/components/generate-pipeline.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwDG;AAEH,OAAO,EAAyB,KAAK,eAAe,EAAE,MAAM,oCAAoC,CAAC;AA6CjG,OAAO,KAAK,EACV,oBAAoB,IAAI,YAAY,EACpC,wBAAwB,IAAI,qBAAqB,EACjD,uBAAuB,IAAI,oBAAoB,EAEhD,MAAM,0BAA0B,CAAC;AAGlC,YAAY,EAAE,YAAY,EAAE,qBAAqB,EAAE,oBAAoB,EAAE,CAAC;AAE1E;;;;;;GAMG;AACH,MAAM,WAAW,iBAAiB;IAChC;;;;;OAKG;IACH,IAAI,EAAE,MAAM,CAAC;IACb,6FAA6F;IAC7F,WAAW,EAAE,MAAM,CAAC;IACpB,8DAA8D;IAC9D,EAAE,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC5B,mIAAmI;IACnI,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACnC;;;;;;OAMG;IACH,GAAG,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC9B,qDAAqD;IACrD,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACjC,uEAAuE;IACvE,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,0DAA0D;IAC1D,IAAI,EAAE,YAAY,EAAE,CAAC;CACtB;AA6qBD;;;;GAIG;AACH,wBAAgB,YAAY,CAAC,KAAK,EAAE,MAAM,EAAE,GAAG,MAAM,CAWpD;AAED;;;;;;;;GAQG;AACH,wBAAgB,sBAAsB,CACpC,UAAU,EAAE,eAAe,EAAE,EAC7B,OAAO,GAAE,qBAA0B,GAClC,iBAAiB,CA+JnB;AA4CD;;;GAGG;AACH,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,iBAAiB,GAAG,MAAM,CAE/D;AAED;;;;;GAKG;AACH,wBAAgB,sBAAsB,CACpC,UAAU,EAAE,eAAe,EAAE,EAC7B,OAAO,GAAE,qBAA0B,GAClC,oBAAoB,CAGtB"}
|
|
@@ -61,7 +61,7 @@ import { GATED_WAVE_RECORD, gatedWaveJobs } from "@intentius/chant/components/ga
|
|
|
61
61
|
import { CI_IMAGE, ciJobName, dirnameOf, fillCommand, GITHUB_JOB_TOKEN, GITHUB_RUNNER, githubRunsOn } from "@intentius/chant/ci-jobs";
|
|
62
62
|
import { CHECKOUT_TOKEN_ENV, checkoutScript, dropForgeTokens, handoverOutputs, handoverTrap, takeOverScript } from "@intentius/chant/ci-handover";
|
|
63
63
|
import { OIDC_REQUEST_VARS, TRADE_SPEC_ENV, tradeLine } from "@intentius/chant/ci-identity";
|
|
64
|
-
import { prApplyComment, GREEN_TAG_PATTERN, GREEN_TICK_WORKFLOW, PR_APPLY_RECORD, PR_LOOP_REPORT_DIR, prApplyGroup, prRecordGroup, prApplyRecordKey, prApplyWaveJobName, prLoopJobs, prLoopPostJob, prLoopSetup, PR_PLAN_NOTE_JOB, PR_LOCK_JOB, PR_UNLOCK_JOB,
|
|
64
|
+
import { prApplyComment, GREEN_TAG_PATTERN, GREEN_TICK_WORKFLOW, PR_APPLY_RECORD, PR_LOOP_REPORT_DIR, prApplyGroup, prRecordGroup, prApplyRecordKey, prApplyWaveJobName, prLoopJobs, prLoopPostJob, prLoopSetup, PR_PLAN_NOTE_JOB, PR_LOCK_JOB, PR_UNLOCK_JOB, prUnlockCommand, prLockCommand, prCommentLockJobs, prCommentPreviewJob, prActionJob, prLoopPassedEnv, PREVIEW_REF_ENV, PREVIEW_HEAD_OUTPUT, prLoopWaveJobs, prLoopWithWaves, prEnvJobs, prLoopJobStage, prStaleJob, PR_STALE_JOB, prLoopRunner, PR_ENV_REPORT_DIR, PR_ENV_SWEEP_CRON, PR_ENV_POST_JOB, } from "@intentius/chant/components/pr-pipeline";
|
|
65
65
|
import { forgejoRepoLockLines, REPO_APPLY_GROUP } from "@intentius/chant/components/repo-lock";
|
|
66
66
|
import { memberRepoPath } from "@intentius/chant/lexicon";
|
|
67
67
|
import { productStatusContext, resolveProductWords } from "@intentius/chant/ci-product-words";
|
|
@@ -239,7 +239,10 @@ function prLoopGithubDoc(components, env, image, beforeScript, extraScript, opti
|
|
|
239
239
|
];
|
|
240
240
|
// #4168 step 9: under apply on merge the apply runs on a green signal, and a push to the branch records.
|
|
241
241
|
const forgejo = loop.forge === "forgejo";
|
|
242
|
-
|
|
242
|
+
// #4654: the action job answers its comment and a dispatch that names an action; such a dispatch starts no apply.
|
|
243
|
+
const action = prActionJob(loop.forge ?? "github", loop);
|
|
244
|
+
const plainDispatch = action ? `(github.event_name == 'workflow_dispatch' && github.event.inputs.${action.inputs.action} == '')` : "github.event_name == 'workflow_dispatch'";
|
|
245
|
+
const onGreen = forgejo ? "startsWith(github.ref, 'refs/tags/ci/green/')" : `github.event_name == 'workflow_run' || ${plainDispatch}`;
|
|
243
246
|
const onPush = loop.applyOnMerge && forgejo ? "github.event_name == 'push' && startsWith(github.ref, 'refs/heads/')" : "github.event_name == 'push'";
|
|
244
247
|
// #4301: a `/chant apply` comment on a pull request starts the apply before merge. GitHub also asks for a commenter who can write.
|
|
245
248
|
const onComment = [
|
|
@@ -356,6 +359,8 @@ function prLoopGithubDoc(components, env, image, beforeScript, extraScript, opti
|
|
|
356
359
|
const prEnv = loop.prEnvironment ? prEnvGithubJobs(loop.forge ?? "github", image, beforeScript, extraScript, loop, member) : undefined;
|
|
357
360
|
// #4327: with ci.locks: plan the pull_request trigger fires on close too, for the unlock job, and the plan skips that event.
|
|
358
361
|
const locks = loop.locks === "plan";
|
|
362
|
+
// plywood#22: the lock lines run through the product's bin, as its other job lines do.
|
|
363
|
+
const lockBin = resolveProductWords(loop.words).bin;
|
|
359
364
|
const closes = Boolean(prEnv) || locks;
|
|
360
365
|
const lockJobs = locks
|
|
361
366
|
? {
|
|
@@ -374,7 +379,7 @@ function prLoopGithubDoc(components, env, image, beforeScript, extraScript, opti
|
|
|
374
379
|
{
|
|
375
380
|
name: "Lock the components the plan selected",
|
|
376
381
|
env: { PR_NUMBER: "${{ github.event.pull_request.number }}", HEAD_SHA: "${{ github.event.pull_request.head.sha }}", GITHUB_TOKEN: GITHUB_JOB_TOKEN },
|
|
377
|
-
run: prLockCommand(forgeName),
|
|
382
|
+
run: prLockCommand(forgeName, lockBin),
|
|
378
383
|
},
|
|
379
384
|
],
|
|
380
385
|
},
|
|
@@ -386,7 +391,7 @@ function prLoopGithubDoc(components, env, image, beforeScript, extraScript, opti
|
|
|
386
391
|
steps: [
|
|
387
392
|
{ name: "Check out the target branch's commit", uses: CHECKOUT, with: { ref: "${{ github.event.pull_request.base.sha }}", "fetch-depth": 0 } },
|
|
388
393
|
...runSteps(beforeScript),
|
|
389
|
-
{ name: "Release the pull request's locks", env: { PR_NUMBER: "${{ github.event.pull_request.number }}" }, run:
|
|
394
|
+
{ name: "Release the pull request's locks", env: { PR_NUMBER: "${{ github.event.pull_request.number }}" }, run: prUnlockCommand(lockBin) },
|
|
390
395
|
],
|
|
391
396
|
},
|
|
392
397
|
}
|
|
@@ -465,6 +470,34 @@ function prLoopGithubDoc(components, env, image, beforeScript, extraScript, opti
|
|
|
465
470
|
},
|
|
466
471
|
}
|
|
467
472
|
: {};
|
|
473
|
+
// #4654: `/<product> <action word> <member> <action>`, or a dispatch naming both, runs the product's action verb at the target branch's commit.
|
|
474
|
+
const actionJobs = action
|
|
475
|
+
? {
|
|
476
|
+
[action.jobName]: {
|
|
477
|
+
if: [
|
|
478
|
+
`(github.event_name == 'workflow_dispatch' && github.event.inputs.${action.inputs.action} != '')`,
|
|
479
|
+
`(${[
|
|
480
|
+
"github.event_name == 'issue_comment'",
|
|
481
|
+
`startsWith(github.event.comment.body, '${action.comment.replace(/'/g, "''")} ')`,
|
|
482
|
+
...(forgejo ? [] : ["contains(fromJSON('[\"OWNER\",\"MEMBER\",\"COLLABORATOR\"]'), github.event.comment.author_association)"]),
|
|
483
|
+
].join(" && ")})`,
|
|
484
|
+
].join(" || "),
|
|
485
|
+
"runs-on": GITHUB_RUNNER,
|
|
486
|
+
container: image,
|
|
487
|
+
// One action of a pipeline at a time, and never two runs of one request.
|
|
488
|
+
concurrency: concurrency(`chant-action-${env}${member ? `-${member.name}` : ""}`),
|
|
489
|
+
permissions: withIdToken({ contents: "write", issues: "write", "pull-requests": "write" }),
|
|
490
|
+
steps: [
|
|
491
|
+
...(identity ? [tradeStep(identity.apply, "apply")] : []),
|
|
492
|
+
// An issue_comment run checks out the target branch's commit, a dispatch the ref it names: never a pull request's head.
|
|
493
|
+
{ name: "Check out the target branch's commit", uses: CHECKOUT, with: { "fetch-depth": 0 } },
|
|
494
|
+
...runSteps(prLoopSetup(image, loop.engineSetup)),
|
|
495
|
+
...runSteps(beforeScript),
|
|
496
|
+
{ name: "Run the requested action under its gated request", env: { ...prLoopPassedEnv(forgeName, loop), ...action.env }, run: action.script },
|
|
497
|
+
],
|
|
498
|
+
},
|
|
499
|
+
}
|
|
500
|
+
: {};
|
|
468
501
|
const jobsDoc = {
|
|
469
502
|
plan: {
|
|
470
503
|
if: closes ? "github.event_name == 'pull_request' && github.event.action != 'closed'" : "github.event_name == 'pull_request'",
|
|
@@ -486,6 +519,7 @@ function prLoopGithubDoc(components, env, image, beforeScript, extraScript, opti
|
|
|
486
519
|
...lockJobs,
|
|
487
520
|
...commentLockJobs,
|
|
488
521
|
...previewJobs,
|
|
522
|
+
...actionJobs,
|
|
489
523
|
...staleJobs,
|
|
490
524
|
...Object.fromEntries(applyJobs),
|
|
491
525
|
...Object.fromEntries(controlJobs),
|
|
@@ -504,8 +538,18 @@ function prLoopGithubDoc(components, env, image, beforeScript, extraScript, opti
|
|
|
504
538
|
on.schedule = [{ cron: PR_ENV_SWEEP_CRON }];
|
|
505
539
|
}
|
|
506
540
|
// #4301: a /chant apply comment starts the apply before merge. #4588: and a lock or unlock comment its job.
|
|
507
|
-
if (loop.applyBeforeMerge || commentLocks.length > 0 || preview)
|
|
541
|
+
if (loop.applyBeforeMerge || commentLocks.length > 0 || preview || action)
|
|
508
542
|
on.issue_comment = { types: ["created"] };
|
|
543
|
+
// #4654: a dispatch names a member and an action; both empty, it is the dispatch it was before.
|
|
544
|
+
if (action) {
|
|
545
|
+
const input = (description) => ({ description, required: false, type: "string", default: "" });
|
|
546
|
+
on.workflow_dispatch = {
|
|
547
|
+
inputs: {
|
|
548
|
+
[action.inputs.member]: input(`The ${resolveProductWords(loop.words).noun[0]} the action runs on`),
|
|
549
|
+
[action.inputs.action]: input("The action to run, by its name"),
|
|
550
|
+
},
|
|
551
|
+
};
|
|
552
|
+
}
|
|
509
553
|
// #4625: each job on its stage's runner, the plan side's, the apply side's, or the default.
|
|
510
554
|
if (loop.runners) {
|
|
511
555
|
for (const [name, job] of Object.entries(jobsDoc)) {
|
|
@@ -519,7 +563,7 @@ function prLoopGithubDoc(components, env, image, beforeScript, extraScript, opti
|
|
|
519
563
|
...(rooted ? {} : { defaults: { run: { "working-directory": member.dir } } }),
|
|
520
564
|
env: { ...options.variables, CHANT_ENV: env },
|
|
521
565
|
jobsDoc,
|
|
522
|
-
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"] : [])],
|
|
566
|
+
stages: ["plan", PR_PLAN_NOTE_JOB, ...(locks ? [PR_LOCK_JOB, PR_UNLOCK_JOB] : []), ...commentLocks.map((j) => j.jobName), ...(preview ? [preview.jobName, preview.noteJobName] : []), ...(action ? [action.jobName] : []), ...(stale ? [PR_STALE_JOB] : []), ...applyNames, ...controlJobs.map(([name]) => name), ...(prEnv ? ["env-up", PR_ENV_POST_JOB, "env-down", "env-sweep"] : [])],
|
|
523
567
|
jobs: [
|
|
524
568
|
{ jobName: "plan", component: "pull request plan", stage: "plan", needs: [] },
|
|
525
569
|
{ jobName: PR_PLAN_NOTE_JOB, component: "pull request plan note", stage: PR_PLAN_NOTE_JOB, needs: ["plan"] },
|
|
@@ -537,6 +581,7 @@ function prLoopGithubDoc(components, env, image, beforeScript, extraScript, opti
|
|
|
537
581
|
{ jobName: preview.noteJobName, component: "pull request preview comment note", stage: preview.noteJobName, needs: [preview.jobName] },
|
|
538
582
|
]
|
|
539
583
|
: []),
|
|
584
|
+
...(action ? [{ jobName: action.jobName, component: "day-2 action", stage: action.jobName, needs: [] }] : []),
|
|
540
585
|
...applyNames.map((name, i) => ({
|
|
541
586
|
jobName: name,
|
|
542
587
|
component: name === "record" ? "merge record" : loop.applyBeforeMerge ? "apply before merge" : loop.waveGate ? `green apply, wave ${i}` : "green apply",
|
package/dist/integrity.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"algorithm": "sha256",
|
|
3
3
|
"artifacts": {
|
|
4
|
-
"manifest.json": "
|
|
4
|
+
"manifest.json": "418be40e957e0634548ba848e3b607febd9850185be3dfd8d278b7cb356aa61a",
|
|
5
5
|
"meta.json": "c50e0ef3845c370c3ad2210b8bba455980417c0f818aa6ed46e06d4fb8b1b9f9",
|
|
6
6
|
"types/index.d.ts": "0ead95536e947ddfb98a5acf0ddb88547913b5a53a342df67711c59266f84b3e",
|
|
7
7
|
"rules/deprecated-action-version.ts": "a9ba7cc924ff1c7055110416d8989273690415bfb5e8dcc32dc9431b9351f5ca",
|
|
@@ -78,5 +78,5 @@
|
|
|
78
78
|
"skills/chant-github-patterns.md": "bb3abef289a8fdfcf07d6bb2d7289dcb2f38bc0cb0321ea320b78b45a6f548c0",
|
|
79
79
|
"skills/chant-github-security.md": "aab111cb0871cad30281ce48d7da23663689619351029219e2be019a1a61e394"
|
|
80
80
|
},
|
|
81
|
-
"composite": "
|
|
81
|
+
"composite": "f8d68a8078ebf75ac45adc4efce5ab4b263bc8c4808cdee6c07380458a1f0390"
|
|
82
82
|
}
|
package/dist/manifest.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@intentius/chant-lexicon-github",
|
|
3
|
-
"version": "0.124.
|
|
3
|
+
"version": "0.124.58",
|
|
4
4
|
"description": "GitHub Actions lexicon for chant — declarative IaC in TypeScript",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"homepage": "https://intentius.io/chant",
|
|
@@ -61,6 +61,6 @@
|
|
|
61
61
|
"typescript": "^5.9.3"
|
|
62
62
|
},
|
|
63
63
|
"peerDependencies": {
|
|
64
|
-
"@intentius/chant": "^0.124.
|
|
64
|
+
"@intentius/chant": "^0.124.58"
|
|
65
65
|
}
|
|
66
66
|
}
|
|
@@ -6,10 +6,6 @@ on:
|
|
|
6
6
|
- main
|
|
7
7
|
workflow_dispatch: {}
|
|
8
8
|
|
|
9
|
-
concurrency:
|
|
10
|
-
group: migrations
|
|
11
|
-
cancel-in-progress: false
|
|
12
|
-
|
|
13
9
|
permissions:
|
|
14
10
|
contents: write
|
|
15
11
|
|
|
@@ -19,6 +15,9 @@ jobs:
|
|
|
19
15
|
- self-hosted
|
|
20
16
|
- dev
|
|
21
17
|
container: node:22
|
|
18
|
+
timeout-minutes: 180
|
|
19
|
+
env:
|
|
20
|
+
CHANT_LOCK_WAIT: '10500'
|
|
22
21
|
steps:
|
|
23
22
|
- name: Check the job runs where the spec set it to run
|
|
24
23
|
env:
|
|
@@ -47,7 +46,9 @@ jobs:
|
|
|
47
46
|
permissions:
|
|
48
47
|
contents: write
|
|
49
48
|
id-token: write
|
|
49
|
+
timeout-minutes: 180
|
|
50
50
|
env:
|
|
51
|
+
CHANT_LOCK_WAIT: '10500'
|
|
51
52
|
STAGE: apply
|
|
52
53
|
PLAN_TOKEN_AUDIENCE: plan.example
|
|
53
54
|
steps:
|
|
@@ -95,7 +96,9 @@ jobs:
|
|
|
95
96
|
permissions:
|
|
96
97
|
contents: write
|
|
97
98
|
id-token: write
|
|
99
|
+
timeout-minutes: 180
|
|
98
100
|
env:
|
|
101
|
+
CHANT_LOCK_WAIT: '10500'
|
|
99
102
|
STAGE: apply
|
|
100
103
|
REGION: us
|
|
101
104
|
CLOUD_TOKEN_AUDIENCE: us.example
|
|
@@ -141,7 +144,9 @@ jobs:
|
|
|
141
144
|
permissions:
|
|
142
145
|
contents: write
|
|
143
146
|
id-token: write
|
|
147
|
+
timeout-minutes: 180
|
|
144
148
|
env:
|
|
149
|
+
CHANT_LOCK_WAIT: '10500'
|
|
145
150
|
STAGE: apply
|
|
146
151
|
REGION: us
|
|
147
152
|
CLOUD_TOKEN_AUDIENCE: us.example
|
|
@@ -184,7 +189,9 @@ jobs:
|
|
|
184
189
|
permissions:
|
|
185
190
|
contents: write
|
|
186
191
|
id-token: write
|
|
192
|
+
timeout-minutes: 180
|
|
187
193
|
env:
|
|
194
|
+
CHANT_LOCK_WAIT: '10500'
|
|
188
195
|
STAGE: apply
|
|
189
196
|
PLAN_TOKEN_AUDIENCE: plan.example
|
|
190
197
|
steps:
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
name: chant-pr-prod
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
pull_request:
|
|
5
|
+
branches:
|
|
6
|
+
- main
|
|
7
|
+
push:
|
|
8
|
+
branches:
|
|
9
|
+
- main
|
|
10
|
+
workflow_run:
|
|
11
|
+
workflows:
|
|
12
|
+
- chant-ci-green
|
|
13
|
+
types:
|
|
14
|
+
- completed
|
|
15
|
+
workflow_dispatch:
|
|
16
|
+
inputs:
|
|
17
|
+
stack:
|
|
18
|
+
description: The stack the action runs on
|
|
19
|
+
required: false
|
|
20
|
+
type: string
|
|
21
|
+
default: ''
|
|
22
|
+
action:
|
|
23
|
+
description: The action to run, by its name
|
|
24
|
+
required: false
|
|
25
|
+
type: string
|
|
26
|
+
default: ''
|
|
27
|
+
issue_comment:
|
|
28
|
+
types:
|
|
29
|
+
- created
|
|
30
|
+
|
|
31
|
+
env:
|
|
32
|
+
CHANT_ENV: prod
|
|
33
|
+
|
|
34
|
+
jobs:
|
|
35
|
+
plan:
|
|
36
|
+
if: github.event_name == 'pull_request'
|
|
37
|
+
runs-on: ubuntu-latest
|
|
38
|
+
container: node:22
|
|
39
|
+
permissions:
|
|
40
|
+
contents: read
|
|
41
|
+
outputs:
|
|
42
|
+
report_0: '${{ steps.plan.outputs.report_0 }}'
|
|
43
|
+
steps:
|
|
44
|
+
- name: Check out the change
|
|
45
|
+
env:
|
|
46
|
+
CHANT_CHECKOUT_TOKEN: '${{ github.token }}'
|
|
47
|
+
run: |
|
|
48
|
+
set -eu
|
|
49
|
+
git config --global --add safe.directory "$PWD" 2>/dev/null || true
|
|
50
|
+
[ -d .git ] || git init -q .
|
|
51
|
+
git remote get-url origin >/dev/null 2>&1 || git remote add origin "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY"
|
|
52
|
+
if [ -n "${CHANT_CHECKOUT_TOKEN:-}" ]; then
|
|
53
|
+
chant_auth="$(printf 'x-access-token:%s' "$CHANT_CHECKOUT_TOKEN" | base64 | tr -d '\n')"
|
|
54
|
+
export GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=http.extraheader GIT_CONFIG_VALUE_0="AUTHORIZATION: basic $chant_auth"
|
|
55
|
+
unset chant_auth
|
|
56
|
+
fi
|
|
57
|
+
ref="$GITHUB_REF"
|
|
58
|
+
want="$GITHUB_SHA"
|
|
59
|
+
case "$ref" in
|
|
60
|
+
refs/heads/*) target="refs/remotes/origin/${ref#refs/heads/}" ;;
|
|
61
|
+
refs/tags/*) target="$ref" ;;
|
|
62
|
+
refs/*) target="refs/remotes/${ref#refs/}" ;;
|
|
63
|
+
*) target="refs/remotes/origin/chant-head" ;;
|
|
64
|
+
esac
|
|
65
|
+
if git rev-parse -q --verify HEAD >/dev/null; then git clean -ffdxq; fi
|
|
66
|
+
git -c protocol.version=2 fetch -q --prune --no-recurse-submodules origin '+refs/heads/*:refs/remotes/origin/*' '+refs/tags/*:refs/tags/*' "+$want:$target"
|
|
67
|
+
git -c protocol.version=2 fetch -q --no-tags origin '+refs/heads/chant/lifecycle:refs/heads/chant/lifecycle' 2>/dev/null || true
|
|
68
|
+
case "$ref" in
|
|
69
|
+
refs/heads/*) git checkout -q --force -B "${ref#refs/heads/}" "$target" ;;
|
|
70
|
+
*) git checkout -q --force --detach "$target" ;;
|
|
71
|
+
esac
|
|
72
|
+
- run: |
|
|
73
|
+
if [ -z "${CHANT_NO_FORGE_TOKEN:-}" ] && [ "${0#*/}" != "$0" ] && [ -f "$0" ] && ! { [ "${0##*/}" = sh ] || [ "${0##*/}" = bash ] || [ "${0##*/}" = dash ]; } && command -v env >/dev/null 2>&1 && command -v sh >/dev/null 2>&1; then exec env -u CHANT_FORGE_TOKEN -u CHANT_GITHUB_TOKEN -u CHANT_FORGEJO_TOKEN -u CHANT_GITLAB_TOKEN -u CHANT_CHECKOUT_TOKEN -u GITHUB_TOKEN -u GH_TOKEN -u FORGEJO_TOKEN -u GITEA_TOKEN -u GITLAB_TOKEN -u CI_JOB_TOKEN -u ACTIONS_RUNTIME_TOKEN CHANT_NO_FORGE_TOKEN=1 sh -e "$0"; fi; unset CHANT_FORGE_TOKEN CHANT_GITHUB_TOKEN CHANT_FORGEJO_TOKEN CHANT_GITLAB_TOKEN CHANT_CHECKOUT_TOKEN GITHUB_TOKEN GH_TOKEN FORGEJO_TOKEN GITEA_TOKEN GITLAB_TOKEN CI_JOB_TOKEN ACTIONS_RUNTIME_TOKEN
|
|
74
|
+
command -v tofu >/dev/null 2>&1 || (curl -fsSL https://get.opentofu.org/install-opentofu.sh -o /tmp/install-opentofu.sh && sh /tmp/install-opentofu.sh --install-method standalone)
|
|
75
|
+
- name: Plan the members this pull request reaches
|
|
76
|
+
id: plan
|
|
77
|
+
env:
|
|
78
|
+
BASE_SHA: '${{ github.event.pull_request.base.sha }}'
|
|
79
|
+
PR_NUMBER: '${{ github.event.pull_request.number }}'
|
|
80
|
+
run: |
|
|
81
|
+
if [ -z "${CHANT_NO_FORGE_TOKEN:-}" ] && [ "${0#*/}" != "$0" ] && [ -f "$0" ] && ! { [ "${0##*/}" = sh ] || [ "${0##*/}" = bash ] || [ "${0##*/}" = dash ]; } && command -v env >/dev/null 2>&1 && command -v sh >/dev/null 2>&1; then exec env -u CHANT_FORGE_TOKEN -u CHANT_GITHUB_TOKEN -u CHANT_FORGEJO_TOKEN -u CHANT_GITLAB_TOKEN -u CHANT_CHECKOUT_TOKEN -u GITHUB_TOKEN -u GH_TOKEN -u FORGEJO_TOKEN -u GITEA_TOKEN -u GITLAB_TOKEN -u CI_JOB_TOKEN -u ACTIONS_RUNTIME_TOKEN CHANT_NO_FORGE_TOKEN=1 sh -e "$0"; fi; unset CHANT_FORGE_TOKEN CHANT_GITHUB_TOKEN CHANT_FORGEJO_TOKEN CHANT_GITLAB_TOKEN CHANT_CHECKOUT_TOKEN GITHUB_TOKEN GH_TOKEN FORGEJO_TOKEN GITEA_TOKEN GITLAB_TOKEN CI_JOB_TOKEN ACTIONS_RUNTIME_TOKEN
|
|
82
|
+
chant_report="$PWD/.chant/pr"
|
|
83
|
+
chant_handover() { node -e 'const fs=require("fs"),p=require("path"),z=require("zlib");const [dir,out]=process.argv.slice(1);const CHUNK=450000,PARTS=1,NOTE=["pr-plan.json","pr-note.md"];const all={};const walk=(d,r)=>{let n=[];try{n=fs.readdirSync(d).sort()}catch{return}for(const x of n){const f=p.join(d,x),k=r?r+"/"+x:x;let s;try{s=fs.lstatSync(f)}catch{continue}if(s.isDirectory())walk(f,k);else if(s.isFile())all[k]=fs.readFileSync(f).toString("base64")}};walk(dir,"");const pack=(o)=>z.gzipSync(JSON.stringify(o)).toString("base64");let b=pack(all);if(b.length>CHUNK*PARTS){b=pack(Object.fromEntries(Object.entries(all).filter(([k])=>NOTE.includes(k))));if(b.length>CHUNK*PARTS){b=pack({});console.error("chant: the plan report is too large to hand to the note job, even cut to the report and the note; it hands on none of it")}else console.error("chant: the plan report is too large to hand to the note job whole; it hands on pr-plan.json and pr-note.md")}let o="";for(let i=0;i*CHUNK<b.length;i++)o+="report_"+i+"="+b.slice(i*CHUNK,(i+1)*CHUNK)+"\n";fs.appendFileSync(out,o);' "$chant_report" "${GITHUB_OUTPUT:-/dev/null}" || echo "chant: the plan report could not be handed to the note job" >&2; }
|
|
84
|
+
trap chant_handover EXIT
|
|
85
|
+
npx --no-install plywood ci run plan --chant "npx --no-install plywood" --in-place --no-push --base "$BASE_SHA" --pr "$PR_NUMBER" --env prod --gate pr-apply --forge github
|
|
86
|
+
plan-note:
|
|
87
|
+
needs:
|
|
88
|
+
- plan
|
|
89
|
+
if: always() && github.event_name == 'pull_request' && (needs.plan.result == 'success' || needs.plan.result == 'failure')
|
|
90
|
+
runs-on: ubuntu-latest
|
|
91
|
+
container: node:22
|
|
92
|
+
permissions:
|
|
93
|
+
actions: read
|
|
94
|
+
contents: read
|
|
95
|
+
pull-requests: write
|
|
96
|
+
statuses: write
|
|
97
|
+
steps:
|
|
98
|
+
- name: Check out the target branch's commit
|
|
99
|
+
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
100
|
+
with:
|
|
101
|
+
ref: '${{ github.event.pull_request.base.sha }}'
|
|
102
|
+
- name: Take the plan report from the plan job
|
|
103
|
+
run: |
|
|
104
|
+
set -u
|
|
105
|
+
node -e 'const fs=require("fs"),p=require("path"),z=require("zlib");const dir=process.argv[1];let b="";for(const l of fs.readFileSync(0,"utf8").split("\n")){if(!l.trim())continue;let s="";try{s=JSON.parse(l)}catch{}if(typeof s==="string")b+=s}if(!b){console.log("chant: the plan job handed on no report");process.exit(0)}let files;try{files=JSON.parse(z.gunzipSync(Buffer.from(b,"base64"),{maxOutputLength:268435456}).toString("utf8"))}catch(e){console.log("chant: the report the plan job handed on does not read: "+e.message);process.exit(0)}if(!files||typeof files!=="object"||Array.isArray(files)){console.log("chant: the report the plan job handed on is not a set of files");process.exit(0)}let n=0;for(const [k,v] of Object.entries(files)){const parts=k.split("/");if(typeof v!=="string"||k.length>1024||parts.some((x)=>!x||x==="."||x===".."||/[\\\x00-\x1f]/.test(x))){console.log("chant: left out "+JSON.stringify(k));continue}try{const f=p.join(dir,...parts);fs.mkdirSync(p.dirname(f),{recursive:true});fs.writeFileSync(f,Buffer.from(v,"base64"));n++}catch(e){console.log("chant: could not write "+JSON.stringify(k)+": "+e.message)}}console.log("chant: took "+n+" report files from the plan job")' .chant/pr <<'END_OF_REPORT'
|
|
106
|
+
${{ toJSON(needs.plan.outputs.report_0) }}
|
|
107
|
+
END_OF_REPORT
|
|
108
|
+
- name: Keep the plan report
|
|
109
|
+
if: always()
|
|
110
|
+
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
|
111
|
+
with:
|
|
112
|
+
name: chant-pr-plan
|
|
113
|
+
path: .chant/pr
|
|
114
|
+
if-no-files-found: ignore
|
|
115
|
+
include-hidden-files: true
|
|
116
|
+
- name: Post the plan note and plywood/plan
|
|
117
|
+
if: always()
|
|
118
|
+
env:
|
|
119
|
+
PR_NUMBER: '${{ github.event.pull_request.number }}'
|
|
120
|
+
HEAD_SHA: '${{ github.sha }}'
|
|
121
|
+
PLAN_RESULT: '${{ needs.plan.result }}'
|
|
122
|
+
GITHUB_TOKEN: '${{ github.token }}'
|
|
123
|
+
run: npx --no-install plywood components pr-post --pr "$PR_NUMBER" --head "$HEAD_SHA" --env prod --output .chant/pr --forge github --result "$PLAN_RESULT"
|
|
124
|
+
action:
|
|
125
|
+
if: (github.event_name == 'workflow_dispatch' && github.event.inputs.action != '') || (github.event_name == 'issue_comment' && startsWith(github.event.comment.body, '/plywood action ') && contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association))
|
|
126
|
+
runs-on: ubuntu-latest
|
|
127
|
+
container: node:22
|
|
128
|
+
concurrency:
|
|
129
|
+
group: chant-action-prod
|
|
130
|
+
cancel-in-progress: false
|
|
131
|
+
permissions:
|
|
132
|
+
contents: write
|
|
133
|
+
issues: write
|
|
134
|
+
pull-requests: write
|
|
135
|
+
steps:
|
|
136
|
+
- name: Check out the target branch's commit
|
|
137
|
+
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
138
|
+
with:
|
|
139
|
+
fetch-depth: 0
|
|
140
|
+
- run: command -v tofu >/dev/null 2>&1 || (curl -fsSL https://get.opentofu.org/install-opentofu.sh -o /tmp/install-opentofu.sh && sh /tmp/install-opentofu.sh --install-method standalone)
|
|
141
|
+
- name: Run the requested action under its gated request
|
|
142
|
+
env:
|
|
143
|
+
CHANT_ACTION_COMMENT: '${{ github.event.comment.body }}'
|
|
144
|
+
CHANT_ACTION_MEMBER: '${{ github.event.inputs.stack }}'
|
|
145
|
+
CHANT_ACTION: '${{ github.event.inputs.action }}'
|
|
146
|
+
PR_NUMBER: '${{ github.event.issue.number }}'
|
|
147
|
+
GITHUB_TOKEN: '${{ github.token }}'
|
|
148
|
+
run: |
|
|
149
|
+
set +e
|
|
150
|
+
npx --no-install plywood action --from-event
|
|
151
|
+
code="$?"
|
|
152
|
+
if [ "$code" -eq 3 ]; then echo "The action waits for an approval."; exit 0; fi
|
|
153
|
+
exit "$code"
|
|
154
|
+
record:
|
|
155
|
+
if: github.event_name == 'push'
|
|
156
|
+
runs-on: ubuntu-latest
|
|
157
|
+
container: node:22
|
|
158
|
+
concurrency:
|
|
159
|
+
group: chant-record-prod
|
|
160
|
+
cancel-in-progress: false
|
|
161
|
+
permissions:
|
|
162
|
+
contents: write
|
|
163
|
+
pull-requests: write
|
|
164
|
+
statuses: write
|
|
165
|
+
steps:
|
|
166
|
+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
167
|
+
with:
|
|
168
|
+
fetch-depth: 0
|
|
169
|
+
- run: command -v tofu >/dev/null 2>&1 || (curl -fsSL https://get.opentofu.org/install-opentofu.sh -o /tmp/install-opentofu.sh && sh /tmp/install-opentofu.sh --install-method standalone)
|
|
170
|
+
- name: Record the plan on the merged commit; nothing applies
|
|
171
|
+
env:
|
|
172
|
+
BASE_SHA: '${{ github.event.before }}'
|
|
173
|
+
GITHUB_TOKEN: '${{ github.token }}'
|
|
174
|
+
CHANT_RUNNER_KEY: '${{ secrets.CHANT_RUNNER_KEY }}'
|
|
175
|
+
run: |
|
|
176
|
+
if [ -z "$BASE_SHA" ] || [ "$BASE_SHA" = "0000000000000000000000000000000000000000" ]; then git fetch origin "main" && BASE_SHA="$(git merge-base FETCH_HEAD "$GITHUB_SHA")"; if [ "$BASE_SHA" = "$GITHUB_SHA" ]; then BASE_SHA="$(git rev-parse --verify --quiet "$GITHUB_SHA^" || echo 4b825dc642cb6eb9a060e54bf8d69288fbee4904)"; fi; fi; export BASE_SHA
|
|
177
|
+
npx --no-install plywood ci run record --chant "npx --no-install plywood" --in-place --base "$BASE_SHA" --env prod --gate pr-apply --forge github
|
|
178
|
+
- name: Keep the record report
|
|
179
|
+
if: always()
|
|
180
|
+
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
|
181
|
+
with:
|
|
182
|
+
name: chant-pr-record
|
|
183
|
+
path: .chant/pr
|
|
184
|
+
if-no-files-found: ignore
|
|
185
|
+
include-hidden-files: true
|
|
186
|
+
apply:
|
|
187
|
+
if: github.event_name == 'workflow_run' || (github.event_name == 'workflow_dispatch' && github.event.inputs.action == '')
|
|
188
|
+
runs-on: ubuntu-latest
|
|
189
|
+
container: node:22
|
|
190
|
+
concurrency:
|
|
191
|
+
group: chant-apply-prod
|
|
192
|
+
cancel-in-progress: false
|
|
193
|
+
permissions:
|
|
194
|
+
contents: write
|
|
195
|
+
pull-requests: write
|
|
196
|
+
statuses: write
|
|
197
|
+
steps:
|
|
198
|
+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
199
|
+
with:
|
|
200
|
+
fetch-depth: 0
|
|
201
|
+
- run: command -v tofu >/dev/null 2>&1 || (curl -fsSL https://get.opentofu.org/install-opentofu.sh -o /tmp/install-opentofu.sh && sh /tmp/install-opentofu.sh --install-method standalone)
|
|
202
|
+
- name: Restore the record of an earlier attempt at this commit
|
|
203
|
+
uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
|
204
|
+
with:
|
|
205
|
+
path: .chant/pr-resume/pr-apply.json
|
|
206
|
+
key: chant-apply-prod-${{ github.sha }}-${{ github.run_id }}-${{ github.run_attempt }}
|
|
207
|
+
restore-keys: chant-apply-prod-${{ github.sha }}-
|
|
208
|
+
- name: Apply the newest green commit under the merged changes' approvals
|
|
209
|
+
env:
|
|
210
|
+
GITHUB_TOKEN: '${{ github.token }}'
|
|
211
|
+
CHANT_RUNNER_KEY: '${{ secrets.CHANT_RUNNER_KEY }}'
|
|
212
|
+
run: |
|
|
213
|
+
git fetch --quiet --tags origin "+refs/heads/main:refs/remotes/origin/main" && GREEN_SHA="$(npx --no-install plywood ci last-green)" && git checkout --quiet --detach "$GREEN_SHA"
|
|
214
|
+
npx --no-install plywood ci run apply --chant "npx --no-install plywood" --in-place --env prod --gate pr-apply --forge github
|
|
215
|
+
- name: Keep the record for a re-run
|
|
216
|
+
if: always()
|
|
217
|
+
uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
|
218
|
+
with:
|
|
219
|
+
path: .chant/pr-resume/pr-apply.json
|
|
220
|
+
key: chant-apply-prod-${{ github.sha }}-${{ github.run_id }}-${{ github.run_attempt }}
|
|
221
|
+
- name: Keep the apply report
|
|
222
|
+
if: always()
|
|
223
|
+
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
|
224
|
+
with:
|
|
225
|
+
name: chant-pr-apply
|
|
226
|
+
path: .chant/pr
|
|
227
|
+
if-no-files-found: ignore
|
|
228
|
+
include-hidden-files: true
|
|
@@ -141,8 +141,8 @@ export interface GithubOpPipelineDoc {
|
|
|
141
141
|
on: Record<string, unknown>;
|
|
142
142
|
/** The `env:` mapping, when `options.variables` is set. */
|
|
143
143
|
env?: Record<string, unknown>;
|
|
144
|
-
/** The `concurrency:` mapping — one run at a time per Op. */
|
|
145
|
-
concurrency
|
|
144
|
+
/** The `concurrency:` mapping — one run at a time per Op. Absent on a waves file (ruling for ci-products 2026-10-28). */
|
|
145
|
+
concurrency?: Record<string, unknown>;
|
|
146
146
|
/**
|
|
147
147
|
* The `permissions:` mapping for this Op's finding-mode. GitHub-only:
|
|
148
148
|
* Forgejo Actions ignores `permissions:` entirely, so the forgejo dialect
|
|
@@ -655,14 +655,14 @@ function scopeOpFileToMember(file: GithubOpPipelineFile, member: PipelineMember)
|
|
|
655
655
|
return [job, { ...rest, defaults: { run: { "working-directory": member.dir } }, steps }];
|
|
656
656
|
}),
|
|
657
657
|
);
|
|
658
|
-
const group = (file.doc.concurrency as { group?: unknown })
|
|
658
|
+
const group = (file.doc.concurrency as { group?: unknown } | undefined)?.group;
|
|
659
659
|
return {
|
|
660
660
|
name,
|
|
661
661
|
doc: {
|
|
662
662
|
...file.doc,
|
|
663
663
|
...(file.doc.name ? { name: `${member.name}/${file.doc.name}` } : {}),
|
|
664
664
|
on,
|
|
665
|
-
concurrency: { ...file.doc.concurrency, ...(typeof group === "string" ? { group: `${member.name}-${group}` } : {}) },
|
|
665
|
+
...(file.doc.concurrency ? { concurrency: { ...file.doc.concurrency, ...(typeof group === "string" ? { group: `${member.name}-${group}` } : {}) } } : {}),
|
|
666
666
|
jobsDoc,
|
|
667
667
|
},
|
|
668
668
|
};
|
|
@@ -105,14 +105,14 @@ describe("a runner per wave and job groups per wave (#3914)", () => {
|
|
|
105
105
|
const yaml = files[0]!.yaml;
|
|
106
106
|
expect(yaml).toMatch(/wave-1-dev:\n\s+runs-on:\n\s+- self-hosted\n\s+- dev\n/);
|
|
107
107
|
expect(yaml).toMatch(
|
|
108
|
-
/wave-2-prod-decide:\n\s+runs-on: plan\n[\s\S]*?permissions:\n\s+contents: write\n\s+id-token: write\n\s+env:\n\s+STAGE: apply\n\s+PLAN_TOKEN_AUDIENCE: plan.example\n/,
|
|
108
|
+
/wave-2-prod-decide:\n\s+runs-on: plan\n[\s\S]*?permissions:\n\s+contents: write\n\s+id-token: write\n\s+timeout-minutes: 180\n\s+env:\n\s+CHANT_LOCK_WAIT: '10500'\n\s+STAGE: apply\n\s+PLAN_TOKEN_AUDIENCE: plan.example\n/,
|
|
109
109
|
);
|
|
110
110
|
expect(yaml).toMatch(
|
|
111
|
-
/wave-2-prod-us-share-1:\n\s+runs-on:\n\s+group: prod-apply\n\s+labels:\n\s+- linux\n\s+needs:\n\s+- wave-2-prod-decide\n\s+container: node:22\n\s+environment:\n\s+name: prod-us\n\s+permissions:\n\s+contents: write\n\s+id-token: write\n\s+env:\n\s+STAGE: apply\n\s+REGION: us\n\s+CLOUD_TOKEN_AUDIENCE: us.example\n/,
|
|
111
|
+
/wave-2-prod-us-share-1:\n\s+runs-on:\n\s+group: prod-apply\n\s+labels:\n\s+- linux\n\s+needs:\n\s+- wave-2-prod-decide\n\s+container: node:22\n\s+environment:\n\s+name: prod-us\n\s+permissions:\n\s+contents: write\n\s+id-token: write\n\s+timeout-minutes: 180\n\s+env:\n\s+CHANT_LOCK_WAIT: '10500'\n\s+STAGE: apply\n\s+REGION: us\n\s+CLOUD_TOKEN_AUDIENCE: us.example\n/,
|
|
112
112
|
);
|
|
113
113
|
expect(yaml).toContain("- run: echo us\n - name: Apply share 1 of job group us of wave 2 (prod)\n run: chant run wave --spec waves.json --wave 2 --group us --share 1\n");
|
|
114
114
|
// A group with no id tokens of its own gets the wave's.
|
|
115
|
-
expect(yaml).toMatch(/wave-2-prod-eu:\n\s+runs-on: eu-apply\n[\s\S]*?name: prod-eu\n\s+permissions:\n\s+contents: write\n\s+id-token: write\n\s+env:\n\s+STAGE: apply\n\s+PLAN_TOKEN_AUDIENCE: plan.example\n/);
|
|
115
|
+
expect(yaml).toMatch(/wave-2-prod-eu:\n\s+runs-on: eu-apply\n[\s\S]*?name: prod-eu\n\s+permissions:\n\s+contents: write\n\s+id-token: write\n\s+timeout-minutes: 180\n\s+env:\n\s+CHANT_LOCK_WAIT: '10500'\n\s+STAGE: apply\n\s+PLAN_TOKEN_AUDIENCE: plan.example\n/);
|
|
116
116
|
expect(yaml).toContain("run: chant run wave --spec waves.json --wave 2 --group eu\n");
|
|
117
117
|
});
|
|
118
118
|
|
|
@@ -139,3 +139,19 @@ describe("a runner per wave and job groups per wave (#3914)", () => {
|
|
|
139
139
|
expect(jobs.every((j) => j.command[0] === "chant")).toBe(true);
|
|
140
140
|
});
|
|
141
141
|
});
|
|
142
|
+
|
|
143
|
+
describe("the ledger lock (ruling for ci-products 2026-10-28)", () => {
|
|
144
|
+
test("the waves workflow carries no concurrency group; each wave job carries its timeout and the lock wait under it", () => {
|
|
145
|
+
const yaml = generateGithubOpWavesPipeline(spec, { specFile: "waves.json" }).files[0]!.yaml;
|
|
146
|
+
expect(yaml).not.toMatch(/^concurrency:/m);
|
|
147
|
+
expect(yaml).not.toContain("group: migrations");
|
|
148
|
+
expect(yaml.match(/timeout-minutes: 180\n/g)).toHaveLength(4);
|
|
149
|
+
expect(yaml.match(/CHANT_LOCK_WAIT: '10500'\n/g)).toHaveLength(4);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("jobTimeoutMinutes sets both", () => {
|
|
153
|
+
const yaml = generateGithubOpWavesPipeline(spec, { specFile: "waves.json", jobTimeoutMinutes: 60 }).files[0]!.yaml;
|
|
154
|
+
expect(yaml).toContain("timeout-minutes: 60\n");
|
|
155
|
+
expect(yaml).toContain("CHANT_LOCK_WAIT: '3300'\n");
|
|
156
|
+
});
|
|
157
|
+
});
|
|
@@ -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
|
*
|
|
@@ -79,6 +79,7 @@ import {
|
|
|
79
79
|
notifyVariables,
|
|
80
80
|
opWaveGroupOf,
|
|
81
81
|
opWaveJobs,
|
|
82
|
+
opWaveJobTimeout,
|
|
82
83
|
opWaveOutcomeDir,
|
|
83
84
|
opWavesControlTarget,
|
|
84
85
|
opWavesLastJobs,
|
|
@@ -146,6 +147,8 @@ export function buildGithubOpWavesDoc(
|
|
|
146
147
|
}
|
|
147
148
|
}
|
|
148
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);
|
|
149
152
|
|
|
150
153
|
const jobsDoc: Record<string, unknown> = {};
|
|
151
154
|
for (const job of jobs) {
|
|
@@ -194,7 +197,8 @@ export function buildGithubOpWavesDoc(
|
|
|
194
197
|
...(environment ? { environment: environmentDoc(environment) } : {}),
|
|
195
198
|
// #3914: a job's own permissions replace the workflow's, so they repeat them.
|
|
196
199
|
...(idToken ? { permissions: { ...permissions, "id-token": "write" } } : {}),
|
|
197
|
-
|
|
200
|
+
"timeout-minutes": timeout.minutes,
|
|
201
|
+
env: { ...timeout.env, ...(group || idTokens ? { ...wave.variables, ...group?.variables, ...idTokenAudienceEnv(idTokens) } : wave.variables) },
|
|
198
202
|
steps,
|
|
199
203
|
};
|
|
200
204
|
}
|
|
@@ -211,7 +215,8 @@ export function buildGithubOpWavesDoc(
|
|
|
211
215
|
name: spec.name,
|
|
212
216
|
on: { push: { branches: pushBranches(spec.branches) }, workflow_dispatch: {} },
|
|
213
217
|
...nonEmpty("env", env),
|
|
214
|
-
|
|
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.
|
|
215
220
|
permissions,
|
|
216
221
|
jobsDoc,
|
|
217
222
|
};
|
|
@@ -78,10 +78,12 @@ 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,
|
|
85
|
+
prActionJob,
|
|
86
|
+
prLoopPassedEnv,
|
|
85
87
|
PREVIEW_REF_ENV,
|
|
86
88
|
PREVIEW_HEAD_OUTPUT,
|
|
87
89
|
prLoopWaveJobs,
|
|
@@ -336,7 +338,10 @@ function prLoopGithubDoc(
|
|
|
336
338
|
];
|
|
337
339
|
// #4168 step 9: under apply on merge the apply runs on a green signal, and a push to the branch records.
|
|
338
340
|
const forgejo = loop.forge === "forgejo";
|
|
339
|
-
|
|
341
|
+
// #4654: the action job answers its comment and a dispatch that names an action; such a dispatch starts no apply.
|
|
342
|
+
const action = prActionJob(loop.forge ?? "github", loop);
|
|
343
|
+
const plainDispatch = action ? `(github.event_name == 'workflow_dispatch' && github.event.inputs.${action.inputs.action} == '')` : "github.event_name == 'workflow_dispatch'";
|
|
344
|
+
const onGreen = forgejo ? "startsWith(github.ref, 'refs/tags/ci/green/')" : `github.event_name == 'workflow_run' || ${plainDispatch}`;
|
|
340
345
|
const onPush = loop.applyOnMerge && forgejo ? "github.event_name == 'push' && startsWith(github.ref, 'refs/heads/')" : "github.event_name == 'push'";
|
|
341
346
|
// #4301: a `/chant apply` comment on a pull request starts the apply before merge. GitHub also asks for a commenter who can write.
|
|
342
347
|
const onComment = [
|
|
@@ -453,6 +458,8 @@ function prLoopGithubDoc(
|
|
|
453
458
|
const prEnv = loop.prEnvironment ? prEnvGithubJobs(loop.forge ?? "github", image, beforeScript, extraScript, loop, member) : undefined;
|
|
454
459
|
// #4327: with ci.locks: plan the pull_request trigger fires on close too, for the unlock job, and the plan skips that event.
|
|
455
460
|
const locks = loop.locks === "plan";
|
|
461
|
+
// plywood#22: the lock lines run through the product's bin, as its other job lines do.
|
|
462
|
+
const lockBin = resolveProductWords(loop.words).bin;
|
|
456
463
|
const closes = Boolean(prEnv) || locks;
|
|
457
464
|
const lockJobs: Record<string, unknown> = locks
|
|
458
465
|
? {
|
|
@@ -471,7 +478,7 @@ function prLoopGithubDoc(
|
|
|
471
478
|
{
|
|
472
479
|
name: "Lock the components the plan selected",
|
|
473
480
|
env: { PR_NUMBER: "${{ github.event.pull_request.number }}", HEAD_SHA: "${{ github.event.pull_request.head.sha }}", GITHUB_TOKEN: GITHUB_JOB_TOKEN },
|
|
474
|
-
run: prLockCommand(forgeName),
|
|
481
|
+
run: prLockCommand(forgeName, lockBin),
|
|
475
482
|
},
|
|
476
483
|
],
|
|
477
484
|
},
|
|
@@ -483,7 +490,7 @@ function prLoopGithubDoc(
|
|
|
483
490
|
steps: [
|
|
484
491
|
{ name: "Check out the target branch's commit", uses: CHECKOUT, with: { ref: "${{ github.event.pull_request.base.sha }}", "fetch-depth": 0 } },
|
|
485
492
|
...runSteps(beforeScript),
|
|
486
|
-
{ name: "Release the pull request's locks", env: { PR_NUMBER: "${{ github.event.pull_request.number }}" }, run:
|
|
493
|
+
{ name: "Release the pull request's locks", env: { PR_NUMBER: "${{ github.event.pull_request.number }}" }, run: prUnlockCommand(lockBin) },
|
|
487
494
|
],
|
|
488
495
|
},
|
|
489
496
|
}
|
|
@@ -564,6 +571,34 @@ function prLoopGithubDoc(
|
|
|
564
571
|
},
|
|
565
572
|
}
|
|
566
573
|
: {};
|
|
574
|
+
// #4654: `/<product> <action word> <member> <action>`, or a dispatch naming both, runs the product's action verb at the target branch's commit.
|
|
575
|
+
const actionJobs: Record<string, unknown> = action
|
|
576
|
+
? {
|
|
577
|
+
[action.jobName]: {
|
|
578
|
+
if: [
|
|
579
|
+
`(github.event_name == 'workflow_dispatch' && github.event.inputs.${action.inputs.action} != '')`,
|
|
580
|
+
`(${[
|
|
581
|
+
"github.event_name == 'issue_comment'",
|
|
582
|
+
`startsWith(github.event.comment.body, '${action.comment.replace(/'/g, "''")} ')`,
|
|
583
|
+
...(forgejo ? [] : ["contains(fromJSON('[\"OWNER\",\"MEMBER\",\"COLLABORATOR\"]'), github.event.comment.author_association)"]),
|
|
584
|
+
].join(" && ")})`,
|
|
585
|
+
].join(" || "),
|
|
586
|
+
"runs-on": GITHUB_RUNNER,
|
|
587
|
+
container: image,
|
|
588
|
+
// One action of a pipeline at a time, and never two runs of one request.
|
|
589
|
+
concurrency: concurrency(`chant-action-${env}${member ? `-${member.name}` : ""}`),
|
|
590
|
+
permissions: withIdToken({ contents: "write", issues: "write", "pull-requests": "write" }),
|
|
591
|
+
steps: [
|
|
592
|
+
...(identity ? [tradeStep(identity.apply, "apply")] : []),
|
|
593
|
+
// An issue_comment run checks out the target branch's commit, a dispatch the ref it names: never a pull request's head.
|
|
594
|
+
{ name: "Check out the target branch's commit", uses: CHECKOUT, with: { "fetch-depth": 0 } },
|
|
595
|
+
...runSteps(prLoopSetup(image, loop.engineSetup)),
|
|
596
|
+
...runSteps(beforeScript),
|
|
597
|
+
{ name: "Run the requested action under its gated request", env: { ...prLoopPassedEnv(forgeName, loop), ...action.env }, run: action.script },
|
|
598
|
+
],
|
|
599
|
+
},
|
|
600
|
+
}
|
|
601
|
+
: {};
|
|
567
602
|
const jobsDoc: Record<string, unknown> = {
|
|
568
603
|
plan: {
|
|
569
604
|
if: closes ? "github.event_name == 'pull_request' && github.event.action != 'closed'" : "github.event_name == 'pull_request'",
|
|
@@ -585,6 +620,7 @@ function prLoopGithubDoc(
|
|
|
585
620
|
...lockJobs,
|
|
586
621
|
...commentLockJobs,
|
|
587
622
|
...previewJobs,
|
|
623
|
+
...actionJobs,
|
|
588
624
|
...staleJobs,
|
|
589
625
|
...Object.fromEntries(applyJobs),
|
|
590
626
|
...Object.fromEntries(controlJobs),
|
|
@@ -602,7 +638,17 @@ function prLoopGithubDoc(
|
|
|
602
638
|
on.schedule = [{ cron: PR_ENV_SWEEP_CRON }];
|
|
603
639
|
}
|
|
604
640
|
// #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"] };
|
|
641
|
+
if (loop.applyBeforeMerge || commentLocks.length > 0 || preview || action) on.issue_comment = { types: ["created"] };
|
|
642
|
+
// #4654: a dispatch names a member and an action; both empty, it is the dispatch it was before.
|
|
643
|
+
if (action) {
|
|
644
|
+
const input = (description: string) => ({ description, required: false, type: "string", default: "" });
|
|
645
|
+
on.workflow_dispatch = {
|
|
646
|
+
inputs: {
|
|
647
|
+
[action.inputs.member]: input(`The ${resolveProductWords(loop.words).noun[0]} the action runs on`),
|
|
648
|
+
[action.inputs.action]: input("The action to run, by its name"),
|
|
649
|
+
},
|
|
650
|
+
};
|
|
651
|
+
}
|
|
606
652
|
// #4625: each job on its stage's runner, the plan side's, the apply side's, or the default.
|
|
607
653
|
if (loop.runners) {
|
|
608
654
|
for (const [name, job] of Object.entries(jobsDoc) as Array<[string, Record<string, unknown>]>) {
|
|
@@ -616,7 +662,7 @@ function prLoopGithubDoc(
|
|
|
616
662
|
...(rooted ? {} : { defaults: { run: { "working-directory": member!.dir } } }),
|
|
617
663
|
env: { ...options.variables, CHANT_ENV: env },
|
|
618
664
|
jobsDoc,
|
|
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"] : [])],
|
|
665
|
+
stages: ["plan", PR_PLAN_NOTE_JOB, ...(locks ? [PR_LOCK_JOB, PR_UNLOCK_JOB] : []), ...commentLocks.map((j) => j.jobName), ...(preview ? [preview.jobName, preview.noteJobName] : []), ...(action ? [action.jobName] : []), ...(stale ? [PR_STALE_JOB] : []), ...applyNames, ...controlJobs.map(([name]) => name), ...(prEnv ? ["env-up", PR_ENV_POST_JOB, "env-down", "env-sweep"] : [])],
|
|
620
666
|
jobs: [
|
|
621
667
|
{ jobName: "plan", component: "pull request plan", stage: "plan", needs: [] },
|
|
622
668
|
{ jobName: PR_PLAN_NOTE_JOB, component: "pull request plan note", stage: PR_PLAN_NOTE_JOB, needs: ["plan"] },
|
|
@@ -634,6 +680,7 @@ function prLoopGithubDoc(
|
|
|
634
680
|
{ jobName: preview.noteJobName, component: "pull request preview comment note", stage: preview.noteJobName, needs: [preview.jobName] },
|
|
635
681
|
]
|
|
636
682
|
: []),
|
|
683
|
+
...(action ? [{ jobName: action.jobName, component: "day-2 action", stage: action.jobName, needs: [] }] : []),
|
|
637
684
|
...applyNames.map((name, i) => ({
|
|
638
685
|
jobName: name,
|
|
639
686
|
component: name === "record" ? "merge record" : loop.applyBeforeMerge ? "apply before merge" : loop.waveGate ? `green apply, wave ${i}` : "green apply",
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The action job (#4654): with `actionJob`, the pull request loop's
|
|
3
|
+
* workflow answers `/<product> <action word> <member> <action>` and a
|
|
4
|
+
* dispatch with the member and action inputs, at the target branch's
|
|
5
|
+
* commit, through the product's action verb. A dispatch that names an
|
|
6
|
+
* action starts no apply.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, expect, test } from "vitest";
|
|
10
|
+
import { parseYAML } from "@intentius/chant/yaml";
|
|
11
|
+
import type { DriverComponent } from "@intentius/chant/components/driver";
|
|
12
|
+
import { generateGithubPipeline } from "./generate-pipeline";
|
|
13
|
+
|
|
14
|
+
const ESTATE: DriverComponent[] = [
|
|
15
|
+
{ name: "shop", dependsOn: [], deploy: [{ phase: "Apply", steps: [{ kind: "pulumi-up", stack: "shop/prod-us" }] }] },
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
const WORDS = { product: "plywood", noun: ["stack", "stacks"] as [string, string], commands: { preview: "plan" as const, action: "action" as const }, bin: "npx --no-install plywood" };
|
|
19
|
+
|
|
20
|
+
interface Step { name?: string; uses?: string; run?: string; env?: Record<string, string>; with?: Record<string, unknown> }
|
|
21
|
+
interface Job { if?: string; needs?: string[]; permissions: Record<string, string>; steps: Step[] }
|
|
22
|
+
type Doc = { on: Record<string, unknown>; jobs: Record<string, Job> };
|
|
23
|
+
|
|
24
|
+
const gen = (prLoop: Record<string, unknown>) => generateGithubPipeline(ESTATE, { env: "prod", prLoop }).yaml;
|
|
25
|
+
|
|
26
|
+
describe("the GitHub workflow's action job", () => {
|
|
27
|
+
const yaml = gen({ words: WORDS, actionJob: true, applyOnMerge: true });
|
|
28
|
+
const doc = parseYAML(yaml) as Doc;
|
|
29
|
+
|
|
30
|
+
test("a dispatch takes the stack and action inputs, and a comment starts the job too", () => {
|
|
31
|
+
expect(doc.on.workflow_dispatch).toEqual({
|
|
32
|
+
inputs: {
|
|
33
|
+
stack: { description: "The stack the action runs on", required: false, type: "string", default: "" },
|
|
34
|
+
action: { description: "The action to run, by its name", required: false, type: "string", default: "" },
|
|
35
|
+
},
|
|
36
|
+
});
|
|
37
|
+
expect(doc.on.issue_comment).toEqual({ types: ["created"] });
|
|
38
|
+
const job = doc.jobs.action!;
|
|
39
|
+
expect(job.if).toContain("github.event_name == 'workflow_dispatch' && github.event.inputs.action != ''");
|
|
40
|
+
expect(job.if).toContain("startsWith(github.event.comment.body, '/plywood action ')");
|
|
41
|
+
expect(job.if).toContain("github.event.comment.author_association");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("the job runs the product's verb at the target branch's commit, with the comment and inputs as variables", () => {
|
|
45
|
+
const job = doc.jobs.action!;
|
|
46
|
+
expect(job.steps[0]!.uses).toMatch(/^actions\/checkout@/);
|
|
47
|
+
expect(job.steps[0]!.with).toEqual({ "fetch-depth": 0 });
|
|
48
|
+
const run = job.steps.at(-1)!;
|
|
49
|
+
expect(run.run).toContain("npx --no-install plywood action --from-event");
|
|
50
|
+
expect(run.env).toMatchObject({ CHANT_ACTION_COMMENT: "${{ github.event.comment.body }}", CHANT_ACTION_MEMBER: "${{ github.event.inputs.stack }}", CHANT_ACTION: "${{ github.event.inputs.action }}" });
|
|
51
|
+
expect(job.permissions).toEqual({ contents: "write", issues: "write", "pull-requests": "write" });
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("a dispatch that names an action starts no apply", () => {
|
|
55
|
+
expect(doc.jobs.apply!.if).toContain("(github.event_name == 'workflow_dispatch' && github.event.inputs.action == '')");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("without actionJob nothing changes", () => {
|
|
59
|
+
const plain = parseYAML(gen({ words: { ...WORDS, commands: { preview: "plan" as const } }, applyOnMerge: true })) as Doc;
|
|
60
|
+
expect(plain.jobs.action).toBeUndefined();
|
|
61
|
+
expect(plain.on.workflow_dispatch).toEqual({});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("golden", async () => {
|
|
65
|
+
await expect(yaml).toMatchFileSnapshot("./__golden__/pr-loop-action.github.yml");
|
|
66
|
+
});
|
|
67
|
+
});
|
|
@@ -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 });
|