@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.
- package/dist/action-pins.d.ts +53 -0
- package/dist/action-pins.d.ts.map +1 -0
- package/dist/codegen/naming.d.ts.map +1 -1
- package/dist/components/generate-op-pipeline.d.ts.map +1 -1
- package/dist/components/generate-pipeline.d.ts.map +1 -1
- package/dist/composites/checkout.d.ts +7 -0
- package/dist/composites/checkout.d.ts.map +1 -1
- package/dist/composites/dependabot.d.ts.map +1 -1
- package/dist/composites/deploy-environment.d.ts.map +1 -1
- package/dist/composites/docker-build.d.ts.map +1 -1
- package/dist/composites/go-ci.d.ts.map +1 -1
- package/dist/composites/node-ci.d.ts.map +1 -1
- package/dist/composites/node-pipeline.d.ts +30 -30
- package/dist/composites/node-pipeline.d.ts.map +1 -1
- package/dist/composites/python-ci.d.ts.map +1 -1
- package/dist/composites/setup-node.d.ts +7 -0
- package/dist/composites/setup-node.d.ts.map +1 -1
- package/dist/expression.d.ts.map +1 -1
- package/dist/generated/index.d.ts +22 -22
- package/dist/generated/index.d.ts.map +1 -1
- package/dist/import/generator.d.ts.map +1 -1
- package/dist/import/parser.d.ts.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/integrity.json +7 -6
- package/dist/lint/audit-catalog.d.ts.map +1 -1
- package/dist/lint/audit-lineage.d.ts +13 -0
- package/dist/lint/audit-lineage.d.ts.map +1 -1
- package/dist/lint/post-synth/gha021.d.ts.map +1 -1
- package/dist/lint/post-synth/gha029.d.ts.map +1 -1
- package/dist/lint/post-synth/gha059.d.ts.map +1 -1
- package/dist/lint/post-synth/gha069.d.ts +33 -0
- package/dist/lint/post-synth/gha069.d.ts.map +1 -0
- package/dist/lint/post-synth/index.d.ts.map +1 -1
- package/dist/lint/post-synth/yaml-helpers.d.ts +20 -0
- package/dist/lint/post-synth/yaml-helpers.d.ts.map +1 -1
- package/dist/lint/rules/data/deprecated-versions.d.ts.map +1 -1
- package/dist/manifest.json +1 -1
- package/dist/okf/index.md +1 -0
- package/dist/okf/rules/GHA069.md +16 -0
- package/dist/okf/types/Job.md +1 -0
- package/dist/okf/types/Workflow.md +1 -0
- package/dist/rules/gha021.ts +24 -24
- package/dist/rules/gha029.ts +3 -2
- package/dist/rules/gha059.ts +10 -2
- package/dist/rules/gha069.ts +111 -0
- package/dist/rules/yaml-helpers.ts +41 -47
- package/dist/variables.d.ts.map +1 -1
- package/package.json +3 -3
- package/src/action-pins.test.ts +87 -0
- package/src/action-pins.ts +75 -0
- package/src/codegen/docs.ts +1 -1
- package/src/components/__fixtures__/pr-loop.github.golden.yml +68 -0
- package/src/components/generate-op-pipeline.test.ts +4 -4
- package/src/components/generate-op-pipeline.ts +8 -7
- package/src/components/generate-pipeline.test.ts +30 -1
- package/src/components/generate-pipeline.ts +157 -14
- package/src/components/generate-pr-pipeline.test.ts +98 -0
- package/src/composites/checkout.ts +8 -1
- package/src/composites/composites.test.ts +4 -4
- package/src/composites/deploy-environment.ts +3 -2
- package/src/composites/docker-build.ts +2 -1
- package/src/composites/go-ci.ts +4 -3
- package/src/composites/node-ci.ts +3 -2
- package/src/composites/node-pipeline.ts +8 -7
- package/src/composites/python-ci.ts +3 -2
- package/src/composites/setup-node.ts +8 -1
- package/src/index.ts +4 -0
- package/src/lint/audit-catalog.ts +1 -0
- package/src/lint/audit-lineage.ts +13 -0
- package/src/lint/post-synth/fixtures/GHA069/negative-no-job-block.yml +16 -0
- package/src/lint/post-synth/fixtures/GHA069/negative-no-oidc-step.yml +22 -0
- package/src/lint/post-synth/fixtures/GHA069/negative.yml +21 -0
- package/src/lint/post-synth/fixtures/GHA069/positive.yml +20 -0
- package/src/lint/post-synth/gha011.test.ts +16 -0
- package/src/lint/post-synth/gha018.test.ts +18 -0
- package/src/lint/post-synth/gha019.test.ts +42 -0
- package/src/lint/post-synth/gha021.test.ts +18 -0
- package/src/lint/post-synth/gha021.ts +24 -24
- package/src/lint/post-synth/gha022.test.ts +15 -0
- package/src/lint/post-synth/gha023.test.ts +19 -0
- package/src/lint/post-synth/gha029.ts +3 -2
- package/src/lint/post-synth/gha038.test.ts +20 -0
- package/src/lint/post-synth/gha059.ts +10 -2
- package/src/lint/post-synth/gha069.test.ts +173 -0
- package/src/lint/post-synth/gha069.ts +111 -0
- package/src/lint/post-synth/index.ts +2 -0
- package/src/lint/post-synth/yaml-helpers.test.ts +83 -0
- package/src/lint/post-synth/yaml-helpers.ts +41 -47
- package/src/lint/rules/data/deprecated-versions.ts +5 -2
- package/src/lint/rules/deprecated-action-version.test.ts +1 -1
- package/src/lint/rules/rules.test.ts +1 -1
- package/src/mcp/context-tools.test.ts +17 -0
- package/src/plugin.test.ts +1 -1
- package/src/serializer.ts +15 -4
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
name: chant-pr-prod
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
pull_request:
|
|
5
|
+
branches:
|
|
6
|
+
- main
|
|
7
|
+
push:
|
|
8
|
+
branches:
|
|
9
|
+
- main
|
|
10
|
+
|
|
11
|
+
env:
|
|
12
|
+
CHANT_ENV: prod
|
|
13
|
+
|
|
14
|
+
jobs:
|
|
15
|
+
plan:
|
|
16
|
+
if: github.event_name == 'pull_request'
|
|
17
|
+
runs-on: ubuntu-latest
|
|
18
|
+
container: node:22-slim
|
|
19
|
+
permissions:
|
|
20
|
+
contents: read
|
|
21
|
+
pull-requests: write
|
|
22
|
+
statuses: write
|
|
23
|
+
steps:
|
|
24
|
+
- uses: actions/checkout@v7
|
|
25
|
+
with:
|
|
26
|
+
fetch-depth: 0
|
|
27
|
+
- name: Plan the members this pull request reaches
|
|
28
|
+
env:
|
|
29
|
+
BASE_SHA: '${{ github.event.pull_request.base.sha }}'
|
|
30
|
+
PR_NUMBER: '${{ github.event.pull_request.number }}'
|
|
31
|
+
GITHUB_TOKEN: '${{ github.token }}'
|
|
32
|
+
run: chant components pr-plan --base "$BASE_SHA" --pr "$PR_NUMBER" --env prod --gate pr-apply --output .chant/pr --forge github
|
|
33
|
+
- name: Keep the plan report
|
|
34
|
+
if: always()
|
|
35
|
+
uses: actions/upload-artifact@v4
|
|
36
|
+
with:
|
|
37
|
+
name: chant-pr-plan
|
|
38
|
+
path: .chant/pr
|
|
39
|
+
if-no-files-found: ignore
|
|
40
|
+
include-hidden-files: true
|
|
41
|
+
apply:
|
|
42
|
+
if: github.event_name == 'push'
|
|
43
|
+
runs-on: ubuntu-latest
|
|
44
|
+
container: node:22-slim
|
|
45
|
+
concurrency:
|
|
46
|
+
group: chant-apply-prod
|
|
47
|
+
cancel-in-progress: false
|
|
48
|
+
permissions:
|
|
49
|
+
contents: write
|
|
50
|
+
pull-requests: write
|
|
51
|
+
statuses: write
|
|
52
|
+
steps:
|
|
53
|
+
- uses: actions/checkout@v7
|
|
54
|
+
with:
|
|
55
|
+
fetch-depth: 0
|
|
56
|
+
- name: Apply the plan a reviewer approved
|
|
57
|
+
env:
|
|
58
|
+
BASE_SHA: '${{ github.event.before }}'
|
|
59
|
+
GITHUB_TOKEN: '${{ github.token }}'
|
|
60
|
+
run: chant components pr-apply --base "$BASE_SHA" --env prod --gate pr-apply --output .chant/pr --forge github --require-review
|
|
61
|
+
- name: Keep the apply report
|
|
62
|
+
if: always()
|
|
63
|
+
uses: actions/upload-artifact@v4
|
|
64
|
+
with:
|
|
65
|
+
name: chant-pr-apply
|
|
66
|
+
path: .chant/pr
|
|
67
|
+
if-no-files-found: ignore
|
|
68
|
+
include-hidden-files: true
|
|
@@ -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@
|
|
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@
|
|
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@
|
|
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@
|
|
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
|
|
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
|
|
826
|
-
if (doc.env && Object.keys(doc.env).length > 0) sections.push("env
|
|
827
|
-
sections.push("concurrency
|
|
828
|
-
if (Object.keys(doc.permissions).length > 0) sections.push("permissions
|
|
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
|
|
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@
|
|
107
|
+
expect(job.steps[0].uses).toBe("actions/checkout@v7");
|
|
108
108
|
}
|
|
109
109
|
});
|
|
110
110
|
|
|
@@ -421,3 +421,32 @@ describe("generateGithubPipeline: a promote job (#2575)", () => {
|
|
|
421
421
|
expect(artifactRoot(["/tmp/out/a.tar", "/tmp/out/b.tar"])).toBe("/tmp/out");
|
|
422
422
|
});
|
|
423
423
|
});
|
|
424
|
+
|
|
425
|
+
describe("generateGithubPipeline: gated waves (#3049)", () => {
|
|
426
|
+
const roots: DriverComponent[] = [
|
|
427
|
+
{ name: "net", deploy: [] },
|
|
428
|
+
{ name: "a", dependsOn: ["net"], deploy: [] },
|
|
429
|
+
{ name: "app", dependsOn: ["a"], deploy: [] },
|
|
430
|
+
];
|
|
431
|
+
|
|
432
|
+
test("one job per wave, each handing the attempt record to the next", () => {
|
|
433
|
+
const result = generateGithubPipeline(roots, { env: "prod", gatedWaves: { gate: "release" } });
|
|
434
|
+
const parsed = parseYAML(result.yaml) as { jobs: Record<string, { needs?: string[]; steps: Array<Record<string, unknown>> }> };
|
|
435
|
+
expect(Object.keys(parsed.jobs)).toEqual(["wave-1", "wave-2", "wave-3"]);
|
|
436
|
+
expect(parsed.jobs["wave-2"].needs).toEqual(["wave-1"]);
|
|
437
|
+
|
|
438
|
+
const steps = parsed.jobs["wave-2"].steps;
|
|
439
|
+
expect(steps[0].with).toEqual({ "fetch-depth": 0 });
|
|
440
|
+
expect(steps[1]).toMatchObject({ uses: "actions/download-artifact@v4", with: { name: "fan-out-record-wave-1", path: ".chant" } });
|
|
441
|
+
expect(steps[2].run).toBe(
|
|
442
|
+
"chant components fan-out --base HEAD~1 --env prod --wave-gate release --wave 2 --resume .chant/fan-out.json",
|
|
443
|
+
);
|
|
444
|
+
expect(steps[3]).toMatchObject({ if: "always()", with: { name: "fan-out-record-wave-2", path: ".chant/fan-out.json" } });
|
|
445
|
+
// Wave 1 has nothing to download.
|
|
446
|
+
expect(parsed.jobs["wave-1"].steps.some((s) => s.uses === "actions/download-artifact@v4")).toBe(false);
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
test("a gated-wave pipeline has no promote job", () => {
|
|
450
|
+
expect(() => generateGithubPipeline(roots, { gatedWaves: { gate: "release" }, promoteTo: "prod" })).toThrow(/no promote job/);
|
|
451
|
+
});
|
|
452
|
+
});
|
|
@@ -56,9 +56,11 @@
|
|
|
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
|
+
import { GATED_WAVE_RECORD, gatedWaveJobs } from "@intentius/chant/components/gated-wave-pipeline";
|
|
63
|
+
import { PR_LOOP_REPORT_DIR, prApplyGroup, prLoopJobs } from "@intentius/chant/components/pr-pipeline";
|
|
62
64
|
import { memberRepoPath } from "@intentius/chant/lexicon";
|
|
63
65
|
import type {
|
|
64
66
|
ComponentPipelineJob as GeneratedJob,
|
|
@@ -66,6 +68,7 @@ import type {
|
|
|
66
68
|
ComponentPipelineResult as GenerateGithubResult,
|
|
67
69
|
PipelineMember,
|
|
68
70
|
} from "@intentius/chant/lexicon";
|
|
71
|
+
import { actionRef } from "../action-pins";
|
|
69
72
|
|
|
70
73
|
export type { GeneratedJob, GenerateGithubOptions, GenerateGithubResult };
|
|
71
74
|
|
|
@@ -106,6 +109,140 @@ export interface GithubPipelineDoc {
|
|
|
106
109
|
jobs: GeneratedJob[];
|
|
107
110
|
}
|
|
108
111
|
|
|
112
|
+
/** The workflow artifact the gated-wave jobs hand their attempt record through, one per wave. */
|
|
113
|
+
function waveRecordArtifact(wave: number): string {
|
|
114
|
+
return `fan-out-record-wave-${wave}`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* The gated-wave workflow (#3049): one job per wave, each running one wave of
|
|
119
|
+
* `chant components fan-out --wave-gate`, chained by `needs:`. Each job
|
|
120
|
+
* downloads the record the job before it uploaded and uploads its own, even
|
|
121
|
+
* when it stops at a gate (exit 3), so re-running it after `chant approve`
|
|
122
|
+
* reads the record. The checkout fetches full history for `--base`.
|
|
123
|
+
*/
|
|
124
|
+
function gatedWaveGithubDoc(
|
|
125
|
+
components: DriverComponent[],
|
|
126
|
+
env: string,
|
|
127
|
+
image: string,
|
|
128
|
+
beforeScript: string[],
|
|
129
|
+
extraScript: string[],
|
|
130
|
+
options: GenerateGithubOptions,
|
|
131
|
+
): GithubPipelineDoc {
|
|
132
|
+
const waveJobs = gatedWaveJobs(components, env, options.gatedWaves!);
|
|
133
|
+
const jobsDoc: Record<string, unknown> = {};
|
|
134
|
+
const jobs: GeneratedJob[] = [];
|
|
135
|
+
for (const job of waveJobs) {
|
|
136
|
+
jobs.push({ jobName: job.jobName, component: `wave ${job.wave}`, stage: job.jobName, needs: job.needs });
|
|
137
|
+
const steps: Array<Record<string, unknown>> = [{ uses: actionRef("actions/checkout"), with: { "fetch-depth": 0 } }];
|
|
138
|
+
if (job.wave > 1) {
|
|
139
|
+
steps.push({
|
|
140
|
+
name: `Download the record wave ${job.wave - 1} left`,
|
|
141
|
+
uses: "actions/download-artifact@v4",
|
|
142
|
+
with: { name: waveRecordArtifact(job.wave - 1), path: dirnameOf(GATED_WAVE_RECORD) },
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
for (const line of beforeScript) steps.push({ run: line });
|
|
146
|
+
steps.push({ run: job.command.join(" ") });
|
|
147
|
+
for (const line of extraScript) steps.push({ run: line });
|
|
148
|
+
steps.push({
|
|
149
|
+
name: `Upload the record for wave ${job.wave}`,
|
|
150
|
+
if: "always()",
|
|
151
|
+
uses: "actions/upload-artifact@v4",
|
|
152
|
+
with: {
|
|
153
|
+
name: waveRecordArtifact(job.wave),
|
|
154
|
+
path: GATED_WAVE_RECORD,
|
|
155
|
+
"if-no-files-found": "ignore",
|
|
156
|
+
"include-hidden-files": true,
|
|
157
|
+
overwrite: true,
|
|
158
|
+
},
|
|
159
|
+
});
|
|
160
|
+
jobsDoc[job.jobName] = {
|
|
161
|
+
"runs-on": "ubuntu-latest",
|
|
162
|
+
...(job.needs.length > 0 ? { needs: job.needs } : {}),
|
|
163
|
+
container: image,
|
|
164
|
+
steps,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
const doc: GithubPipelineDoc = {
|
|
168
|
+
name: `chant-components-${env}`,
|
|
169
|
+
environment: env,
|
|
170
|
+
on: { workflow_dispatch: {} },
|
|
171
|
+
env: { ...options.variables, CHANT_ENV: env },
|
|
172
|
+
jobsDoc,
|
|
173
|
+
stages: waveJobs.map((j) => j.jobName),
|
|
174
|
+
jobs,
|
|
175
|
+
};
|
|
176
|
+
return options.member ? scopeToMember(doc, options.member) : doc;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* The pull-request workflow (#3183): `plan` on each pull request into the
|
|
181
|
+
* target branch, `apply` on each push to it. The plan job can comment and set
|
|
182
|
+
* statuses but cannot push, since it runs the pull request's code; the apply
|
|
183
|
+
* job can push, to record a pending fact on `chant/lifecycle`, and runs one
|
|
184
|
+
* at a time per environment. Both keep their report as an artifact, and both
|
|
185
|
+
* check out the full history, since each measures the change with git.
|
|
186
|
+
*/
|
|
187
|
+
function prLoopGithubDoc(
|
|
188
|
+
env: string,
|
|
189
|
+
image: string,
|
|
190
|
+
beforeScript: string[],
|
|
191
|
+
extraScript: string[],
|
|
192
|
+
options: GenerateGithubOptions,
|
|
193
|
+
): GithubPipelineDoc {
|
|
194
|
+
const loop = options.prLoop!;
|
|
195
|
+
const branch = loop.branch ?? "main";
|
|
196
|
+
const [plan, apply] = prLoopJobs(loop.forge ?? "github", env, loop);
|
|
197
|
+
const steps = (job: typeof plan, name: string): Array<Record<string, unknown>> => [
|
|
198
|
+
{ uses: actionRef("actions/checkout"), with: { "fetch-depth": 0 } },
|
|
199
|
+
...beforeScript.map((line) => ({ run: line })),
|
|
200
|
+
{ name, env: job.env, run: job.command },
|
|
201
|
+
...extraScript.map((line) => ({ run: line })),
|
|
202
|
+
{
|
|
203
|
+
name: `Keep the ${job.jobName} report`,
|
|
204
|
+
if: "always()",
|
|
205
|
+
uses: "actions/upload-artifact@v4",
|
|
206
|
+
with: { name: `chant-pr-${job.jobName}`, path: PR_LOOP_REPORT_DIR, "if-no-files-found": "ignore", "include-hidden-files": true },
|
|
207
|
+
},
|
|
208
|
+
];
|
|
209
|
+
const jobsDoc: Record<string, unknown> = {
|
|
210
|
+
plan: {
|
|
211
|
+
if: "github.event_name == 'pull_request'",
|
|
212
|
+
"runs-on": "ubuntu-latest",
|
|
213
|
+
container: image,
|
|
214
|
+
permissions: { contents: "read", "pull-requests": "write", statuses: "write" },
|
|
215
|
+
steps: steps(plan, "Plan the members this pull request reaches"),
|
|
216
|
+
},
|
|
217
|
+
apply: {
|
|
218
|
+
if: "github.event_name == 'push'",
|
|
219
|
+
"runs-on": "ubuntu-latest",
|
|
220
|
+
container: image,
|
|
221
|
+
concurrency: { group: prApplyGroup(env), "cancel-in-progress": false },
|
|
222
|
+
permissions: { contents: "write", "pull-requests": "write", statuses: "write" },
|
|
223
|
+
steps: steps(apply, "Apply the plan a reviewer approved"),
|
|
224
|
+
},
|
|
225
|
+
};
|
|
226
|
+
return {
|
|
227
|
+
name: `chant-pr-${env}`,
|
|
228
|
+
environment: env,
|
|
229
|
+
on: { pull_request: { branches: [branch] }, push: { branches: [branch] } },
|
|
230
|
+
env: { ...options.variables, CHANT_ENV: env },
|
|
231
|
+
jobsDoc,
|
|
232
|
+
stages: ["plan", "apply"],
|
|
233
|
+
jobs: [
|
|
234
|
+
{ jobName: "plan", component: "pull request plan", stage: "plan", needs: [] },
|
|
235
|
+
{ jobName: "apply", component: "pull request apply", stage: "apply", needs: [] },
|
|
236
|
+
],
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** The directory part of a relative path, `.` when there is none. */
|
|
241
|
+
function dirnameOf(path: string): string {
|
|
242
|
+
const i = path.lastIndexOf("/");
|
|
243
|
+
return i <= 0 ? "." : path.slice(0, i);
|
|
244
|
+
}
|
|
245
|
+
|
|
109
246
|
/** GitHub Actions job ids must match `[a-zA-Z_][a-zA-Z0-9_-]*`; component names are already kebab-case in every fixture, but normalize defensively (mirrors gitlab's `toJobName`). */
|
|
110
247
|
function toJobName(componentName: string): string {
|
|
111
248
|
return componentName.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
|
|
@@ -173,6 +310,19 @@ export function buildGithubPipelineDoc(
|
|
|
173
310
|
const { waves } = resolveComponentGraph(components);
|
|
174
311
|
const byName = new Map(components.map((c) => [c.name, c]));
|
|
175
312
|
|
|
313
|
+
if (options.prLoop) {
|
|
314
|
+
if (options.gatedWaves || options.promoteTo !== undefined) {
|
|
315
|
+
throw new Error("a pull-request pipeline has no wave or promote jobs; drop --wave-gate and --promote-to, or --pr-loop");
|
|
316
|
+
}
|
|
317
|
+
if (options.member) throw new Error("a pull-request pipeline is not generated for a workspace member yet; generate it from the project outside the workspace");
|
|
318
|
+
return prLoopGithubDoc(env, image, beforeScript, extraScript, options);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
if (options.gatedWaves) {
|
|
322
|
+
if (options.promoteTo !== undefined) throw new Error("a gated-wave pipeline has no promote job; drop --promote-to or --wave-gate");
|
|
323
|
+
return gatedWaveGithubDoc(components, env, image, beforeScript, extraScript, options);
|
|
324
|
+
}
|
|
325
|
+
|
|
176
326
|
// Components that something else depends on must hand their resolved outputs
|
|
177
327
|
// (stack outputs, published artifact refs) to their dependents, which run as
|
|
178
328
|
// separate jobs on separate runners. Each such producer dumps its outputs to
|
|
@@ -232,7 +382,7 @@ export function buildGithubPipelineDoc(
|
|
|
232
382
|
// One step per script line — mirrors gitlab's `script:` array of
|
|
233
383
|
// discrete shell lines, rather than a single multi-line `run:` block, so
|
|
234
384
|
// each line is independently inspectable (and machine-parseable).
|
|
235
|
-
const steps: Array<Record<string, unknown>> = [{ uses: "actions/checkout
|
|
385
|
+
const steps: Array<Record<string, unknown>> = [{ uses: actionRef("actions/checkout") }];
|
|
236
386
|
|
|
237
387
|
for (const dep of component.dependsOn ?? []) {
|
|
238
388
|
steps.push({
|
|
@@ -297,7 +447,7 @@ export function buildGithubPipelineDoc(
|
|
|
297
447
|
for (const name of [...pinned].sort()) {
|
|
298
448
|
command.push("--digest", `"${name}=\${{ needs.${jobNameByComponent.get(name)!}.outputs.digest }}"`);
|
|
299
449
|
}
|
|
300
|
-
const steps: Array<Record<string, unknown>> = [{ uses: "actions/checkout
|
|
450
|
+
const steps: Array<Record<string, unknown>> = [{ uses: actionRef("actions/checkout") }];
|
|
301
451
|
for (const [name, paths] of archives) {
|
|
302
452
|
steps.push({
|
|
303
453
|
name: `Download ${name} build archive`,
|
|
@@ -375,21 +525,14 @@ function memberArtifactStep(step: Record<string, unknown>, member: PipelineMembe
|
|
|
375
525
|
* Shared with the forgejo dialect (#969), which transforms the doc first.
|
|
376
526
|
*/
|
|
377
527
|
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
528
|
const sections: string[] = [];
|
|
386
529
|
sections.push("name: " + emitYAML(doc.name, 0));
|
|
387
|
-
sections.push(
|
|
530
|
+
sections.push(emitYAMLEntry("on", doc.on));
|
|
388
531
|
if (doc.env && Object.keys(doc.env).length > 0) {
|
|
389
|
-
sections.push(
|
|
532
|
+
sections.push(emitYAMLEntry("env", doc.env));
|
|
390
533
|
}
|
|
391
|
-
if (doc.defaults) sections.push(
|
|
392
|
-
sections.push(
|
|
534
|
+
if (doc.defaults) sections.push(emitYAMLEntry("defaults", doc.defaults));
|
|
535
|
+
sections.push(emitYAMLEntry("jobs", doc.jobsDoc));
|
|
393
536
|
return sections.join("\n\n") + "\n";
|
|
394
537
|
}
|
|
395
538
|
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The generated pull-request workflow for GitHub Actions (#3183): a plan job
|
|
3
|
+
* on each pull request, an apply job on each push to the target branch. The
|
|
4
|
+
* whole file has a golden, and actionlint checks it when it is installed.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { execFileSync } from "node:child_process";
|
|
8
|
+
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
9
|
+
import { tmpdir } from "node:os";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { describe, expect, test } from "vitest";
|
|
12
|
+
import { parseYAML } from "@intentius/chant/yaml";
|
|
13
|
+
import type { DriverComponent } from "@intentius/chant/components/driver";
|
|
14
|
+
import { generateGithubPipeline } from "./generate-pipeline";
|
|
15
|
+
|
|
16
|
+
const ESTATE: DriverComponent[] = [
|
|
17
|
+
{ name: "net", dependsOn: [], deploy: [{ phase: "Apply", steps: [{ kind: "terraform-apply", root: "net" }] }] },
|
|
18
|
+
{ name: "app", dependsOn: ["net"], deploy: [{ phase: "Apply", steps: [{ kind: "terraform-apply", root: "app" }] }] },
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
const GOLDEN = join(import.meta.dirname, "__fixtures__", "pr-loop.github.golden.yml");
|
|
22
|
+
|
|
23
|
+
function hasActionlint(): boolean {
|
|
24
|
+
try {
|
|
25
|
+
execFileSync("actionlint", ["-version"], { stdio: "ignore" });
|
|
26
|
+
return true;
|
|
27
|
+
} catch {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface Step {
|
|
33
|
+
uses?: string;
|
|
34
|
+
run?: string;
|
|
35
|
+
env?: Record<string, string>;
|
|
36
|
+
with?: Record<string, unknown>;
|
|
37
|
+
}
|
|
38
|
+
interface Job {
|
|
39
|
+
if: string;
|
|
40
|
+
permissions: Record<string, string>;
|
|
41
|
+
concurrency?: Record<string, unknown>;
|
|
42
|
+
steps: Step[];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
describe("the GitHub pull-request workflow", () => {
|
|
46
|
+
const result = generateGithubPipeline(ESTATE, { env: "prod", prLoop: {} });
|
|
47
|
+
const doc = parseYAML(result.yaml) as { name: string; on: Record<string, unknown>; jobs: Record<string, Job> };
|
|
48
|
+
|
|
49
|
+
test("matches its golden", () => {
|
|
50
|
+
if (process.env.UPDATE_GOLDEN) writeFileSync(GOLDEN, result.yaml);
|
|
51
|
+
expect(result.yaml).toBe(readFileSync(GOLDEN, "utf-8"));
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("plans on a pull request into main and applies on a push to it", () => {
|
|
55
|
+
expect(doc.name).toBe("chant-pr-prod");
|
|
56
|
+
expect(doc.on).toEqual({ pull_request: { branches: ["main"] }, push: { branches: ["main"] } });
|
|
57
|
+
expect(doc.jobs.plan.if).toBe("github.event_name == 'pull_request'");
|
|
58
|
+
expect(doc.jobs.apply.if).toBe("github.event_name == 'push'");
|
|
59
|
+
expect(result.jobs.map((j) => j.jobName)).toEqual(["plan", "apply"]);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("the plan job measures from the pull request's base and cannot push; the apply job can, one at a time", () => {
|
|
63
|
+
const plan = doc.jobs.plan.steps.find((s) => s.run?.includes("pr-plan"))!;
|
|
64
|
+
expect(plan.run).toBe('chant components pr-plan --base "$BASE_SHA" --pr "$PR_NUMBER" --env prod --gate pr-apply --output .chant/pr --forge github');
|
|
65
|
+
expect(plan.env).toMatchObject({ BASE_SHA: "${{ github.event.pull_request.base.sha }}", PR_NUMBER: "${{ github.event.pull_request.number }}" });
|
|
66
|
+
expect(doc.jobs.plan.permissions).toEqual({ contents: "read", "pull-requests": "write", statuses: "write" });
|
|
67
|
+
|
|
68
|
+
const apply = doc.jobs.apply.steps.find((s) => s.run?.includes("pr-apply"))!;
|
|
69
|
+
expect(apply.run).toContain("--require-review");
|
|
70
|
+
expect(apply.env).toMatchObject({ BASE_SHA: "${{ github.event.before }}" });
|
|
71
|
+
expect(doc.jobs.apply.permissions.contents).toBe("write");
|
|
72
|
+
expect(doc.jobs.apply.concurrency).toEqual({ group: "chant-apply-prod", "cancel-in-progress": false });
|
|
73
|
+
for (const job of Object.values(doc.jobs)) expect(job.steps[0].with).toEqual({ "fetch-depth": 0 });
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("the gate, branch and review requirement are options", () => {
|
|
77
|
+
const yaml = generateGithubPipeline(ESTATE, { env: "prod", prLoop: { gate: "infra-apply", branch: "trunk", requireReview: false } }).yaml;
|
|
78
|
+
expect(yaml).toContain("--gate infra-apply");
|
|
79
|
+
expect(yaml).toContain("- trunk");
|
|
80
|
+
expect(yaml).not.toContain("--require-review");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("refuses a wave or promote job beside it", () => {
|
|
84
|
+
expect(() => generateGithubPipeline(ESTATE, { prLoop: {}, gatedWaves: { gate: "g" } })).toThrow(/pull-request pipeline has no wave or promote jobs/);
|
|
85
|
+
expect(() => generateGithubPipeline(ESTATE, { prLoop: {}, promoteTo: "prod" })).toThrow(/pull-request pipeline has no wave or promote jobs/);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test.skipIf(!hasActionlint())("passes actionlint", () => {
|
|
89
|
+
const dir = mkdtempSync(join(tmpdir(), "chant-actionlint-"));
|
|
90
|
+
try {
|
|
91
|
+
mkdirSync(join(dir, ".github", "workflows"), { recursive: true });
|
|
92
|
+
writeFileSync(join(dir, ".github", "workflows", "chant-pr.yml"), result.yaml);
|
|
93
|
+
expect(() => execFileSync("actionlint", [".github/workflows/chant-pr.yml"], { cwd: dir, encoding: "utf8" })).not.toThrow();
|
|
94
|
+
} finally {
|
|
95
|
+
rmSync(dir, { recursive: true, force: true });
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
});
|
|
@@ -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
|
|
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@
|
|
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@
|
|
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@
|
|
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@
|
|
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
|
|
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
|
|
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
|
|
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",
|
package/src/composites/go-ci.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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",
|