@intentius/chant-lexicon-github 0.124.55 → 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/generate-op-pipeline.d.ts.map +1 -1
- package/dist/components/generate-op-pipeline.js +71 -6
- package/dist/components/generate-op-waves-pipeline.d.ts +14 -1
- package/dist/components/generate-op-waves-pipeline.d.ts.map +1 -1
- package/dist/components/generate-op-waves-pipeline.js +74 -13
- package/dist/components/generate-pipeline.d.ts.map +1 -1
- package/dist/components/generate-pipeline.js +30 -4
- 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/generate-op-pipeline.cloud.test.ts +96 -0
- package/src/components/generate-op-pipeline.ts +85 -11
- package/src/components/generate-op-waves-pipeline.approval.test.ts +46 -0
- package/src/components/generate-op-waves-pipeline.guard.test.ts +57 -0
- package/src/components/generate-op-waves-pipeline.identity.test.ts +8 -8
- package/src/components/generate-op-waves-pipeline.ts +76 -11
- package/src/components/generate-pipeline.ts +33 -3
- package/src/components/generate-pr-pipeline.comment-targets.test.ts +24 -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
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #4631: cloud identities on a scheduled Op's job, traded before the
|
|
3
|
+
* checkout, and an update on drift in a job of its own that alone holds
|
|
4
|
+
* the update's identity.
|
|
5
|
+
*/
|
|
6
|
+
import { describe, expect, test } from "vitest";
|
|
7
|
+
import { parseYAML } from "@intentius/chant/yaml";
|
|
8
|
+
import type { ScheduledOpSpec } from "@intentius/chant/lexicon";
|
|
9
|
+
import { generateGithubOpPipeline } from "./generate-op-pipeline";
|
|
10
|
+
|
|
11
|
+
interface Step { id?: string; name?: string; uses?: string; run?: string; env?: Record<string, string> }
|
|
12
|
+
interface Job { needs?: string; if?: string; permissions?: Record<string, string>; outputs?: Record<string, string>; environment?: unknown; steps: Step[] }
|
|
13
|
+
interface Doc { permissions?: Record<string, string>; jobs: Record<string, Job> }
|
|
14
|
+
|
|
15
|
+
const PREVIEW = { aws: { role: "arn:aws:iam::1:role/preview", region: "us-east-1", audience: "sts.amazonaws.com" } };
|
|
16
|
+
const UPDATE = { aws: { role: "arn:aws:iam::1:role/update", region: "us-east-1", audience: "sts.amazonaws.com" } };
|
|
17
|
+
const DRIFT: ScheduledOpSpec = { name: "plywood-drift-dev", trigger: { kind: "cron", schedule: "0 6 * * *" }, findingMode: "issue", cloud: PREVIEW };
|
|
18
|
+
|
|
19
|
+
const gen = (spec: ScheduledOpSpec) => parseYAML(generateGithubOpPipeline([spec]).files[0]!.yaml) as unknown as Doc;
|
|
20
|
+
|
|
21
|
+
describe("a scheduled Op's cloud identities (#4631)", () => {
|
|
22
|
+
test("the trade runs before the checkout, and every later run step drops the token request variables", () => {
|
|
23
|
+
const doc = gen(DRIFT);
|
|
24
|
+
const job = doc.jobs["plywood-drift-dev"]!;
|
|
25
|
+
expect(job.steps[0]!.name).toBe("Trade the job's OIDC token for its cloud identities");
|
|
26
|
+
expect(JSON.parse(job.steps[0]!.env!.CHANT_CLOUD_TRADE!)).toEqual(PREVIEW);
|
|
27
|
+
expect(job.steps[1]!.uses).toMatch(/^actions\/checkout@/);
|
|
28
|
+
for (const step of job.steps.slice(2)) if (step.run) expect(step.run).toMatch(/^unset ACTIONS_ID_TOKEN_REQUEST_TOKEN ACTIONS_ID_TOKEN_REQUEST_URL/);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("the finding-mode's permissions stay; the job alone adds id-token: write", () => {
|
|
32
|
+
const plain = gen({ ...DRIFT, cloud: undefined } as ScheduledOpSpec);
|
|
33
|
+
const doc = gen(DRIFT);
|
|
34
|
+
expect(doc.permissions).toEqual(plain.permissions);
|
|
35
|
+
expect(doc.jobs["plywood-drift-dev"]!.permissions).toEqual({ ...plain.permissions, "id-token": "write" });
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("a malformed cloud and a cloud on a review split are refused", () => {
|
|
39
|
+
expect(() => gen({ ...DRIFT, cloud: { aws: { region: "us-east-1" } } as never })).toThrow(/audience/);
|
|
40
|
+
expect(() => gen({ name: "review", trigger: { kind: "pull_request" }, reviewAgent: true, cloud: PREVIEW })).toThrow(/no cloud identity/);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe("an update on drift (#4631)", () => {
|
|
45
|
+
const spec: ScheduledOpSpec = { ...DRIFT, update: { command: "npx --no-install plywood up", cloud: UPDATE, environment: { name: "production" } } };
|
|
46
|
+
const doc = gen(spec);
|
|
47
|
+
const check = doc.jobs["plywood-drift-dev"]!;
|
|
48
|
+
const update = doc.jobs["plywood-drift-dev-update"]!;
|
|
49
|
+
|
|
50
|
+
test("the check says when it found drift, and holds only its own identity", () => {
|
|
51
|
+
expect(check.outputs).toEqual({ drift: "${{ steps.drift-check.outputs.drift }}" });
|
|
52
|
+
const run = check.steps.find((s) => s.id === "drift-check")!;
|
|
53
|
+
expect(run.run).toContain('if [ "$code" -eq 2 ]; then echo "drift=true"');
|
|
54
|
+
expect(JSON.stringify(check)).not.toContain("role/update");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("the update job runs after a drift, trades for its own identity first, and runs the command", () => {
|
|
58
|
+
expect(update.needs).toBe("plywood-drift-dev");
|
|
59
|
+
expect(update.if).toBe("always() && needs.plywood-drift-dev.outputs.drift == 'true'");
|
|
60
|
+
expect(update.permissions).toEqual({ contents: "read", "id-token": "write" });
|
|
61
|
+
expect(update.environment).toEqual({ name: "production" });
|
|
62
|
+
expect(JSON.parse(update.steps[0]!.env!.CHANT_CLOUD_TRADE!)).toEqual(UPDATE);
|
|
63
|
+
expect(update.steps[1]!.uses).toMatch(/^actions\/checkout@/);
|
|
64
|
+
expect(update.steps.at(-1)!.run).toMatch(/^unset .*\nnpx --no-install plywood up\n?$/);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("an update on a push trigger, or with no command, is refused", () => {
|
|
68
|
+
expect(() => gen({ name: "apply", trigger: { kind: "push" }, update: { command: "x" } })).toThrow(/cron or pull_request/);
|
|
69
|
+
expect(() => gen({ ...DRIFT, update: { command: " " } })).toThrow(/command/);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe("contents: write on the update job (#4631, 2026-10-28 ruling)", () => {
|
|
74
|
+
const plain = gen(DRIFT);
|
|
75
|
+
const spec: ScheduledOpSpec = { ...DRIFT, update: { command: "npx --no-install plywood up", cloud: UPDATE, permissions: { contents: "write" } } };
|
|
76
|
+
const doc = gen(spec);
|
|
77
|
+
|
|
78
|
+
test("the update job may raise contents to write, so it records on chant/lifecycle", () => {
|
|
79
|
+
expect(doc.jobs["plywood-drift-dev-update"]!.permissions).toEqual({ contents: "write", "id-token": "write" });
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("the drift check's job and the workflow keep their scopes", () => {
|
|
83
|
+
expect(doc.permissions).toEqual(plain.permissions);
|
|
84
|
+
expect(doc.jobs["plywood-drift-dev"]!.permissions).toEqual(plain.jobs["plywood-drift-dev"]!.permissions);
|
|
85
|
+
expect(doc.jobs["plywood-drift-dev"]!.permissions!.contents).not.toBe("write");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("the identity scoping stays: the check holds the preview identity, the update its own", () => {
|
|
89
|
+
expect(JSON.stringify(doc.jobs["plywood-drift-dev"])).not.toContain("role/update");
|
|
90
|
+
expect(JSON.parse(doc.jobs["plywood-drift-dev-update"]!.steps[0]!.env!.CHANT_CLOUD_TRADE!)).toEqual(UPDATE);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("contents restated at read is still refused", () => {
|
|
94
|
+
expect(() => gen({ ...DRIFT, update: { command: "x", permissions: { contents: "read" } } })).toThrow(/contents may only be raised to write/);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
@@ -100,6 +100,9 @@ import { assertReviewSplit, reviewJobs, reviewNoteJobName } from "./review-jobs"
|
|
|
100
100
|
import { resolveAgentSpec } from "@intentius/chant/ci-agent";
|
|
101
101
|
import { activeProductWords } from "@intentius/chant/ci-product-words";
|
|
102
102
|
import { agentJobName, agentPushJobName, driftRunOutputs, driftRunStep, githubAgentJobs } from "./agent-jobs";
|
|
103
|
+
import { assertScheduledOpCloud, scheduledOpUpdateJobName, withoutTokenRequestLine } from "@intentius/chant/scheduled-op-cloud";
|
|
104
|
+
import { TRADE_SPEC_ENV, tradeLine, type CloudTradeSpec } from "@intentius/chant/ci-identity";
|
|
105
|
+
import { CHANT_EXIT } from "@intentius/chant/exit-codes";
|
|
103
106
|
|
|
104
107
|
export type { GenerateGithubOpOptions, GenerateGithubOpResult };
|
|
105
108
|
|
|
@@ -437,6 +440,8 @@ export function buildGithubOpPipelineDocs(
|
|
|
437
440
|
|
|
438
441
|
const words = activeProductWords();
|
|
439
442
|
for (const spec of ops) {
|
|
443
|
+
// #4631: the job's cloud identities and the update on drift, checked before anything renders.
|
|
444
|
+
assertScheduledOpCloud(spec);
|
|
440
445
|
// #4616: the comment agent is a workflow of its own, on pull request comments; the spec's trigger is not read.
|
|
441
446
|
if (spec.agent?.on === "comment") {
|
|
442
447
|
const settings = resolveAgentSpec(spec.name, spec.agent, words.product);
|
|
@@ -489,6 +494,8 @@ export function buildGithubOpPipelineDocs(
|
|
|
489
494
|
throw new Error(`Scheduled Op "${spec.name}" sets a drift agent, which follows a drift check's run; give it a cron or pull_request trigger and no reviewAgent.`);
|
|
490
495
|
}
|
|
491
496
|
const stepEnv = split ? {} : jobTokenEnv(findingMode !== "report");
|
|
497
|
+
const cloud = spec.cloud;
|
|
498
|
+
const update = spec.update;
|
|
492
499
|
|
|
493
500
|
const setup = spec.setup ?? [];
|
|
494
501
|
assertSetupSteps(spec.name, setup);
|
|
@@ -503,15 +510,19 @@ export function buildGithubOpPipelineDocs(
|
|
|
503
510
|
? [...runParts, ...GATED_EXIT_FLAG, "--json"].join(" ")
|
|
504
511
|
: runParts.join(" ");
|
|
505
512
|
|
|
506
|
-
const
|
|
507
|
-
|
|
513
|
+
const later: Array<Record<string, unknown>> = [...setup.map(setupStepDoc), ...runSteps(beforeScript)];
|
|
514
|
+
later.push(
|
|
508
515
|
gated
|
|
509
516
|
? gatedRunStep(RUN_STEP_ID, spec.name, invocation, stepEnv)
|
|
510
517
|
: driftAgent
|
|
511
518
|
? driftRunStep(invocation, stepEnv, forge)
|
|
512
|
-
:
|
|
519
|
+
: update
|
|
520
|
+
? driftFlagStep(invocation, stepEnv)
|
|
521
|
+
: { run: invocation, env: stepEnv },
|
|
513
522
|
);
|
|
514
|
-
|
|
523
|
+
later.push(...runSteps(extraScript));
|
|
524
|
+
// #4631: the trade before the checkout, and every step after it without the token request variables.
|
|
525
|
+
const steps: Array<Record<string, unknown>> = cloud ? [tradeStep(cloud), checkoutStep(), ...later.map(withoutTokenRequest)] : [checkoutStep(), ...later];
|
|
515
526
|
|
|
516
527
|
const review = split
|
|
517
528
|
? reviewJobs({
|
|
@@ -527,6 +538,12 @@ export function buildGithubOpPipelineDocs(
|
|
|
527
538
|
})
|
|
528
539
|
: undefined;
|
|
529
540
|
|
|
541
|
+
const workflowPermissions = mergePermissions(
|
|
542
|
+
spec.name,
|
|
543
|
+
permissionsFor(findingMode, trigger),
|
|
544
|
+
spec.permissions ?? {},
|
|
545
|
+
trigger,
|
|
546
|
+
);
|
|
530
547
|
const doc: GithubOpPipelineDoc = {
|
|
531
548
|
name: spec.name,
|
|
532
549
|
on: onTrigger(trigger),
|
|
@@ -534,12 +551,7 @@ export function buildGithubOpPipelineDocs(
|
|
|
534
551
|
// One run at a time per Op — a slow audit must not overlap its own next
|
|
535
552
|
// scheduled trigger. A push Op's `build` (#4169) widens the group.
|
|
536
553
|
concurrency: concurrency(opConcurrencyGroup(jobName, trigger)),
|
|
537
|
-
permissions:
|
|
538
|
-
spec.name,
|
|
539
|
-
permissionsFor(findingMode, trigger),
|
|
540
|
-
spec.permissions ?? {},
|
|
541
|
-
trigger,
|
|
542
|
-
),
|
|
554
|
+
permissions: workflowPermissions,
|
|
543
555
|
jobsDoc: {
|
|
544
556
|
[jobName]: {
|
|
545
557
|
"runs-on": githubRunsOn(spec.runner),
|
|
@@ -554,6 +566,8 @@ export function buildGithubOpPipelineDocs(
|
|
|
554
566
|
// behind the same reviewer would hold the message back until
|
|
555
567
|
// somebody had already acted.
|
|
556
568
|
...(spec.environment ? { environment: environmentDoc(spec.environment) } : {}),
|
|
569
|
+
// #4631: the trade needs id-token: write, on this job alone; the finding-mode's scopes stay as they are.
|
|
570
|
+
...(cloud ? { permissions: { ...workflowPermissions, "id-token": "write" } } : {}),
|
|
557
571
|
...(gated
|
|
558
572
|
? {
|
|
559
573
|
outputs: Object.fromEntries(
|
|
@@ -565,9 +579,21 @@ export function buildGithubOpPipelineDocs(
|
|
|
565
579
|
}
|
|
566
580
|
: driftAgent
|
|
567
581
|
? { outputs: driftRunOutputs(forge) }
|
|
568
|
-
:
|
|
582
|
+
: update
|
|
583
|
+
? { outputs: { drift: `\${{ steps.${DRIFT_FLAG_STEP_ID}.outputs.drift }}` } }
|
|
584
|
+
: {}),
|
|
569
585
|
steps,
|
|
570
586
|
},
|
|
587
|
+
...(update
|
|
588
|
+
? {
|
|
589
|
+
[scheduledOpUpdateJobName(jobName)]: updateJob(jobName, update, {
|
|
590
|
+
image,
|
|
591
|
+
...(spec.runner !== undefined ? { runner: spec.runner } : {}),
|
|
592
|
+
setup: setup.map(setupStepDoc),
|
|
593
|
+
beforeScript,
|
|
594
|
+
}),
|
|
595
|
+
}
|
|
596
|
+
: {}),
|
|
571
597
|
},
|
|
572
598
|
...(gated ? { gatedNoticeDoc: { [`${jobName}-gate-notice`]: gateNoticeJob(jobName, image, spec.runner) } } : {}),
|
|
573
599
|
};
|
|
@@ -671,3 +697,51 @@ export function generateGithubOpPipeline(
|
|
|
671
697
|
jobs,
|
|
672
698
|
};
|
|
673
699
|
}
|
|
700
|
+
|
|
701
|
+
/** The id of the run step that says whether an update's drift check found drift (#4631). */
|
|
702
|
+
const DRIFT_FLAG_STEP_ID = "drift-check";
|
|
703
|
+
|
|
704
|
+
/** The Op's run step when an update follows it (#4631): `drift=true` as an output on chant's drift code, and the run's own exit code. */
|
|
705
|
+
function driftFlagStep(invocation: string, env: Record<string, string>): Record<string, unknown> {
|
|
706
|
+
return {
|
|
707
|
+
id: DRIFT_FLAG_STEP_ID,
|
|
708
|
+
env,
|
|
709
|
+
run: ["set +e", invocation, "code=$?", `if [ "$code" -eq ${CHANT_EXIT.drift} ]; then echo "drift=true" >>"$GITHUB_OUTPUT"; fi`, 'exit "$code"'].join("\n"),
|
|
710
|
+
};
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
/** The trade step (#4631), as an op wave job's (#4332): the job's OIDC token for `cloud`, written to GITHUB_ENV. */
|
|
714
|
+
function tradeStep(cloud: CloudTradeSpec): Record<string, unknown> {
|
|
715
|
+
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") };
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
/** A run step after the trade, with the token request variables unset first. A `uses:` step is left as it is. */
|
|
719
|
+
function withoutTokenRequest(step: Record<string, unknown>): Record<string, unknown> {
|
|
720
|
+
return typeof step.run === "string" ? { ...step, run: withoutTokenRequestLine(step.run) } : step;
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
/**
|
|
724
|
+
* The update job (#4631): after the Op's job, only when its run found
|
|
725
|
+
* drift; its own trade for `update.cloud` before its checkout, then the
|
|
726
|
+
* update's command. Its permissions are `contents: read`, `id-token: write`
|
|
727
|
+
* with a cloud, and the update's own scopes, which may raise `contents` to
|
|
728
|
+
* `write` so the update records on `chant/lifecycle` (2026-10-28 ruling).
|
|
729
|
+
* The Op's own job keeps its scopes.
|
|
730
|
+
*/
|
|
731
|
+
function updateJob(
|
|
732
|
+
after: string,
|
|
733
|
+
update: NonNullable<ScheduledOpSpec["update"]>,
|
|
734
|
+
i: { image: string; runner?: ScheduledOpSpec["runner"]; setup: Array<Record<string, unknown>>; beforeScript: readonly string[] },
|
|
735
|
+
): Record<string, unknown> {
|
|
736
|
+
const later: Array<Record<string, unknown>> = [...i.setup, ...runSteps(i.beforeScript), { name: "Update what drifted", run: update.command }];
|
|
737
|
+
return {
|
|
738
|
+
needs: after,
|
|
739
|
+
if: `always() && needs.${after}.outputs.drift == 'true'`,
|
|
740
|
+
"runs-on": githubRunsOn(i.runner),
|
|
741
|
+
container: i.image,
|
|
742
|
+
permissions: { contents: "read", ...(update.cloud ? { "id-token": "write" } : {}), ...(update.permissions ?? {}) },
|
|
743
|
+
...nonEmpty("env", update.variables),
|
|
744
|
+
...(update.environment ? { environment: environmentDoc(update.environment) } : {}),
|
|
745
|
+
steps: update.cloud ? [tradeStep(update.cloud), checkoutStep(), ...later.map(withoutTokenRequest)] : [checkoutStep(), ...later],
|
|
746
|
+
};
|
|
747
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #4630: a pr-review wave's plans workflow posts `<product>/approval` on the
|
|
3
|
+
* head after the plans are recorded and on every review, with a token that
|
|
4
|
+
* reads and writes statuses only.
|
|
5
|
+
*/
|
|
6
|
+
import { describe, expect, test } from "vitest";
|
|
7
|
+
import type { OpWavesSpec } from "@intentius/chant/op/op-waves";
|
|
8
|
+
import { generateGithubOpWavesPipeline } from "./generate-op-waves-pipeline";
|
|
9
|
+
|
|
10
|
+
const spec: OpWavesSpec = {
|
|
11
|
+
name: "plywood-up",
|
|
12
|
+
op: "plywood-up",
|
|
13
|
+
plan: ["./bin/plan", "{target}", "{plan}"],
|
|
14
|
+
waves: [{ name: "prod", runs: [{ target: "app" }], approval: "pr-review" }],
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
describe("the approval status job (#4630)", () => {
|
|
18
|
+
test("runs after record-plans on a push and alone on a review, with a read token", () => {
|
|
19
|
+
const { files } = generateGithubOpWavesPipeline(spec, { specFile: ".chant/op-waves/plywood-up.spec.json", bin: "npx --no-install plywood" });
|
|
20
|
+
const plans = files.find((f) => f.name === "plywood-up-plans.yml")!.yaml;
|
|
21
|
+
expect(plans).toContain("pull_request_review:");
|
|
22
|
+
expect(plans).toMatch(/types:\n\s+- submitted\n\s+- dismissed/);
|
|
23
|
+
expect(plans).toContain("github.event_name == 'pull_request'");
|
|
24
|
+
expect(plans).toMatch(/approval-status:\n(.*\n)*?\s+needs:\n\s+- record-plans/);
|
|
25
|
+
expect(plans).toMatch(/permissions:\n\s+contents: read\n\s+pull-requests: read\n\s+statuses: write/);
|
|
26
|
+
expect(plans).toContain('npx --no-install plywood run wave --spec .chant/op-waves/plywood-up.spec.json --approval-status --pr "$PR_NUMBER" --head "$HEAD_SHA" --base "$BASE_SHA" --forge github');
|
|
27
|
+
expect(plans).toContain("HEAD_SHA: '${{ github.event.pull_request.head.sha }}'");
|
|
28
|
+
expect(plans).toContain("BASE_SHA: '${{ github.event.pull_request.base.sha }}'");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("a spec with no pr-review wave has no plans workflow", () => {
|
|
32
|
+
const { files } = generateGithubOpWavesPipeline({ ...spec, waves: [{ name: "prod", runs: [{ target: "app" }] }] }, { specFile: "waves.json" });
|
|
33
|
+
expect(files.map((f) => f.name)).toEqual(["plywood-up.yml"]);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("a pull-request wave gets the plans workflow and the status too (2026-10-28 ruling)", () => {
|
|
37
|
+
const pullRequest: OpWavesSpec = { ...spec, waves: [{ name: "prod", runs: [{ target: "app" }], approval: "pull-request" }], approvals: { pullRequest: { gate: "up" } } };
|
|
38
|
+
const { files } = generateGithubOpWavesPipeline(pullRequest, { specFile: "waves.json", bin: "npx --no-install plywood" });
|
|
39
|
+
expect(files.map((f) => f.name)).toEqual(["plywood-up.yml", "plywood-up-plans.yml"]);
|
|
40
|
+
const plans = files.find((f) => f.name === "plywood-up-plans.yml")!.yaml;
|
|
41
|
+
expect(plans).toContain("npx --no-install plywood run wave --spec waves.json --record-plans");
|
|
42
|
+
expect(plans).toContain("--approval-status --pr \"$PR_NUMBER\"");
|
|
43
|
+
// The waves read no review, so they ask for no pull-requests scope.
|
|
44
|
+
expect(files[0]!.yaml).not.toContain("pull-requests: read");
|
|
45
|
+
});
|
|
46
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The apply-job guard in GitHub op-wave jobs (#4624): the first step of
|
|
3
|
+
* every wave, deciding and share job, before the trade and the checkout,
|
|
4
|
+
* carrying the job's forge environment, branches and runner. Written for
|
|
5
|
+
* the ci-products post-merge pass.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, expect, test } from "vitest";
|
|
9
|
+
import type { OpWavesSpec } from "@intentius/chant/op/op-waves";
|
|
10
|
+
import { JOB_GUARD_ENV, jobGuardLine } from "@intentius/chant/ci-job-guard";
|
|
11
|
+
import { buildGithubOpWavesDoc } from "./generate-op-waves-pipeline";
|
|
12
|
+
|
|
13
|
+
const spec: OpWavesSpec = {
|
|
14
|
+
name: "deploy",
|
|
15
|
+
op: "pulumi-up",
|
|
16
|
+
plan: ["./bin/plan", "{target}", "{plan}"],
|
|
17
|
+
branches: ["main", "release"],
|
|
18
|
+
runner: ["self-hosted", "linux"],
|
|
19
|
+
waves: [
|
|
20
|
+
{ name: "dev", runs: [{ target: "dev" }], gate: "never" },
|
|
21
|
+
{ name: "prod", runs: [{ target: "prod" }], environment: { name: "production" }, idTokens: { CLOUD_TOKEN: "sts.amazonaws.com" } },
|
|
22
|
+
{ name: "wide", runs: [{ target: "a" }, { target: "b" }], shares: 2, environment: { name: "production" } },
|
|
23
|
+
],
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
type Job = { steps: Array<{ name?: string; env?: Record<string, string>; run?: string; uses?: string }> };
|
|
27
|
+
const jobsOf = (s: OpWavesSpec, words?: { product: string }) => buildGithubOpWavesDoc(s, { specFile: "waves.json", ...(words ? { words } : {}) }).doc.jobsDoc as Record<string, Job>;
|
|
28
|
+
const guardOf = (job: Job) => JSON.parse(job.steps[0]!.env![JOB_GUARD_ENV]!) as Record<string, unknown>;
|
|
29
|
+
|
|
30
|
+
describe("the apply-job guard on GitHub (#4624)", () => {
|
|
31
|
+
test("every wave job starts with it, before the checkout", () => {
|
|
32
|
+
const jobs = jobsOf(spec);
|
|
33
|
+
for (const [name, job] of Object.entries(jobs)) {
|
|
34
|
+
expect(job.steps[0]!.run, name).toBe(jobGuardLine());
|
|
35
|
+
expect(job.steps[1]!.uses, name).toMatch(/checkout/);
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("carries the branches, the runner and, where it can read it, the environment", () => {
|
|
40
|
+
const jobs = jobsOf(spec);
|
|
41
|
+
expect(guardOf(jobs["wave-1-dev"]!)).toEqual({ forge: "github", job: "wave 1 (dev) of deploy", branches: ["main", "release"], selfHosted: true });
|
|
42
|
+
// Its id tokens give the job id-token: write, so the guard reads the environment from a token.
|
|
43
|
+
expect(guardOf(jobs["wave-2-prod"]!)).toMatchObject({ forgeEnvironment: "production" });
|
|
44
|
+
// A job that may request no token cannot be checked for its environment on GitHub.
|
|
45
|
+
expect(guardOf(jobs["wave-3-wide-decide"]!)).not.toHaveProperty("forgeEnvironment");
|
|
46
|
+
expect(guardOf(jobs["wave-3-wide-share-2"]!)).toMatchObject({ job: "share 2 of wave 3 (wide) of deploy" });
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("names the product", () => {
|
|
50
|
+
expect(guardOf(jobsOf(spec, { product: "plywood" })["wave-1-dev"]!)).toMatchObject({ product: "plywood" });
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("the default branch when the spec names none", () => {
|
|
54
|
+
const { branches: _b, ...plain } = spec;
|
|
55
|
+
expect(guardOf(jobsOf(plain)["wave-1-dev"]!)).toMatchObject({ branches: ["main"] });
|
|
56
|
+
});
|
|
57
|
+
});
|
|
@@ -57,7 +57,7 @@ interface Job {
|
|
|
57
57
|
}
|
|
58
58
|
|
|
59
59
|
const jobsOf = (spec: OpWavesSpec): Record<string, Job> => (parseYAML(generateGithubOpWavesPipeline(spec, { specFile: ".chant/op-waves/tf-apply.spec.json", beforeScript: ["npm ci"] }).files[0]!.yaml) as { jobs: Record<string, Job> }).jobs;
|
|
60
|
-
const tradeOf = (job: Job): unknown => JSON.parse(job.steps[
|
|
60
|
+
const tradeOf = (job: Job): unknown => JSON.parse(job.steps[1]!.env![TRADE_SPEC_ENV]!);
|
|
61
61
|
const UNSET = `unset ${OIDC_REQUEST_VARS.join(" ")}`;
|
|
62
62
|
|
|
63
63
|
describe("an Op wave's jobs by cloud identity (#4332)", () => {
|
|
@@ -70,9 +70,9 @@ describe("an Op wave's jobs by cloud identity (#4332)", () => {
|
|
|
70
70
|
|
|
71
71
|
test("the deciding job trades for every plan identity, before the checkout, and names no apply identity", () => {
|
|
72
72
|
const decide = jobs["wave-1-wave-1-decide"]!;
|
|
73
|
-
expect(decide.steps[
|
|
74
|
-
expect(decide.steps[
|
|
75
|
-
expect(decide.steps[
|
|
73
|
+
expect(decide.steps[1]!.name).toBe("Trade the job's OIDC token for its cloud identities");
|
|
74
|
+
expect(decide.steps[1]!.run).toMatch(/^\(cd \/ && node -e '/);
|
|
75
|
+
expect(decide.steps[2]!.uses).toMatch(/^actions\/checkout@/);
|
|
76
76
|
expect(tradeOf(decide)).toEqual(plan);
|
|
77
77
|
expect(decide.permissions).toEqual({ contents: "write", "id-token": "write" });
|
|
78
78
|
for (const id of [role("prod-apply"), role("staging-apply"), role("own-apply")]) expect(JSON.stringify(decide)).not.toContain(id);
|
|
@@ -83,14 +83,14 @@ describe("an Op wave's jobs by cloud identity (#4332)", () => {
|
|
|
83
83
|
for (const [name, id] of Object.entries(want)) {
|
|
84
84
|
const job = jobs[name]!;
|
|
85
85
|
expect(tradeIdentities(tradeOf(job) as never)).toEqual([id]);
|
|
86
|
-
expect(job.steps[
|
|
86
|
+
expect(job.steps[2]!.uses).toMatch(/^actions\/checkout@/);
|
|
87
87
|
for (const other of Object.values(want).filter((o) => o !== id)) expect(JSON.stringify(job)).not.toContain(other);
|
|
88
88
|
}
|
|
89
89
|
});
|
|
90
90
|
|
|
91
91
|
test("every run step after the trade starts by unsetting the token request variables", () => {
|
|
92
92
|
for (const job of Object.values(jobs)) {
|
|
93
|
-
const runs = job.steps.slice(
|
|
93
|
+
const runs = job.steps.slice(2).filter((s) => s.run !== undefined);
|
|
94
94
|
expect(runs.length).toBeGreaterThan(0);
|
|
95
95
|
for (const s of runs) expect(s.run!.split("\n")[0]).toBe(UNSET);
|
|
96
96
|
}
|
|
@@ -124,8 +124,8 @@ describe("the deciding job, run step by step against the emulator (#4332)", () =
|
|
|
124
124
|
const githubEnv = join(temp, "github-env");
|
|
125
125
|
writeFileSync(githubEnv, "");
|
|
126
126
|
const base = { PATH: process.env.PATH ?? "/usr/bin:/bin", HOME: process.env.HOME ?? "/tmp", RUNNER_TEMP: temp, ...emu.requestEnv };
|
|
127
|
-
const spec = emu.pointAt(JSON.parse(decide.steps[
|
|
128
|
-
await run("sh", ["-c", decide.steps[
|
|
127
|
+
const spec = emu.pointAt(JSON.parse(decide.steps[1]!.env![TRADE_SPEC_ENV]!));
|
|
128
|
+
await run("sh", ["-c", decide.steps[1]!.run!.replace("$GITHUB_ENV", githubEnv)], { env: { ...base, [TRADE_SPEC_ENV]: JSON.stringify(spec) } });
|
|
129
129
|
const traded = Object.fromEntries(readFileSync(githubEnv, "utf-8").split("\n").filter(Boolean).map((l) => [l.slice(0, l.indexOf("=")), l.slice(l.indexOf("=") + 1)]));
|
|
130
130
|
expect(traded.AWS_ACCESS_KEY_ID).toBe(emulatedCredentials(role("own-plan")).accessKeyId);
|
|
131
131
|
const before = emu.log.minted.length;
|
|
@@ -34,6 +34,8 @@
|
|
|
34
34
|
* `pull-requests: read`, so a wave can read the merged pull request's
|
|
35
35
|
* reviews, and a second workflow, `<name>-plans.yml`, runs `chant run wave
|
|
36
36
|
* --record-plans` on each pull request to record the digests its head plans.
|
|
37
|
+
* A `pull-request` wave (#4630) gets the plans workflow too, for the approval
|
|
38
|
+
* status before merge; it asks for no `pull-requests: read` on the waves.
|
|
37
39
|
*
|
|
38
40
|
* With `notify` on the spec (#3761), the workflow maps each variable it names
|
|
39
41
|
* from the repository secret of the same name, so a wave job can post to
|
|
@@ -62,6 +64,15 @@
|
|
|
62
64
|
* step holds the control token: the `tokenSecret` secret, mapped into
|
|
63
65
|
* `CHANT_CONTROL_TOKEN` and handed to curl on its stdin
|
|
64
66
|
* (`controlDispatchScript`, #4568).
|
|
67
|
+
*
|
|
68
|
+
* Every wave, deciding and share job starts with the apply-job guard
|
|
69
|
+
* (#4624, `@intentius/chant/ci-job-guard`), before the trade and the
|
|
70
|
+
* checkout: it stops the job unless `GITHUB_REF` is one of the spec's
|
|
71
|
+
* branches, the job runs in its forge environment (read from an OIDC
|
|
72
|
+
* token's claims, so only in a job that may request one), and, for a
|
|
73
|
+
* runner whose labels include `self-hosted`, on a self-hosted runner. The
|
|
74
|
+
* forgejo generator passes `forge: "forgejo"`, which checks the ref and
|
|
75
|
+
* runner only.
|
|
65
76
|
*/
|
|
66
77
|
|
|
67
78
|
import {
|
|
@@ -71,6 +82,8 @@ import {
|
|
|
71
82
|
opWaveOutcomeDir,
|
|
72
83
|
opWavesControlTarget,
|
|
73
84
|
opWavesLastJobs,
|
|
85
|
+
opWavesApprovalStatusCommand,
|
|
86
|
+
opWavesPostsApprovalStatus,
|
|
74
87
|
opWavesOutcomeCommand,
|
|
75
88
|
opWavesRecordPlansCommand,
|
|
76
89
|
opWavesResumeCommand,
|
|
@@ -93,6 +106,8 @@ import {
|
|
|
93
106
|
setupStepDoc,
|
|
94
107
|
} from "@intentius/chant/ci-jobs";
|
|
95
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";
|
|
96
111
|
import { checkoutStep, concurrency, DOWNLOAD_ARTIFACT, downloadArtifactStep, keepRecordStep, runSteps } from "./ci-jobs";
|
|
97
112
|
import { emitOpPipelineYAML, type GithubOpPipelineDoc } from "./generate-op-pipeline";
|
|
98
113
|
|
|
@@ -113,10 +128,15 @@ export function opWaveOutcomeArtifact(spec: Pick<OpWavesSpec, "name">, jobName:
|
|
|
113
128
|
export function buildGithubOpWavesDoc(
|
|
114
129
|
spec: OpWavesSpec,
|
|
115
130
|
options: OpWavesPipelineOptions,
|
|
131
|
+
dialect: { forge?: "github" | "forgejo" } = {},
|
|
116
132
|
): { doc: GithubOpPipelineDoc; jobs: OpWavesPipelineResult["jobs"]; resumeDoc?: GithubOpPipelineDoc; plansDoc?: GithubOpPipelineDoc } {
|
|
117
133
|
const jobs = opWaveJobs(spec, options.specFile, { bin: options.bin });
|
|
134
|
+
const forge = dialect.forge ?? "github";
|
|
135
|
+
const product = resolveProductWords(options.words ?? {}).product;
|
|
118
136
|
const image = options.image ?? OP_WAVES_IMAGE;
|
|
119
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);
|
|
120
140
|
for (const wave of spec.waves) {
|
|
121
141
|
assertSetupSteps(`${spec.name}/${wave.name}`, wave.setup ?? []);
|
|
122
142
|
if (wave.environment) assertEnvironment(`${spec.name}/${wave.name}`, wave.environment);
|
|
@@ -134,7 +154,21 @@ export function buildGithubOpWavesDoc(
|
|
|
134
154
|
const group = opWaveGroupOf(spec, job);
|
|
135
155
|
// #4332: the job's cloud identities, traded before the checkout.
|
|
136
156
|
const cloud = group?.cloud ?? wave.cloud;
|
|
137
|
-
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)];
|
|
138
172
|
if (job.kind === "share") {
|
|
139
173
|
steps.push(downloadArtifactStep(`Download wave ${job.wave}'s decision`, opWaveRecordArtifact(spec, job.wave), dirnameOf(job.record!)));
|
|
140
174
|
}
|
|
@@ -151,11 +185,8 @@ export function buildGithubOpWavesDoc(
|
|
|
151
185
|
steps.push(keepRecordStep(`Keep wave ${job.wave}'s decision`, opWaveRecordArtifact(spec, job.wave), job.record!));
|
|
152
186
|
}
|
|
153
187
|
steps.push(keepRecordStep(`Keep the job's outcome`, opWaveOutcomeArtifact(spec, job.jobName), job.outcome));
|
|
154
|
-
const environment = group?.environment ?? wave.environment;
|
|
155
|
-
const idTokens = group?.idTokens ?? wave.idTokens;
|
|
156
|
-
const idToken = idTokens !== undefined || cloud !== undefined;
|
|
157
188
|
jobsDoc[job.jobName] = {
|
|
158
|
-
"runs-on": githubRunsOn(
|
|
189
|
+
"runs-on": githubRunsOn(runner),
|
|
159
190
|
...(job.needs.length > 0 ? { needs: job.needs } : {}),
|
|
160
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.
|
|
161
192
|
...(job.needs.length > 0 && job.kind !== "share" ? { if: "!cancelled()" } : {}),
|
|
@@ -188,7 +219,7 @@ export function buildGithubOpWavesDoc(
|
|
|
188
219
|
doc,
|
|
189
220
|
jobs,
|
|
190
221
|
...(spec.resume ? { resumeDoc: buildResumeDoc(spec, options) } : {}),
|
|
191
|
-
...(
|
|
222
|
+
...(statusBeforeMerge ? { plansDoc: buildPlansDoc(spec, options, forge) } : {}),
|
|
192
223
|
};
|
|
193
224
|
}
|
|
194
225
|
|
|
@@ -256,21 +287,55 @@ function buildResumeDoc(spec: OpWavesSpec, options: OpWavesPipelineOptions): Git
|
|
|
256
287
|
};
|
|
257
288
|
}
|
|
258
289
|
|
|
259
|
-
/**
|
|
260
|
-
|
|
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 {
|
|
261
299
|
const steps: Array<Record<string, unknown>> = [checkoutStep(true), ...(spec.waves[0]?.setup ?? []).map(setupStepDoc), ...runSteps(options.beforeScript ?? [])];
|
|
262
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
|
+
});
|
|
263
312
|
return {
|
|
264
313
|
name: `${spec.name}-plans`,
|
|
265
|
-
on: { pull_request: { branches: pushBranches(spec.branches) } },
|
|
314
|
+
on: { pull_request: { branches: pushBranches(spec.branches) }, pull_request_review: { types: ["submitted", "dismissed"] } },
|
|
266
315
|
...nonEmpty("env", options.variables),
|
|
267
|
-
concurrency: concurrency(spec.name + "-plans-${{ github.event.number }}", true),
|
|
316
|
+
concurrency: concurrency(spec.name + "-plans-${{ github.event_name }}-${{ github.event.pull_request.number }}", true),
|
|
268
317
|
// contents: write pushes the record to chant/lifecycle.
|
|
269
318
|
permissions: { contents: "write" },
|
|
270
|
-
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
|
+
},
|
|
271
331
|
};
|
|
272
332
|
}
|
|
273
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
|
+
|
|
274
339
|
/** The step that trades the job's OIDC token for `cloud` (#4332), writing the credentials to GITHUB_ENV. */
|
|
275
340
|
function tradeStep(cloud: CloudTradeSpec): Record<string, unknown> {
|
|
276
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") };
|