@intentius/chant-lexicon-github 0.49.0 → 0.50.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.
Files changed (92) hide show
  1. package/dist/components/generate-op-pipeline.d.ts +81 -0
  2. package/dist/components/generate-op-pipeline.d.ts.map +1 -0
  3. package/dist/integrity.json +15 -5
  4. package/dist/lint/audit-catalog.d.ts.map +1 -1
  5. package/dist/lint/post-synth/gha059.d.ts +40 -0
  6. package/dist/lint/post-synth/gha059.d.ts.map +1 -0
  7. package/dist/lint/post-synth/gha060.d.ts +34 -0
  8. package/dist/lint/post-synth/gha060.d.ts.map +1 -0
  9. package/dist/lint/post-synth/gha061.d.ts +27 -0
  10. package/dist/lint/post-synth/gha061.d.ts.map +1 -0
  11. package/dist/lint/post-synth/gha062.d.ts +24 -0
  12. package/dist/lint/post-synth/gha062.d.ts.map +1 -0
  13. package/dist/lint/post-synth/gha063.d.ts +12 -0
  14. package/dist/lint/post-synth/gha063.d.ts.map +1 -0
  15. package/dist/lint/post-synth/gha064.d.ts +12 -0
  16. package/dist/lint/post-synth/gha064.d.ts.map +1 -0
  17. package/dist/lint/post-synth/gha065.d.ts +19 -0
  18. package/dist/lint/post-synth/gha065.d.ts.map +1 -0
  19. package/dist/lint/post-synth/gha066.d.ts +12 -0
  20. package/dist/lint/post-synth/gha066.d.ts.map +1 -0
  21. package/dist/lint/post-synth/gha067.d.ts +12 -0
  22. package/dist/lint/post-synth/gha067.d.ts.map +1 -0
  23. package/dist/lint/post-synth/gha068.d.ts +13 -0
  24. package/dist/lint/post-synth/gha068.d.ts.map +1 -0
  25. package/dist/lint/post-synth/index.d.ts.map +1 -1
  26. package/dist/lint/post-synth/yaml-helpers.d.ts +20 -0
  27. package/dist/lint/post-synth/yaml-helpers.d.ts.map +1 -1
  28. package/dist/lint/rules/data/action-usage-policy.d.ts +30 -0
  29. package/dist/lint/rules/data/action-usage-policy.d.ts.map +1 -0
  30. package/dist/lint/rules/data/advisory-feed.d.ts +36 -0
  31. package/dist/lint/rules/data/advisory-feed.d.ts.map +1 -0
  32. package/dist/manifest.json +109 -1
  33. package/dist/meta.json +5 -0
  34. package/dist/okf/index.md +10 -0
  35. package/dist/okf/rules/GHA059.md +15 -0
  36. package/dist/okf/rules/GHA060.md +15 -0
  37. package/dist/okf/rules/GHA061.md +15 -0
  38. package/dist/okf/rules/GHA062.md +15 -0
  39. package/dist/okf/rules/GHA063.md +15 -0
  40. package/dist/okf/rules/GHA064.md +15 -0
  41. package/dist/okf/rules/GHA065.md +15 -0
  42. package/dist/okf/rules/GHA066.md +15 -0
  43. package/dist/okf/rules/GHA067.md +15 -0
  44. package/dist/okf/rules/GHA068.md +15 -0
  45. package/dist/okf/types/Job.md +9 -0
  46. package/dist/okf/types/Workflow.md +1 -0
  47. package/dist/plugin.d.ts.map +1 -1
  48. package/dist/rules/gha059.ts +132 -0
  49. package/dist/rules/gha060.ts +146 -0
  50. package/dist/rules/gha061.ts +85 -0
  51. package/dist/rules/gha062.ts +68 -0
  52. package/dist/rules/gha063.ts +60 -0
  53. package/dist/rules/gha064.ts +55 -0
  54. package/dist/rules/gha065.ts +129 -0
  55. package/dist/rules/gha066.ts +47 -0
  56. package/dist/rules/gha067.ts +76 -0
  57. package/dist/rules/gha068.ts +51 -0
  58. package/dist/rules/yaml-helpers.ts +37 -2
  59. package/dist/types/index.d.ts +1 -0
  60. package/package.json +2 -2
  61. package/src/codegen/docs.ts +1 -1
  62. package/src/components/generate-op-pipeline.test.ts +146 -0
  63. package/src/components/generate-op-pipeline.ts +183 -0
  64. package/src/generated/index.d.ts +1 -0
  65. package/src/generated/lexicon-github.json +5 -0
  66. package/src/lint/audit-catalog.ts +16 -1
  67. package/src/lint/post-synth/gha059.test.ts +147 -0
  68. package/src/lint/post-synth/gha059.ts +132 -0
  69. package/src/lint/post-synth/gha060.test.ts +144 -0
  70. package/src/lint/post-synth/gha060.ts +146 -0
  71. package/src/lint/post-synth/gha061.test.ts +85 -0
  72. package/src/lint/post-synth/gha061.ts +85 -0
  73. package/src/lint/post-synth/gha062.test.ts +88 -0
  74. package/src/lint/post-synth/gha062.ts +68 -0
  75. package/src/lint/post-synth/gha063.test.ts +86 -0
  76. package/src/lint/post-synth/gha063.ts +60 -0
  77. package/src/lint/post-synth/gha064.test.ts +73 -0
  78. package/src/lint/post-synth/gha064.ts +55 -0
  79. package/src/lint/post-synth/gha065.test.ts +85 -0
  80. package/src/lint/post-synth/gha065.ts +129 -0
  81. package/src/lint/post-synth/gha066.test.ts +66 -0
  82. package/src/lint/post-synth/gha066.ts +47 -0
  83. package/src/lint/post-synth/gha067.test.ts +85 -0
  84. package/src/lint/post-synth/gha067.ts +76 -0
  85. package/src/lint/post-synth/gha068.test.ts +71 -0
  86. package/src/lint/post-synth/gha068.ts +51 -0
  87. package/src/lint/post-synth/index.ts +20 -0
  88. package/src/lint/post-synth/yaml-helpers.ts +37 -2
  89. package/src/lint/rules/data/action-usage-policy.ts +31 -0
  90. package/src/lint/rules/data/advisory-feed.ts +38 -0
  91. package/src/plugin.test.ts +24 -3
  92. package/src/plugin.ts +28 -0
@@ -25,7 +25,7 @@ npm install --save-dev @intentius/chant-lexicon-github
25
25
 
26
26
  {{file:docs-snippets/src/quickstart.ts}}
27
27
 
28
- The lexicon provides **3 resources** (Workflow, Job, Dependabot config), **14 composites** (Checkout, SetupNode, SetupGo, SetupPython, CacheAction, UploadArtifact, DownloadArtifact, NodeCI, NodePipeline, PythonCI, DockerBuild, DeployEnvironment, GoCI, Dependabot) + **3 presets** (BunPipeline, PnpmPipeline, YarnPipeline), a typed **Expression** system with 24 GitHub and 5 Runner context variables, and **13 lint rules** + **45 post-synth checks** (including a CI/CD supply-chain security pass, GHA029–058).
28
+ The lexicon provides **3 resources** (Workflow, Job, Dependabot config), **14 composites** (Checkout, SetupNode, SetupGo, SetupPython, CacheAction, UploadArtifact, DownloadArtifact, NodeCI, NodePipeline, PythonCI, DockerBuild, DeployEnvironment, GoCI, Dependabot) + **3 presets** (BunPipeline, PnpmPipeline, YarnPipeline), a typed **Expression** system with 24 GitHub and 5 Runner context variables, and **13 lint rules** + **49 post-synth checks** (including a CI/CD supply-chain security pass, GHA029–062).
29
29
  `;
30
30
 
31
31
  const outputFormat = `The GitHub Actions lexicon serializes resources into **\`.github/workflows/*.yml\`** YAML files.
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Tests for generate mode's scheduled Op → GitHub Actions workflow YAML
3
+ * synthesis (#927). Mirrors `./generate-pipeline.test.ts`'s style:
4
+ *
5
+ * 1. Each `ScheduledOpSpec` produces its own structurally valid workflow
6
+ * (parses back via `../yaml.ts`'s `parseYAML`) with a `schedule` +
7
+ * `workflow_dispatch` trigger and one job.
8
+ * 2. `permissions:` is least-privilege per finding-mode — read-only for
9
+ * `report`, scoped write for `issue`/`pull-request`.
10
+ * 3. A cross-cutting generator change (extraScript/beforeScript/runCommand)
11
+ * is a single edit reflected in every generated file.
12
+ */
13
+
14
+ import { describe, test, expect } from "vitest";
15
+ import { parseYAML } from "@intentius/chant/yaml";
16
+ import { generateGithubOpPipeline } from "./generate-op-pipeline";
17
+ import type { ScheduledOpSpec } from "@intentius/chant/lexicon";
18
+
19
+ interface ParsedStep {
20
+ name?: string;
21
+ uses?: string;
22
+ run?: string;
23
+ env?: Record<string, string>;
24
+ }
25
+
26
+ interface ParsedJob {
27
+ "runs-on"?: string;
28
+ container?: string;
29
+ steps: ParsedStep[];
30
+ }
31
+
32
+ interface ParsedDoc {
33
+ on?: Record<string, unknown>;
34
+ concurrency?: Record<string, unknown>;
35
+ permissions?: Record<string, string>;
36
+ jobs?: Record<string, ParsedJob>;
37
+ }
38
+
39
+ function parseFile(yaml: string): ParsedDoc {
40
+ return parseYAML(yaml) as ParsedDoc;
41
+ }
42
+
43
+ describe("generateGithubOpPipeline: one file per scheduled Op", () => {
44
+ test("each spec produces its own workflow with a schedule + workflow_dispatch trigger", () => {
45
+ const specs: ScheduledOpSpec[] = [
46
+ { name: "actions-audit", schedule: "0 6 * * *" },
47
+ { name: "prod-reconcile", schedule: "0 * * * *", findingMode: "pull-request" },
48
+ ];
49
+ const result = generateGithubOpPipeline(specs);
50
+
51
+ expect(result.files).toHaveLength(2);
52
+ expect(result.files.map((f) => f.name)).toEqual(["actions-audit.yml", "prod-reconcile.yml"]);
53
+
54
+ const auditDoc = parseFile(result.files[0].yaml);
55
+ expect(auditDoc.on).toEqual({ schedule: [{ cron: "0 6 * * *" }], workflow_dispatch: {} });
56
+
57
+ const reconcileDoc = parseFile(result.files[1].yaml);
58
+ expect(reconcileDoc.on).toEqual({ schedule: [{ cron: "0 * * * *" }], workflow_dispatch: {} });
59
+ });
60
+
61
+ test("each file's single job runs `chant run <name>` and starts with a checkout step", () => {
62
+ const result = generateGithubOpPipeline([{ name: "actions-audit", schedule: "0 6 * * *" }]);
63
+ const doc = parseFile(result.files[0].yaml);
64
+ const job = doc.jobs!["actions-audit"];
65
+
66
+ expect(job["runs-on"]).toBe("ubuntu-latest");
67
+ expect(job.steps[0].uses).toBe("actions/checkout@v4");
68
+ const runStep = job.steps.find((s) => typeof s.run === "string")!;
69
+ expect(runStep.run).toBe("chant run actions-audit");
70
+ });
71
+
72
+ test("an op-name job id is normalized the same way the component generator does", () => {
73
+ const result = generateGithubOpPipeline([{ name: "cost-observe", schedule: "0 0 * * *" }]);
74
+ expect(result.jobs[0].jobName).toBe("cost-observe");
75
+ });
76
+ });
77
+
78
+ describe("generateGithubOpPipeline: least-privilege permissions per finding-mode", () => {
79
+ test("report needs no write access", () => {
80
+ const result = generateGithubOpPipeline([{ name: "actions-audit", schedule: "0 6 * * *", findingMode: "report" }]);
81
+ const doc = parseFile(result.files[0].yaml);
82
+ expect(doc.permissions).toEqual({ contents: "read" });
83
+
84
+ const runStep = doc.jobs!["actions-audit"].steps.find((s) => typeof s.run === "string")!;
85
+ expect(runStep.env).toEqual({ GITHUB_TOKEN: "${{ github.token }}" });
86
+ });
87
+
88
+ test("issue mode adds issues: write and the gh CLI token", () => {
89
+ const result = generateGithubOpPipeline([{ name: "actions-audit", schedule: "0 6 * * *", findingMode: "issue" }]);
90
+ const doc = parseFile(result.files[0].yaml);
91
+ expect(doc.permissions).toEqual({ contents: "read", issues: "write" });
92
+
93
+ const runStep = doc.jobs!["actions-audit"].steps.find((s) => typeof s.run === "string")!;
94
+ expect(runStep.env).toEqual({ GITHUB_TOKEN: "${{ github.token }}", GH_TOKEN: "${{ github.token }}" });
95
+ });
96
+
97
+ test("pull-request mode grants contents + pull-requests write, not issues", () => {
98
+ const result = generateGithubOpPipeline([{ name: "prod-reconcile", schedule: "0 * * * *", findingMode: "pull-request" }]);
99
+ const doc = parseFile(result.files[0].yaml);
100
+ expect(doc.permissions).toEqual({ contents: "write", "pull-requests": "write" });
101
+ });
102
+
103
+ test("defaults to report (read-only) when findingMode is omitted", () => {
104
+ const result = generateGithubOpPipeline([{ name: "actions-audit", schedule: "0 6 * * *" }]);
105
+ expect(result.jobs[0].findingMode).toBe("report");
106
+ const doc = parseFile(result.files[0].yaml);
107
+ expect(doc.permissions).toEqual({ contents: "read" });
108
+ });
109
+ });
110
+
111
+ describe("generateGithubOpPipeline: concurrency guards against overlapping runs", () => {
112
+ test("each file's concurrency group is scoped to its own job", () => {
113
+ const result = generateGithubOpPipeline([{ name: "actions-audit", schedule: "0 6 * * *" }]);
114
+ const doc = parseFile(result.files[0].yaml);
115
+ expect(doc.concurrency).toEqual({ group: "actions-audit", "cancel-in-progress": false });
116
+ });
117
+ });
118
+
119
+ describe("generateGithubOpPipeline: a cross-cutting change is one generator edit, not per-file", () => {
120
+ test("extraScript/beforeScript/runCommand apply uniformly across every generated file", () => {
121
+ const specs: ScheduledOpSpec[] = [
122
+ { name: "actions-audit", schedule: "0 6 * * *" },
123
+ { name: "prod-reconcile", schedule: "0 * * * *" },
124
+ ];
125
+ const result = generateGithubOpPipeline(specs, {
126
+ runCommand: ["chant", "run", "{name}", "--temporal"],
127
+ beforeScript: ["npm ci"],
128
+ extraScript: ["echo done"],
129
+ });
130
+
131
+ for (const file of result.files) {
132
+ const doc = parseFile(file.yaml);
133
+ const jobName = Object.keys(doc.jobs!)[0];
134
+ const runLines = doc.jobs![jobName].steps.filter((s) => typeof s.run === "string").map((s) => s.run as string);
135
+ expect(runLines[0]).toBe("npm ci");
136
+ expect(runLines[1]).toContain("--temporal");
137
+ expect(runLines[2]).toBe("echo done");
138
+ }
139
+ });
140
+
141
+ test("an empty Op set produces no files", () => {
142
+ const result = generateGithubOpPipeline([]);
143
+ expect(result.files).toEqual([]);
144
+ expect(result.jobs).toEqual([]);
145
+ });
146
+ });
@@ -0,0 +1,183 @@
1
+ /**
2
+ * Generate mode — scheduled Op → GitHub Actions workflow YAML (#927).
3
+ *
4
+ * The Op counterpart to `./generate-pipeline.ts` (#891): that module
5
+ * synthesizes a `workflow_dispatch`-triggered pipeline from a deploy-time
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).
11
+ *
12
+ * GitHub Actions' `on.schedule` is workflow-scoped, not job-scoped, so unlike
13
+ * the component generator (one combined pipeline for the whole graph) this
14
+ * 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`;
22
+ * - runs exactly one invocation, `chant run <name>` by default — never
23
+ * inlined audit/reconcile logic. The finding-mode itself is already baked
24
+ * 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.
26
+ */
27
+
28
+ import { emitYAML } from "@intentius/chant/yaml";
29
+ import type {
30
+ ComponentPipelineOptions as GenerateGithubOpOptions,
31
+ OpFindingMode,
32
+ OpPipelineJob,
33
+ OpPipelineResult as GenerateGithubOpResult,
34
+ ScheduledOpSpec,
35
+ } from "@intentius/chant/lexicon";
36
+
37
+ export type { GenerateGithubOpOptions, GenerateGithubOpResult };
38
+
39
+ /**
40
+ * The structured pipeline document behind one generated file, before YAML
41
+ * emission — exposed so a GitHub-Actions dialect (the forgejo lexicon, #969)
42
+ * can reuse the exact trigger/concurrency/job structure and only apply its
43
+ * dialect transform + emit, rather than re-deriving it. Mirrors
44
+ * `./generate-pipeline.ts`'s `GithubPipelineDoc` split.
45
+ */
46
+ export interface GithubOpPipelineDoc {
47
+ /** The `on:` trigger mapping (`schedule` + `workflow_dispatch`). */
48
+ on: Record<string, unknown>;
49
+ /** The `env:` mapping, when `options.variables` is set. */
50
+ env?: Record<string, unknown>;
51
+ /** The `concurrency:` mapping — one run at a time per Op. */
52
+ concurrency: Record<string, unknown>;
53
+ /**
54
+ * The `permissions:` mapping for this Op's finding-mode. GitHub-only:
55
+ * Forgejo Actions ignores `permissions:` entirely, so the forgejo dialect
56
+ * drops this section rather than translating it (see ../../forgejo/src/dialect.ts).
57
+ */
58
+ permissions: Record<string, unknown>;
59
+ /** The `jobs:` mapping — one entry, this Op's trigger job. */
60
+ jobsDoc: Record<string, unknown>;
61
+ }
62
+
63
+ /** One generated file: a suggested name plus its pipeline document, pre-emission. */
64
+ export interface GithubOpPipelineFile {
65
+ /** Suggested file name (`<op-name>.yml`), relative to the provider's workflow directory. */
66
+ name: string;
67
+ doc: GithubOpPipelineDoc;
68
+ }
69
+
70
+ /** GitHub Actions job ids must match `[a-zA-Z_][a-zA-Z0-9_-]*`; Op names are already kebab-case in every fixture, but normalize defensively (mirrors `./generate-pipeline.ts`'s `toJobName`). */
71
+ function toJobName(opName: string): string {
72
+ return opName.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
73
+ }
74
+
75
+ const DEFAULT_IMAGE = "node:22-slim";
76
+
77
+ /**
78
+ * Least-privilege `permissions:` for a scheduled Op's finding-mode. `report`
79
+ * needs no write access; `issue` needs only `issues: write`; `pull-request`
80
+ * (and `merge-request`, generated the same way when a GitLab-authored spec is
81
+ * targeted at github) needs `contents: write` to push the reconcile branch
82
+ * plus `pull-requests: write` to open the PR.
83
+ */
84
+ function permissionsFor(mode: OpFindingMode): Record<string, "read" | "write"> {
85
+ switch (mode) {
86
+ case "issue":
87
+ return { contents: "read", issues: "write" };
88
+ case "pull-request":
89
+ case "merge-request":
90
+ return { contents: "write", "pull-requests": "write" };
91
+ case "report":
92
+ return { contents: "read" };
93
+ }
94
+ }
95
+
96
+ /**
97
+ * Build one `GithubOpPipelineDoc` per scheduled Op: cron trigger,
98
+ * least-privilege `permissions:` for its finding-mode, one job that runs
99
+ * `chant run <name>`. Throws nothing — every `ScheduledOpSpec` is independent,
100
+ * unlike the component generator there is no shared graph to resolve.
101
+ */
102
+ export function buildGithubOpPipelineDocs(
103
+ ops: ScheduledOpSpec[],
104
+ options: GenerateGithubOpOptions = {},
105
+ ): { files: GithubOpPipelineFile[]; jobs: OpPipelineJob[] } {
106
+ const image = options.image ?? DEFAULT_IMAGE;
107
+ const runCommand = options.runCommand ?? ["chant", "run", "{name}"];
108
+ const beforeScript = options.beforeScript ?? [];
109
+ const extraScript = options.extraScript ?? [];
110
+
111
+ const files: GithubOpPipelineFile[] = [];
112
+ const jobs: OpPipelineJob[] = [];
113
+
114
+ for (const spec of ops) {
115
+ const findingMode = spec.findingMode ?? "report";
116
+ const jobName = toJobName(spec.name);
117
+ jobs.push({ jobName, op: spec.name, schedule: spec.schedule, findingMode });
118
+
119
+ const runParts = runCommand.map((part) => part.replace("{name}", spec.name));
120
+
121
+ // A live-resolution read (rate limits) always benefits from a token;
122
+ // creating an issue/PR additionally needs `gh` CLI's own token variable.
123
+ const stepEnv: Record<string, string> = { GITHUB_TOKEN: "${{ github.token }}" };
124
+ if (findingMode !== "report") stepEnv.GH_TOKEN = "${{ github.token }}";
125
+
126
+ const steps: Array<Record<string, unknown>> = [{ uses: "actions/checkout@v4" }];
127
+ for (const line of beforeScript) steps.push({ run: line });
128
+ steps.push({ run: runParts.join(" "), env: stepEnv });
129
+ for (const line of extraScript) steps.push({ run: line });
130
+
131
+ const doc: GithubOpPipelineDoc = {
132
+ on: { schedule: [{ cron: spec.schedule }], workflow_dispatch: {} },
133
+ ...(options.variables && Object.keys(options.variables).length > 0 ? { env: options.variables } : {}),
134
+ // One run at a time per Op — a slow audit must not overlap its own next
135
+ // scheduled trigger.
136
+ concurrency: { group: jobName, "cancel-in-progress": false },
137
+ permissions: permissionsFor(findingMode),
138
+ jobsDoc: {
139
+ [jobName]: {
140
+ "runs-on": "ubuntu-latest",
141
+ container: image,
142
+ steps,
143
+ },
144
+ },
145
+ };
146
+
147
+ files.push({ name: `${spec.name}.yml`, doc });
148
+ }
149
+
150
+ return { files, jobs };
151
+ }
152
+
153
+ /**
154
+ * Emit a `GithubOpPipelineDoc`'s `on`/`env`/`concurrency`/`permissions`/`jobs`
155
+ * mappings as workflow YAML. Shared with the forgejo dialect (#969), which
156
+ * transforms the doc first (and drops `permissions`, which it ignores).
157
+ */
158
+ export function emitOpPipelineYAML(doc: GithubOpPipelineDoc): string {
159
+ const sections: string[] = [];
160
+ sections.push("on:" + emitYAML(doc.on, 1));
161
+ if (doc.env && Object.keys(doc.env).length > 0) sections.push("env:" + emitYAML(doc.env, 1));
162
+ sections.push("concurrency:" + emitYAML(doc.concurrency, 1));
163
+ if (Object.keys(doc.permissions).length > 0) sections.push("permissions:" + emitYAML(doc.permissions, 1));
164
+ sections.push("jobs:" + emitYAML(doc.jobsDoc, 1));
165
+ return sections.join("\n\n") + "\n";
166
+ }
167
+
168
+ /**
169
+ * Synthesize one `.github/workflows/*.yml` per scheduled Op. Thin wrapper
170
+ * over {@link buildGithubOpPipelineDocs} + {@link emitOpPipelineYAML}. Wired
171
+ * into core's Op generate mode via the github lexicon plugin's
172
+ * `generateOpPipeline` (../plugin.ts).
173
+ */
174
+ export function generateGithubOpPipeline(
175
+ ops: ScheduledOpSpec[],
176
+ options: GenerateGithubOpOptions = {},
177
+ ): GenerateGithubOpResult {
178
+ const { files, jobs } = buildGithubOpPipelineDocs(ops, options);
179
+ return {
180
+ files: files.map((f) => ({ name: f.name, yaml: emitOpPipelineYAML(f.doc) })),
181
+ jobs,
182
+ };
183
+ }
@@ -100,6 +100,7 @@ export declare class Permissions {
100
100
  checks?: "read" | "write" | "none";
101
101
  "code-quality"?: "read" | "write" | "none";
102
102
  contents?: "read" | "write" | "none";
103
+ "copilot-requests"?: "write";
103
104
  deployments?: "read" | "write" | "none";
104
105
  discussions?: "read" | "write" | "none";
105
106
  "id-token"?: "read" | "write" | "none";
@@ -53,6 +53,11 @@
53
53
  "read",
54
54
  "none"
55
55
  ]
56
+ },
57
+ "copilot-requests": {
58
+ "enum": [
59
+ "write"
60
+ ]
56
61
  }
57
62
  }
58
63
  },
@@ -2,7 +2,7 @@
2
2
  * The github lexicon's chant audit catalog — metadata for its post-synth rules
3
3
  * (GHA GitHub Actions rules). Contributed via githubPlugin.auditCatalog() (#687).
4
4
  */
5
- import { auditRule, GH_INJECTION, GH_PWN, GH_SECRETS, GH_THIRD_PARTY, GH_TOKEN, SCORECARD_PINNED, SCORECARD_TOKEN, type RuleMeta } from "@intentius/chant/audit/catalog";
5
+ import { auditRule, GH_INJECTION, GH_PWN, GH_SECRETS, GH_THIRD_PARTY, GH_TOKEN, SCORECARD_PINNED, SCORECARD_TOKEN, SCORECARD_VULN, type RuleMeta } from "@intentius/chant/audit/catalog";
6
6
 
7
7
  export const githubAuditCatalog: Record<string, RuleMeta> = {
8
8
  GHA006: auditRule("GHA006", "report-only", "guidance", "Duplicate workflow name", "Give each workflow a unique `name:`.", { category: "correctness" }),
@@ -50,4 +50,19 @@ export const githubAuditCatalog: Record<string, RuleMeta> = {
50
50
  GHA056: auditRule("GHA056", "report-only", "guidance", "Workflow without a name", "Add a `name:` to the workflow.", { category: "best-practice" }),
51
51
  GHA057: auditRule("GHA057", "merge-worthy", "guidance", "Dependency update can execute untrusted code", "Disable the option that lets dependency updates run external code.", { authority: [GH_PWN] }),
52
52
  GHA058: auditRule("GHA058", "report-only", "guidance", "Dependency update has no cooldown window", "Add a cooldown so new releases aren't merged instantly.", { category: "best-practice" }),
53
+
54
+ // Supply-chain and token-scoping expansion (#445).
55
+ GHA059: auditRule("GHA059", "merge-worthy", "guidance", "Stale or missing pin annotation", "Add or correct the trailing version comment on the SHA-pinned reference so it matches the digest.", { authority: [SCORECARD_PINNED, GH_THIRD_PARTY] }),
56
+ GHA060: auditRule("GHA060", "merge-worthy", "guidance", "Over-scoped generated token", "Narrow the GitHub App token's permission-* inputs to the scopes its consuming steps actually use, or remove the unused ones.", { authority: [SCORECARD_TOKEN, GH_TOKEN] }),
57
+ GHA061: auditRule("GHA061", "merge-worthy", "guidance", "Action reference outside the configured usage policy", "Replace the reference with one from the approved allowlist, or get it added if it should be trusted.", { category: "security" }),
58
+ GHA062: auditRule("GHA062", "merge-worthy", "guidance", "Pinned reference matches a known-vulnerability advisory", "Bump the reference to a patched ref/commit named by the advisory.", { authority: [SCORECARD_VULN] }),
59
+
60
+ // Efficiency (#444) — waste, not a safety/correctness issue. Always
61
+ // report-only: none of these warrant a merge on their own.
62
+ GHA063: auditRule("GHA063", "report-only", "guidance", "Dependency setup action without caching enabled", "Enable the setup action's `cache:` option or add an actions/cache step.", { category: "efficiency" }),
63
+ GHA064: auditRule("GHA064", "report-only", "guidance", "Expensive runner without justification", "Confirm the job needs that OS, or move it to a Linux runner.", { category: "efficiency" }),
64
+ GHA065: auditRule("GHA065", "report-only", "guidance", "Unbounded matrix fan-out", "Trim the matrix with include/exclude, or cap it with max-parallel.", { category: "efficiency" }),
65
+ GHA066: auditRule("GHA066", "report-only", "guidance", "Unbounded artifact retention", "Set a retention-days sized to how long the artifact is actually needed.", { category: "efficiency" }),
66
+ GHA067: auditRule("GHA067", "report-only", "guidance", "Unconditional heavy step with no path filter", "Scope the trigger's paths, or add an if: guard to the step.", { category: "efficiency" }),
67
+ GHA068: auditRule("GHA068", "report-only", "guidance", "Pull-request workflow missing a concurrency group", "Add a concurrency group with cancel-in-progress: true.", { category: "efficiency" }),
53
68
  };
@@ -0,0 +1,147 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import type { PostSynthContext } from "@intentius/chant/lint/post-synth";
3
+ import { gha059, findStalePinAnnotations } from "./gha059";
4
+
5
+ function makeCtx(yaml: string): PostSynthContext {
6
+ return {
7
+ outputs: new Map([["github", yaml]]),
8
+ entities: new Map(),
9
+ buildResult: {
10
+ outputs: new Map([["github", yaml]]),
11
+ entities: new Map(),
12
+ warnings: [],
13
+ errors: [],
14
+ sourceFileCount: 1,
15
+ },
16
+ };
17
+ }
18
+
19
+ const SHA_A = "1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b";
20
+ const SHA_B = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
21
+
22
+ describe("GHA059: stale or missing pin annotation", () => {
23
+ test("flags a SHA-pinned ref with no trailing comment", () => {
24
+ const yaml = `name: CI
25
+ on:
26
+ push:
27
+ jobs:
28
+ build:
29
+ runs-on: ubuntu-latest
30
+ steps:
31
+ - uses: actions/setup-node@${SHA_A}
32
+ `;
33
+ const diags = gha059.check(makeCtx(yaml));
34
+ expect(diags).toHaveLength(1);
35
+ expect(diags[0].checkId).toBe("GHA059");
36
+ expect(diags[0].severity).toBe("warning");
37
+ expect(diags[0].message).toContain("no trailing version comment");
38
+ });
39
+
40
+ test("does not flag a SHA-pinned ref with a comment", () => {
41
+ const yaml = `name: CI
42
+ on:
43
+ push:
44
+ jobs:
45
+ build:
46
+ runs-on: ubuntu-latest
47
+ steps:
48
+ - uses: actions/setup-node@${SHA_A} # v4.0.2
49
+ `;
50
+ expect(gha059.check(makeCtx(yaml))).toHaveLength(0);
51
+ });
52
+
53
+ test("treats a bare '# ' with nothing after it as missing", () => {
54
+ const yaml = `name: CI
55
+ on:
56
+ push:
57
+ jobs:
58
+ build:
59
+ runs-on: ubuntu-latest
60
+ steps:
61
+ - uses: actions/setup-node@${SHA_A} #
62
+ `;
63
+ const diags = gha059.check(makeCtx(yaml));
64
+ expect(diags).toHaveLength(1);
65
+ expect(diags[0].message).toContain("no trailing version comment");
66
+ });
67
+
68
+ test("flags the same commit annotated with two different labels (mismatch)", () => {
69
+ const yaml = `name: CI
70
+ on:
71
+ push:
72
+ jobs:
73
+ build:
74
+ runs-on: ubuntu-latest
75
+ steps:
76
+ - uses: actions/setup-node@${SHA_A} # v4.0.2
77
+ test:
78
+ runs-on: ubuntu-latest
79
+ steps:
80
+ - uses: actions/setup-node@${SHA_A} # v4.0.3
81
+ `;
82
+ const diags = gha059.check(makeCtx(yaml));
83
+ expect(diags).toHaveLength(2);
84
+ expect(diags.every((d) => d.severity === "warning")).toBe(true);
85
+ expect(diags.every((d) => d.message.includes("internally inconsistent"))).toBe(true);
86
+ });
87
+
88
+ test("flags the same label attached to two different commits (mismatch)", () => {
89
+ const yaml = `name: CI
90
+ on:
91
+ push:
92
+ jobs:
93
+ build:
94
+ runs-on: ubuntu-latest
95
+ steps:
96
+ - uses: actions/setup-node@${SHA_A} # v4.0.2
97
+ test:
98
+ runs-on: ubuntu-latest
99
+ steps:
100
+ - uses: actions/setup-node@${SHA_B} # v4.0.2
101
+ `;
102
+ const diags = gha059.check(makeCtx(yaml));
103
+ expect(diags).toHaveLength(2);
104
+ expect(diags.every((d) => d.message.includes("internally inconsistent"))).toBe(true);
105
+ });
106
+
107
+ test("does not flag two different actions annotated independently", () => {
108
+ const yaml = `name: CI
109
+ on:
110
+ push:
111
+ jobs:
112
+ build:
113
+ runs-on: ubuntu-latest
114
+ steps:
115
+ - uses: actions/setup-node@${SHA_A} # v4.0.2
116
+ - uses: actions/setup-go@${SHA_B} # v5.0.0
117
+ `;
118
+ expect(findStalePinAnnotations(yaml)).toHaveLength(0);
119
+ });
120
+
121
+ test("does not flag a tag/branch-pinned ref (owned by GHA029)", () => {
122
+ const yaml = `name: CI
123
+ on:
124
+ push:
125
+ jobs:
126
+ build:
127
+ runs-on: ubuntu-latest
128
+ steps:
129
+ - uses: actions/setup-node@v4
130
+ `;
131
+ expect(findStalePinAnnotations(yaml)).toHaveLength(0);
132
+ });
133
+
134
+ test("does not flag local or docker references", () => {
135
+ const yaml = `name: CI
136
+ on:
137
+ push:
138
+ jobs:
139
+ build:
140
+ runs-on: ubuntu-latest
141
+ steps:
142
+ - uses: ./.github/actions/local
143
+ - uses: docker://alpine@sha256:abc
144
+ `;
145
+ expect(findStalePinAnnotations(yaml)).toHaveLength(0);
146
+ });
147
+ });
@@ -0,0 +1,132 @@
1
+ /**
2
+ * GHA059: Stale or Missing Pin Annotation
3
+ *
4
+ * A ref pinned to a commit SHA is only as trustworthy as the label a reviewer
5
+ * reads next to it — `uses: actions/setup-node@1a2b…9a0b # v4.0.2` lets a
6
+ * reviewer sanity-check the digest without resolving it themselves. This check
7
+ * flags two ways that label goes stale:
8
+ *
9
+ * 1. Missing — a SHA-pinned ref with no trailing version comment at all.
10
+ * 2. Mismatched — inferred without any network call, from internal
11
+ * inconsistency in the workflow itself: the same action is pinned to the
12
+ * same commit SHA in one place and a *different* label in another, or the
13
+ * same label is attached to two *different* commit SHAs. Either shape
14
+ * proves at least one of the labels is wrong — a real digest has exactly
15
+ * one correct human-readable name.
16
+ *
17
+ * Cross-referencing a label against the actual git history (to catch a lone,
18
+ * internally-consistent but still-wrong annotation) needs a network call and
19
+ * is out of scope here — see GHA062 for the feed-based advisory check, which
20
+ * takes the same "no network in the rule itself" stance via an injected feed.
21
+ */
22
+
23
+ import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
24
+ import { getPrimaryOutput, extractActionRefs, extractUsesComment, parseActionUses } from "./yaml-helpers";
25
+
26
+ const SHA_RE = /^[0-9a-f]{40}$/;
27
+
28
+ export interface StalePinFinding {
29
+ job: string;
30
+ ref: string;
31
+ slug: string;
32
+ sha: string;
33
+ kind: "missing" | "mismatched";
34
+ /** For "mismatched": the other label/SHA this one conflicts with. */
35
+ conflict?: string;
36
+ }
37
+
38
+ /**
39
+ * Find every SHA-pinned `uses:` with a missing or internally-inconsistent
40
+ * version annotation. Scoped to one workflow document — cross-file
41
+ * consistency isn't attempted since two independent workflows pinning the
42
+ * same action differently isn't evidence of drift.
43
+ */
44
+ export function findStalePinAnnotations(yaml: string): StalePinFinding[] {
45
+ const refs = extractActionRefs(yaml)
46
+ .map(({ job, ref }) => ({ job, ref, parsed: parseActionUses(ref) }))
47
+ .filter((r): r is { job: string; ref: string; parsed: NonNullable<ReturnType<typeof parseActionUses>> } => !!r.parsed && SHA_RE.test(r.parsed.gitRef));
48
+
49
+ const findings: StalePinFinding[] = [];
50
+
51
+ // First pass: missing annotations.
52
+ const withComment: Array<{ job: string; ref: string; slug: string; sha: string; comment: string }> = [];
53
+ for (const { job, ref, parsed } of refs) {
54
+ const comment = extractUsesComment(ref);
55
+ if (!comment) {
56
+ findings.push({ job, ref, slug: parsed.slug, sha: parsed.gitRef, kind: "missing" });
57
+ } else {
58
+ withComment.push({ job, ref, slug: parsed.slug, sha: parsed.gitRef, comment });
59
+ }
60
+ }
61
+
62
+ // Second pass: internal mismatch. Group by slug — the same action's
63
+ // commit-SHA↔label mapping must be one-to-one across the whole file.
64
+ const bySlug = new Map<string, Array<{ job: string; ref: string; slug: string; sha: string; comment: string }>>();
65
+ for (const entry of withComment) {
66
+ const list = bySlug.get(entry.slug) ?? [];
67
+ list.push(entry);
68
+ bySlug.set(entry.slug, list);
69
+ }
70
+
71
+ for (const [, entries] of bySlug) {
72
+ const shaToComments = new Map<string, Set<string>>();
73
+ const commentToShas = new Map<string, Set<string>>();
74
+ for (const e of entries) {
75
+ (shaToComments.get(e.sha) ?? shaToComments.set(e.sha, new Set()).get(e.sha)!).add(e.comment);
76
+ (commentToShas.get(e.comment) ?? commentToShas.set(e.comment, new Set()).get(e.comment)!).add(e.sha);
77
+ }
78
+ for (const e of entries) {
79
+ const otherComments = [...(shaToComments.get(e.sha) ?? [])].filter((c) => c !== e.comment);
80
+ const otherShas = [...(commentToShas.get(e.comment) ?? [])].filter((s) => s !== e.sha);
81
+ if (otherComments.length > 0) {
82
+ findings.push({
83
+ job: e.job,
84
+ ref: e.ref,
85
+ slug: e.slug,
86
+ sha: e.sha,
87
+ kind: "mismatched",
88
+ conflict: `commit ${e.sha} is also annotated "# ${otherComments[0]}" elsewhere`,
89
+ });
90
+ } else if (otherShas.length > 0) {
91
+ findings.push({
92
+ job: e.job,
93
+ ref: e.ref,
94
+ slug: e.slug,
95
+ sha: e.sha,
96
+ kind: "mismatched",
97
+ conflict: `label "# ${e.comment}" is also attached to commit ${otherShas[0]} elsewhere`,
98
+ });
99
+ }
100
+ }
101
+ }
102
+
103
+ return findings;
104
+ }
105
+
106
+ export const gha059: PostSynthCheck = {
107
+ id: "GHA059",
108
+ description: "SHA-pinned action reference has a missing or internally-inconsistent version annotation",
109
+
110
+ check(ctx: PostSynthContext): PostSynthDiagnostic[] {
111
+ const diagnostics: PostSynthDiagnostic[] = [];
112
+
113
+ for (const [, output] of ctx.outputs) {
114
+ const yaml = getPrimaryOutput(output);
115
+ for (const finding of findStalePinAnnotations(yaml)) {
116
+ const message =
117
+ finding.kind === "missing"
118
+ ? `Job "${finding.job}" pins ${finding.ref} to a commit SHA with no trailing version comment (e.g. "# v4.0.2") — add one so reviewers can sanity-check the digest.`
119
+ : `Job "${finding.job}" pins ${finding.ref} whose annotation is internally inconsistent (${finding.conflict}) — one of these labels no longer matches the digest it's attached to.`;
120
+ diagnostics.push({
121
+ checkId: "GHA059",
122
+ severity: "warning",
123
+ message,
124
+ entity: finding.job,
125
+ lexicon: "github",
126
+ });
127
+ }
128
+ }
129
+
130
+ return diagnostics;
131
+ },
132
+ };