@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
@@ -1,5 +1,6 @@
1
1
  import { Composite, mergeDefaults } from "@intentius/chant";
2
2
  import { Job, Step, Workflow } from "../generated/index";
3
+ import { actionRef } from "../action-pins";
3
4
 
4
5
  export interface NodeCIProps {
5
6
  nodeVersion?: string;
@@ -28,12 +29,12 @@ export const NodeCI = Composite((props: NodeCIProps) => {
28
29
 
29
30
  const checkoutStep = new Step({
30
31
  name: "Checkout",
31
- uses: "actions/checkout@v4",
32
+ uses: actionRef("actions/checkout"),
32
33
  });
33
34
 
34
35
  const setupNodeStep = new Step({
35
36
  name: "Setup Node.js",
36
- uses: "actions/setup-node@v4",
37
+ uses: actionRef("actions/setup-node"),
37
38
  with: {
38
39
  "node-version": nodeVersion,
39
40
  cache: packageManager === "bun" ? undefined : packageManager,
@@ -1,5 +1,6 @@
1
1
  import { Composite, withDefaults, mergeDefaults } from "@intentius/chant";
2
2
  import { Job, Step, Workflow } from "../generated/index";
3
+ import { actionRef } from "../action-pins";
3
4
 
4
5
  export interface NodePipelineProps {
5
6
  /** Node.js version. Default: "22" */
@@ -32,19 +33,19 @@ const cacheConfig = {
32
33
  cache: "npm",
33
34
  installCmd: "npm ci",
34
35
  runPrefix: "npm run",
35
- setupAction: "actions/setup-node@v4",
36
+ setupAction: actionRef("actions/setup-node"),
36
37
  },
37
38
  pnpm: {
38
39
  cache: "pnpm",
39
40
  installCmd: "pnpm install --frozen-lockfile",
40
41
  runPrefix: "pnpm run",
41
- setupAction: "actions/setup-node@v4",
42
+ setupAction: actionRef("actions/setup-node"),
42
43
  },
43
44
  yarn: {
44
45
  cache: "yarn",
45
46
  installCmd: "yarn install --frozen-lockfile",
46
47
  runPrefix: "yarn",
47
- setupAction: "actions/setup-node@v4",
48
+ setupAction: actionRef("actions/setup-node"),
48
49
  },
49
50
  bun: {
50
51
  cache: undefined,
@@ -75,13 +76,13 @@ export const NodePipeline = Composite((props: NodePipelineProps) => {
75
76
  const isBun = packageManager === "bun";
76
77
 
77
78
  // ── Build job steps ────────────────────────────────────────────────
78
- const buildCheckout = new Step({ name: "Checkout", uses: "actions/checkout@v4" });
79
+ const buildCheckout = new Step({ name: "Checkout", uses: actionRef("actions/checkout") });
79
80
 
80
81
  const buildSetup = isBun
81
82
  ? new Step({ name: "Setup Bun", uses: "oven-sh/setup-bun@v2" })
82
83
  : new Step({
83
84
  name: "Setup Node.js",
84
- uses: "actions/setup-node@v4",
85
+ uses: actionRef("actions/setup-node"),
85
86
  with: { "node-version": nodeVersion, cache: pm.cache },
86
87
  });
87
88
 
@@ -105,13 +106,13 @@ export const NodePipeline = Composite((props: NodePipelineProps) => {
105
106
  }, defaults?.buildJob));
106
107
 
107
108
  // ── Test job steps ─────────────────────────────────────────────────
108
- const testCheckout = new Step({ name: "Checkout", uses: "actions/checkout@v4" });
109
+ const testCheckout = new Step({ name: "Checkout", uses: actionRef("actions/checkout") });
109
110
 
110
111
  const testSetup = isBun
111
112
  ? new Step({ name: "Setup Bun", uses: "oven-sh/setup-bun@v2" })
112
113
  : new Step({
113
114
  name: "Setup Node.js",
114
- uses: "actions/setup-node@v4",
115
+ uses: actionRef("actions/setup-node"),
115
116
  with: { "node-version": nodeVersion, cache: pm.cache },
116
117
  });
117
118
 
@@ -1,5 +1,6 @@
1
1
  import { Composite, mergeDefaults } from "@intentius/chant";
2
2
  import { Job, Step, Workflow } from "../generated/index";
3
+ import { actionRef } from "../action-pins";
3
4
 
4
5
  export interface PythonCIProps {
5
6
  /** Python version. Default: "3.12" */
@@ -46,7 +47,7 @@ export const PythonCI = Composite((props: PythonCIProps) => {
46
47
  const testJob = new Job(mergeDefaults({
47
48
  "runs-on": runsOn,
48
49
  steps: [
49
- new Step({ name: "Checkout", uses: "actions/checkout@v4" }),
50
+ new Step({ name: "Checkout", uses: actionRef("actions/checkout") }),
50
51
  new Step({
51
52
  name: "Setup Python",
52
53
  uses: "actions/setup-python@v5",
@@ -63,7 +64,7 @@ export const PythonCI = Composite((props: PythonCIProps) => {
63
64
  ? new Job(mergeDefaults({
64
65
  "runs-on": runsOn,
65
66
  steps: [
66
- new Step({ name: "Checkout", uses: "actions/checkout@v4" }),
67
+ new Step({ name: "Checkout", uses: actionRef("actions/checkout") }),
67
68
  new Step({
68
69
  name: "Setup Python",
69
70
  uses: "actions/setup-python@v5",
@@ -1,11 +1,18 @@
1
1
  import { Composite, mergeDefaults } from "@intentius/chant";
2
2
  import { Step } from "../generated/index";
3
+ import { actionRef, type ActionPinMode } from "../action-pins";
3
4
 
4
5
  export interface SetupNodeProps {
5
6
  nodeVersion?: string;
6
7
  registryUrl?: string;
7
8
  cache?: string;
8
9
  cacheFilePath?: string;
10
+ /**
11
+ * `"tag"` (default) emits the action's major tag. `"sha"` emits the commit
12
+ * SHA from the lexicon's pin table with the version as a YAML comment, which
13
+ * passes GHA021, GHA029 and GHA059.
14
+ */
15
+ pin?: ActionPinMode;
9
16
  defaults?: {
10
17
  step?: Partial<ConstructorParameters<typeof Step>[0]>;
11
18
  };
@@ -21,7 +28,7 @@ export const SetupNode = Composite((props: SetupNodeProps) => {
21
28
 
22
29
  const step = new Step(mergeDefaults({
23
30
  name: "Setup Node.js",
24
- uses: "actions/setup-node@v4",
31
+ uses: actionRef("actions/setup-node", props.pin),
25
32
  ...(Object.keys(withObj).length > 0 ? { with: withObj } : {}),
26
33
  }, defaults?.step));
27
34
 
package/src/index.ts CHANGED
@@ -20,6 +20,10 @@ export { GitHub, Runner } from "./variables";
20
20
  // After running `chant generate`, this re-exports all entity classes
21
21
  export * from "./generated/index";
22
22
 
23
+ // Action versions the composites emit, and their pinned commits
24
+ export { ACTION_PINS, actionRef, actionPin } from "./action-pins";
25
+ export type { ActionPin, ActionPinMode, PinnedAction } from "./action-pins";
26
+
23
27
  // Composites
24
28
  export {
25
29
  Checkout, SetupNode, SetupGo, SetupPython,
@@ -66,6 +66,7 @@ export const githubAuditCatalog: Record<string, RuleMeta> = {
66
66
  GHA066: auditRule("GHA066", "report-only", "guidance", "Unbounded artifact retention", "Set a retention-days sized to how long the artifact is actually needed.", { category: "efficiency" }),
67
67
  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" }),
68
68
  GHA068: auditRule("GHA068", "report-only", "guidance", "Pull-request workflow missing a concurrency group", "Add a concurrency group with cancel-in-progress: true.", { category: "efficiency" }),
69
+ GHA069: auditRule("GHA069", "merge-worthy", "guidance", "Job permissions block drops the workflow's id-token: write", "Add `id-token: write` to the job's `permissions:` block; keep the job block rather than removing it.", { category: "correctness" }),
69
70
  };
70
71
 
71
72
  // Prior art credits live beside the rules in ./audit-lineage.ts (see core audit/prior-art.ts).
@@ -4,6 +4,19 @@
4
4
  * the registry, the relation vocabulary, and why this is credit rather than
5
5
  * authority. Kept by hand; the prior-art sweep (scripts/prior-art-sweep.ts) reports
6
6
  * when a credited tool's index no longer lists a rule cited here.
7
+ *
8
+ * GHA069 (a job-level permissions block drops the workflow's `id-token:
9
+ * write`, #2273) has no entry here. Checked 2026-10-02: zizmor's 41 audits
10
+ * (docs.zizmor.sh/audits) cover over-broad scopes (`excessive-permissions`)
11
+ * and undocumented ones (`undocumented-permissions`), not a scope a job
12
+ * block silently loses. actionlint's `permissions` check
13
+ * (github.com/rhysd/actionlint/blob/main/docs/checks.md#permissions)
14
+ * validates scope names and levels only. poutine's rules (opa/rego/rules)
15
+ * have `default_permissions_on_risky_events`, the GHA013 condition, and
16
+ * nothing about a dropped grant. octoscan's `debug-oidc-action` flags OIDC
17
+ * action use as a debug finding and does not read the permission blocks.
18
+ * Scorecard's Token-Permissions rewards narrower blocks, the opposite
19
+ * concern. Ship with no lineage rather than invent a credit.
7
20
  */
8
21
  import type { Lineage } from "@intentius/chant/audit/catalog";
9
22
 
@@ -0,0 +1,16 @@
1
+ name: Deploy
2
+ on:
3
+ push:
4
+ branches: [main]
5
+ permissions:
6
+ id-token: write
7
+ contents: read
8
+ jobs:
9
+ deploy:
10
+ runs-on: ubuntu-latest
11
+ steps:
12
+ - uses: actions/checkout@v4
13
+ - uses: aws-actions/configure-aws-credentials@v4
14
+ with:
15
+ role-to-assume: arn:aws:iam::123456789012:role/deploy
16
+ aws-region: us-east-1
@@ -0,0 +1,22 @@
1
+ name: Deploy
2
+ on:
3
+ push:
4
+ branches: [main]
5
+ permissions:
6
+ id-token: write
7
+ contents: read
8
+ jobs:
9
+ test:
10
+ runs-on: ubuntu-latest
11
+ permissions:
12
+ contents: read
13
+ steps:
14
+ - uses: actions/checkout@v4
15
+ - run: npm test
16
+ deploy:
17
+ runs-on: ubuntu-latest
18
+ steps:
19
+ - uses: aws-actions/configure-aws-credentials@v4
20
+ with:
21
+ role-to-assume: arn:aws:iam::123456789012:role/deploy
22
+ aws-region: us-east-1
@@ -0,0 +1,21 @@
1
+ name: Deploy
2
+ on:
3
+ push:
4
+ branches: [main]
5
+ permissions:
6
+ id-token: write
7
+ contents: read
8
+ jobs:
9
+ deploy:
10
+ runs-on: ubuntu-latest
11
+ permissions:
12
+ contents: read
13
+ pull-requests: write
14
+ id-token: write
15
+ steps:
16
+ - uses: actions/checkout@v4
17
+ - uses: aws-actions/configure-aws-credentials@v4
18
+ with:
19
+ role-to-assume: arn:aws:iam::123456789012:role/deploy
20
+ aws-region: us-east-1
21
+ - run: aws s3 sync ./dist s3://example-bucket
@@ -0,0 +1,20 @@
1
+ name: Deploy
2
+ on:
3
+ push:
4
+ branches: [main]
5
+ permissions:
6
+ id-token: write
7
+ contents: read
8
+ jobs:
9
+ deploy:
10
+ runs-on: ubuntu-latest
11
+ permissions:
12
+ contents: read
13
+ pull-requests: write
14
+ steps:
15
+ - uses: actions/checkout@v4
16
+ - uses: aws-actions/configure-aws-credentials@v4
17
+ with:
18
+ role-to-assume: arn:aws:iam::123456789012:role/deploy
19
+ aws-region: us-east-1
20
+ - run: aws s3 sync ./dist s3://example-bucket
@@ -58,4 +58,20 @@ jobs:
58
58
  const diags = gha011.check(makeCtx(yaml));
59
59
  expect(diags).toHaveLength(0);
60
60
  });
61
+
62
+ test("flags a scalar needs: naming a missing job (#3201)", () => {
63
+ const yaml = `name: CI
64
+ on: push
65
+ jobs:
66
+ test:
67
+ needs: biuld
68
+ runs-on: ubuntu-latest
69
+ steps:
70
+ - name: Test
71
+ run: npm test
72
+ `;
73
+ const diags = gha011.check(makeCtx(yaml));
74
+ expect(diags).toHaveLength(1);
75
+ expect(diags[0].message).toContain('"biuld"');
76
+ });
61
77
  });
@@ -63,4 +63,22 @@ jobs:
63
63
  const diags = gha018.check(makeCtx(yaml));
64
64
  expect(diags).toHaveLength(0);
65
65
  });
66
+
67
+ test("flags a checkout step that starts with name: (#3201)", () => {
68
+ const yaml = `name: CI
69
+ on:
70
+ pull_request_target:
71
+ jobs:
72
+ build:
73
+ runs-on: ubuntu-latest
74
+ steps:
75
+ - name: Label
76
+ run: echo label
77
+ - name: Checkout
78
+ uses: actions/checkout@v7
79
+ `;
80
+ const diags = gha018.check(makeCtx(yaml));
81
+ expect(diags).toHaveLength(1);
82
+ expect(diags[0].entity).toBe("build");
83
+ });
66
84
  });
@@ -59,6 +59,48 @@ jobs:
59
59
  needs: [test]
60
60
  steps:
61
61
  - run: echo deploy
62
+ `;
63
+ const diags = gha019.check(makeCtx(yaml));
64
+ expect(diags).toHaveLength(0);
65
+ });
66
+
67
+ test("detects a cycle through scalar needs: (#3201)", () => {
68
+ const yaml = `name: CI
69
+ on: push
70
+ jobs:
71
+ a:
72
+ needs: b
73
+ runs-on: ubuntu-latest
74
+ steps:
75
+ - run: echo a
76
+ b:
77
+ needs: a
78
+ runs-on: ubuntu-latest
79
+ steps:
80
+ - run: echo b
81
+ `;
82
+ const diags = gha019.check(makeCtx(yaml));
83
+ expect(diags).toHaveLength(1);
84
+ });
85
+
86
+ test("no false cycle when a later job id has an underscore (#3201)", () => {
87
+ // The line-based extractJobs did not split on "extra_thing", so its
88
+ // needs: landed on shared-alb and read as shared-alb -> shared-alb.
89
+ // generateGithubPipeline emits this shape for a component named Extra_Thing.
90
+ const yaml = `name: chant-components
91
+ on:
92
+ workflow_dispatch: {}
93
+ jobs:
94
+ shared-alb:
95
+ runs-on: ubuntu-latest
96
+ steps:
97
+ - uses: actions/checkout@v7
98
+ extra_thing:
99
+ runs-on: ubuntu-latest
100
+ needs:
101
+ - shared-alb
102
+ steps:
103
+ - uses: actions/checkout@v7
62
104
  `;
63
105
  const diags = gha019.check(makeCtx(yaml));
64
106
  expect(diags).toHaveLength(0);
@@ -79,4 +79,22 @@ jobs:
79
79
  const diags = gha021.check(makeCtx(yaml));
80
80
  expect(diags).toHaveLength(0);
81
81
  });
82
+
83
+ test("flags a checkout step that starts with name: (the composite shape)", () => {
84
+ const yaml = `name: CI
85
+ on:
86
+ push:
87
+ jobs:
88
+ build:
89
+ runs-on: ubuntu-latest
90
+ steps:
91
+ - name: Checkout
92
+ uses: actions/checkout@v7
93
+ - name: Test
94
+ run: echo test
95
+ `;
96
+ const diags = gha021.check(makeCtx(yaml));
97
+ expect(diags).toHaveLength(1);
98
+ expect(diags[0].message).toContain('Checkout({ pin: "sha" })');
99
+ });
82
100
  });
@@ -6,7 +6,8 @@
6
6
  */
7
7
 
8
8
  import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
9
- import { getPrimaryOutput, extractJobs } from "./yaml-helpers";
9
+ import { getPrimaryOutput, extractActionRefs, stripUsesComment } from "./yaml-helpers";
10
+ import { pinFixHint } from "../../action-pins";
10
11
 
11
12
  export const gha021: PostSynthCheck = {
12
13
  id: "GHA021",
@@ -17,29 +18,28 @@ export const gha021: PostSynthCheck = {
17
18
 
18
19
  for (const [, output] of ctx.outputs) {
19
20
  const yaml = getPrimaryOutput(output);
20
- const jobs = extractJobs(yaml);
21
-
22
- for (const [jobName, job] of jobs) {
23
- if (!job.steps) continue;
24
-
25
- for (const step of job.steps) {
26
- if (!step.uses) continue;
27
-
28
- const match = step.uses.match(/^actions\/checkout@(.+)$/);
29
- if (!match) continue;
30
-
31
- const ref = match[1];
32
- // A pinned SHA is 40 hex characters
33
- if (/^[0-9a-f]{40}$/.test(ref)) continue;
34
-
35
- diagnostics.push({
36
- checkId: "GHA021",
37
- severity: "warning",
38
- message: `Job "${jobName}" uses actions/checkout@${ref} — pin to a full commit SHA for supply-chain security.`,
39
- entity: jobName,
40
- lexicon: "github",
41
- });
42
- }
21
+ // The structural parser sees every step. The line-based extractJobs
22
+ // only reads a step's first line, so it missed `uses:` whenever a step
23
+ // starts with `name:`, which is how every composite emits it.
24
+ for (const { job: jobName, ref: uses, level } of extractActionRefs(yaml)) {
25
+ if (level !== "step") continue;
26
+
27
+ // A pinned ref carries its version as a trailing comment
28
+ // (`actions/checkout@<sha> # v7.0.1`); only the ref is judged.
29
+ const match = stripUsesComment(uses).match(/^actions\/checkout@(.+)$/);
30
+ if (!match) continue;
31
+
32
+ const ref = match[1];
33
+ // A pinned SHA is 40 hex characters
34
+ if (/^[0-9a-f]{40}$/.test(ref)) continue;
35
+
36
+ diagnostics.push({
37
+ checkId: "GHA021",
38
+ severity: "warning",
39
+ message: `Job "${jobName}" uses actions/checkout@${ref} — pin to a full commit SHA for supply-chain security.${pinFixHint("actions/checkout")}`,
40
+ entity: jobName,
41
+ lexicon: "github",
42
+ });
43
43
  }
44
44
  }
45
45
 
@@ -80,4 +80,19 @@ on:
80
80
  const diags = gha022.check(makeCtx(yaml));
81
81
  expect(diags).toHaveLength(0);
82
82
  });
83
+
84
+ test("flags a job whose id has capitals or underscores (#3201)", () => {
85
+ const yaml = `name: CI
86
+ on: push
87
+ jobs:
88
+ Build_App:
89
+ runs-on: ubuntu-latest
90
+ steps:
91
+ - name: Build
92
+ run: npm run build
93
+ `;
94
+ const diags = gha022.check(makeCtx(yaml));
95
+ expect(diags).toHaveLength(1);
96
+ expect(diags[0].entity).toBe("Build_App");
97
+ });
83
98
  });
@@ -49,4 +49,23 @@ jobs:
49
49
  const diags = gha023.check(makeCtx(yaml));
50
50
  expect(diags).toHaveLength(0);
51
51
  });
52
+
53
+ test("flags ::set-output in a later name:-first step's block script (#3201)", () => {
54
+ const yaml = `name: CI
55
+ on: push
56
+ jobs:
57
+ build:
58
+ runs-on: ubuntu-latest
59
+ steps:
60
+ - name: Checkout
61
+ uses: actions/checkout@v7
62
+ - name: Version
63
+ run: |
64
+ v=$(cat VERSION)
65
+ echo "::set-output name=version::$v"
66
+ `;
67
+ const diags = gha023.check(makeCtx(yaml));
68
+ expect(diags).toHaveLength(1);
69
+ expect(diags[0].message).toContain('step "Version"');
70
+ });
52
71
  });
@@ -14,6 +14,7 @@
14
14
  import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
15
15
  import { getPrimaryOutput, extractActionRefs, parseActionUses } from "./yaml-helpers";
16
16
  import { TRUSTED_ACTION_OWNERS } from "../rules/data/trusted-action-owners";
17
+ import { pinFixHint } from "../../action-pins";
17
18
 
18
19
  const SHA_RE = /^[0-9a-f]{40}$/;
19
20
 
@@ -52,11 +53,11 @@ export const gha029: PostSynthCheck = {
52
53
 
53
54
  for (const [, output] of ctx.outputs) {
54
55
  const yaml = getPrimaryOutput(output);
55
- for (const { job, ref } of findUnpinnedActions(yaml)) {
56
+ for (const { job, ref, slug } of findUnpinnedActions(yaml)) {
56
57
  diagnostics.push({
57
58
  checkId: "GHA029",
58
59
  severity: "warning",
59
- message: `Job "${job}" uses ${ref} pinned to a tag or branch — pin to a full commit SHA for supply-chain security.`,
60
+ message: `Job "${job}" uses ${ref} pinned to a tag or branch — pin to a full commit SHA for supply-chain security.${pinFixHint(slug)}`,
60
61
  entity: job,
61
62
  lexicon: "github",
62
63
  });
@@ -61,4 +61,24 @@ jobs:
61
61
  const diags = gha038.check(makeCtx(yaml));
62
62
  expect(diags).toHaveLength(0);
63
63
  });
64
+
65
+ test("flags a checkout step that starts with name: (#3201)", () => {
66
+ const yaml = `name: After CI
67
+ on:
68
+ workflow_run:
69
+ workflows: [CI]
70
+ types: [completed]
71
+ jobs:
72
+ report:
73
+ runs-on: ubuntu-latest
74
+ steps:
75
+ - name: Checkout
76
+ uses: actions/checkout@v7
77
+ - name: Report
78
+ run: ./report.sh
79
+ `;
80
+ const diags = gha038.check(makeCtx(yaml));
81
+ expect(diags).toHaveLength(1);
82
+ expect(diags[0].entity).toBe("report");
83
+ });
64
84
  });
@@ -21,7 +21,7 @@
21
21
  */
22
22
 
23
23
  import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
24
- import { getPrimaryOutput, extractActionRefs, extractUsesComment, parseActionUses } from "./yaml-helpers";
24
+ import { getPrimaryOutput, extractActionRefs, extractUsesComment, parseActionUses, usesLineComments } from "./yaml-helpers";
25
25
 
26
26
  const SHA_RE = /^[0-9a-f]{40}$/;
27
27
 
@@ -49,9 +49,17 @@ export function findStalePinAnnotations(yaml: string): StalePinFinding[] {
49
49
  const findings: StalePinFinding[] = [];
50
50
 
51
51
  // First pass: missing annotations.
52
+ // The parsed value carries no comment, so match each ref to its `uses:`
53
+ // line, in document order, to read the label.
54
+ const lines = usesLineComments(yaml);
55
+ const commentFor = (ref: string): string | undefined => {
56
+ const k = lines.findIndex((l) => l.ref === ref);
57
+ if (k === -1) return extractUsesComment(ref);
58
+ return lines.splice(k, 1)[0].comment;
59
+ };
52
60
  const withComment: Array<{ job: string; ref: string; slug: string; sha: string; comment: string }> = [];
53
61
  for (const { job, ref, parsed } of refs) {
54
- const comment = extractUsesComment(ref);
62
+ const comment = commentFor(ref);
55
63
  if (!comment) {
56
64
  findings.push({ job, ref, slug: parsed.slug, sha: parsed.gitRef, kind: "missing" });
57
65
  } else {