@intentius/chant-lexicon-github 0.57.0 → 0.59.0

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.
@@ -4,27 +4,50 @@
4
4
  * The Op counterpart to `./generate-pipeline.ts` (#891): that module
5
5
  * synthesizes a `workflow_dispatch`-triggered pipeline from a deploy-time
6
6
  * component graph, this one synthesizes a cron-triggered workflow per
7
- * stateless Op the CI-native alternative to a Temporal `TemporalSchedule`
8
- * for downstream projects that don't run Temporal (`WorkflowAuditOp`,
9
- * `PipelineAuditOp`, `ReconcileOp`, all accept an optional `schedule`
10
- * precisely for this).
7
+ * stateless Op. An Op's cadence is an `OpSchedule` on the Op itself
8
+ * (`packages/core/src/op/types.ts`), runtime-neutral data each reader
9
+ * interprets; this module is the reader that turns it into a cron a GitHub
10
+ * runner fires (`WorkflowAuditOp`, `PipelineAuditOp`, `ReconcileOp`, … all
11
+ * accept an optional `schedule` precisely for this).
11
12
  *
12
13
  * GitHub Actions' `on.schedule` is workflow-scoped, not job-scoped, so unlike
13
14
  * the component generator (one combined pipeline for the whole graph) this
14
15
  * emits one workflow file per `ScheduledOpSpec`. Each workflow:
15
- * - triggers on `schedule` (the Op's cron) and `workflow_dispatch` (manual
16
- * runs stay available for testing/dry-runs);
17
- * - declares only the `permissions:` its `findingMode` needs `report`
18
- * stays read-only, `issue`/`pull-request` add the write scope the Op's own
19
- * activity uses (`gh issue create` / `gh pr create`, see
20
- * `@intentius/chant-lexicon-temporal`'s `reconcilePr` activity) — never a
21
- * blanket `write-all`;
16
+ * - triggers on its `ScheduledOpSpec`'s trigger (#2084): `cron` (the Op's
17
+ * schedule, plus `workflow_dispatch` so manual runs stay available for
18
+ * testing/dry-runs), `pull_request` (optionally filtered to `branches`,
19
+ * no `workflow_dispatch` a PR event needs no manual escape hatch), or
20
+ * `push` (filtered to `branches`, defaulting to the repository's default
21
+ * branch);
22
+ * - declares only the `permissions:` its `findingMode` (and, for a
23
+ * `pull_request` trigger, whether that mode posts a comment) needs —
24
+ * `report` stays read-only, `issue`/`comment`/`pull-request` add the write
25
+ * scope the Op's own activity uses (`gh issue create` / a comment on the
26
+ * triggering PR / `gh pr create`, see `@intentius/chant/op`'s
27
+ * `reconcilePr` activity) — never a blanket `write-all`. `comment` is the
28
+ * one mode that constrains the trigger rather than only the scope: it
29
+ * needs a pull request to post onto, so this generator refuses it by name
30
+ * on any other trigger (#2231);
22
31
  * - runs exactly one invocation, `chant run <name>` by default — never
23
32
  * inlined audit/reconcile logic. The finding-mode itself is already baked
24
33
  * into the Op's own activity args at build time by the composite that
25
- * created it; this workflow only supplies the token the mode needs to act.
34
+ * created it; this workflow only supplies the token the mode needs to act;
35
+ * - on a `push` trigger only, runs that invocation with `--gated-exit 0`
36
+ * and adds a follow-up job that says where the approval is pending
37
+ * (#2243). See {@link GATED_EXIT_FLAG} and {@link gateNoticeJob}.
38
+ *
39
+ * Two per-Op options widen that shape without loosening it (#2242). A spec's
40
+ * `setup` list emits steps between the checkout and the `beforeScript` lines,
41
+ * `uses:` steps included, which is the only way a generated job can reach an
42
+ * action like `aws-actions/configure-aws-credentials`; {@link
43
+ * assertSetupSteps} refuses an unpinned or default-branch ref at build time.
44
+ * A spec's `permissions` map is merged over {@link permissionsFor}, adding
45
+ * scopes the finding-mode never grants (`id-token: write` is the whole
46
+ * reason) and never touching one it does; {@link mergePermissions} refuses a
47
+ * blanket grant, an overlap with the mode's own set, an unknown scope name,
48
+ * and pull-request write on a trigger that has no pull request.
26
49
  */
27
- import type { ComponentPipelineOptions as GenerateGithubOpOptions, OpPipelineJob, OpPipelineResult as GenerateGithubOpResult, ScheduledOpSpec } from "@intentius/chant/lexicon";
50
+ import type { ComponentPipelineOptions as GenerateGithubOpOptions, OpPipelineJob, OpPipelineResult as GenerateGithubOpResult, OpSetupStep, OpTrigger, ScheduledOpSpec } from "@intentius/chant/lexicon";
28
51
  export type { GenerateGithubOpOptions, GenerateGithubOpResult };
29
52
  /**
30
53
  * The structured pipeline document behind one generated file, before YAML
@@ -34,7 +57,11 @@ export type { GenerateGithubOpOptions, GenerateGithubOpResult };
34
57
  * `./generate-pipeline.ts`'s `GithubPipelineDoc` split.
35
58
  */
36
59
  export interface GithubOpPipelineDoc {
37
- /** The `on:` trigger mapping (`schedule` + `workflow_dispatch`). */
60
+ /**
61
+ * The `on:` trigger mapping, per {@link ScheduledOpSpec}'s trigger kind
62
+ * (#2084): `{ schedule, workflow_dispatch }` for cron, `{ pull_request }`
63
+ * for `pull_request`, `{ push }` for `push`.
64
+ */
38
65
  on: Record<string, unknown>;
39
66
  /** The `env:` mapping, when `options.variables` is set. */
40
67
  env?: Record<string, unknown>;
@@ -48,6 +75,15 @@ export interface GithubOpPipelineDoc {
48
75
  permissions: Record<string, unknown>;
49
76
  /** The `jobs:` mapping — one entry, this Op's trigger job. */
50
77
  jobsDoc: Record<string, unknown>;
78
+ /**
79
+ * The gated-apply notice job (#2243), when this Op's trigger is `push`.
80
+ * Kept out of {@link jobsDoc} so a dialect that cannot run it drops it by
81
+ * simply not copying it: the job shells to `gh` against the GitHub API and
82
+ * needs `gh` on the runner, which is the same reason the `comment` finding
83
+ * mode is refused on forgejo and gitlab (#2231). {@link emitOpPipelineYAML}
84
+ * merges it into `jobs:` for the forges that can.
85
+ */
86
+ gatedNoticeDoc?: Record<string, unknown>;
51
87
  }
52
88
  /** One generated file: a suggested name plus its pipeline document, pre-emission. */
53
89
  export interface GithubOpPipelineFile {
@@ -56,10 +92,48 @@ export interface GithubOpPipelineFile {
56
92
  doc: GithubOpPipelineDoc;
57
93
  }
58
94
  /**
59
- * Build one `GithubOpPipelineDoc` per scheduled Op: cron trigger,
60
- * least-privilege `permissions:` for its finding-mode, one job that runs
61
- * `chant run <name>`. Throws nothing every `ScheduledOpSpec` is independent,
62
- * unlike the component generator there is no shared graph to resolve.
95
+ * Validate a spec's `setup` list (#2242). A `run` entry needs a non-empty
96
+ * line and nothing else. A `uses` entry has to be a pinned
97
+ * `owner/repo[/subpath]@ref`: no bare `owner/repo`, since an unpinned action
98
+ * resolves to its default branch, and no ref in {@link DEFAULT_BRANCH_REFS}
99
+ * for the same reason spelled out loud. Local (`./path`) and container
100
+ * (`docker://`) refs are refused too — they are legal GitHub Actions, but the
101
+ * generator emits a workflow into a repository it has never seen, so it
102
+ * cannot know a local path resolves there.
103
+ */
104
+ export declare function assertSetupSteps(name: string, setup: OpSetupStep[]): void;
105
+ /**
106
+ * Merge a spec's additive `permissions` over the finding-mode's own set
107
+ * (#2242), refusing by name anything that is not strictly additive:
108
+ *
109
+ * - a blanket `write-all`/`read-all`, in either the key or the value
110
+ * position, which is the exact thing {@link permissionsForMode} exists to
111
+ * avoid;
112
+ * - a scope GitHub does not define ({@link GITHUB_TOKEN_SCOPES}), because
113
+ * GitHub ignores the key and the run silently gets nothing;
114
+ * - a scope the mode already grants, at any value — additive means additive,
115
+ * so this can neither downgrade `contents: write` to read nor restate it.
116
+ * A mode whose set is wrong is fixed by changing the mode, where the
117
+ * scope and the behavior that spends it stay together;
118
+ * - `pull-requests: write` on a trigger with no pull request. Pull-request
119
+ * access is what the finding-modes own: `pull-request` grants it together
120
+ * with the `contents: write` needed to push the branch first, and
121
+ * `comment` grants it on the one trigger that carries a pull request to
122
+ * comment on. Adding it beside a mode that posts nothing, on a cron or
123
+ * push run, grants write access no step in the generated job can spend.
124
+ */
125
+ export declare function mergePermissions(name: string, base: Record<string, "read" | "write">, additive: Record<string, "read" | "write">, trigger: OpTrigger): Record<string, "read" | "write">;
126
+ /**
127
+ * Build one `GithubOpPipelineDoc` per scheduled Op: its trigger, its `setup`
128
+ * steps, least-privilege `permissions:` for its finding-mode plus whatever
129
+ * the spec adds, one job that runs `chant run <name>`. Every
130
+ * `ScheduledOpSpec` is independent — unlike the component generator there is
131
+ * no shared graph to resolve — so the only thing this refuses is a spec that
132
+ * contradicts itself: no trigger at all (`resolveOpTrigger`), `findingMode:
133
+ * "comment"` on a trigger that has no pull request ({@link
134
+ * assertTriggerSupportsMode}), an unpinned `setup` action ({@link
135
+ * assertSetupSteps}), or a `permissions` entry that is not additive ({@link
136
+ * mergePermissions}).
63
137
  */
64
138
  export declare function buildGithubOpPipelineDocs(ops: ScheduledOpSpec[], options?: GenerateGithubOpOptions): {
65
139
  files: GithubOpPipelineFile[];
@@ -1 +1 @@
1
- {"version":3,"file":"generate-op-pipeline.d.ts","sourceRoot":"","sources":["../../src/components/generate-op-pipeline.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AAGH,OAAO,KAAK,EACV,wBAAwB,IAAI,uBAAuB,EAEnD,aAAa,EACb,gBAAgB,IAAI,sBAAsB,EAC1C,eAAe,EAChB,MAAM,0BAA0B,CAAC;AAElC,YAAY,EAAE,uBAAuB,EAAE,sBAAsB,EAAE,CAAC;AAEhE;;;;;;GAMG;AACH,MAAM,WAAW,mBAAmB;IAClC,oEAAoE;IACpE,EAAE,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC5B,2DAA2D;IAC3D,GAAG,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC9B,6DAA6D;IAC7D,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACrC;;;;OAIG;IACH,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACrC,8DAA8D;IAC9D,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CAClC;AAED,qFAAqF;AACrF,MAAM,WAAW,oBAAoB;IACnC,4FAA4F;IAC5F,IAAI,EAAE,MAAM,CAAC;IACb,GAAG,EAAE,mBAAmB,CAAC;CAC1B;AA4BD;;;;;GAKG;AACH,wBAAgB,yBAAyB,CACvC,GAAG,EAAE,eAAe,EAAE,EACtB,OAAO,GAAE,uBAA4B,GACpC;IAAE,KAAK,EAAE,oBAAoB,EAAE,CAAC;IAAC,IAAI,EAAE,aAAa,EAAE,CAAA;CAAE,CA8C1D;AAED;;;;GAIG;AACH,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,mBAAmB,GAAG,MAAM,CAQnE;AAED;;;;;GAKG;AACH,wBAAgB,wBAAwB,CACtC,GAAG,EAAE,eAAe,EAAE,EACtB,OAAO,GAAE,uBAA4B,GACpC,sBAAsB,CAMxB"}
1
+ {"version":3,"file":"generate-op-pipeline.d.ts","sourceRoot":"","sources":["../../src/components/generate-op-pipeline.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgDG;AAIH,OAAO,KAAK,EACV,wBAAwB,IAAI,uBAAuB,EAEnD,aAAa,EACb,gBAAgB,IAAI,sBAAsB,EAC1C,WAAW,EACX,SAAS,EACT,eAAe,EAChB,MAAM,0BAA0B,CAAC;AAElC,YAAY,EAAE,uBAAuB,EAAE,sBAAsB,EAAE,CAAC;AAEhE;;;;;;GAMG;AACH,MAAM,WAAW,mBAAmB;IAClC;;;;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,6DAA6D;IAC7D,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACrC;;;;OAIG;IACH,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACrC,8DAA8D;IAC9D,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACjC;;;;;;;OAOG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CAC1C;AAED,qFAAqF;AACrF,MAAM,WAAW,oBAAoB;IACnC,4FAA4F;IAC5F,IAAI,EAAE,MAAM,CAAC;IACb,GAAG,EAAE,mBAAmB,CAAC;CAC1B;AAsQD;;;;;;;;;GASG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,WAAW,EAAE,GAAG,IAAI,CA8BzE;AAED;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,gBAAgB,CAC9B,IAAI,EAAE,MAAM,EACZ,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,EACtC,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,EAC1C,OAAO,EAAE,SAAS,GACjB,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,CAqClC;AAiBD;;;;;;;;;;;GAWG;AACH,wBAAgB,yBAAyB,CACvC,GAAG,EAAE,eAAe,EAAE,EACtB,OAAO,GAAE,uBAA4B,GACpC;IAAE,KAAK,EAAE,oBAAoB,EAAE,CAAC;IAAC,IAAI,EAAE,aAAa,EAAE,CAAA;CAAE,CAiF1D;AAED;;;;GAIG;AACH,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,mBAAmB,GAAG,MAAM,CAWnE;AAED;;;;;GAKG;AACH,wBAAgB,wBAAwB,CACtC,GAAG,EAAE,eAAe,EAAE,EACtB,OAAO,GAAE,uBAA4B,GACpC,sBAAsB,CAMxB"}
@@ -1 +1 @@
1
- {"version":3,"file":"pr-plan-report.d.ts","sourceRoot":"","sources":["../../src/composites/pr-plan-report.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,GAAG,EAAqB,MAAM,oBAAoB,CAAC;AAI5D,MAAM,WAAW,iBAAiB;IAChC;;;OAGG;IACH,WAAW,EAAE,MAAM,CAAC;IACpB,4EAA4E;IAC5E,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,kEAAkE;IAClE,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,+CAA+C;IAC/C,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,iEAAiE;IACjE,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,+DAA+D;IAC/D,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB;;;OAGG;IACH,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;IAClB;;;;;;OAMG;IACH,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB;;;;;OAKG;IACH,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,6DAA6D;IAC7D,QAAQ,CAAC,EAAE;QACT,GAAG,CAAC,EAAE,OAAO,CAAC,qBAAqB,CAAC,OAAO,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;KACrD,CAAC;CACH;AAoBD;;;;;;;;;;;;;;;;;GAiBG;AACH,eAAO,MAAM,YAAY;;EAkEP,CAAC"}
1
+ {"version":3,"file":"pr-plan-report.d.ts","sourceRoot":"","sources":["../../src/composites/pr-plan-report.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,GAAG,EAAqB,MAAM,oBAAoB,CAAC;AAI5D,MAAM,WAAW,iBAAiB;IAChC;;;OAGG;IACH,WAAW,EAAE,MAAM,CAAC;IACpB,4EAA4E;IAC5E,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,kEAAkE;IAClE,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,+CAA+C;IAC/C,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,iEAAiE;IACjE,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,+DAA+D;IAC/D,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB;;;OAGG;IACH,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;IAClB;;;;;;OAMG;IACH,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB;;;;;OAKG;IACH,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,6DAA6D;IAC7D,QAAQ,CAAC,EAAE;QACT,GAAG,CAAC,EAAE,OAAO,CAAC,qBAAqB,CAAC,OAAO,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;KACrD,CAAC;CACH;AAyBD;;;;;;;;;;;;;;;;;GAiBG;AACH,eAAO,MAAM,YAAY;;EAkEP,CAAC"}
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "algorithm": "sha256",
3
3
  "artifacts": {
4
- "manifest.json": "6ed02140e5bbb599e6a2c400adf6b320e3ea45a57969e4d9d13969b372eb02b9",
4
+ "manifest.json": "3015db1776d8467a6769b316b4ffde3bbd225d53df528e97605b4b1645be3f27",
5
5
  "meta.json": "dc3977afc2b6ddc4904de906d1b1e448b786b2aced9d82f8eade2ff915203344",
6
6
  "types/index.d.ts": "f207946d7ab52f712d0c09995366440a6bba8b39358057b0da38ff9c0acca429",
7
7
  "rules/deprecated-action-version.ts": "d41e6e532ab7f623af1bee4ac5279fcb2baada7defa1c5d022a5bc71983e8797",
@@ -77,5 +77,5 @@
77
77
  "skills/chant-github-patterns.md": "bb3abef289a8fdfcf07d6bb2d7289dcb2f38bc0cb0321ea320b78b45a6f548c0",
78
78
  "skills/chant-github-security.md": "aab111cb0871cad30281ce48d7da23663689619351029219e2be019a1a61e394"
79
79
  },
80
- "composite": "2eb54e808eb76cc1fc7a94ee7fd6ead00c1d25bb0f776b8a360261224b8ff8e2"
80
+ "composite": "17318d4ba3cbdc98681abd0d398a4e5ea0c596397464c6622c53e6f2289669e4"
81
81
  }
@@ -1 +1 @@
1
- {"version":3,"file":"audit-catalog.d.ts","sourceRoot":"","sources":["../../src/lint/audit-catalog.ts"],"names":[],"mappings":"AAAA;;;GAGG;AACH,OAAO,EAA4H,KAAK,QAAQ,EAAE,MAAM,gCAAgC,CAAC;AAEzL,eAAO,MAAM,kBAAkB,EAAE,MAAM,CAAC,MAAM,EAAE,QAAQ,CA6DvD,CAAC"}
1
+ {"version":3,"file":"audit-catalog.d.ts","sourceRoot":"","sources":["../../src/lint/audit-catalog.ts"],"names":[],"mappings":"AAAA;;;GAGG;AACH,OAAO,EAA4H,KAAK,QAAQ,EAAgB,MAAM,gCAAgC,CAAC;AAGvM,eAAO,MAAM,kBAAkB,EAAE,MAAM,CAAC,MAAM,EAAE,QAAQ,CA6DvD,CAAC"}
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Prior art for these audit rules: the open-source tools whose checks cover the
3
+ * same condition, credited per rule. See packages/core/src/audit/prior-art.ts for
4
+ * the registry, the relation vocabulary, and why this is credit rather than
5
+ * authority. Kept by hand; the prior-art sweep (scripts/prior-art-sweep.ts) reports
6
+ * when a credited tool's index no longer lists a rule cited here.
7
+ */
8
+ import type { Lineage } from "@intentius/chant/audit/catalog";
9
+ export declare const githubAuditLineage: Record<string, Lineage[]>;
10
+ //# sourceMappingURL=audit-lineage.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"audit-lineage.d.ts","sourceRoot":"","sources":["../../src/lint/audit-lineage.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AACH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,gCAAgC,CAAC;AAE9D,eAAO,MAAM,kBAAkB,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,EAAE,CAuKxD,CAAC"}
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "github",
3
- "version": "0.57.0",
3
+ "version": "0.59.0",
4
4
  "chantVersion": ">=0.1.0",
5
5
  "namespace": "GitHub",
6
6
  "intrinsics": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/chant-lexicon-github",
3
- "version": "0.57.0",
3
+ "version": "0.59.0",
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,7 +61,7 @@
61
61
  "typescript": "^5.9.3"
62
62
  },
63
63
  "peerDependencies": {
64
- "@intentius/chant": "^0.57.0",
64
+ "@intentius/chant": "^0.59.0",
65
65
  "typescript": "^5.9.3"
66
66
  }
67
67
  }
@@ -6,7 +6,7 @@
6
6
  * (parses back via `../yaml.ts`'s `parseYAML`) with a `schedule` +
7
7
  * `workflow_dispatch` trigger and one job.
8
8
  * 2. `permissions:` is least-privilege per finding-mode — read-only for
9
- * `report`, scoped write for `issue`/`pull-request`.
9
+ * `report`, scoped write for `issue`/`comment`/`pull-request`.
10
10
  * 3. A cross-cutting generator change (extraScript/beforeScript/runCommand)
11
11
  * is a single edit reflected in every generated file.
12
12
  */
@@ -18,6 +18,7 @@ import type { ScheduledOpSpec } from "@intentius/chant/lexicon";
18
18
 
19
19
  interface ParsedStep {
20
20
  name?: string;
21
+ id?: string;
21
22
  uses?: string;
22
23
  run?: string;
23
24
  env?: Record<string, string>;
@@ -26,6 +27,10 @@ interface ParsedStep {
26
27
  interface ParsedJob {
27
28
  "runs-on"?: string;
28
29
  container?: string;
30
+ needs?: string;
31
+ if?: string;
32
+ permissions?: Record<string, string>;
33
+ outputs?: Record<string, string>;
29
34
  steps: ParsedStep[];
30
35
  }
31
36
 
@@ -100,6 +105,23 @@ describe("generateGithubOpPipeline: least-privilege permissions per finding-mode
100
105
  expect(doc.permissions).toEqual({ contents: "write", "pull-requests": "write" });
101
106
  });
102
107
 
108
+ test("comment mode is exactly contents: read + pull-requests: write (#2231)", () => {
109
+ // The least-privilege set a plan-on-PR job wants, and the one no mode
110
+ // could produce before this one existed: `issue` adds `issues: write`,
111
+ // `pull-request` widens `contents` to write, `report` gets no forge write
112
+ // scope at all. `toEqual` is what makes this an exact set rather than a
113
+ // containment check.
114
+ const result = generateGithubOpPipeline([
115
+ { name: "app-plan", trigger: { kind: "pull_request", branches: ["main"] }, findingMode: "comment" },
116
+ ]);
117
+ const doc = parseFile(result.files[0].yaml);
118
+ expect(doc.permissions).toEqual({ contents: "read", "pull-requests": "write" });
119
+
120
+ // The activity shells to `gh`, so the CLI's own token variable rides too.
121
+ const runStep = doc.jobs!["app-plan"].steps.find((s) => typeof s.run === "string")!;
122
+ expect(runStep.env).toEqual({ GITHUB_TOKEN: "${{ github.token }}", GH_TOKEN: "${{ github.token }}" });
123
+ });
124
+
103
125
  test("defaults to report (read-only) when findingMode is omitted", () => {
104
126
  const result = generateGithubOpPipeline([{ name: "actions-audit", schedule: "0 6 * * *" }]);
105
127
  expect(result.jobs[0].findingMode).toBe("report");
@@ -108,6 +130,102 @@ describe("generateGithubOpPipeline: least-privilege permissions per finding-mode
108
130
  });
109
131
  });
110
132
 
133
+ describe("generateGithubOpPipeline: trigger kinds (#2084)", () => {
134
+ test("a legacy `{ schedule }` spec with no `trigger` is unchanged", () => {
135
+ const specs: ScheduledOpSpec[] = [{ name: "actions-audit", schedule: "0 6 * * *", findingMode: "issue" }];
136
+ const result = generateGithubOpPipeline(specs);
137
+ const doc = parseFile(result.files[0].yaml);
138
+
139
+ expect(doc.on).toEqual({ schedule: [{ cron: "0 6 * * *" }], workflow_dispatch: {} });
140
+ expect(doc.permissions).toEqual({ contents: "read", issues: "write" });
141
+ expect(result.jobs[0].trigger).toEqual({ kind: "cron", schedule: "0 6 * * *" });
142
+ });
143
+
144
+ test("pull_request trigger: filters to branches, has no workflow_dispatch", () => {
145
+ const specs: ScheduledOpSpec[] = [
146
+ { name: "tf-plan", trigger: { kind: "pull_request", branches: ["main"] } },
147
+ ];
148
+ const result = generateGithubOpPipeline(specs);
149
+ const doc = parseFile(result.files[0].yaml);
150
+
151
+ expect(doc.on).toEqual({ pull_request: { branches: ["main"] } });
152
+ expect(doc.on).not.toHaveProperty("workflow_dispatch");
153
+ expect(result.jobs[0].trigger).toEqual({ kind: "pull_request", branches: ["main"] });
154
+ });
155
+
156
+ test("pull_request trigger with no branches filter triggers on every PR", () => {
157
+ const result = generateGithubOpPipeline([{ name: "tf-plan", trigger: { kind: "pull_request" } }]);
158
+ const doc = parseFile(result.files[0].yaml);
159
+ expect(doc.on).toEqual({ pull_request: {} });
160
+ });
161
+
162
+ test("pull_request trigger with a comment-posting finding mode gets pull-requests: write", () => {
163
+ const specs: ScheduledOpSpec[] = [
164
+ { name: "tf-plan", trigger: { kind: "pull_request" }, findingMode: "issue" },
165
+ ];
166
+ const result = generateGithubOpPipeline(specs);
167
+ const doc = parseFile(result.files[0].yaml);
168
+ expect(doc.permissions).toEqual({ contents: "read", issues: "write", "pull-requests": "write" });
169
+ });
170
+
171
+ test("pull_request trigger stays read-only when findingMode is report", () => {
172
+ const result = generateGithubOpPipeline([{ name: "tf-plan", trigger: { kind: "pull_request" } }]);
173
+ const doc = parseFile(result.files[0].yaml);
174
+ expect(doc.permissions).toEqual({ contents: "read" });
175
+ });
176
+
177
+ test("comment mode is refused by name on a cron trigger (#2231)", () => {
178
+ const specs: ScheduledOpSpec[] = [
179
+ { name: "app-plan", schedule: "0 6 * * *", findingMode: "comment" },
180
+ ];
181
+ expect(() => generateGithubOpPipeline(specs)).toThrow(
182
+ /findingMode "comment".*trigger is "cron".*no pull request/s,
183
+ );
184
+ });
185
+
186
+ test("comment mode is refused by name on a push trigger (#2231)", () => {
187
+ const specs: ScheduledOpSpec[] = [
188
+ { name: "app-apply", trigger: { kind: "push", branches: ["main"] }, findingMode: "comment" },
189
+ ];
190
+ expect(() => generateGithubOpPipeline(specs)).toThrow(
191
+ /Scheduled Op "app-apply".*findingMode "comment".*trigger is "push"/s,
192
+ );
193
+ });
194
+
195
+ test("push trigger: filters to branches", () => {
196
+ const specs: ScheduledOpSpec[] = [
197
+ { name: "tf-apply", trigger: { kind: "push", branches: ["release"] } },
198
+ ];
199
+ const result = generateGithubOpPipeline(specs);
200
+ const doc = parseFile(result.files[0].yaml);
201
+
202
+ expect(doc.on).toEqual({ push: { branches: ["release"] } });
203
+ expect(doc.on).not.toHaveProperty("workflow_dispatch");
204
+ expect(result.jobs[0].trigger).toEqual({ kind: "push", branches: ["release"] });
205
+ });
206
+
207
+ test("push trigger defaults to the repository default branch (main) when branches is omitted", () => {
208
+ const result = generateGithubOpPipeline([{ name: "tf-apply", trigger: { kind: "push" } }]);
209
+ const doc = parseFile(result.files[0].yaml);
210
+ expect(doc.on).toEqual({ push: { branches: ["main"] } });
211
+ });
212
+
213
+ test("push trigger is read-only by default", () => {
214
+ const result = generateGithubOpPipeline([{ name: "tf-apply", trigger: { kind: "push" } }]);
215
+ const doc = parseFile(result.files[0].yaml);
216
+ expect(doc.permissions).toEqual({ contents: "read" });
217
+ });
218
+
219
+ test("push trigger honors an elevated findingMode, same as cron", () => {
220
+ const specs: ScheduledOpSpec[] = [
221
+ { name: "tf-apply", trigger: { kind: "push" }, findingMode: "pull-request" },
222
+ ];
223
+ const result = generateGithubOpPipeline(specs);
224
+ const doc = parseFile(result.files[0].yaml);
225
+ expect(doc.permissions).toEqual({ contents: "write", "pull-requests": "write" });
226
+ });
227
+ });
228
+
111
229
  describe("generateGithubOpPipeline: concurrency guards against overlapping runs", () => {
112
230
  test("each file's concurrency group is scoped to its own job", () => {
113
231
  const result = generateGithubOpPipeline([{ name: "actions-audit", schedule: "0 6 * * *" }]);
@@ -123,7 +241,7 @@ describe("generateGithubOpPipeline: a cross-cutting change is one generator edit
123
241
  { name: "prod-reconcile", schedule: "0 * * * *" },
124
242
  ];
125
243
  const result = generateGithubOpPipeline(specs, {
126
- runCommand: ["chant", "run", "{name}", "--temporal"],
244
+ runCommand: ["chant", "run", "{name}", "--json"],
127
245
  beforeScript: ["npm ci"],
128
246
  extraScript: ["echo done"],
129
247
  });
@@ -133,7 +251,7 @@ describe("generateGithubOpPipeline: a cross-cutting change is one generator edit
133
251
  const jobName = Object.keys(doc.jobs!)[0];
134
252
  const runLines = doc.jobs![jobName].steps.filter((s) => typeof s.run === "string").map((s) => s.run as string);
135
253
  expect(runLines[0]).toBe("npm ci");
136
- expect(runLines[1]).toContain("--temporal");
254
+ expect(runLines[1]).toContain("--json");
137
255
  expect(runLines[2]).toBe("echo done");
138
256
  }
139
257
  });
@@ -144,3 +262,256 @@ describe("generateGithubOpPipeline: a cross-cutting change is one generator edit
144
262
  expect(result.jobs).toEqual([]);
145
263
  });
146
264
  });
265
+
266
+ describe("generateGithubOpPipeline: the Op's own schedule (#2120)", () => {
267
+ test("an Op that declares its cadence needs no cron on the spec — opSchedule supplies it", () => {
268
+ const specs: ScheduledOpSpec[] = [{ name: "prod-watch", opSchedule: { cron: "*/10 * * * *", overlap: "skip" } }];
269
+ const result = generateGithubOpPipeline(specs);
270
+ const doc = parseFile(result.files[0].yaml);
271
+
272
+ expect(doc.on).toEqual({ schedule: [{ cron: "*/10 * * * *" }], workflow_dispatch: {} });
273
+ expect(result.jobs[0].trigger).toEqual({ kind: "cron", schedule: "*/10 * * * *" });
274
+ });
275
+
276
+ test("an explicit spec `schedule` still wins over the Op's own", () => {
277
+ const specs: ScheduledOpSpec[] = [
278
+ { name: "prod-watch", schedule: "0 6 * * *", opSchedule: { cron: "*/10 * * * *" } },
279
+ ];
280
+ const doc = parseFile(generateGithubOpPipeline(specs).files[0].yaml);
281
+ expect(doc.on).toEqual({ schedule: [{ cron: "0 6 * * *" }], workflow_dispatch: {} });
282
+ });
283
+
284
+ test("an explicit `trigger` overrides the Op's own cadence entirely", () => {
285
+ const specs: ScheduledOpSpec[] = [
286
+ { name: "tf-plan", trigger: { kind: "pull_request" }, opSchedule: { cron: "*/10 * * * *" } },
287
+ ];
288
+ const result = generateGithubOpPipeline(specs);
289
+ const doc = parseFile(result.files[0].yaml);
290
+ expect(doc.on).toEqual({ pull_request: {} });
291
+ expect(result.jobs[0].trigger).toEqual({ kind: "pull_request" });
292
+ });
293
+
294
+ test("an Op with no cadence anywhere is still an error naming the Op", () => {
295
+ expect(() => generateGithubOpPipeline([{ name: "no-cadence" }])).toThrow(
296
+ /Scheduled Op "no-cadence" has neither/,
297
+ );
298
+ });
299
+ });
300
+
301
+ /**
302
+ * The two per-Op options from #2242: `setup` steps between the checkout and
303
+ * the `beforeScript` lines, and `permissions` merged additively over the
304
+ * finding-mode's own set. Together they are what makes an OIDC job
305
+ * expressible — `aws-actions/configure-aws-credentials` is a `uses:` step,
306
+ * and no finding-mode grants `id-token: write`.
307
+ */
308
+ describe("generateGithubOpPipeline: setup steps and additive permissions (#2242)", () => {
309
+ const OIDC_SPEC: ScheduledOpSpec = {
310
+ name: "app-apply",
311
+ trigger: { kind: "push", branches: ["main"] },
312
+ setup: [
313
+ {
314
+ uses: "aws-actions/configure-aws-credentials@v6",
315
+ with: { "role-to-assume": "${{ vars.AWS_ROLE_ARN }}", "aws-region": "eu-west-1" },
316
+ },
317
+ ],
318
+ permissions: { "id-token": "write" },
319
+ };
320
+
321
+ test("emits the action between the checkout and the beforeScript install", () => {
322
+ const result = generateGithubOpPipeline([OIDC_SPEC], { beforeScript: ["install terraform"] });
323
+ const doc = parseFile(result.files[0].yaml);
324
+ const steps = doc.jobs!["app-apply"].steps;
325
+
326
+ expect(steps.slice(0, 3).map((s) => s.uses ?? s.run)).toEqual([
327
+ "actions/checkout@v4",
328
+ "aws-actions/configure-aws-credentials@v6",
329
+ "install terraform",
330
+ ]);
331
+ // The last step is the invocation. This spec's trigger is `push`, so it
332
+ // is the gated-apply script rather than a bare line (#2243); what this
333
+ // test owns is that the setup action lands between the checkout and the
334
+ // `beforeScript` install, whatever shape the invocation takes.
335
+ expect(steps).toHaveLength(4);
336
+ expect(steps[3].run).toContain("chant run app-apply");
337
+ expect((steps[1] as { with?: Record<string, string> }).with).toEqual({
338
+ "role-to-assume": "${{ vars.AWS_ROLE_ARN }}",
339
+ "aws-region": "eu-west-1",
340
+ });
341
+ });
342
+
343
+ test("adds id-token: write to the mode's own set without replacing it", () => {
344
+ const doc = parseFile(generateGithubOpPipeline([OIDC_SPEC]).files[0].yaml);
345
+ expect(doc.permissions).toEqual({ contents: "read", "id-token": "write" });
346
+ });
347
+
348
+ test("carries a setup step's own `env` and emits a `run` entry as a plain step", () => {
349
+ const specs: ScheduledOpSpec[] = [
350
+ {
351
+ name: "app-apply",
352
+ schedule: "0 6 * * *",
353
+ setup: [{ run: "aws sts get-caller-identity", env: { AWS_REGION: "eu-west-1" } }],
354
+ },
355
+ ];
356
+ const steps = parseFile(generateGithubOpPipeline(specs).files[0].yaml).jobs!["app-apply"].steps;
357
+ expect(steps[1]).toEqual({ run: "aws sts get-caller-identity", env: { AWS_REGION: "eu-west-1" } });
358
+ });
359
+
360
+ test("refuses an action pinned to its own default branch", () => {
361
+ expect(() =>
362
+ generateGithubOpPipeline([{ ...OIDC_SPEC, setup: [{ uses: "aws-actions/configure-aws-credentials@main" }] }]),
363
+ ).toThrow(/setup step 1 pins .* to "main", the action repository's own default branch/s);
364
+ });
365
+
366
+ test("refuses an action with no ref at all", () => {
367
+ expect(() =>
368
+ generateGithubOpPipeline([{ ...OIDC_SPEC, setup: [{ uses: "aws-actions/configure-aws-credentials" }] }]),
369
+ ).toThrow(/is not a pinned action reference/);
370
+ });
371
+
372
+ test("accepts a subpath ref and a commit sha", () => {
373
+ const specs: ScheduledOpSpec[] = [
374
+ {
375
+ name: "app-apply",
376
+ schedule: "0 6 * * *",
377
+ setup: [
378
+ { uses: "github/codeql-action/upload-sarif@v4" },
379
+ { uses: "aws-actions/configure-aws-credentials@0e613a0980cbf65ed5b322eb7a1e075d28913a83" },
380
+ ],
381
+ },
382
+ ];
383
+ const steps = parseFile(generateGithubOpPipeline(specs).files[0].yaml).jobs!["app-apply"].steps;
384
+ expect(steps.map((s) => s.uses).filter(Boolean)).toEqual([
385
+ "actions/checkout@v4",
386
+ "github/codeql-action/upload-sarif@v4",
387
+ "aws-actions/configure-aws-credentials@0e613a0980cbf65ed5b322eb7a1e075d28913a83",
388
+ ]);
389
+ });
390
+
391
+ test("refuses a blanket write-all", () => {
392
+ expect(() =>
393
+ generateGithubOpPipeline([{ ...OIDC_SPEC, permissions: { "write-all": "write" } }]),
394
+ ).toThrow(/a blanket grant/);
395
+ });
396
+
397
+ test("refuses widening a scope the finding-mode already grants", () => {
398
+ expect(() =>
399
+ generateGithubOpPipeline([
400
+ { name: "prod-reconcile", schedule: "0 * * * *", findingMode: "issue", permissions: { issues: "write" } },
401
+ ]),
402
+ ).toThrow(/its finding-mode already grants "issues: write"/);
403
+ });
404
+
405
+ test("refuses downgrading a scope the finding-mode already grants", () => {
406
+ expect(() =>
407
+ generateGithubOpPipeline([
408
+ {
409
+ name: "prod-reconcile",
410
+ schedule: "0 * * * *",
411
+ findingMode: "pull-request",
412
+ permissions: { contents: "read" },
413
+ },
414
+ ]),
415
+ ).toThrow(/its finding-mode already grants "contents: write"/);
416
+ });
417
+
418
+ test("refuses a scope name GitHub does not define, which it would silently ignore", () => {
419
+ expect(() => generateGithubOpPipeline([{ ...OIDC_SPEC, permissions: { id_token: "write" } }])).toThrow(
420
+ /not a GITHUB_TOKEN permission scope/,
421
+ );
422
+ });
423
+
424
+ test("refuses pull-requests: write on a trigger that has no pull request", () => {
425
+ expect(() =>
426
+ generateGithubOpPipeline([{ ...OIDC_SPEC, permissions: { "pull-requests": "write" } }]),
427
+ ).toThrow(/trigger is "push", which carries no pull request/);
428
+ });
429
+
430
+ test("allows id-token: write beside the comment mode's own pull-request scope", () => {
431
+ const doc = parseFile(
432
+ generateGithubOpPipeline([
433
+ {
434
+ name: "app-plan",
435
+ trigger: { kind: "pull_request", branches: ["main"] },
436
+ findingMode: "comment",
437
+ permissions: { "id-token": "write" },
438
+ },
439
+ ]).files[0].yaml,
440
+ );
441
+ expect(doc.permissions).toEqual({
442
+ contents: "read",
443
+ "pull-requests": "write",
444
+ "id-token": "write",
445
+ });
446
+ });
447
+ });
448
+
449
+ /**
450
+ * chant #2243 — a `push` job whose Op gates would otherwise be a red workflow
451
+ * run on every merge until someone approves. The mapping is `chant run`'s own
452
+ * (`--gated-exit 0`); what the generator adds is asking for it on the one
453
+ * trigger that needs it, and a job that says where the approval is pending.
454
+ */
455
+ describe("generateGithubOpPipeline: the gated apply on push (#2243)", () => {
456
+ const pushSpec: ScheduledOpSpec = { name: "app-apply", trigger: { kind: "push", branches: ["main"] } };
457
+
458
+ function pushDoc(): ParsedDoc {
459
+ return parseFile(generateGithubOpPipeline([pushSpec]).files[0].yaml);
460
+ }
461
+
462
+ test("a push job runs with --gated-exit 0 and publishes what it stopped on", () => {
463
+ const doc = pushDoc();
464
+ const job = doc.jobs!["app-apply"];
465
+ const step = job.steps.find((s) => s.id === "chant-run");
466
+ expect(step?.run).toContain("chant run app-apply --gated-exit 0 --json");
467
+ expect(job.outputs).toEqual({
468
+ gated: "${{ steps.chant-run.outputs.gated }}",
469
+ op: "${{ steps.chant-run.outputs.op }}",
470
+ gate: "${{ steps.chant-run.outputs.gate }}",
471
+ approve: "${{ steps.chant-run.outputs.approve }}",
472
+ });
473
+ });
474
+
475
+ test("a cron watch and a pull_request plan keep the plain one-line invocation", () => {
476
+ for (const spec of [
477
+ { name: "app-watch", schedule: "0 6 * * *" },
478
+ { name: "app-plan", trigger: { kind: "pull_request" as const } },
479
+ ] satisfies ScheduledOpSpec[]) {
480
+ const doc = parseFile(generateGithubOpPipeline([spec]).files[0].yaml);
481
+ const job = doc.jobs![spec.name];
482
+ expect(job.steps.some((s) => s.run?.includes("--gated-exit"))).toBe(false);
483
+ expect(job.outputs).toBeUndefined();
484
+ expect(doc.jobs![`${spec.name}-gate-notice`]).toBeUndefined();
485
+ }
486
+ });
487
+
488
+ test("the notice job needs the apply, runs only on gated, and posts outside the log", () => {
489
+ const notice = pushDoc().jobs!["app-apply-gate-notice"];
490
+ expect(notice.needs).toBe("app-apply");
491
+ expect(notice.if).toBe("needs.app-apply.outputs.gated == 'true'");
492
+ // It shells to `gh`, which a hosted runner carries and the Op's own
493
+ // container image does not.
494
+ expect(notice.container).toBeUndefined();
495
+ const script = notice.steps[0].run ?? "";
496
+ expect(script).toContain('gh api "repos/$GITHUB_REPOSITORY/commits/$GITHUB_SHA/pulls"');
497
+ expect(script).toContain('marker="<!-- chant-gate:$CHANT_OP -->"');
498
+ expect(script).toContain("gh issue create");
499
+ });
500
+
501
+ test("the notice job's permissions are its two posting paths and the lookup", () => {
502
+ const notice = pushDoc().jobs!["app-apply-gate-notice"];
503
+ expect(notice.permissions).toEqual({
504
+ contents: "read",
505
+ issues: "write",
506
+ "pull-requests": "write",
507
+ });
508
+ // Job-level, so the apply beside it keeps the workflow's own read-only set.
509
+ expect(pushDoc().permissions).toEqual({ contents: "read" });
510
+ expect(pushDoc().jobs!["app-apply"].permissions).toBeUndefined();
511
+ });
512
+
513
+ test("a failing run stays a failing job: the pipe cannot swallow its exit code", () => {
514
+ const step = pushDoc().jobs!["app-apply"].steps.find((s) => s.id === "chant-run");
515
+ expect(step?.run).toContain("set -o pipefail");
516
+ });
517
+ });