@intentius/chant-lexicon-github 0.101.0 → 0.104.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 (95) hide show
  1. package/dist/action-pins.d.ts +53 -0
  2. package/dist/action-pins.d.ts.map +1 -0
  3. package/dist/codegen/naming.d.ts.map +1 -1
  4. package/dist/components/generate-op-pipeline.d.ts.map +1 -1
  5. package/dist/components/generate-pipeline.d.ts.map +1 -1
  6. package/dist/composites/checkout.d.ts +7 -0
  7. package/dist/composites/checkout.d.ts.map +1 -1
  8. package/dist/composites/dependabot.d.ts.map +1 -1
  9. package/dist/composites/deploy-environment.d.ts.map +1 -1
  10. package/dist/composites/docker-build.d.ts.map +1 -1
  11. package/dist/composites/go-ci.d.ts.map +1 -1
  12. package/dist/composites/node-ci.d.ts.map +1 -1
  13. package/dist/composites/node-pipeline.d.ts +30 -30
  14. package/dist/composites/node-pipeline.d.ts.map +1 -1
  15. package/dist/composites/python-ci.d.ts.map +1 -1
  16. package/dist/composites/setup-node.d.ts +7 -0
  17. package/dist/composites/setup-node.d.ts.map +1 -1
  18. package/dist/expression.d.ts.map +1 -1
  19. package/dist/generated/index.d.ts +22 -22
  20. package/dist/generated/index.d.ts.map +1 -1
  21. package/dist/import/generator.d.ts.map +1 -1
  22. package/dist/import/parser.d.ts.map +1 -1
  23. package/dist/index.d.ts +2 -0
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/integrity.json +7 -6
  26. package/dist/lint/audit-catalog.d.ts.map +1 -1
  27. package/dist/lint/audit-lineage.d.ts +13 -0
  28. package/dist/lint/audit-lineage.d.ts.map +1 -1
  29. package/dist/lint/post-synth/gha021.d.ts.map +1 -1
  30. package/dist/lint/post-synth/gha029.d.ts.map +1 -1
  31. package/dist/lint/post-synth/gha059.d.ts.map +1 -1
  32. package/dist/lint/post-synth/gha069.d.ts +33 -0
  33. package/dist/lint/post-synth/gha069.d.ts.map +1 -0
  34. package/dist/lint/post-synth/index.d.ts.map +1 -1
  35. package/dist/lint/post-synth/yaml-helpers.d.ts +20 -0
  36. package/dist/lint/post-synth/yaml-helpers.d.ts.map +1 -1
  37. package/dist/lint/rules/data/deprecated-versions.d.ts.map +1 -1
  38. package/dist/manifest.json +1 -1
  39. package/dist/okf/index.md +1 -0
  40. package/dist/okf/rules/GHA069.md +16 -0
  41. package/dist/okf/types/Job.md +1 -0
  42. package/dist/okf/types/Workflow.md +1 -0
  43. package/dist/rules/gha021.ts +24 -24
  44. package/dist/rules/gha029.ts +3 -2
  45. package/dist/rules/gha059.ts +10 -2
  46. package/dist/rules/gha069.ts +111 -0
  47. package/dist/rules/yaml-helpers.ts +41 -47
  48. package/dist/variables.d.ts.map +1 -1
  49. package/package.json +3 -3
  50. package/src/action-pins.test.ts +87 -0
  51. package/src/action-pins.ts +75 -0
  52. package/src/codegen/docs.ts +1 -1
  53. package/src/components/__fixtures__/pr-loop.github.golden.yml +68 -0
  54. package/src/components/generate-op-pipeline.test.ts +4 -4
  55. package/src/components/generate-op-pipeline.ts +8 -7
  56. package/src/components/generate-pipeline.test.ts +30 -1
  57. package/src/components/generate-pipeline.ts +157 -14
  58. package/src/components/generate-pr-pipeline.test.ts +98 -0
  59. package/src/composites/checkout.ts +8 -1
  60. package/src/composites/composites.test.ts +4 -4
  61. package/src/composites/deploy-environment.ts +3 -2
  62. package/src/composites/docker-build.ts +2 -1
  63. package/src/composites/go-ci.ts +4 -3
  64. package/src/composites/node-ci.ts +3 -2
  65. package/src/composites/node-pipeline.ts +8 -7
  66. package/src/composites/python-ci.ts +3 -2
  67. package/src/composites/setup-node.ts +8 -1
  68. package/src/index.ts +4 -0
  69. package/src/lint/audit-catalog.ts +1 -0
  70. package/src/lint/audit-lineage.ts +13 -0
  71. package/src/lint/post-synth/fixtures/GHA069/negative-no-job-block.yml +16 -0
  72. package/src/lint/post-synth/fixtures/GHA069/negative-no-oidc-step.yml +22 -0
  73. package/src/lint/post-synth/fixtures/GHA069/negative.yml +21 -0
  74. package/src/lint/post-synth/fixtures/GHA069/positive.yml +20 -0
  75. package/src/lint/post-synth/gha011.test.ts +16 -0
  76. package/src/lint/post-synth/gha018.test.ts +18 -0
  77. package/src/lint/post-synth/gha019.test.ts +42 -0
  78. package/src/lint/post-synth/gha021.test.ts +18 -0
  79. package/src/lint/post-synth/gha021.ts +24 -24
  80. package/src/lint/post-synth/gha022.test.ts +15 -0
  81. package/src/lint/post-synth/gha023.test.ts +19 -0
  82. package/src/lint/post-synth/gha029.ts +3 -2
  83. package/src/lint/post-synth/gha038.test.ts +20 -0
  84. package/src/lint/post-synth/gha059.ts +10 -2
  85. package/src/lint/post-synth/gha069.test.ts +173 -0
  86. package/src/lint/post-synth/gha069.ts +111 -0
  87. package/src/lint/post-synth/index.ts +2 -0
  88. package/src/lint/post-synth/yaml-helpers.test.ts +83 -0
  89. package/src/lint/post-synth/yaml-helpers.ts +41 -47
  90. package/src/lint/rules/data/deprecated-versions.ts +5 -2
  91. package/src/lint/rules/deprecated-action-version.test.ts +1 -1
  92. package/src/lint/rules/rules.test.ts +1 -1
  93. package/src/mcp/context-tools.test.ts +17 -0
  94. package/src/plugin.test.ts +1 -1
  95. package/src/serializer.ts +15 -4
@@ -0,0 +1,173 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import { readFileSync } from "node:fs";
3
+ import { dirname, join } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import type { PostSynthContext } from "@intentius/chant/lint/post-synth";
6
+ import { gha069 } from "./gha069";
7
+
8
+ const FIXTURES = join(dirname(fileURLToPath(import.meta.url)), "fixtures", "GHA069");
9
+
10
+ function makeCtx(yaml: string): PostSynthContext {
11
+ return {
12
+ outputs: new Map([["github", yaml]]),
13
+ entities: new Map(),
14
+ buildResult: {
15
+ outputs: new Map([["github", yaml]]),
16
+ entities: new Map(),
17
+ warnings: [],
18
+ errors: [],
19
+ sourceFileCount: 1,
20
+ },
21
+ };
22
+ }
23
+
24
+ function fixture(name: string): PostSynthContext {
25
+ return makeCtx(readFileSync(join(FIXTURES, `${name}.yml`), "utf-8"));
26
+ }
27
+
28
+ /** The issue's fixture with the workflow block, the job block and the step swapped in. */
29
+ function workflow(opts: { wf?: string; job?: string; step: string }): string {
30
+ const wf = opts.wf ?? "permissions:\n id-token: write\n contents: read\n";
31
+ const job = opts.job === undefined ? "" : ` permissions: ${opts.job}\n`;
32
+ return `name: Deploy
33
+ on:
34
+ push:
35
+ ${wf}jobs:
36
+ deploy:
37
+ runs-on: ubuntu-latest
38
+ ${job} steps:
39
+ ${opts.step}`;
40
+ }
41
+
42
+ const AWS_STEP = ` - uses: aws-actions/configure-aws-credentials@v4
43
+ with:
44
+ role-to-assume: arn:aws:iam::123456789012:role/deploy
45
+ aws-region: us-east-1
46
+ `;
47
+
48
+ describe("GHA069: job-level permissions block drops the workflow's id-token: write", () => {
49
+ test("fires on the issue's fixture and names the scope, the job and the fix", () => {
50
+ const diags = gha069.check(fixture("positive"));
51
+ expect(diags).toHaveLength(1);
52
+ expect(diags[0].checkId).toBe("GHA069");
53
+ expect(diags[0].severity).toBe("warning");
54
+ expect(diags[0].entity).toBe("deploy");
55
+ expect(diags[0].message).toContain("id-token: write");
56
+ expect(diags[0].message).toContain('Job "deploy"');
57
+ expect(diags[0].message).toContain("aws-actions/configure-aws-credentials");
58
+ expect(diags[0].message).toContain("Add `id-token: write` to job \"deploy\"'s permissions block");
59
+ expect(diags[0].message).toContain("rather than removing it");
60
+ });
61
+
62
+ test("does not fire when the job block carries the scope", () => {
63
+ expect(gha069.check(fixture("negative"))).toHaveLength(0);
64
+ });
65
+
66
+ test("does not fire when there is no job-level block", () => {
67
+ expect(gha069.check(fixture("negative-no-job-block"))).toHaveLength(0);
68
+ });
69
+
70
+ test("does not fire on a job block whose job needs no OIDC token", () => {
71
+ expect(gha069.check(fixture("negative-no-oidc-step"))).toHaveLength(0);
72
+ });
73
+
74
+ test("does not fire when the workflow block never granted id-token", () => {
75
+ const yaml = workflow({ wf: "permissions:\n contents: read\n", job: "\n contents: read\n", step: AWS_STEP });
76
+ expect(gha069.check(makeCtx(yaml))).toHaveLength(0);
77
+ });
78
+
79
+ test("does not fire when there is no workflow block at all", () => {
80
+ const yaml = workflow({ wf: "", job: "\n contents: read\n", step: AWS_STEP });
81
+ expect(gha069.check(makeCtx(yaml))).toHaveLength(0);
82
+ });
83
+
84
+ test.each([
85
+ ["azure/login", "azure/login@v2", "client-id: abc"],
86
+ ["Azure/login, as usually written", "Azure/login@v2", "client-id: abc"],
87
+ ["google-github-actions/auth", "google-github-actions/auth@v2", "workload_identity_provider: projects/1/locations/global/workloadIdentityPools/p/providers/gh"],
88
+ [
89
+ "a full SHA pin with a version comment",
90
+ "aws-actions/configure-aws-credentials@e3dd6a429d7300a6a4c196c26e071d42e0343502 # v4.0.2",
91
+ "role-to-assume: arn:aws:iam::123456789012:role/deploy",
92
+ ],
93
+ ["a major-only ref", "aws-actions/configure-aws-credentials@v4", "role-to-assume: arn:aws:iam::123456789012:role/deploy"],
94
+ ["an exact tag", "aws-actions/configure-aws-credentials@v4.0.2", "role-to-assume: arn:aws:iam::123456789012:role/deploy"],
95
+ ])("matches %s on the action path whatever the ref", (_label, uses, input) => {
96
+ const step = ` - uses: ${uses}\n with:\n ${input}\n`;
97
+ const diags = gha069.check(makeCtx(workflow({ job: "\n contents: read\n", step })));
98
+ expect(diags).toHaveLength(1);
99
+ });
100
+
101
+ test("a job block of {} drops id-token too", () => {
102
+ const diags = gha069.check(makeCtx(workflow({ job: "{}", step: AWS_STEP })));
103
+ expect(diags).toHaveLength(1);
104
+ expect(diags[0].message).toContain("`permissions: {}`");
105
+ });
106
+
107
+ test("a job block of read-all drops id-token too", () => {
108
+ const diags = gha069.check(makeCtx(workflow({ job: "read-all", step: AWS_STEP })));
109
+ expect(diags).toHaveLength(1);
110
+ expect(diags[0].message).toContain("`permissions: read-all`");
111
+ });
112
+
113
+ test("a job block of write-all grants id-token, so it does not fire", () => {
114
+ expect(gha069.check(makeCtx(workflow({ job: "write-all", step: AWS_STEP })))).toHaveLength(0);
115
+ });
116
+
117
+ test("a workflow block of write-all grants id-token, so a narrower job block drops it", () => {
118
+ const yaml = workflow({ wf: "permissions: write-all\n", job: "\n contents: read\n", step: AWS_STEP });
119
+ expect(gha069.check(makeCtx(yaml))).toHaveLength(1);
120
+ });
121
+
122
+ test("a workflow block of read-all never granted id-token, so it does not fire", () => {
123
+ const yaml = workflow({ wf: "permissions: read-all\n", job: "\n contents: read\n", step: AWS_STEP });
124
+ expect(gha069.check(makeCtx(yaml))).toHaveLength(0);
125
+ });
126
+
127
+ test("a job block setting id-token: none still drops it", () => {
128
+ const diags = gha069.check(makeCtx(workflow({ job: "\n id-token: none\n", step: AWS_STEP })));
129
+ expect(diags).toHaveLength(1);
130
+ });
131
+
132
+ test.each([
133
+ ["aws-actions/configure-aws-credentials@v4", "aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}"],
134
+ ["azure/login@v2", "creds: ${{ secrets.AZURE_CREDENTIALS }}"],
135
+ ["google-github-actions/auth@v2", "credentials_json: ${{ secrets.GCP_SA_KEY }}"],
136
+ ])("does not fire when %s is given a static credential", (uses, input) => {
137
+ const step = ` - uses: ${uses}\n with:\n ${input}\n`;
138
+ expect(gha069.check(makeCtx(workflow({ job: "\n contents: read\n", step })))).toHaveLength(0);
139
+ });
140
+
141
+ test("reports each affected job once, and only those", () => {
142
+ const yaml = `name: Deploy
143
+ on: push
144
+ permissions:
145
+ id-token: write
146
+ jobs:
147
+ staging:
148
+ runs-on: ubuntu-latest
149
+ permissions:
150
+ contents: read
151
+ steps:
152
+ - uses: aws-actions/configure-aws-credentials@v4
153
+ with:
154
+ role-to-assume: arn:aws:iam::123456789012:role/staging
155
+ - uses: google-github-actions/auth@v2
156
+ with:
157
+ workload_identity_provider: projects/1/locations/global/workloadIdentityPools/p/providers/gh
158
+ prod:
159
+ runs-on: ubuntu-latest
160
+ permissions:
161
+ contents: read
162
+ id-token: write
163
+ steps:
164
+ - uses: aws-actions/configure-aws-credentials@v4
165
+ with:
166
+ role-to-assume: arn:aws:iam::123456789012:role/prod
167
+ `;
168
+ const diags = gha069.check(makeCtx(yaml));
169
+ expect(diags).toHaveLength(1);
170
+ expect(diags[0].entity).toBe("staging");
171
+ expect(diags[0].message).toContain("aws-actions/configure-aws-credentials, google-github-actions/auth");
172
+ });
173
+ });
@@ -0,0 +1,111 @@
1
+ /**
2
+ * GHA069: Job-Level Permissions Block Drops the Workflow's `id-token: write`
3
+ *
4
+ * A job-level `permissions:` block replaces the workflow-level one; GitHub
5
+ * does not merge the two. A workflow that grants `id-token: write` at the top
6
+ * and then gives a job its own block without that scope leaves the job with
7
+ * no OIDC token, and the job's cloud-credential action fails with an error
8
+ * that names neither the scope nor the job block (#2273).
9
+ *
10
+ * Fires when all three hold:
11
+ * - the workflow-level block grants `id-token: write` (directly, or through
12
+ * `write-all`);
13
+ * - a job declares its own block that does not (a map without
14
+ * `id-token: write`, `{}`, `read-all`, or any other preset but `write-all`);
15
+ * - that job has a step using a known OIDC credential action, matched on the
16
+ * action path whatever the ref (tag, major or full SHA), with no static
17
+ * credential input that would make the step skip OIDC.
18
+ *
19
+ * Only `id-token` is checked. The reasoning for not generalising to every
20
+ * scope a job block narrows is recorded on #2273.
21
+ */
22
+
23
+ import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
24
+ import {
25
+ getPrimaryOutput,
26
+ extractWorkflowPermissions,
27
+ extractJobPermissions,
28
+ extractStepsByJob,
29
+ parseActionUses,
30
+ type PermissionsValue,
31
+ } from "./yaml-helpers";
32
+
33
+ /**
34
+ * Actions that exchange the job's OIDC token for cloud credentials, keyed by
35
+ * lowercased `owner/repo`, each with the inputs that select a static
36
+ * credential instead (when one is set, the step does not need `id-token`).
37
+ */
38
+ export const OIDC_CREDENTIAL_ACTIONS: ReadonlyMap<string, readonly string[]> = new Map([
39
+ ["aws-actions/configure-aws-credentials", ["aws-access-key-id", "web-identity-token-file"]],
40
+ ["azure/login", ["creds"]],
41
+ ["google-github-actions/auth", ["credentials_json"]],
42
+ ]);
43
+
44
+ /** True when the permissions value grants `id-token: write`. */
45
+ export function grantsIdTokenWrite(perms: PermissionsValue): boolean {
46
+ if (typeof perms === "string") return perms.trim() === "write-all";
47
+ return perms["id-token"] === "write";
48
+ }
49
+
50
+ /** The OIDC credential action a step uses, as written, or undefined. */
51
+ function oidcActionOf(step: Record<string, unknown>): string | undefined {
52
+ if (typeof step.uses !== "string") return undefined;
53
+ const parsed = parseActionUses(step.uses);
54
+ if (!parsed) return undefined;
55
+ const staticInputs = OIDC_CREDENTIAL_ACTIONS.get(parsed.slug.toLowerCase());
56
+ if (!staticInputs) return undefined;
57
+ const withInputs = step.with;
58
+ if (withInputs && typeof withInputs === "object" && !Array.isArray(withInputs)) {
59
+ const inputs = withInputs as Record<string, unknown>;
60
+ if (staticInputs.some((k) => inputs[k] !== undefined && inputs[k] !== null && inputs[k] !== "")) return undefined;
61
+ }
62
+ return parsed.slug;
63
+ }
64
+
65
+ function describeJobBlock(perms: PermissionsValue): string {
66
+ if (typeof perms === "string") return `\`permissions: ${perms}\``;
67
+ if (Object.keys(perms).length === 0) return "`permissions: {}`";
68
+ return "its own `permissions:` block";
69
+ }
70
+
71
+ export const gha069: PostSynthCheck = {
72
+ id: "GHA069",
73
+ description: "Job-level permissions block drops the workflow's id-token: write that an OIDC credential step needs",
74
+
75
+ check(ctx: PostSynthContext): PostSynthDiagnostic[] {
76
+ const diagnostics: PostSynthDiagnostic[] = [];
77
+
78
+ for (const [, output] of ctx.outputs) {
79
+ const yaml = getPrimaryOutput(output);
80
+
81
+ const wf = extractWorkflowPermissions(yaml);
82
+ if (!wf || !grantsIdTokenWrite(wf)) continue;
83
+
84
+ const jobPermissions = extractJobPermissions(yaml);
85
+ if (jobPermissions.size === 0) continue;
86
+
87
+ const stepsByJob = extractStepsByJob(yaml);
88
+ for (const [job, perms] of jobPermissions) {
89
+ if (grantsIdTokenWrite(perms)) continue;
90
+
91
+ const actions = [
92
+ ...new Set((stepsByJob.get(job) ?? []).map(oidcActionOf).filter((a): a is string => a !== undefined)),
93
+ ];
94
+ if (actions.length === 0) continue;
95
+
96
+ diagnostics.push({
97
+ checkId: "GHA069",
98
+ severity: "warning",
99
+ message:
100
+ `Job "${job}" declares ${describeJobBlock(perms)}, which replaces the workflow-level block instead of merging with it, ` +
101
+ `so the workflow's \`id-token: write\` does not reach the job and ${actions.join(", ")} cannot request an OIDC token. ` +
102
+ `Add \`id-token: write\` to job "${job}"'s permissions block; keep the job block rather than removing it.`,
103
+ entity: job,
104
+ lexicon: "github",
105
+ });
106
+ }
107
+ }
108
+
109
+ return diagnostics;
110
+ },
111
+ };
@@ -55,6 +55,7 @@ import { gha065 } from "./gha065";
55
55
  import { gha066 } from "./gha066";
56
56
  import { gha067 } from "./gha067";
57
57
  import { gha068 } from "./gha068";
58
+ import { gha069 } from "./gha069";
58
59
 
59
60
  export const postSynthChecks: PostSynthCheck[] = [
60
61
  gha006,
@@ -112,4 +113,5 @@ export const postSynthChecks: PostSynthCheck[] = [
112
113
  gha066,
113
114
  gha067,
114
115
  gha068,
116
+ gha069,
115
117
  ];
@@ -0,0 +1,83 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import { extractJobs, buildNeedsGraph } from "./yaml-helpers";
3
+
4
+ // The shape chant's serializer emits: every step starts with `name:`, and
5
+ // `run:` is often a block scalar. The line-based extractJobs read only the
6
+ // first line of a job's first step, so it saw none of these steps (#3201).
7
+ const NAME_FIRST = `name: CI
8
+ on:
9
+ pull_request_target:
10
+ branches:
11
+ - main
12
+ jobs:
13
+ build:
14
+ runs-on: ubuntu-latest
15
+ steps:
16
+ - name: Checkout
17
+ uses: actions/checkout@v7 # v7.0.1
18
+ - name: Setup Node
19
+ uses: actions/setup-node@v7
20
+ with:
21
+ node-version: "22"
22
+ - name: Test
23
+ run: |
24
+ npm ci
25
+ npm test
26
+ Publish_Docs:
27
+ needs: build
28
+ runs-on: ubuntu-latest
29
+ steps:
30
+ - name: Publish
31
+ run: echo publish
32
+ deploy:
33
+ needs:
34
+ - build
35
+ - Publish_Docs
36
+ runs-on: ubuntu-latest
37
+ steps:
38
+ - id: go
39
+ name: Deploy
40
+ run: echo deploy
41
+ `;
42
+
43
+ describe("extractJobs (#3201)", () => {
44
+ test("reads every step of a job whose steps start with name:", () => {
45
+ const build = extractJobs(NAME_FIRST).get("build");
46
+ expect(build?.steps).toEqual([
47
+ { name: "Checkout", uses: "actions/checkout@v7", run: undefined },
48
+ { name: "Setup Node", uses: "actions/setup-node@v7", run: undefined },
49
+ { name: "Test", uses: undefined, run: "npm ci\nnpm test\n" },
50
+ ]);
51
+ });
52
+
53
+ test("reads a step whose first key is neither name, uses nor run", () => {
54
+ expect(extractJobs(NAME_FIRST).get("deploy")?.steps).toEqual([
55
+ { name: "Deploy", uses: undefined, run: "echo deploy" },
56
+ ]);
57
+ });
58
+
59
+ test("reads job ids with capitals and underscores", () => {
60
+ expect([...extractJobs(NAME_FIRST).keys()]).toEqual(["build", "Publish_Docs", "deploy"]);
61
+ });
62
+
63
+ test("reads needs as a scalar, a block list and a flow list", () => {
64
+ const jobs = extractJobs(NAME_FIRST);
65
+ expect(jobs.get("build")?.needs).toBeUndefined();
66
+ expect(jobs.get("Publish_Docs")?.needs).toEqual(["build"]);
67
+ expect(jobs.get("deploy")?.needs).toEqual(["build", "Publish_Docs"]);
68
+ expect(extractJobs(`jobs:\n a:\n needs: [x, "y"]\n runs-on: ubuntu-latest\n`).get("a")?.needs).toEqual(["x", "y"]);
69
+ });
70
+
71
+ test("buildNeedsGraph sees scalar needs", () => {
72
+ expect(buildNeedsGraph(NAME_FIRST).get("Publish_Docs")).toEqual(["build"]);
73
+ });
74
+
75
+ test("a reusable-workflow job has no steps", () => {
76
+ const jobs = extractJobs(`jobs:\n call:\n uses: ./.github/workflows/x.yml\n`);
77
+ expect(jobs.get("call")).toEqual({ name: "call" });
78
+ });
79
+
80
+ test("unparseable YAML yields no jobs", () => {
81
+ expect(extractJobs("jobs:\n a: [\n").size).toBe(0);
82
+ });
83
+ });
@@ -37,66 +37,45 @@ export interface ParsedJob {
37
37
 
38
38
  /**
39
39
  * Extract jobs from a serialized GitHub Actions workflow YAML.
40
+ *
41
+ * Built on the structural parser ({@link jobEntries}), so every step of every
42
+ * job is read whatever key the step starts with, and `needs:` is read in all
43
+ * three forms (a scalar, a flow list, a block list). The line-based version
44
+ * this replaced stopped after the first line of a job's first step, so a step
45
+ * that starts with `name:` (the shape chant's serializer emits) had no `uses:`
46
+ * or `run:` and later steps were never seen (#3201).
47
+ *
48
+ * `run` is the whole script, block scalars included. `permissions` is not
49
+ * filled here; use {@link extractJobPermissions}.
40
50
  */
41
51
  export function extractJobs(yaml: string): Map<string, ParsedJob> {
42
52
  const jobs = new Map<string, ParsedJob>();
43
-
44
- // Find the jobs: section — take everything after `jobs:\n`
45
- const jobsIdx = yaml.search(/^jobs:\s*$/m);
46
- if (jobsIdx === -1) return jobs;
47
-
48
- // Content after the `jobs:` line. Stop at the next top-level key (non-indented) or EOF.
49
- const afterJobs = yaml.slice(jobsIdx + yaml.slice(jobsIdx).indexOf("\n") + 1);
50
- const endMatch = afterJobs.search(/^[a-z]/m);
51
- const jobsContent = endMatch === -1 ? afterJobs : afterJobs.slice(0, endMatch);
52
-
53
- // Split into individual jobs by finding lines with exactly 2 spaces of indent followed by a key
54
- const jobSections = jobsContent.split(/\n(?= [a-z][a-z0-9-]*:)/);
55
-
56
- for (const section of jobSections) {
57
- const nameMatch = section.match(/^\s{2}([a-z][a-z0-9-]*):/);
58
- if (!nameMatch) continue;
59
-
60
- const name = nameMatch[1];
53
+ for (const [name, jobObj] of jobEntries(yaml)) {
61
54
  const job: ParsedJob = { name };
62
55
 
63
- // Extract needs
64
- const needsInline = section.match(/^\s{4}needs:\s+\[(.+)\]$/m);
65
- if (needsInline) {
66
- job.needs = needsInline[1].split(",").map((s) => s.trim().replace(/^'|'$/g, "").replace(/^"|"$/g, ""));
67
- } else {
68
- const needsList = section.match(/^\s{4}needs:\n((?:\s{6}- .+\n?)+)/m);
69
- if (needsList) {
70
- job.needs = [];
71
- for (const line of needsList[1].split("\n")) {
72
- const item = line.match(/^\s{6}- (.+)$/);
73
- if (item) job.needs.push(item[1].trim().replace(/^'|'$/g, "").replace(/^"|"$/g, ""));
74
- }
75
- }
56
+ const needs = jobObj.needs;
57
+ if (typeof needs === "string") {
58
+ job.needs = [needs];
59
+ } else if (Array.isArray(needs)) {
60
+ job.needs = needs.filter((n) => typeof n === "string" || typeof n === "number").map(String);
76
61
  }
77
62
 
78
- // Extract steps with uses:
79
- const stepsMatch = section.match(/^\s{4}steps:\n([\s\S]*?)(?=\n\s{4}[a-z]|\n\s{2}[a-z]|$)/m);
80
- if (stepsMatch) {
63
+ const steps = jobObj.steps;
64
+ if (Array.isArray(steps)) {
81
65
  job.steps = [];
82
- const stepEntries = stepsMatch[1].split(/\n(?=\s{6}- )/);
83
- for (const stepEntry of stepEntries) {
84
- const usesMatch = stepEntry.match(/uses:\s+(.+)$/m);
85
- const runMatch = stepEntry.match(/run:\s+(.+)$/m);
86
- const stepNameMatch = stepEntry.match(/name:\s+(.+)$/m);
87
- if (usesMatch || runMatch) {
88
- job.steps.push({
89
- uses: usesMatch?.[1] ? stripUsesComment(usesMatch[1].trim().replace(/^'|'$/g, "")) : undefined,
90
- run: runMatch?.[1]?.trim(),
91
- name: stepNameMatch?.[1]?.trim().replace(/^'|'$/g, ""),
92
- });
93
- }
66
+ for (const raw of steps) {
67
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) continue;
68
+ const step = raw as Record<string, unknown>;
69
+ job.steps.push({
70
+ uses: typeof step.uses === "string" ? stripUsesComment(step.uses) : undefined,
71
+ run: typeof step.run === "string" ? step.run : undefined,
72
+ name: typeof step.name === "string" ? step.name : undefined,
73
+ });
94
74
  }
95
75
  }
96
76
 
97
77
  jobs.set(name, job);
98
78
  }
99
-
100
79
  return jobs;
101
80
  }
102
81
 
@@ -497,6 +476,21 @@ export function extractUsesComment(rawUses: string): string | undefined {
497
476
  return comment ? comment : undefined;
498
477
  }
499
478
 
479
+ /**
480
+ * Every `uses:` line in the workflow text, in document order, with its
481
+ * trailing comment. The parsed document has no comments (core's parseYAML
482
+ * drops them, #3006), so a check that reads the pin label (GHA059) takes it
483
+ * from here.
484
+ */
485
+ export function usesLineComments(yaml: string): Array<{ ref: string; comment: string | undefined }> {
486
+ const out: Array<{ ref: string; comment: string | undefined }> = [];
487
+ for (const line of yaml.split(/\r?\n/)) {
488
+ const m = line.match(/^\s*(?:-\s+)?uses:\s*(["']?)([^\s"'#]+)\1(\s+#.*)?\s*$/);
489
+ if (m) out.push({ ref: m[2], comment: m[3] ? extractUsesComment(m[3]) : undefined });
490
+ }
491
+ return out;
492
+ }
493
+
500
494
  export function parseActionUses(rawUses: string): { owner: string; repo: string; slug: string; gitRef: string } | undefined {
501
495
  const uses = stripUsesComment(rawUses);
502
496
  if (uses.startsWith("./") || uses.startsWith("../") || uses.startsWith("docker://")) return undefined;
@@ -2,9 +2,12 @@
2
2
  * Deprecated action versions and recommended replacements.
3
3
  */
4
4
 
5
+ import { ACTION_PINS } from "../../../action-pins";
6
+
5
7
  export const deprecatedVersions: Record<string, { deprecated: string[]; recommended: string }> = {
6
- "actions/checkout": { deprecated: ["v1", "v2", "v3"], recommended: "v4" },
7
- "actions/setup-node": { deprecated: ["v1", "v2", "v3"], recommended: "v4" },
8
+ // v4 runs on Node 20, which GitHub has deprecated for actions.
9
+ "actions/checkout": { deprecated: ["v1", "v2", "v3", "v4"], recommended: ACTION_PINS["actions/checkout"].major },
10
+ "actions/setup-node": { deprecated: ["v1", "v2", "v3", "v4"], recommended: ACTION_PINS["actions/setup-node"].major },
8
11
  "actions/setup-go": { deprecated: ["v1", "v2", "v3", "v4"], recommended: "v5" },
9
12
  "actions/setup-python": { deprecated: ["v1", "v2", "v3", "v4"], recommended: "v5" },
10
13
  "actions/cache": { deprecated: ["v1", "v2", "v3"], recommended: "v4" },
@@ -19,7 +19,7 @@ describe("GHA012: deprecated-action-version", () => {
19
19
  });
20
20
 
21
21
  test("does not flag current version", () => {
22
- const ctx = createContext(`const s = "actions/checkout@v4";`);
22
+ const ctx = createContext(`const s = "actions/checkout@v7";`);
23
23
  const diags = deprecatedActionVersionRule.check(ctx);
24
24
  expect(diags).toHaveLength(0);
25
25
  });
@@ -244,7 +244,7 @@ describe("GHA012: deprecated-action-version", () => {
244
244
  });
245
245
 
246
246
  test("does not flag current version", () => {
247
- const ctx = createContext(`const s = "actions/checkout@v4";`);
247
+ const ctx = createContext(`const s = "actions/checkout@v7";`);
248
248
  const diags = deprecatedActionVersionRule.check(ctx);
249
249
  expect(diags).toHaveLength(0);
250
250
  });
@@ -33,6 +33,23 @@ describe("downstreamJobs / actionPinned (#327)", () => {
33
33
  expect(downstreamJobs(yaml, "deploy")).toEqual([]);
34
34
  });
35
35
 
36
+ test("downstreamJobs follows a scalar needs: (#3201)", () => {
37
+ const yaml = `jobs:
38
+ build:
39
+ runs-on: ubuntu-latest
40
+ steps:
41
+ - name: Build
42
+ run: echo build
43
+ test:
44
+ runs-on: ubuntu-latest
45
+ needs: build
46
+ steps:
47
+ - name: Test
48
+ run: echo test
49
+ `;
50
+ expect(downstreamJobs(yaml, "build")).toEqual(["test"]);
51
+ });
52
+
36
53
  test("actionPinned only accepts a full commit SHA", () => {
37
54
  expect(actionPinned("actions/checkout@" + "a".repeat(40))).toBe(true);
38
55
  expect(actionPinned("actions/checkout@v4")).toBe(false);
@@ -24,7 +24,7 @@ describe("githubPlugin", () => {
24
24
 
25
25
  test("provides post-synth checks", () => {
26
26
  const checks = githubPlugin.postSynthChecks!();
27
- expect(checks.length).toBe(55);
27
+ expect(checks.length).toBe(56);
28
28
 
29
29
  const checkIds = checks.map((c) => c.id);
30
30
  expect(checkIds).toContain("GHA006");
package/src/serializer.ts CHANGED
@@ -12,7 +12,7 @@ import type { Serializer, SerializerResult } from "@intentius/chant/serializer";
12
12
  import type { LexiconOutput } from "@intentius/chant/lexicon-output";
13
13
  import { walkValue, type SerializerVisitor } from "@intentius/chant/serializer-walker";
14
14
  import { INTRINSIC_MARKER } from "@intentius/chant/intrinsic";
15
- import { emitYAML } from "@intentius/chant/yaml";
15
+ import { emitYAMLEntry } from "@intentius/chant/yaml";
16
16
 
17
17
  // ── Key conversion ────────────────────────────────────────────────
18
18
 
@@ -459,7 +459,7 @@ function emitYAMLDocument(doc: Record<string, unknown>): string {
459
459
  } else if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
460
460
  sections.push(`${key}: ${yamlScalar(value)}`);
461
461
  } else {
462
- sections.push(`${key}:` + emitYAML(value, 1));
462
+ sections.push(emitYAMLEntry(key, value));
463
463
  }
464
464
  }
465
465
  }
@@ -470,11 +470,22 @@ function emitYAMLDocument(doc: Record<string, unknown>): string {
470
470
  if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
471
471
  sections.push(`${key}: ${yamlScalar(value)}`);
472
472
  } else {
473
- sections.push(`${key}:` + emitYAML(value, 1));
473
+ sections.push(emitYAMLEntry(key, value));
474
474
  }
475
475
  }
476
476
 
477
- return sections.join("\n\n") + "\n";
477
+ return unquotePinComments(sections.join("\n\n") + "\n");
478
+ }
479
+
480
+ /**
481
+ * A SHA-pinned `uses:` carries its version as a trailing comment
482
+ * (`actions/checkout@<sha> # v7.0.1`). The YAML emitter quotes any string with
483
+ * a `#`, which would make the comment part of the ref GitHub resolves. A ref
484
+ * holds no whitespace, so `<ref> # <label>` inside quotes is always this
485
+ * pattern, and writing it unquoted turns the label back into a comment.
486
+ */
487
+ function unquotePinComments(yaml: string): string {
488
+ return yaml.replace(/^(\s*(?:- )?uses: )'([^\s']+) # ([^'\n]*)'$/gm, "$1$2 # $3");
478
489
  }
479
490
 
480
491
  function yamlScalar(value: string | number | boolean): string {