@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.
@@ -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 steps: Array<Record<string, unknown>> = [checkoutStep(), ...setup.map(setupStepDoc), ...runSteps(beforeScript)];
507
- steps.push(
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
- : { run: invocation, env: stepEnv },
519
+ : update
520
+ ? driftFlagStep(invocation, stepEnv)
521
+ : { run: invocation, env: stepEnv },
513
522
  );
514
- steps.push(...runSteps(extraScript));
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: mergePermissions(
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[0]!.env![TRADE_SPEC_ENV]!);
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[0]!.name).toBe("Trade the job's OIDC token for its cloud identities");
74
- expect(decide.steps[0]!.run).toMatch(/^\(cd \/ && node -e '/);
75
- expect(decide.steps[1]!.uses).toMatch(/^actions\/checkout@/);
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[1]!.uses).toMatch(/^actions\/checkout@/);
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(1).filter((s) => s.run !== undefined);
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[0]!.env![TRADE_SPEC_ENV]!));
128
- await run("sh", ["-c", decide.steps[0]!.run!.replace("$GITHUB_ENV", githubEnv)], { env: { ...base, [TRADE_SPEC_ENV]: JSON.stringify(spec) } });
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 steps: Array<Record<string, unknown>> = [...(cloud ? [tradeStep(cloud)] : []), checkoutStep(true)];
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(group?.runner ?? wave.runner ?? spec.runner),
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
- ...(byReview ? { plansDoc: buildPlansDoc(spec, options) } : {}),
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
- /** The pull request workflow a `pr-review` wave needs (#3684): record the digests each head plans. */
260
- function buildPlansDoc(spec: OpWavesSpec, options: OpWavesPipelineOptions): GithubOpPipelineDoc {
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: { "record-plans": { "runs-on": githubRunsOn(spec.runner), container: options.image ?? OP_WAVES_IMAGE, steps } },
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") };