@intentius/chant-lexicon-github 0.124.46 → 0.124.49

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 (69) hide show
  1. package/dist/action-pins.d.ts +17 -27
  2. package/dist/action-pins.d.ts.map +1 -1
  3. package/dist/action-pins.js +39 -27
  4. package/dist/components/ci-jobs.d.ts +5 -2
  5. package/dist/components/ci-jobs.d.ts.map +1 -1
  6. package/dist/components/ci-jobs.js +8 -5
  7. package/dist/components/generate-op-pipeline.d.ts +7 -0
  8. package/dist/components/generate-op-pipeline.d.ts.map +1 -1
  9. package/dist/components/generate-op-pipeline.js +26 -3
  10. package/dist/components/generate-op-waves-pipeline.d.ts +13 -3
  11. package/dist/components/generate-op-waves-pipeline.d.ts.map +1 -1
  12. package/dist/components/generate-op-waves-pipeline.js +24 -5
  13. package/dist/components/generate-pipeline.d.ts.map +1 -1
  14. package/dist/components/generate-pipeline.js +55 -10
  15. package/dist/components/review-jobs.d.ts +55 -0
  16. package/dist/components/review-jobs.d.ts.map +1 -0
  17. package/dist/components/review-jobs.js +130 -0
  18. package/dist/composites/cache.d.ts.map +1 -1
  19. package/dist/composites/cache.js +2 -1
  20. package/dist/composites/checkout.d.ts +3 -3
  21. package/dist/composites/checkout.js +1 -1
  22. package/dist/composites/docker-build.js +4 -4
  23. package/dist/composites/download-artifact.d.ts.map +1 -1
  24. package/dist/composites/download-artifact.js +2 -1
  25. package/dist/composites/go-ci.js +4 -4
  26. package/dist/composites/node-pipeline.js +5 -5
  27. package/dist/composites/python-ci.js +2 -2
  28. package/dist/composites/setup-go.d.ts.map +1 -1
  29. package/dist/composites/setup-go.js +2 -1
  30. package/dist/composites/setup-node.d.ts +3 -3
  31. package/dist/composites/setup-node.js +1 -1
  32. package/dist/composites/setup-python.d.ts.map +1 -1
  33. package/dist/composites/setup-python.js +2 -1
  34. package/dist/composites/upload-artifact.d.ts.map +1 -1
  35. package/dist/composites/upload-artifact.js +2 -1
  36. package/dist/integrity.json +2 -2
  37. package/dist/manifest.json +1 -1
  38. package/package.json +2 -2
  39. package/src/action-pins.test.ts +29 -6
  40. package/src/action-pins.ts +48 -34
  41. package/src/components/__fixtures__/pr-loop-build.github.golden.yml +8 -9
  42. package/src/components/__fixtures__/pr-loop-record.github.golden.yml +4 -5
  43. package/src/components/__fixtures__/pr-loop-waves.github.golden.yml +16 -17
  44. package/src/components/__fixtures__/pr-loop.github.golden.yml +8 -9
  45. package/src/components/__golden__/op-waves-jobs.yml +71 -9
  46. package/src/components/ci-jobs.ts +8 -5
  47. package/src/components/generate-op-pipeline.review.test.ts +152 -0
  48. package/src/components/generate-op-pipeline.test.ts +5 -4
  49. package/src/components/generate-op-pipeline.ts +33 -3
  50. package/src/components/generate-op-waves-pipeline.test.ts +2 -1
  51. package/src/components/generate-op-waves-pipeline.ts +25 -5
  52. package/src/components/generate-pipeline.test.ts +10 -9
  53. package/src/components/generate-pipeline.ts +57 -9
  54. package/src/components/generate-pr-pipeline.locks.test.ts +68 -0
  55. package/src/components/generate-pr-pipeline.test.ts +6 -4
  56. package/src/components/generate-pr-waves-pipeline.test.ts +3 -2
  57. package/src/components/review-jobs.ts +165 -0
  58. package/src/composites/cache.ts +2 -1
  59. package/src/composites/checkout.ts +4 -4
  60. package/src/composites/composites.test.ts +23 -22
  61. package/src/composites/docker-build.ts +4 -4
  62. package/src/composites/download-artifact.ts +2 -1
  63. package/src/composites/go-ci.ts +4 -4
  64. package/src/composites/node-pipeline.ts +5 -5
  65. package/src/composites/python-ci.ts +2 -2
  66. package/src/composites/setup-go.ts +2 -1
  67. package/src/composites/setup-node.ts +4 -4
  68. package/src/composites/setup-python.ts +2 -1
  69. package/src/composites/upload-artifact.ts +2 -1
@@ -0,0 +1,68 @@
1
+ /**
2
+ * The pull-request workflow with `ci.locks: plan` (#4327): a lock job after
3
+ * the plan runs `chant lock --from-report` and an unlock job runs `chant
4
+ * unlock` when the pull request closes. Both check out the target branch's
5
+ * commit. Without the option the workflow is unchanged.
6
+ */
7
+
8
+ import { describe, expect, test } from "vitest";
9
+ import { parseYAML } from "@intentius/chant/yaml";
10
+ import type { DriverComponent } from "@intentius/chant/components/driver";
11
+ import { generateGithubPipeline } from "./generate-pipeline";
12
+
13
+ const ESTATE: DriverComponent[] = [
14
+ { name: "net", dependsOn: [], deploy: [{ phase: "Apply", steps: [{ kind: "terraform-apply", root: "net" }] }] },
15
+ ];
16
+
17
+ interface Step { name?: string; uses?: string; run?: string; env?: Record<string, string>; with?: Record<string, unknown> }
18
+ interface Job { if?: string; needs?: string[]; permissions: Record<string, string>; steps: Step[] }
19
+ type Doc = { on: Record<string, unknown>; jobs: Record<string, Job> };
20
+
21
+ const gen = (prLoop: Record<string, unknown>) => parseYAML(generateGithubPipeline(ESTATE, { env: "prod", prLoop }).yaml) as Doc;
22
+
23
+ describe("the GitHub workflow with ci.locks: plan", () => {
24
+ const doc = gen({ locks: "plan" });
25
+
26
+ test("adds a lock job and an unlock job, and fires on close", () => {
27
+ expect(Object.keys(doc.jobs)).toEqual(["plan", "plan-note", "lock", "unlock", "record"]);
28
+ expect(doc.on.pull_request).toEqual({ branches: ["main"], types: ["opened", "synchronize", "reopened", "closed"] });
29
+ expect(doc.jobs.plan!.if).toBe("github.event_name == 'pull_request' && github.event.action != 'closed'");
30
+ });
31
+
32
+ test("the lock job needs the plan, takes its report and runs chant lock --from-report from the base commit", () => {
33
+ const job = doc.jobs.lock!;
34
+ expect(job.needs).toEqual(["plan"]);
35
+ expect(job.steps[0]!.with!.ref).toBe("${{ github.event.pull_request.base.sha }}");
36
+ expect(job.steps.some((s) => s.run?.includes("needs.plan.outputs"))).toBe(true);
37
+ const lock = job.steps.find((s) => s.run?.startsWith("chant lock"))!;
38
+ expect(lock.run).toBe('chant lock --from-report .chant/pr/pr-plan.json --pr "$PR_NUMBER" --head "$HEAD_SHA" --stage plan --forge github');
39
+ expect(lock.env!.PR_NUMBER).toBe("${{ github.event.pull_request.number }}");
40
+ expect(job.permissions).toEqual({ contents: "write", "pull-requests": "read", statuses: "write" });
41
+ });
42
+
43
+ test("the unlock job runs chant unlock on close, from the base commit, and runs no head code", () => {
44
+ const job = doc.jobs.unlock!;
45
+ expect(job.if).toBe("github.event_name == 'pull_request' && github.event.action == 'closed'");
46
+ expect(job.steps[0]!.with!.ref).toBe("${{ github.event.pull_request.base.sha }}");
47
+ expect(job.steps.find((s) => s.run?.startsWith("chant unlock"))!.run).toBe('chant unlock --pr "$PR_NUMBER"');
48
+ });
49
+
50
+ test("without ci.locks the workflow has neither job and fires on the default pull request types", () => {
51
+ const plain = generateGithubPipeline(ESTATE, { env: "prod", prLoop: {} }).yaml;
52
+ expect(parseYAML(plain)).toMatchObject({ on: { pull_request: { branches: ["main"] } } });
53
+ expect(plain).not.toContain("closed");
54
+ expect(plain).not.toContain("chant lock");
55
+ expect(plain).not.toContain("chant unlock");
56
+ });
57
+ });
58
+
59
+ describe("with a pull request environment too", () => {
60
+ test("the pull_request types and the plan condition are the same as with the environment alone", () => {
61
+ const both = gen({ locks: "plan", prEnvironment: true });
62
+ const env = gen({ prEnvironment: true });
63
+ expect(both.on.pull_request).toEqual(env.on.pull_request);
64
+ expect(both.jobs.plan!.if).toBe(env.jobs.plan!.if);
65
+ expect(Object.keys(both.jobs)).toContain("lock");
66
+ expect(Object.keys(both.jobs)).toContain("env-down");
67
+ });
68
+ });
@@ -14,6 +14,7 @@ import type { DriverComponent } from "@intentius/chant/components/driver";
14
14
  import { githubBaseFallback, greenCheckout, prApplyGroup, prApplyRecordKey, prLoopJobs } from "@intentius/chant/components/pr-pipeline";
15
15
  import { dropForgeTokens } from "@intentius/chant/ci-handover";
16
16
  import { generateGithubPipeline } from "./generate-pipeline";
17
+ import { ACTION_PINS, usesRef } from "@intentius/chant/action-pins";
17
18
 
18
19
  const ESTATE: DriverComponent[] = [
19
20
  { name: "net", dependsOn: [], deploy: [{ phase: "Apply", steps: [{ kind: "terraform-apply", root: "net" }] }] },
@@ -80,7 +81,8 @@ describe("the GitHub pull-request workflow", () => {
80
81
  expect(lastLine(plan.run)).toBe('chant ci run plan --in-place --no-push --base "$BASE_SHA" --pr "$PR_NUMBER" --env prod --gate pr-apply --forge github');
81
82
  expect(plan.env).toMatchObject({ BASE_SHA: "${{ github.event.pull_request.base.sha }}", PR_NUMBER: "${{ github.event.pull_request.number }}" });
82
83
  // The runner key is an optional secret: unset, the expression is empty and the record is unsigned.
83
- expect(plan.env?.CHANT_RUNNER_KEY).toBe("${{ secrets.CHANT_RUNNER_KEY }}");
84
+ // #4328: the plan runs the pull request's code, so its job never gets the runner key.
85
+ expect(plan.env?.CHANT_RUNNER_KEY).toBeUndefined();
84
86
  // #4295: the plan job only reads; the plan-note job writes the note and the status.
85
87
  expect(doc.jobs.plan.permissions).toEqual({ contents: "read" });
86
88
  expect(doc.jobs["plan-note"].permissions).toEqual({ actions: "read", contents: "read", "pull-requests": "write", statuses: "write" });
@@ -123,9 +125,9 @@ describe("the GitHub pull-request workflow", () => {
123
125
 
124
126
  test("the apply resumes from a record the cache keeps for the pushed commit across a re-run (#3543)", () => {
125
127
  const steps = doc.jobs.apply.steps;
126
- const restore = steps.findIndex((s) => s.uses === "actions/cache/restore@v4");
128
+ const restore = steps.findIndex((s) => s.uses === `${usesRef(ACTION_PINS.cacheRestore4)}`);
127
129
  const run = steps.findIndex((s) => s.run?.includes("ci run apply"));
128
- const save = steps.findIndex((s) => s.uses === "actions/cache/save@v4");
130
+ const save = steps.findIndex((s) => s.uses === `${usesRef(ACTION_PINS.cacheSave4)}`);
129
131
  expect(restore).toBeGreaterThan(-1);
130
132
  expect(restore).toBeLessThan(run);
131
133
  expect(save).toBeGreaterThan(run);
@@ -199,7 +201,7 @@ describe("the GitHub pull-request workflow", () => {
199
201
  expect(mdoc.jobs.apply.concurrency).toEqual({ group: "chant-apply.network.prod", "cancel-in-progress": false });
200
202
  // The record path is relative to the member for --resume and to the repository for the cache.
201
203
  expect(apply.run).toContain("--in-place");
202
- const restore = mdoc.jobs.apply.steps.find((s) => s.uses === "actions/cache/restore@v4")!;
204
+ const restore = mdoc.jobs.apply.steps.find((s) => s.uses === `${usesRef(ACTION_PINS.cacheRestore4)}`)!;
203
205
  expect(restore.with).toMatchObject({ path: "infra/network/.chant/pr-resume/pr-apply.json", "restore-keys": "chant-apply.network.prod-${{ github.sha }}-" });
204
206
  // #4295: the plan job uploads nothing; the plan-note job keeps its report.
205
207
  expect(mdoc.jobs.plan.steps.some((s) => s.uses?.startsWith("actions/upload-artifact"))).toBe(false);
@@ -14,6 +14,7 @@ import { describe, expect, test } from "vitest";
14
14
  import { parseYAML } from "@intentius/chant/yaml";
15
15
  import type { DriverComponent } from "@intentius/chant/components/driver";
16
16
  import { generateGithubPipeline } from "./generate-pipeline";
17
+ import { ACTION_PINS, usesRef } from "@intentius/chant/action-pins";
17
18
 
18
19
  const root = (name: string, dependsOn: string[] = []): DriverComponent => ({
19
20
  name,
@@ -112,8 +113,8 @@ describe("the GitHub pull-request workflow with a gate per wave (#4003)", () =>
112
113
  test("each wave restores the commit's newest record and saves its own, so wave k reads what wave k-1 left", () => {
113
114
  for (const k of [1, 2, 3]) {
114
115
  const steps = doc.jobs[`apply-wave-${k}`]!.steps;
115
- const restore = steps.find((s) => s.uses === "actions/cache/restore@v4")!;
116
- const save = steps.find((s) => s.uses === "actions/cache/save@v4")!;
116
+ const restore = steps.find((s) => s.uses === `${usesRef(ACTION_PINS.cacheRestore4)}`)!;
117
+ const save = steps.find((s) => s.uses === `${usesRef(ACTION_PINS.cacheSave4)}`)!;
117
118
  const key = `chant-apply-prod-\${{ github.sha }}-\${{ github.run_id }}-\${{ github.run_attempt }}-wave-${k}`;
118
119
  expect(restore.with).toEqual({ path: ".chant/pr-resume/pr-apply.json", key, "restore-keys": "chant-apply-prod-${{ github.sha }}-" });
119
120
  expect(save).toMatchObject({ if: "always()", with: { path: ".chant/pr-resume/pr-apply.json", key } });
@@ -0,0 +1,165 @@
1
+ /**
2
+ * The two jobs of a split review (#4326): `ScheduledOpSpec.reviewAgent`.
3
+ *
4
+ * An Op that runs a `reviewAgent` step in `handoff` mode runs the change's
5
+ * code (`chant run` loads the pull request's Op files) beside a model. This
6
+ * generator keeps the forge write token out of that job and posts the note
7
+ * from another one, as terragucci's review does (`render-review.ts`: the
8
+ * `review` and `review-note` jobs):
9
+ *
10
+ * - the review job holds `contents: read` and no forge token in any step's
11
+ * environment, bar the checkout script's fetches (`checkoutScript`,
12
+ * ../../../../packages/core/src/ci-handover.ts). Every later step starts
13
+ * without the token variables (`dropForgeTokens`). The `chant run` step is
14
+ * its last, and it hands the review directory on as the job's outputs
15
+ * (`handoverTrap`), in place of an artifact upload that would start beside
16
+ * whatever the change's code left running.
17
+ * - the note job checks nothing out. It takes the outputs back as data
18
+ * (`takeOverScript`), installs the chant release this lexicon belongs to
19
+ * and runs `postReviewNote` with the job's token. The Op's name comes from
20
+ * this file, not from the review, which the change's code wrote.
21
+ *
22
+ * Forgejo's runner ignores `permissions:` and may put its token in a step's
23
+ * environment, so the review job drops the token variables on both forges.
24
+ */
25
+
26
+ import { readFileSync } from "node:fs";
27
+ import { GITHUB_JOB_TOKEN, GITHUB_RUNNER } from "@intentius/chant/ci-jobs";
28
+ import { CHECKOUT_TOKEN_ENV, checkoutScript, dropForgeTokens, handoverOutputs, handoverTrap, takeOverScript } from "@intentius/chant/ci-handover";
29
+ import type { OpTrigger, PipelineMember } from "@intentius/chant/lexicon";
30
+
31
+ /** The variable `reviewAgent` reads its handoff directory from (`REVIEW_DIR_ENV`, review-agent.ts). */
32
+ const REVIEW_DIR_ENV = "CHANT_REVIEW_DIR";
33
+
34
+ /** Where the review job writes the review, from the directory the step starts in. */
35
+ const REVIEW_DIR = ".chant/review";
36
+
37
+ /** The id of the review job's `chant run` step, which its outputs come from. */
38
+ const REVIEW_STEP_ID = "chant-review";
39
+
40
+ /** The name of the note job beside the Op's own job. */
41
+ export function reviewNoteJobName(jobName: string): string {
42
+ return `${jobName}-note`;
43
+ }
44
+
45
+ /**
46
+ * Refuse a spec whose review cannot be split the way this shape needs:
47
+ * a trigger with no pull request to post on, a finding-mode that would give
48
+ * the review job a write scope, or an additive scope that would.
49
+ */
50
+ export function assertReviewSplit(name: string, trigger: OpTrigger, findingMode: string, permissions: Record<string, string> | undefined): void {
51
+ if (trigger.kind !== "pull_request") {
52
+ throw new Error(
53
+ `Scheduled Op "${name}" sets reviewAgent, which posts the review as a note on the pull request that triggered the run, but its trigger is "${trigger.kind}". Give it a { kind: "pull_request" } trigger.`,
54
+ );
55
+ }
56
+ if (findingMode !== "report") {
57
+ throw new Error(
58
+ `Scheduled Op "${name}" sets reviewAgent with findingMode "${findingMode}". The review job holds no forge write token, and the note job posts instead, so the Op's own finding-mode must be "report".`,
59
+ );
60
+ }
61
+ for (const [scope, level] of Object.entries(permissions ?? {})) {
62
+ if (level === "write" && scope !== "id-token") {
63
+ throw new Error(
64
+ `Scheduled Op "${name}" sets reviewAgent and adds the permission "${scope}: write". The review job runs the change's code and may hold only "id-token: write" beyond contents: read.`,
65
+ );
66
+ }
67
+ }
68
+ }
69
+
70
+ /** The chant release the note job installs: this lexicon's own, which releases in lockstep with `@intentius/chant`. */
71
+ function chantVersion(): string {
72
+ const pkg = JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8")) as { version?: unknown };
73
+ if (typeof pkg.version !== "string" || pkg.version === "") {
74
+ throw new Error("the github lexicon's package.json has no version to pin the review note job's chant to");
75
+ }
76
+ return pkg.version;
77
+ }
78
+
79
+ /** What the note job runs under node: `postReviewNote` over the review the review job handed on. Plain JavaScript, no `${{ }}`. */
80
+ const NOTE_SCRIPT = [
81
+ 'import { postReviewNote } from "@intentius/chant/op/activities/review-agent";',
82
+ `const { CHANT_OP: op, ${REVIEW_DIR_ENV}: dir } = process.env;`,
83
+ "const result = await postReviewNote({ op, dir });",
84
+ "console.log(result.reason);",
85
+ "if (!result.posted) process.exitCode = 1;",
86
+ ].join("\n");
87
+
88
+ /** Where the note job puts the review it took back: outside any checkout, which it has none of. */
89
+ const NOTE_DIR = '"${RUNNER_TEMP:-/tmp}/chant-review"';
90
+
91
+ /** The note step's shell: install the pinned chant beside the review and run {@link NOTE_SCRIPT} with tsx. */
92
+ function noteScript(version: string): string {
93
+ return [
94
+ `review=${NOTE_DIR}`,
95
+ 'dir="${RUNNER_TEMP:-/tmp}/chant-review-note"',
96
+ 'mkdir -p "$dir"',
97
+ 'cd "$dir"',
98
+ "echo '{\"private\":true}' > package.json",
99
+ `npm install --omit=peer --no-audit --no-fund --loglevel=error @intentius/chant@${version}`,
100
+ "cat > note.mjs <<'NOTE'",
101
+ NOTE_SCRIPT,
102
+ "NOTE",
103
+ `${REVIEW_DIR_ENV}="$review" node --import tsx note.mjs`,
104
+ ].join("\n");
105
+ }
106
+
107
+ export interface ReviewJobsInput {
108
+ /** The Op's name: what `chant run` targets and what keys the note. */
109
+ name: string;
110
+ /** The Op's job name. */
111
+ jobName: string;
112
+ image: string;
113
+ /** The invocation of the Op, `chant run <name>` by default. */
114
+ invocation: string;
115
+ /** Setup steps, as built by `setupStepDoc`. */
116
+ setup: Array<Record<string, unknown>>;
117
+ beforeScript: readonly string[];
118
+ extraScript: readonly string[];
119
+ /** The Op's own per-job environment (`ScheduledOpSpec.variables`). */
120
+ variables?: Record<string, string>;
121
+ member?: PipelineMember;
122
+ }
123
+
124
+ /** The review job and the note job of one split review. */
125
+ export function reviewJobs(i: ReviewJobsInput): { review: Record<string, unknown>; note: Record<string, unknown> } {
126
+ const drop = dropForgeTokens("sh");
127
+ const rooted = !i.member || i.member.dir === "." || i.member.dir === "";
128
+ const headStep = (line: string): Record<string, unknown> => ({ run: `${drop}\n${line}` });
129
+ const review = {
130
+ "runs-on": GITHUB_RUNNER,
131
+ container: i.image,
132
+ ...(i.variables && Object.keys(i.variables).length > 0 ? { env: i.variables } : {}),
133
+ outputs: handoverOutputs(REVIEW_STEP_ID, "github"),
134
+ steps: [
135
+ // A script, not actions/checkout: the token reaches the fetches alone, and the base branch's history comes with them for the diff.
136
+ {
137
+ name: "Check out the change",
138
+ ...(rooted ? {} : { "working-directory": "." }),
139
+ env: { [CHECKOUT_TOKEN_ENV]: GITHUB_JOB_TOKEN },
140
+ run: checkoutScript(true),
141
+ },
142
+ ...i.setup,
143
+ ...i.beforeScript.map(headStep),
144
+ // The job's last step: it hands the review directory on as the job's outputs when it ends, however it ends.
145
+ {
146
+ id: REVIEW_STEP_ID,
147
+ name: "Run the Op and keep its review",
148
+ env: { [REVIEW_DIR_ENV]: REVIEW_DIR },
149
+ run: [drop, ...handoverTrap(REVIEW_DIR, "github"), i.invocation, ...i.extraScript].join("\n"),
150
+ },
151
+ ],
152
+ };
153
+ const note = {
154
+ needs: i.jobName,
155
+ if: `needs.${i.jobName}.result == 'success'`,
156
+ "runs-on": GITHUB_RUNNER,
157
+ container: i.image,
158
+ permissions: { "pull-requests": "write" },
159
+ steps: [
160
+ { name: "Take the review from the review job", run: takeOverScript(i.jobName, NOTE_DIR, "github") },
161
+ { name: "Post the review as a note", env: { GITHUB_TOKEN: GITHUB_JOB_TOKEN, CHANT_OP: i.name }, run: noteScript(chantVersion()) },
162
+ ],
163
+ };
164
+ return { review, note };
165
+ }
@@ -1,4 +1,5 @@
1
1
  import { Composite, mergeDefaults } from "@intentius/chant";
2
+ import { actionRef } from "../action-pins";
2
3
  import { Step } from "../generated/index";
3
4
 
4
5
  export interface CacheActionProps {
@@ -20,7 +21,7 @@ export const CacheAction = Composite((props: CacheActionProps) => {
20
21
 
21
22
  const step = new Step(mergeDefaults({
22
23
  name: "Cache",
23
- uses: "actions/cache@v4",
24
+ uses: actionRef("actions/cache"),
24
25
  with: withObj,
25
26
  }, defaults?.step));
26
27
 
@@ -10,9 +10,9 @@ export interface CheckoutProps {
10
10
  submodules?: boolean | string;
11
11
  sshKey?: string;
12
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.
13
+ * `"sha"` (default) emits the commit SHA from the pin table with the version as
14
+ * a YAML comment, which passes GHA021, GHA029 and GHA059. `"tag"` emits the
15
+ * action's major tag.
16
16
  */
17
17
  pin?: ActionPinMode;
18
18
  defaults?: {
@@ -32,7 +32,7 @@ export const Checkout = Composite((props: CheckoutProps) => {
32
32
 
33
33
  const step = new Step(mergeDefaults({
34
34
  name: "Checkout",
35
- uses: actionRef("actions/checkout", props.pin),
35
+ uses: actionRef("actions/checkout", props.pin ?? "sha"),
36
36
  ...(Object.keys(withObj).length > 0 ? { with: withObj } : {}),
37
37
  }, defaults?.step));
38
38
 
@@ -1,4 +1,5 @@
1
1
  import { describe, test, expect } from "vitest";
2
+ import { actionRef, type PinnedAction } from "../action-pins";
2
3
  import { Checkout } from "./checkout";
3
4
  import { SetupNode } from "./setup-node";
4
5
  import { SetupGo } from "./setup-go";
@@ -22,7 +23,7 @@ describe("Checkout", () => {
22
23
  test("returns step with checkout action", () => {
23
24
  const result = Checkout({});
24
25
  expect(result.step).toBeDefined();
25
- expect(propsOf(result.step).uses).toBe("actions/checkout@v7");
26
+ expect(propsOf(result.step).uses).toBe(actionRef("actions/checkout" as PinnedAction));
26
27
  expect(propsOf(result.step).name).toBe("Checkout");
27
28
  });
28
29
 
@@ -48,7 +49,7 @@ describe("Checkout", () => {
48
49
  describe("SetupNode", () => {
49
50
  test("returns step with setup-node action", () => {
50
51
  const result = SetupNode({});
51
- expect(propsOf(result.step).uses).toBe("actions/setup-node@v7");
52
+ expect(propsOf(result.step).uses).toBe(actionRef("actions/setup-node" as PinnedAction));
52
53
  expect(propsOf(result.step).name).toBe("Setup Node.js");
53
54
  });
54
55
 
@@ -68,7 +69,7 @@ describe("SetupNode", () => {
68
69
  describe("SetupGo", () => {
69
70
  test("returns step with setup-go action", () => {
70
71
  const result = SetupGo({});
71
- expect(propsOf(result.step).uses).toBe("actions/setup-go@v5");
72
+ expect(propsOf(result.step).uses).toBe(actionRef("actions/setup-go" as PinnedAction));
72
73
  });
73
74
 
74
75
  test("passes goVersion", () => {
@@ -82,7 +83,7 @@ describe("SetupGo", () => {
82
83
  describe("SetupPython", () => {
83
84
  test("returns step with setup-python action", () => {
84
85
  const result = SetupPython({});
85
- expect(propsOf(result.step).uses).toBe("actions/setup-python@v5");
86
+ expect(propsOf(result.step).uses).toBe(actionRef("actions/setup-python" as PinnedAction));
86
87
  });
87
88
 
88
89
  test("passes pythonVersion", () => {
@@ -96,7 +97,7 @@ describe("SetupPython", () => {
96
97
  describe("CacheAction", () => {
97
98
  test("returns step with cache action", () => {
98
99
  const result = CacheAction({ path: "node_modules", key: "npm-${{ hashFiles('package-lock.json') }}" });
99
- expect(propsOf(result.step).uses).toBe("actions/cache@v4");
100
+ expect(propsOf(result.step).uses).toBe(actionRef("actions/cache" as PinnedAction));
100
101
  expect((propsOf(result.step).with as Record<string, string>).path).toBe("node_modules");
101
102
  expect((propsOf(result.step).with as Record<string, string>).key).toBe("npm-${{ hashFiles('package-lock.json') }}");
102
103
  });
@@ -107,7 +108,7 @@ describe("CacheAction", () => {
107
108
  describe("UploadArtifact", () => {
108
109
  test("returns step with upload action", () => {
109
110
  const result = UploadArtifact({ name: "build", path: "dist/" });
110
- expect(propsOf(result.step).uses).toBe("actions/upload-artifact@v4");
111
+ expect(propsOf(result.step).uses).toBe(actionRef("actions/upload-artifact" as PinnedAction));
111
112
  expect((propsOf(result.step).with as Record<string, string>).name).toBe("build");
112
113
  expect((propsOf(result.step).with as Record<string, string>).path).toBe("dist/");
113
114
  });
@@ -123,7 +124,7 @@ describe("UploadArtifact", () => {
123
124
  describe("DownloadArtifact", () => {
124
125
  test("returns step with download action", () => {
125
126
  const result = DownloadArtifact({ name: "build" });
126
- expect(propsOf(result.step).uses).toBe("actions/download-artifact@v4");
127
+ expect(propsOf(result.step).uses).toBe(actionRef("actions/download-artifact" as PinnedAction));
127
128
  expect((propsOf(result.step).with as Record<string, string>).name).toBe("build");
128
129
  });
129
130
  });
@@ -228,7 +229,7 @@ describe("NodePipeline", () => {
228
229
  test("bun uses oven-sh/setup-bun instead of setup-node", () => {
229
230
  const result = NodePipeline({ packageManager: "bun" });
230
231
  const steps = propsOf(result.buildJob).steps as Array<{ props: Record<string, unknown> }>;
231
- expect(steps[1].props.uses).toBe("oven-sh/setup-bun@v2");
232
+ expect(steps[1].props.uses).toBe(actionRef("oven-sh/setup-bun" as PinnedAction));
232
233
  expect(steps[1].props.name).toBe("Setup Bun");
233
234
  });
234
235
 
@@ -247,14 +248,14 @@ describe("NodePipeline", () => {
247
248
  test("upload step uses upload-artifact with configured name", () => {
248
249
  const result = NodePipeline({ artifactName: "my-build" });
249
250
  const steps = propsOf(result.buildJob).steps as Array<{ props: Record<string, unknown> }>;
250
- expect(steps[4].props.uses).toBe("actions/upload-artifact@v4");
251
+ expect(steps[4].props.uses).toBe(actionRef("actions/upload-artifact" as PinnedAction));
251
252
  expect((steps[4].props.with as Record<string, string>).name).toBe("my-build");
252
253
  });
253
254
 
254
255
  test("download step in testJob uses configured artifact name", () => {
255
256
  const result = NodePipeline({ artifactName: "my-build" });
256
257
  const steps = propsOf(result.testJob).steps as Array<{ props: Record<string, unknown> }>;
257
- expect(steps[3].props.uses).toBe("actions/download-artifact@v4");
258
+ expect(steps[3].props.uses).toBe(actionRef("actions/download-artifact" as PinnedAction));
258
259
  expect((steps[3].props.with as Record<string, string>).name).toBe("my-build");
259
260
  });
260
261
 
@@ -289,7 +290,7 @@ describe("BunPipeline", () => {
289
290
  test("defaults to bun package manager", () => {
290
291
  const result = BunPipeline({});
291
292
  const steps = propsOf(result.buildJob).steps as Array<{ props: Record<string, unknown> }>;
292
- expect(steps[1].props.uses).toBe("oven-sh/setup-bun@v2");
293
+ expect(steps[1].props.uses).toBe(actionRef("oven-sh/setup-bun" as PinnedAction));
293
294
  expect(steps[2].props.run).toBe("bun install --frozen-lockfile");
294
295
  });
295
296
  });
@@ -340,7 +341,7 @@ describe("PythonCI", () => {
340
341
  const result = PythonCI({});
341
342
  const steps = propsOf(result.testJob).steps as Array<{ props: Record<string, unknown> }>;
342
343
  const setupStep = steps[1];
343
- expect(propsOf(setupStep).uses).toBe("actions/setup-python@v5");
344
+ expect(propsOf(setupStep).uses).toBe(actionRef("actions/setup-python" as PinnedAction));
344
345
  expect((propsOf(setupStep).with as Record<string, string>)["python-version"]).toBe("3.12");
345
346
  });
346
347
 
@@ -430,25 +431,25 @@ describe("DockerBuild", () => {
430
431
  test("uses docker/login-action", () => {
431
432
  const result = DockerBuild({});
432
433
  const steps = propsOf(result.job).steps as Array<{ props: Record<string, unknown> }>;
433
- expect(steps[1].props.uses).toBe("docker/login-action@v3");
434
+ expect(steps[1].props.uses).toBe(actionRef("docker/login-action" as PinnedAction));
434
435
  });
435
436
 
436
437
  test("uses docker/setup-buildx-action", () => {
437
438
  const result = DockerBuild({});
438
439
  const steps = propsOf(result.job).steps as Array<{ props: Record<string, unknown> }>;
439
- expect(steps[2].props.uses).toBe("docker/setup-buildx-action@v3");
440
+ expect(steps[2].props.uses).toBe(actionRef("docker/setup-buildx-action" as PinnedAction));
440
441
  });
441
442
 
442
443
  test("uses docker/metadata-action", () => {
443
444
  const result = DockerBuild({});
444
445
  const steps = propsOf(result.job).steps as Array<{ props: Record<string, unknown> }>;
445
- expect(steps[3].props.uses).toBe("docker/metadata-action@v5");
446
+ expect(steps[3].props.uses).toBe(actionRef("docker/metadata-action" as PinnedAction));
446
447
  });
447
448
 
448
449
  test("uses docker/build-push-action", () => {
449
450
  const result = DockerBuild({});
450
451
  const steps = propsOf(result.job).steps as Array<{ props: Record<string, unknown> }>;
451
- expect(steps[4].props.uses).toBe("docker/build-push-action@v6");
452
+ expect(steps[4].props.uses).toBe(actionRef("docker/build-push-action" as PinnedAction));
452
453
  });
453
454
 
454
455
  test("login step uses ghcr.io by default", () => {
@@ -543,7 +544,7 @@ describe("DeployEnvironment", () => {
543
544
  const result = DeployEnvironment({ name: "staging", deployScript: "npm run deploy" });
544
545
  const steps = propsOf(result.deployJob).steps as Array<{ props: Record<string, unknown> }>;
545
546
  expect(steps.length).toBe(2);
546
- expect(steps[0].props.uses).toBe("actions/checkout@v7");
547
+ expect(steps[0].props.uses).toBe(actionRef("actions/checkout" as PinnedAction));
547
548
  expect(steps[1].props.run).toBe("npm run deploy");
548
549
  });
549
550
 
@@ -630,7 +631,7 @@ describe("GoCI", () => {
630
631
  test("buildJob uses setup-go with default version", () => {
631
632
  const result = GoCI({});
632
633
  const steps = propsOf(result.buildJob).steps as Array<{ props: Record<string, unknown> }>;
633
- expect(steps[1].props.uses).toBe("actions/setup-go@v5");
634
+ expect(steps[1].props.uses).toBe(actionRef("actions/setup-go" as PinnedAction));
634
635
  expect((steps[1].props.with as Record<string, string>)["go-version"]).toBe("1.22");
635
636
  });
636
637
 
@@ -667,7 +668,7 @@ describe("GoCI", () => {
667
668
  test("lintJob uses golangci-lint-action", () => {
668
669
  const result = GoCI({});
669
670
  const steps = propsOf(result.lintJob!).steps as Array<{ props: Record<string, unknown> }>;
670
- expect(steps[2].props.uses).toBe("golangci/golangci-lint-action@v6");
671
+ expect(steps[2].props.uses).toBe(actionRef("golangci/golangci-lint-action" as PinnedAction));
671
672
  });
672
673
 
673
674
  test("uses custom runsOn", () => {
@@ -686,7 +687,7 @@ describe("defaults overrides", () => {
686
687
  defaults: { step: { id: "my-checkout" } },
687
688
  });
688
689
  expect(propsOf(result.step).id).toBe("my-checkout");
689
- expect(propsOf(result.step).uses).toBe("actions/checkout@v7");
690
+ expect(propsOf(result.step).uses).toBe(actionRef("actions/checkout" as PinnedAction));
690
691
  });
691
692
 
692
693
  test("SetupNode defaults override step props", () => {
@@ -769,7 +770,7 @@ describe("defaults overrides", () => {
769
770
  defaults: { step: { id: "upload" } },
770
771
  });
771
772
  expect(propsOf(result.step).id).toBe("upload");
772
- expect(propsOf(result.step).uses).toBe("actions/upload-artifact@v4");
773
+ expect(propsOf(result.step).uses).toBe(actionRef("actions/upload-artifact" as PinnedAction));
773
774
  });
774
775
 
775
776
  test("CacheAction defaults override step props", () => {
@@ -779,6 +780,6 @@ describe("defaults overrides", () => {
779
780
  defaults: { step: { id: "cache-step" } },
780
781
  });
781
782
  expect(propsOf(result.step).id).toBe("cache-step");
782
- expect(propsOf(result.step).uses).toBe("actions/cache@v4");
783
+ expect(propsOf(result.step).uses).toBe(actionRef("actions/cache" as PinnedAction));
783
784
  });
784
785
  });
@@ -49,7 +49,7 @@ export const DockerBuild = Composite((props: DockerBuildProps) => {
49
49
 
50
50
  const login = new Step({
51
51
  name: "Log in to container registry",
52
- uses: "docker/login-action@v3",
52
+ uses: actionRef("docker/login-action"),
53
53
  with: {
54
54
  registry,
55
55
  username: "${{ github.actor }}",
@@ -59,7 +59,7 @@ export const DockerBuild = Composite((props: DockerBuildProps) => {
59
59
 
60
60
  const setupBuildx = new Step({
61
61
  name: "Set up Docker Buildx",
62
- uses: "docker/setup-buildx-action@v3",
62
+ uses: actionRef("docker/setup-buildx-action"),
63
63
  });
64
64
 
65
65
  // Build tags for metadata action
@@ -71,7 +71,7 @@ export const DockerBuild = Composite((props: DockerBuildProps) => {
71
71
  const metadata = new Step({
72
72
  name: "Extract metadata",
73
73
  id: "meta",
74
- uses: "docker/metadata-action@v5",
74
+ uses: actionRef("docker/metadata-action"),
75
75
  with: {
76
76
  images: imageName,
77
77
  tags: tags.join("\n"),
@@ -98,7 +98,7 @@ export const DockerBuild = Composite((props: DockerBuildProps) => {
98
98
 
99
99
  const buildPush = new Step({
100
100
  name: "Build and push",
101
- uses: "docker/build-push-action@v6",
101
+ uses: actionRef("docker/build-push-action"),
102
102
  with: buildPushWith,
103
103
  });
104
104
 
@@ -1,4 +1,5 @@
1
1
  import { Composite, mergeDefaults } from "@intentius/chant";
2
+ import { actionRef } from "../action-pins";
2
3
  import { Step } from "../generated/index";
3
4
 
4
5
  export interface DownloadArtifactProps {
@@ -19,7 +20,7 @@ export const DownloadArtifact = Composite((props: DownloadArtifactProps) => {
19
20
 
20
21
  const step = new Step(mergeDefaults({
21
22
  name: "Download Artifact",
22
- uses: "actions/download-artifact@v4",
23
+ uses: actionRef("actions/download-artifact"),
23
24
  ...(Object.keys(withObj).length > 0 ? { with: withObj } : {}),
24
25
  }, defaults?.step));
25
26
 
@@ -38,7 +38,7 @@ export const GoCI = Composite((props: GoCIProps) => {
38
38
  new Step({ name: "Checkout", uses: actionRef("actions/checkout") }),
39
39
  new Step({
40
40
  name: "Setup Go",
41
- uses: "actions/setup-go@v5",
41
+ uses: actionRef("actions/setup-go"),
42
42
  with: { "go-version": goVersion },
43
43
  }),
44
44
  new Step({ name: "Build", run: buildCommand }),
@@ -52,7 +52,7 @@ export const GoCI = Composite((props: GoCIProps) => {
52
52
  new Step({ name: "Checkout", uses: actionRef("actions/checkout") }),
53
53
  new Step({
54
54
  name: "Setup Go",
55
- uses: "actions/setup-go@v5",
55
+ uses: actionRef("actions/setup-go"),
56
56
  with: { "go-version": goVersion },
57
57
  }),
58
58
  new Step({ name: "Test", run: testCommand }),
@@ -68,12 +68,12 @@ export const GoCI = Composite((props: GoCIProps) => {
68
68
  new Step({ name: "Checkout", uses: actionRef("actions/checkout") }),
69
69
  new Step({
70
70
  name: "Setup Go",
71
- uses: "actions/setup-go@v5",
71
+ uses: actionRef("actions/setup-go"),
72
72
  with: { "go-version": goVersion },
73
73
  }),
74
74
  new Step({
75
75
  name: "Lint",
76
- uses: "golangci/golangci-lint-action@v6",
76
+ uses: actionRef("golangci/golangci-lint-action"),
77
77
  with: { args: lintCommand },
78
78
  }),
79
79
  ],
@@ -51,7 +51,7 @@ const cacheConfig = {
51
51
  cache: undefined,
52
52
  installCmd: "bun install --frozen-lockfile",
53
53
  runPrefix: "bun run",
54
- setupAction: "oven-sh/setup-bun@v2",
54
+ setupAction: actionRef("oven-sh/setup-bun"),
55
55
  },
56
56
  } as const;
57
57
 
@@ -79,7 +79,7 @@ export const NodePipeline = Composite((props: NodePipelineProps) => {
79
79
  const buildCheckout = new Step({ name: "Checkout", uses: actionRef("actions/checkout") });
80
80
 
81
81
  const buildSetup = isBun
82
- ? new Step({ name: "Setup Bun", uses: "oven-sh/setup-bun@v2" })
82
+ ? new Step({ name: "Setup Bun", uses: actionRef("oven-sh/setup-bun") })
83
83
  : new Step({
84
84
  name: "Setup Node.js",
85
85
  uses: actionRef("actions/setup-node"),
@@ -92,7 +92,7 @@ export const NodePipeline = Composite((props: NodePipelineProps) => {
92
92
 
93
93
  const buildUpload = new Step({
94
94
  name: "Upload build artifacts",
95
- uses: "actions/upload-artifact@v4",
95
+ uses: actionRef("actions/upload-artifact"),
96
96
  with: {
97
97
  name: artifactName,
98
98
  path: buildArtifactPaths.join("\n"),
@@ -109,7 +109,7 @@ export const NodePipeline = Composite((props: NodePipelineProps) => {
109
109
  const testCheckout = new Step({ name: "Checkout", uses: actionRef("actions/checkout") });
110
110
 
111
111
  const testSetup = isBun
112
- ? new Step({ name: "Setup Bun", uses: "oven-sh/setup-bun@v2" })
112
+ ? new Step({ name: "Setup Bun", uses: actionRef("oven-sh/setup-bun") })
113
113
  : new Step({
114
114
  name: "Setup Node.js",
115
115
  uses: actionRef("actions/setup-node"),
@@ -120,7 +120,7 @@ export const NodePipeline = Composite((props: NodePipelineProps) => {
120
120
 
121
121
  const testDownload = new Step({
122
122
  name: "Download build artifacts",
123
- uses: "actions/download-artifact@v4",
123
+ uses: actionRef("actions/download-artifact"),
124
124
  with: { name: artifactName },
125
125
  });
126
126