@intentius/chant-lexicon-github 0.101.0 → 0.102.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 (93) 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/generate-op-pipeline.test.ts +4 -4
  54. package/src/components/generate-op-pipeline.ts +8 -7
  55. package/src/components/generate-pipeline.test.ts +1 -1
  56. package/src/components/generate-pipeline.ts +8 -14
  57. package/src/composites/checkout.ts +8 -1
  58. package/src/composites/composites.test.ts +4 -4
  59. package/src/composites/deploy-environment.ts +3 -2
  60. package/src/composites/docker-build.ts +2 -1
  61. package/src/composites/go-ci.ts +4 -3
  62. package/src/composites/node-ci.ts +3 -2
  63. package/src/composites/node-pipeline.ts +8 -7
  64. package/src/composites/python-ci.ts +3 -2
  65. package/src/composites/setup-node.ts +8 -1
  66. package/src/index.ts +4 -0
  67. package/src/lint/audit-catalog.ts +1 -0
  68. package/src/lint/audit-lineage.ts +13 -0
  69. package/src/lint/post-synth/fixtures/GHA069/negative-no-job-block.yml +16 -0
  70. package/src/lint/post-synth/fixtures/GHA069/negative-no-oidc-step.yml +22 -0
  71. package/src/lint/post-synth/fixtures/GHA069/negative.yml +21 -0
  72. package/src/lint/post-synth/fixtures/GHA069/positive.yml +20 -0
  73. package/src/lint/post-synth/gha011.test.ts +16 -0
  74. package/src/lint/post-synth/gha018.test.ts +18 -0
  75. package/src/lint/post-synth/gha019.test.ts +42 -0
  76. package/src/lint/post-synth/gha021.test.ts +18 -0
  77. package/src/lint/post-synth/gha021.ts +24 -24
  78. package/src/lint/post-synth/gha022.test.ts +15 -0
  79. package/src/lint/post-synth/gha023.test.ts +19 -0
  80. package/src/lint/post-synth/gha029.ts +3 -2
  81. package/src/lint/post-synth/gha038.test.ts +20 -0
  82. package/src/lint/post-synth/gha059.ts +10 -2
  83. package/src/lint/post-synth/gha069.test.ts +173 -0
  84. package/src/lint/post-synth/gha069.ts +111 -0
  85. package/src/lint/post-synth/index.ts +2 -0
  86. package/src/lint/post-synth/yaml-helpers.test.ts +83 -0
  87. package/src/lint/post-synth/yaml-helpers.ts +41 -47
  88. package/src/lint/rules/data/deprecated-versions.ts +5 -2
  89. package/src/lint/rules/deprecated-action-version.test.ts +1 -1
  90. package/src/lint/rules/rules.test.ts +1 -1
  91. package/src/mcp/context-tools.test.ts +17 -0
  92. package/src/plugin.test.ts +1 -1
  93. package/src/serializer.ts +15 -4
@@ -72,7 +72,7 @@ describe("generateGithubOpPipeline: one file per scheduled Op", () => {
72
72
  const job = doc.jobs!["actions-audit"];
73
73
 
74
74
  expect(job["runs-on"]).toBe("ubuntu-latest");
75
- expect(job.steps[0].uses).toBe("actions/checkout@v4");
75
+ expect(job.steps[0].uses).toBe("actions/checkout@v7");
76
76
  const runStep = job.steps.find((s) => typeof s.run === "string")!;
77
77
  expect(runStep.run).toBe("chant run actions-audit");
78
78
  });
@@ -327,7 +327,7 @@ describe("generateGithubOpPipeline: setup steps and additive permissions (#2242)
327
327
  const steps = doc.jobs!["app-apply"].steps;
328
328
 
329
329
  expect(steps.slice(0, 3).map((s) => s.uses ?? s.run)).toEqual([
330
- "actions/checkout@v4",
330
+ "actions/checkout@v7",
331
331
  "aws-actions/configure-aws-credentials@v6",
332
332
  "install terraform",
333
333
  ]);
@@ -385,7 +385,7 @@ describe("generateGithubOpPipeline: setup steps and additive permissions (#2242)
385
385
  ];
386
386
  const steps = parseFile(generateGithubOpPipeline(specs).files[0].yaml).jobs!["app-apply"].steps;
387
387
  expect(steps.map((s) => s.uses).filter(Boolean)).toEqual([
388
- "actions/checkout@v4",
388
+ "actions/checkout@v7",
389
389
  "github/codeql-action/upload-sarif@v4",
390
390
  "aws-actions/configure-aws-credentials@0e613a0980cbf65ed5b322eb7a1e075d28913a83",
391
391
  ]);
@@ -622,7 +622,7 @@ describe("generateGithubOpPipeline: a deployment environment on the Op's job (#2
622
622
  " runs-on: ubuntu-latest",
623
623
  " container: node:22-slim",
624
624
  " steps:",
625
- " - uses: actions/checkout@v4",
625
+ " - uses: actions/checkout@v7",
626
626
  " - run: chant run actions-audit",
627
627
  " env:",
628
628
  " GITHUB_TOKEN: '${{ github.token }}'",
@@ -70,7 +70,7 @@
70
70
  * that job either, same as `environment:` above does not).
71
71
  */
72
72
 
73
- import { emitYAML } from "@intentius/chant/yaml";
73
+ import { emitYAML, emitYAMLEntry } from "@intentius/chant/yaml";
74
74
  import { memberPathFilter, resolveOpTrigger } from "@intentius/chant/lexicon";
75
75
  import type {
76
76
  ComponentPipelineOptions as GenerateGithubOpOptions,
@@ -83,6 +83,7 @@ import type {
83
83
  PipelineMember,
84
84
  ScheduledOpSpec,
85
85
  } from "@intentius/chant/lexicon";
86
+ import { actionRef } from "../action-pins";
86
87
 
87
88
  export type { GenerateGithubOpOptions, GenerateGithubOpResult };
88
89
 
@@ -709,7 +710,7 @@ export function buildGithubOpPipelineDocs(
709
710
  ? [...runParts, ...GATED_EXIT_FLAG, "--json"].join(" ")
710
711
  : runParts.join(" ");
711
712
 
712
- const steps: Array<Record<string, unknown>> = [{ uses: "actions/checkout@v4" }];
713
+ const steps: Array<Record<string, unknown>> = [{ uses: actionRef("actions/checkout") }];
713
714
  for (const step of setup) steps.push(setupStepDoc(step));
714
715
  for (const line of beforeScript) steps.push({ run: line });
715
716
  steps.push(
@@ -822,14 +823,14 @@ export function emitOpPipelineYAML(doc: GithubOpPipelineDoc): string {
822
823
  // it was before the field existed.
823
824
  if (doc.header && doc.header.length > 0) sections.push(doc.header.join("\n"));
824
825
  if (doc.name) sections.push("name: " + emitYAML(doc.name, 0));
825
- sections.push("on:" + emitYAML(doc.on, 1));
826
- if (doc.env && Object.keys(doc.env).length > 0) sections.push("env:" + emitYAML(doc.env, 1));
827
- sections.push("concurrency:" + emitYAML(doc.concurrency, 1));
828
- if (Object.keys(doc.permissions).length > 0) sections.push("permissions:" + emitYAML(doc.permissions, 1));
826
+ sections.push(emitYAMLEntry("on", doc.on));
827
+ if (doc.env && Object.keys(doc.env).length > 0) sections.push(emitYAMLEntry("env", doc.env));
828
+ sections.push(emitYAMLEntry("concurrency", doc.concurrency));
829
+ if (Object.keys(doc.permissions).length > 0) sections.push(emitYAMLEntry("permissions", doc.permissions));
829
830
  // The gated-apply notice job rides in `jobs:` beside the Op's own job, but
830
831
  // is carried separately on the doc so a dialect that cannot run it (forgejo,
831
832
  // whose runner has no `gh` pointed at its own instance) drops it by omission.
832
- sections.push("jobs:" + emitYAML({ ...doc.jobsDoc, ...(doc.gatedNoticeDoc ?? {}) }, 1));
833
+ sections.push(emitYAMLEntry("jobs", { ...doc.jobsDoc, ...(doc.gatedNoticeDoc ?? {}) }));
833
834
  return sections.join("\n\n") + "\n";
834
835
  }
835
836
 
@@ -104,7 +104,7 @@ describe("generateGithubPipeline: structurally valid YAML", () => {
104
104
  expect(job.steps.length).toBeGreaterThan(0);
105
105
  // Every job starts with a checkout step — a job runs on a fresh runner
106
106
  // with no repo present, unlike GitLab's auto-cloned workspace.
107
- expect(job.steps[0].uses).toBe("actions/checkout@v4");
107
+ expect(job.steps[0].uses).toBe("actions/checkout@v7");
108
108
  }
109
109
  });
110
110
 
@@ -56,7 +56,7 @@
56
56
  * touching the component declarations.
57
57
  */
58
58
 
59
- import { emitYAML } from "@intentius/chant/yaml";
59
+ import { emitYAML, emitYAMLEntry } from "@intentius/chant/yaml";
60
60
  import { resolveComponentGraph, type DriverComponent } from "@intentius/chant/components/driver";
61
61
  import { hasPublishStep, promoteArchivePaths } from "@intentius/chant/components/promote";
62
62
  import { memberRepoPath } from "@intentius/chant/lexicon";
@@ -66,6 +66,7 @@ import type {
66
66
  ComponentPipelineResult as GenerateGithubResult,
67
67
  PipelineMember,
68
68
  } from "@intentius/chant/lexicon";
69
+ import { actionRef } from "../action-pins";
69
70
 
70
71
  export type { GeneratedJob, GenerateGithubOptions, GenerateGithubResult };
71
72
 
@@ -232,7 +233,7 @@ export function buildGithubPipelineDoc(
232
233
  // One step per script line — mirrors gitlab's `script:` array of
233
234
  // discrete shell lines, rather than a single multi-line `run:` block, so
234
235
  // each line is independently inspectable (and machine-parseable).
235
- const steps: Array<Record<string, unknown>> = [{ uses: "actions/checkout@v4" }];
236
+ const steps: Array<Record<string, unknown>> = [{ uses: actionRef("actions/checkout") }];
236
237
 
237
238
  for (const dep of component.dependsOn ?? []) {
238
239
  steps.push({
@@ -297,7 +298,7 @@ export function buildGithubPipelineDoc(
297
298
  for (const name of [...pinned].sort()) {
298
299
  command.push("--digest", `"${name}=\${{ needs.${jobNameByComponent.get(name)!}.outputs.digest }}"`);
299
300
  }
300
- const steps: Array<Record<string, unknown>> = [{ uses: "actions/checkout@v4" }];
301
+ const steps: Array<Record<string, unknown>> = [{ uses: actionRef("actions/checkout") }];
301
302
  for (const [name, paths] of archives) {
302
303
  steps.push({
303
304
  name: `Download ${name} build archive`,
@@ -375,21 +376,14 @@ function memberArtifactStep(step: Record<string, unknown>, member: PipelineMembe
375
376
  * Shared with the forgejo dialect (#969), which transforms the doc first.
376
377
  */
377
378
  export function emitPipelineYAML(doc: GithubPipelineDoc): string {
378
- // A block value starts with a newline; an inline one (`{}` for an empty
379
- // component set) needs the space after the colon, or `jobs:{}` is a plain
380
- // scalar rather than a key (#2991).
381
- const section = (key: string, value: unknown): string => {
382
- const emitted = emitYAML(value, 1);
383
- return `${key}:${emitted.startsWith("\n") ? "" : " "}${emitted}`;
384
- };
385
379
  const sections: string[] = [];
386
380
  sections.push("name: " + emitYAML(doc.name, 0));
387
- sections.push(section("on", doc.on));
381
+ sections.push(emitYAMLEntry("on", doc.on));
388
382
  if (doc.env && Object.keys(doc.env).length > 0) {
389
- sections.push(section("env", doc.env));
383
+ sections.push(emitYAMLEntry("env", doc.env));
390
384
  }
391
- if (doc.defaults) sections.push(section("defaults", doc.defaults));
392
- sections.push(section("jobs", doc.jobsDoc));
385
+ if (doc.defaults) sections.push(emitYAMLEntry("defaults", doc.defaults));
386
+ sections.push(emitYAMLEntry("jobs", doc.jobsDoc));
393
387
  return sections.join("\n\n") + "\n";
394
388
  }
395
389
 
@@ -1,5 +1,6 @@
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 CheckoutProps {
5
6
  ref?: string;
@@ -8,6 +9,12 @@ export interface CheckoutProps {
8
9
  token?: string;
9
10
  submodules?: boolean | string;
10
11
  sshKey?: string;
12
+ /**
13
+ * `"tag"` (default) emits the action's major tag. `"sha"` emits the commit
14
+ * SHA from the lexicon's pin table with the version as a YAML comment, which
15
+ * passes GHA021, GHA029 and GHA059.
16
+ */
17
+ pin?: ActionPinMode;
11
18
  defaults?: {
12
19
  step?: Partial<ConstructorParameters<typeof Step>[0]>;
13
20
  };
@@ -25,7 +32,7 @@ export const Checkout = Composite((props: CheckoutProps) => {
25
32
 
26
33
  const step = new Step(mergeDefaults({
27
34
  name: "Checkout",
28
- uses: "actions/checkout@v4",
35
+ uses: actionRef("actions/checkout", props.pin),
29
36
  ...(Object.keys(withObj).length > 0 ? { with: withObj } : {}),
30
37
  }, defaults?.step));
31
38
 
@@ -19,7 +19,7 @@ describe("Checkout", () => {
19
19
  test("returns step with checkout action", () => {
20
20
  const result = Checkout({});
21
21
  expect(result.step).toBeDefined();
22
- expect(result.step.props.uses).toBe("actions/checkout@v4");
22
+ expect(result.step.props.uses).toBe("actions/checkout@v7");
23
23
  expect(result.step.props.name).toBe("Checkout");
24
24
  });
25
25
 
@@ -45,7 +45,7 @@ describe("Checkout", () => {
45
45
  describe("SetupNode", () => {
46
46
  test("returns step with setup-node action", () => {
47
47
  const result = SetupNode({});
48
- expect(result.step.props.uses).toBe("actions/setup-node@v4");
48
+ expect(result.step.props.uses).toBe("actions/setup-node@v7");
49
49
  expect(result.step.props.name).toBe("Setup Node.js");
50
50
  });
51
51
 
@@ -540,7 +540,7 @@ describe("DeployEnvironment", () => {
540
540
  const result = DeployEnvironment({ name: "staging", deployScript: "npm run deploy" });
541
541
  const steps = result.deployJob.props.steps as Array<{ props: Record<string, unknown> }>;
542
542
  expect(steps.length).toBe(2);
543
- expect(steps[0].props.uses).toBe("actions/checkout@v4");
543
+ expect(steps[0].props.uses).toBe("actions/checkout@v7");
544
544
  expect(steps[1].props.run).toBe("npm run deploy");
545
545
  });
546
546
 
@@ -683,7 +683,7 @@ describe("defaults overrides", () => {
683
683
  defaults: { step: { id: "my-checkout" } },
684
684
  });
685
685
  expect(result.step.props.id).toBe("my-checkout");
686
- expect(result.step.props.uses).toBe("actions/checkout@v4");
686
+ expect(result.step.props.uses).toBe("actions/checkout@v7");
687
687
  });
688
688
 
689
689
  test("SetupNode defaults override step props", () => {
@@ -1,5 +1,6 @@
1
1
  import { Composite, mergeDefaults } from "@intentius/chant";
2
2
  import { Job, Step } from "../generated/index";
3
+ import { actionRef } from "../action-pins";
3
4
 
4
5
  export interface DeployEnvironmentProps {
5
6
  /** Environment name. Required. */
@@ -40,7 +41,7 @@ export const DeployEnvironment = Composite((props: DeployEnvironmentProps) => {
40
41
 
41
42
  // ── Deploy job ─────────────────────────────────────────────────────
42
43
  const deploySteps = [
43
- new Step({ name: "Checkout", uses: "actions/checkout@v4" }),
44
+ new Step({ name: "Checkout", uses: actionRef("actions/checkout") }),
44
45
  ...deployScriptArr.map(
45
46
  (cmd: string) => new Step({ name: "Deploy", run: cmd }),
46
47
  ),
@@ -63,7 +64,7 @@ export const DeployEnvironment = Composite((props: DeployEnvironmentProps) => {
63
64
 
64
65
  // ── Cleanup job ────────────────────────────────────────────────────
65
66
  const cleanupSteps = [
66
- new Step({ name: "Checkout", uses: "actions/checkout@v4" }),
67
+ new Step({ name: "Checkout", uses: actionRef("actions/checkout") }),
67
68
  ...cleanupScriptArr.map(
68
69
  (cmd: string) => new Step({ name: "Cleanup", run: cmd }),
69
70
  ),
@@ -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 DockerBuildProps {
5
6
  /** Image tag. Default: "${{ github.sha }}" */
@@ -44,7 +45,7 @@ export const DockerBuild = Composite((props: DockerBuildProps) => {
44
45
  } = props;
45
46
 
46
47
  // ── Steps ──────────────────────────────────────────────────────────
47
- const checkout = new Step({ name: "Checkout", uses: "actions/checkout@v4" });
48
+ const checkout = new Step({ name: "Checkout", uses: actionRef("actions/checkout") });
48
49
 
49
50
  const login = new Step({
50
51
  name: "Log in to container registry",
@@ -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 GoCIProps {
5
6
  /** Go version. Default: "1.22" */
@@ -34,7 +35,7 @@ export const GoCI = Composite((props: GoCIProps) => {
34
35
  const buildJob = new Job(mergeDefaults({
35
36
  "runs-on": runsOn,
36
37
  steps: [
37
- new Step({ name: "Checkout", uses: "actions/checkout@v4" }),
38
+ new Step({ name: "Checkout", uses: actionRef("actions/checkout") }),
38
39
  new Step({
39
40
  name: "Setup Go",
40
41
  uses: "actions/setup-go@v5",
@@ -48,7 +49,7 @@ export const GoCI = Composite((props: GoCIProps) => {
48
49
  const testJob = new Job(mergeDefaults({
49
50
  "runs-on": runsOn,
50
51
  steps: [
51
- new Step({ name: "Checkout", uses: "actions/checkout@v4" }),
52
+ new Step({ name: "Checkout", uses: actionRef("actions/checkout") }),
52
53
  new Step({
53
54
  name: "Setup Go",
54
55
  uses: "actions/setup-go@v5",
@@ -64,7 +65,7 @@ export const GoCI = Composite((props: GoCIProps) => {
64
65
  ? new Job(mergeDefaults({
65
66
  "runs-on": runsOn,
66
67
  steps: [
67
- new Step({ name: "Checkout", uses: "actions/checkout@v4" }),
68
+ new Step({ name: "Checkout", uses: actionRef("actions/checkout") }),
68
69
  new Step({
69
70
  name: "Setup Go",
70
71
  uses: "actions/setup-go@v5",
@@ -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);