@intentius/chant-lexicon-github 0.49.0 → 0.51.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.
- package/dist/components/generate-op-pipeline.d.ts +81 -0
- package/dist/components/generate-op-pipeline.d.ts.map +1 -0
- package/dist/integrity.json +15 -5
- package/dist/lint/audit-catalog.d.ts.map +1 -1
- package/dist/lint/post-synth/gha059.d.ts +40 -0
- package/dist/lint/post-synth/gha059.d.ts.map +1 -0
- package/dist/lint/post-synth/gha060.d.ts +34 -0
- package/dist/lint/post-synth/gha060.d.ts.map +1 -0
- package/dist/lint/post-synth/gha061.d.ts +27 -0
- package/dist/lint/post-synth/gha061.d.ts.map +1 -0
- package/dist/lint/post-synth/gha062.d.ts +24 -0
- package/dist/lint/post-synth/gha062.d.ts.map +1 -0
- package/dist/lint/post-synth/gha063.d.ts +12 -0
- package/dist/lint/post-synth/gha063.d.ts.map +1 -0
- package/dist/lint/post-synth/gha064.d.ts +12 -0
- package/dist/lint/post-synth/gha064.d.ts.map +1 -0
- package/dist/lint/post-synth/gha065.d.ts +19 -0
- package/dist/lint/post-synth/gha065.d.ts.map +1 -0
- package/dist/lint/post-synth/gha066.d.ts +12 -0
- package/dist/lint/post-synth/gha066.d.ts.map +1 -0
- package/dist/lint/post-synth/gha067.d.ts +12 -0
- package/dist/lint/post-synth/gha067.d.ts.map +1 -0
- package/dist/lint/post-synth/gha068.d.ts +13 -0
- package/dist/lint/post-synth/gha068.d.ts.map +1 -0
- package/dist/lint/post-synth/index.d.ts.map +1 -1
- package/dist/lint/post-synth/yaml-helpers.d.ts +20 -0
- package/dist/lint/post-synth/yaml-helpers.d.ts.map +1 -1
- package/dist/lint/rules/data/action-usage-policy.d.ts +30 -0
- package/dist/lint/rules/data/action-usage-policy.d.ts.map +1 -0
- package/dist/lint/rules/data/advisory-feed.d.ts +36 -0
- package/dist/lint/rules/data/advisory-feed.d.ts.map +1 -0
- package/dist/manifest.json +109 -1
- package/dist/meta.json +5 -0
- package/dist/okf/index.md +10 -0
- package/dist/okf/rules/GHA059.md +15 -0
- package/dist/okf/rules/GHA060.md +15 -0
- package/dist/okf/rules/GHA061.md +15 -0
- package/dist/okf/rules/GHA062.md +15 -0
- package/dist/okf/rules/GHA063.md +15 -0
- package/dist/okf/rules/GHA064.md +15 -0
- package/dist/okf/rules/GHA065.md +15 -0
- package/dist/okf/rules/GHA066.md +15 -0
- package/dist/okf/rules/GHA067.md +15 -0
- package/dist/okf/rules/GHA068.md +15 -0
- package/dist/okf/types/Job.md +9 -0
- package/dist/okf/types/Workflow.md +1 -0
- package/dist/plugin.d.ts.map +1 -1
- package/dist/rules/gha059.ts +132 -0
- package/dist/rules/gha060.ts +146 -0
- package/dist/rules/gha061.ts +85 -0
- package/dist/rules/gha062.ts +68 -0
- package/dist/rules/gha063.ts +60 -0
- package/dist/rules/gha064.ts +55 -0
- package/dist/rules/gha065.ts +129 -0
- package/dist/rules/gha066.ts +47 -0
- package/dist/rules/gha067.ts +76 -0
- package/dist/rules/gha068.ts +51 -0
- package/dist/rules/yaml-helpers.ts +37 -2
- package/dist/types/index.d.ts +1 -0
- package/package.json +2 -2
- package/src/codegen/docs.ts +1 -1
- package/src/components/generate-op-pipeline.test.ts +146 -0
- package/src/components/generate-op-pipeline.ts +183 -0
- package/src/generated/index.d.ts +1 -0
- package/src/generated/lexicon-github.json +5 -0
- package/src/lint/audit-catalog.ts +16 -1
- package/src/lint/post-synth/gha059.test.ts +147 -0
- package/src/lint/post-synth/gha059.ts +132 -0
- package/src/lint/post-synth/gha060.test.ts +144 -0
- package/src/lint/post-synth/gha060.ts +146 -0
- package/src/lint/post-synth/gha061.test.ts +85 -0
- package/src/lint/post-synth/gha061.ts +85 -0
- package/src/lint/post-synth/gha062.test.ts +88 -0
- package/src/lint/post-synth/gha062.ts +68 -0
- package/src/lint/post-synth/gha063.test.ts +86 -0
- package/src/lint/post-synth/gha063.ts +60 -0
- package/src/lint/post-synth/gha064.test.ts +73 -0
- package/src/lint/post-synth/gha064.ts +55 -0
- package/src/lint/post-synth/gha065.test.ts +85 -0
- package/src/lint/post-synth/gha065.ts +129 -0
- package/src/lint/post-synth/gha066.test.ts +66 -0
- package/src/lint/post-synth/gha066.ts +47 -0
- package/src/lint/post-synth/gha067.test.ts +85 -0
- package/src/lint/post-synth/gha067.ts +76 -0
- package/src/lint/post-synth/gha068.test.ts +71 -0
- package/src/lint/post-synth/gha068.ts +51 -0
- package/src/lint/post-synth/index.ts +20 -0
- package/src/lint/post-synth/yaml-helpers.ts +37 -2
- package/src/lint/rules/data/action-usage-policy.ts +31 -0
- package/src/lint/rules/data/advisory-feed.ts +38 -0
- package/src/plugin.test.ts +24 -3
- package/src/plugin.ts +28 -0
package/src/codegen/docs.ts
CHANGED
|
@@ -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** + **
|
|
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
|
+
}
|
package/src/generated/index.d.ts
CHANGED
|
@@ -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";
|
|
@@ -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
|
+
};
|