@intentius/chant-lexicon-github 0.124.31 → 0.124.33
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.js +53 -0
- package/dist/codegen/docs-cli.js +2 -0
- package/dist/codegen/docs.js +142 -0
- package/dist/codegen/generate-cli.js +26 -0
- package/dist/codegen/generate-lexicon.js +36 -0
- package/dist/codegen/generate-typescript.js +108 -0
- package/dist/codegen/generate.js +111 -0
- package/dist/codegen/naming.js +49 -0
- package/dist/codegen/package.js +40 -0
- package/dist/codegen/parse.js +595 -0
- package/dist/codegen/patches.js +36 -0
- package/dist/components/ci-jobs.d.ts +7 -0
- package/dist/components/ci-jobs.d.ts.map +1 -1
- package/dist/components/ci-jobs.js +98 -0
- package/dist/components/generate-op-pipeline.js +440 -0
- package/dist/components/generate-op-waves-pipeline.js +158 -0
- package/dist/components/generate-pipeline.d.ts.map +1 -1
- package/dist/components/generate-pipeline.js +481 -0
- package/dist/composites/cache.js +17 -0
- package/dist/composites/catalog.js +859 -0
- package/dist/composites/checkout.js +25 -0
- package/dist/composites/dependabot.js +43 -0
- package/dist/composites/deploy-environment.js +38 -0
- package/dist/composites/docker-build.js +70 -0
- package/dist/composites/download-artifact.js +18 -0
- package/dist/composites/go-ci.js +62 -0
- package/dist/composites/index.js +15 -0
- package/dist/composites/node-ci.js +44 -0
- package/dist/composites/node-pipeline.js +96 -0
- package/dist/composites/pr-plan-report.js +129 -0
- package/dist/composites/python-ci.js +60 -0
- package/dist/composites/setup-go.js +18 -0
- package/dist/composites/setup-node.js +21 -0
- package/dist/composites/setup-python.js +18 -0
- package/dist/composites/upload-artifact.js +19 -0
- package/dist/coverage.js +32 -0
- package/dist/expression.js +177 -0
- package/dist/generated/index.js +26 -0
- package/dist/generated/runtime.js +4 -0
- package/dist/graph-edges.js +174 -0
- package/dist/import/generator.js +108 -0
- package/dist/import/parser.js +69 -0
- package/dist/index.js +22 -0
- package/dist/integrity.json +2 -2
- package/dist/lint/audit-catalog.js +69 -0
- package/dist/lint/audit-lineage.js +168 -0
- package/dist/lint/post-synth/gha006.js +50 -0
- package/dist/lint/post-synth/gha009.js +35 -0
- package/dist/lint/post-synth/gha011.js +34 -0
- package/dist/lint/post-synth/gha013.js +40 -0
- package/dist/lint/post-synth/gha017.js +25 -0
- package/dist/lint/post-synth/gha018.js +33 -0
- package/dist/lint/post-synth/gha019.js +58 -0
- package/dist/lint/post-synth/gha021.js +42 -0
- package/dist/lint/post-synth/gha022.js +44 -0
- package/dist/lint/post-synth/gha023.js +37 -0
- package/dist/lint/post-synth/gha024.js +32 -0
- package/dist/lint/post-synth/gha025.js +33 -0
- package/dist/lint/post-synth/gha026.js +32 -0
- package/dist/lint/post-synth/gha027.js +46 -0
- package/dist/lint/post-synth/gha028.js +28 -0
- package/dist/lint/post-synth/gha029.js +56 -0
- package/dist/lint/post-synth/gha030.js +35 -0
- package/dist/lint/post-synth/gha031.js +75 -0
- package/dist/lint/post-synth/gha032.js +35 -0
- package/dist/lint/post-synth/gha033.js +39 -0
- package/dist/lint/post-synth/gha034.js +35 -0
- package/dist/lint/post-synth/gha035.js +50 -0
- package/dist/lint/post-synth/gha036.js +37 -0
- package/dist/lint/post-synth/gha037.js +41 -0
- package/dist/lint/post-synth/gha038.js +35 -0
- package/dist/lint/post-synth/gha039.js +34 -0
- package/dist/lint/post-synth/gha040.js +36 -0
- package/dist/lint/post-synth/gha041.js +32 -0
- package/dist/lint/post-synth/gha042.js +33 -0
- package/dist/lint/post-synth/gha043.js +35 -0
- package/dist/lint/post-synth/gha044.js +36 -0
- package/dist/lint/post-synth/gha045.js +33 -0
- package/dist/lint/post-synth/gha046.js +55 -0
- package/dist/lint/post-synth/gha047.js +44 -0
- package/dist/lint/post-synth/gha048.js +34 -0
- package/dist/lint/post-synth/gha049.js +36 -0
- package/dist/lint/post-synth/gha050.js +37 -0
- package/dist/lint/post-synth/gha051.js +38 -0
- package/dist/lint/post-synth/gha052.js +35 -0
- package/dist/lint/post-synth/gha053.js +34 -0
- package/dist/lint/post-synth/gha054.js +35 -0
- package/dist/lint/post-synth/gha055.js +44 -0
- package/dist/lint/post-synth/gha056.js +32 -0
- package/dist/lint/post-synth/gha057.js +35 -0
- package/dist/lint/post-synth/gha058.js +42 -0
- package/dist/lint/post-synth/gha059.js +119 -0
- package/dist/lint/post-synth/gha060.js +129 -0
- package/dist/lint/post-synth/gha061.js +78 -0
- package/dist/lint/post-synth/gha062.js +65 -0
- package/dist/lint/post-synth/gha063.js +56 -0
- package/dist/lint/post-synth/gha064.js +47 -0
- package/dist/lint/post-synth/gha065.js +125 -0
- package/dist/lint/post-synth/gha066.js +40 -0
- package/dist/lint/post-synth/gha067.js +75 -0
- package/dist/lint/post-synth/gha068.js +47 -0
- package/dist/lint/post-synth/gha069.js +99 -0
- package/dist/lint/post-synth/index.js +114 -0
- package/dist/lint/post-synth/yaml-helpers.js +465 -0
- package/dist/lint/rules/data/action-usage-policy.js +16 -0
- package/dist/lint/rules/data/advisory-feed.js +15 -0
- package/dist/lint/rules/data/deprecated-versions.js +14 -0
- package/dist/lint/rules/data/flagged-actions.js +18 -0
- package/dist/lint/rules/data/known-action-slugs.js +38 -0
- package/dist/lint/rules/data/known-actions.js +12 -0
- package/dist/lint/rules/data/preinstalled-tools.js +36 -0
- package/dist/lint/rules/data/recommended-inputs.js +9 -0
- package/dist/lint/rules/data/risky-features.js +22 -0
- package/dist/lint/rules/data/secret-patterns.js +30 -0
- package/dist/lint/rules/data/trusted-action-owners.js +12 -0
- package/dist/lint/rules/data/untrusted-contexts.js +44 -0
- package/dist/lint/rules/deprecated-action-version.js +44 -0
- package/dist/lint/rules/detect-secrets.js +45 -0
- package/dist/lint/rules/extract-inline-structs.js +55 -0
- package/dist/lint/rules/file-job-limit.js +43 -0
- package/dist/lint/rules/index.js +16 -0
- package/dist/lint/rules/job-timeout.js +53 -0
- package/dist/lint/rules/missing-recommended-inputs.js +51 -0
- package/dist/lint/rules/no-hardcoded-secrets.js +39 -0
- package/dist/lint/rules/no-raw-expressions.js +44 -0
- package/dist/lint/rules/suggest-cache.js +60 -0
- package/dist/lint/rules/use-condition-builders.js +37 -0
- package/dist/lint/rules/use-matrix-builder.js +36 -0
- package/dist/lint/rules/use-typed-actions.js +41 -0
- package/dist/lint/rules/validate-concurrency.js +59 -0
- package/dist/lsp/completions.d.ts.map +1 -1
- package/dist/lsp/completions.js +18 -0
- package/dist/lsp/hover.d.ts.map +1 -1
- package/dist/lsp/hover.js +33 -0
- package/dist/manifest.json +1 -1
- package/dist/mcp/context-tools.js +152 -0
- package/dist/package-cli.js +20 -0
- package/dist/plugin.js +344 -0
- package/dist/serializer.js +572 -0
- package/dist/spec/fetch.js +46 -0
- package/dist/validate-cli.js +16 -0
- package/dist/validate.js +38 -0
- package/dist/variables.js +41 -0
- package/package.json +6 -6
- package/src/codegen/package.ts +2 -2
- package/src/components/__fixtures__/pr-loop-build.github.golden.yml +6 -2
- package/src/components/__fixtures__/pr-loop-record.github.golden.yml +74 -0
- package/src/components/__fixtures__/pr-loop-waves.github.golden.yml +178 -0
- package/src/components/__fixtures__/pr-loop.github.golden.yml +6 -2
- package/src/components/ci-jobs.ts +16 -2
- package/src/components/generate-op-pipeline.test.ts +11 -0
- package/src/components/generate-op-pipeline.ts +3 -3
- package/src/components/generate-pipeline.ts +74 -31
- package/src/components/generate-pr-pipeline.test.ts +99 -29
- package/src/components/generate-pr-waves-pipeline.test.ts +117 -0
- package/src/lsp/completions.ts +2 -1
- package/src/lsp/hover.ts +2 -1
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Op waves → GitHub Actions workflow (#3679).
|
|
3
|
+
*
|
|
4
|
+
* One workflow for the whole spec, on a push to its branches (and by hand),
|
|
5
|
+
* one run at a time. Each job is one of core's `opWaveJobs`: a wave, or a wide
|
|
6
|
+
* wave's deciding job and share jobs, chained by `needs:` so a wave that waits
|
|
7
|
+
* at its gate (exit 3) or fails stops every later wave. Re-running the failed
|
|
8
|
+
* jobs after `chant approve` carries on from that wave.
|
|
9
|
+
*
|
|
10
|
+
* Every job checks out the full history, because the runner reads the gate
|
|
11
|
+
* policy from the spec file at the base commit and records pending gates on
|
|
12
|
+
* `chant/lifecycle`, which is why the workflow asks for `contents: write`. A
|
|
13
|
+
* deciding job uploads its decision, even when it waits, and each share job
|
|
14
|
+
* downloads it. The forgejo generator reuses {@link buildGithubOpWavesDoc}
|
|
15
|
+
* and applies its dialect.
|
|
16
|
+
*
|
|
17
|
+
* With `resume` on the spec (#3683), a second workflow, `<name>-resume.yml`,
|
|
18
|
+
* runs `chant run resume --op <name>` on that schedule: it re-runs the failed
|
|
19
|
+
* jobs of a run whose waiting wave has been approved since, with the job's
|
|
20
|
+
* token and `actions: write`.
|
|
21
|
+
*
|
|
22
|
+
* When a wave's approval is `pr-review` (#3684), the workflow also asks for
|
|
23
|
+
* `pull-requests: read`, so a wave can read the merged pull request's
|
|
24
|
+
* reviews, and a second workflow, `<name>-plans.yml`, runs `chant run wave
|
|
25
|
+
* --record-plans` on each pull request to record the digests its head plans.
|
|
26
|
+
*
|
|
27
|
+
* With `notify` on the spec (#3761), the workflow maps each variable it names
|
|
28
|
+
* from the repository secret of the same name, so a wave job can post to
|
|
29
|
+
* Slack, Teams or a signed webhook. `options.variables` wins over the mapping.
|
|
30
|
+
*
|
|
31
|
+
* `runner` (on the spec, a wave or a job group, #3914) is the job's `runs-on`.
|
|
32
|
+
* A wave's `jobs` renders its deciding job with the wave's own settings and a
|
|
33
|
+
* job per group (per share of a group) with the group's: its runner,
|
|
34
|
+
* environment, variables over the wave's, setup after the wave's, and id
|
|
35
|
+
* tokens. A job with id tokens gets `permissions:` of its own, the
|
|
36
|
+
* workflow's plus `id-token: write`, and each audience as `<NAME>_AUDIENCE`.
|
|
37
|
+
*/
|
|
38
|
+
import { notifyVariables, opWaveGroupOf, opWaveJobs, opWavesResumeCommand } from "@intentius/chant/op/op-waves";
|
|
39
|
+
import { assertEnvironment, assertSetupSteps, CI_IMAGE, dirnameOf, environmentDoc, GITHUB_JOB_TOKEN, githubRunsOn, githubSecretEnv, idTokenAudienceEnv, nonEmpty, pushBranches, setupStepDoc, } from "@intentius/chant/ci-jobs";
|
|
40
|
+
import { checkoutStep, concurrency, downloadArtifactStep, keepRecordStep, runSteps } from "./ci-jobs.js";
|
|
41
|
+
import { emitOpPipelineYAML } from "./generate-op-pipeline.js";
|
|
42
|
+
/** The default image: it carries git, which the runner needs to read the base commit. */
|
|
43
|
+
export const OP_WAVES_IMAGE = CI_IMAGE;
|
|
44
|
+
/** The artifact a wide wave's deciding job hands its share jobs. */
|
|
45
|
+
export function opWaveRecordArtifact(spec, wave) {
|
|
46
|
+
return `${spec.name}-wave-${wave}-decision`;
|
|
47
|
+
}
|
|
48
|
+
/** The workflow document, before emission, and the jobs it holds. */
|
|
49
|
+
export function buildGithubOpWavesDoc(spec, options) {
|
|
50
|
+
const jobs = opWaveJobs(spec, options.specFile);
|
|
51
|
+
const image = options.image ?? OP_WAVES_IMAGE;
|
|
52
|
+
const byReview = spec.waves.some((w) => w.approval === "pr-review");
|
|
53
|
+
for (const wave of spec.waves) {
|
|
54
|
+
assertSetupSteps(`${spec.name}/${wave.name}`, wave.setup ?? []);
|
|
55
|
+
if (wave.environment)
|
|
56
|
+
assertEnvironment(`${spec.name}/${wave.name}`, wave.environment);
|
|
57
|
+
for (const group of wave.jobs ?? []) {
|
|
58
|
+
assertSetupSteps(`${spec.name}/${wave.name}/${group.name}`, group.setup ?? []);
|
|
59
|
+
if (group.environment)
|
|
60
|
+
assertEnvironment(`${spec.name}/${wave.name}/${group.name}`, group.environment);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
const permissions = { contents: "write", ...(byReview ? { "pull-requests": "read" } : {}) };
|
|
64
|
+
const jobsDoc = {};
|
|
65
|
+
for (const job of jobs) {
|
|
66
|
+
const wave = spec.waves[job.wave - 1];
|
|
67
|
+
// #3914: a job group's job runs with the group's settings over the wave's.
|
|
68
|
+
const group = opWaveGroupOf(spec, job);
|
|
69
|
+
const steps = [checkoutStep(true)];
|
|
70
|
+
if (job.kind === "share") {
|
|
71
|
+
steps.push(downloadArtifactStep(`Download wave ${job.wave}'s decision`, opWaveRecordArtifact(spec, job.wave), dirnameOf(job.record)));
|
|
72
|
+
}
|
|
73
|
+
steps.push(...[...(wave.setup ?? []), ...(group?.setup ?? [])].map(setupStepDoc), ...runSteps(options.beforeScript ?? []));
|
|
74
|
+
steps.push({ name: describeJob(job.kind, job.wave, wave.name, job.share, job.group), run: job.command.join(" ") });
|
|
75
|
+
steps.push(...runSteps(options.extraScript ?? []));
|
|
76
|
+
if (job.kind === "decide") {
|
|
77
|
+
steps.push(keepRecordStep(`Keep wave ${job.wave}'s decision`, opWaveRecordArtifact(spec, job.wave), job.record));
|
|
78
|
+
}
|
|
79
|
+
const environment = group?.environment ?? wave.environment;
|
|
80
|
+
const idTokens = group?.idTokens ?? wave.idTokens;
|
|
81
|
+
jobsDoc[job.jobName] = {
|
|
82
|
+
"runs-on": githubRunsOn(group?.runner ?? wave.runner ?? spec.runner),
|
|
83
|
+
...(job.needs.length > 0 ? { needs: job.needs } : {}),
|
|
84
|
+
container: image,
|
|
85
|
+
...(environment ? { environment: environmentDoc(environment) } : {}),
|
|
86
|
+
// #3914: a job's own permissions replace the workflow's, so they repeat them.
|
|
87
|
+
...(idTokens ? { permissions: { ...permissions, "id-token": "write" } } : {}),
|
|
88
|
+
...nonEmpty("env", group || idTokens ? { ...wave.variables, ...group?.variables, ...idTokenAudienceEnv(idTokens) } : wave.variables),
|
|
89
|
+
steps,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
// #3761: the notify variables come from the secrets of the same name.
|
|
93
|
+
const env = { ...githubSecretEnv(notifyVariables(spec.notify)), ...(options.variables ?? {}) };
|
|
94
|
+
const doc = {
|
|
95
|
+
name: spec.name,
|
|
96
|
+
on: { push: { branches: pushBranches(spec.branches) }, workflow_dispatch: {} },
|
|
97
|
+
...nonEmpty("env", env),
|
|
98
|
+
concurrency: concurrency(spec.name),
|
|
99
|
+
permissions,
|
|
100
|
+
jobsDoc,
|
|
101
|
+
};
|
|
102
|
+
return {
|
|
103
|
+
doc,
|
|
104
|
+
jobs,
|
|
105
|
+
...(spec.resume ? { resumeDoc: buildResumeDoc(spec, options) } : {}),
|
|
106
|
+
...(byReview ? { plansDoc: buildPlansDoc(spec, options) } : {}),
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
/** The scheduled resume workflow (#3683). */
|
|
110
|
+
function buildResumeDoc(spec, options) {
|
|
111
|
+
const steps = [checkoutStep(), ...runSteps(options.beforeScript ?? [])];
|
|
112
|
+
steps.push({
|
|
113
|
+
name: `Resume the waves of ${spec.name} whose approval arrived`,
|
|
114
|
+
run: opWavesResumeCommand(spec).join(" "),
|
|
115
|
+
env: { GITHUB_TOKEN: GITHUB_JOB_TOKEN },
|
|
116
|
+
});
|
|
117
|
+
return {
|
|
118
|
+
name: `${spec.name}-resume`,
|
|
119
|
+
on: { schedule: [{ cron: spec.resume.schedule }], workflow_dispatch: {} },
|
|
120
|
+
concurrency: concurrency(`${spec.name}-resume`, true),
|
|
121
|
+
// contents: read fetches chant/lifecycle; actions: write re-runs a waiting run.
|
|
122
|
+
permissions: { contents: "read", actions: "write" },
|
|
123
|
+
jobsDoc: { resume: { "runs-on": githubRunsOn(spec.runner), container: options.image ?? OP_WAVES_IMAGE, steps } },
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
/** The pull request workflow a `pr-review` wave needs (#3684): record the digests each head plans. */
|
|
127
|
+
function buildPlansDoc(spec, options) {
|
|
128
|
+
const steps = [checkoutStep(true), ...(spec.waves[0]?.setup ?? []).map(setupStepDoc), ...runSteps(options.beforeScript ?? [])];
|
|
129
|
+
steps.push({ name: "Plan every wave at the head and record its digests", run: ["chant", "run", "wave", "--spec", options.specFile, "--record-plans"].join(" ") });
|
|
130
|
+
return {
|
|
131
|
+
name: `${spec.name}-plans`,
|
|
132
|
+
on: { pull_request: { branches: pushBranches(spec.branches) } },
|
|
133
|
+
...nonEmpty("env", options.variables),
|
|
134
|
+
concurrency: concurrency(spec.name + "-plans-${{ github.event.number }}", true),
|
|
135
|
+
// contents: write pushes the record to chant/lifecycle.
|
|
136
|
+
permissions: { contents: "write" },
|
|
137
|
+
jobsDoc: { "record-plans": { "runs-on": githubRunsOn(spec.runner), container: options.image ?? OP_WAVES_IMAGE, steps } },
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
function describeJob(kind, wave, name, share, group) {
|
|
141
|
+
if (kind === "decide")
|
|
142
|
+
return `Plan wave ${wave} (${name}) and decide its gate`;
|
|
143
|
+
if (group !== undefined)
|
|
144
|
+
return `Apply ${share !== undefined ? `share ${share} of ` : ""}job group ${group} of wave ${wave} (${name})`;
|
|
145
|
+
if (kind === "share")
|
|
146
|
+
return `Apply share ${share} of wave ${wave} (${name})`;
|
|
147
|
+
return `Plan, gate and apply wave ${wave} (${name})`;
|
|
148
|
+
}
|
|
149
|
+
/** Render an Op waves spec as one GitHub Actions workflow, `<name>.yml`. */
|
|
150
|
+
export function generateGithubOpWavesPipeline(spec, options) {
|
|
151
|
+
const { doc, jobs, resumeDoc, plansDoc } = buildGithubOpWavesDoc(spec, options);
|
|
152
|
+
const files = [{ name: `${spec.name}.yml`, yaml: emitOpPipelineYAML(doc) }];
|
|
153
|
+
if (resumeDoc)
|
|
154
|
+
files.push({ name: `${spec.name}-resume.yml`, yaml: emitOpPipelineYAML(resumeDoc) });
|
|
155
|
+
if (plansDoc)
|
|
156
|
+
files.push({ name: `${spec.name}-plans.yml`, yaml: emitOpPipelineYAML(plansDoc) });
|
|
157
|
+
return { files, jobs };
|
|
158
|
+
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"generate-pipeline.d.ts","sourceRoot":"","sources":["../../src/components/generate-pipeline.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwDG;AAEH,OAAO,EAAyB,KAAK,eAAe,EAAE,MAAM,oCAAoC,CAAC;
|
|
1
|
+
{"version":3,"file":"generate-pipeline.d.ts","sourceRoot":"","sources":["../../src/components/generate-pipeline.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwDG;AAEH,OAAO,EAAyB,KAAK,eAAe,EAAE,MAAM,oCAAoC,CAAC;AAiBjG,OAAO,KAAK,EACV,oBAAoB,IAAI,YAAY,EACpC,wBAAwB,IAAI,qBAAqB,EACjD,uBAAuB,IAAI,oBAAoB,EAEhD,MAAM,0BAA0B,CAAC;AAGlC,YAAY,EAAE,YAAY,EAAE,qBAAqB,EAAE,oBAAoB,EAAE,CAAC;AAE1E;;;;;;GAMG;AACH,MAAM,WAAW,iBAAiB;IAChC;;;;;OAKG;IACH,IAAI,EAAE,MAAM,CAAC;IACb,6FAA6F;IAC7F,WAAW,EAAE,MAAM,CAAC;IACpB,8DAA8D;IAC9D,EAAE,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC5B,mIAAmI;IACnI,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACnC;;;;;;OAMG;IACH,GAAG,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC9B,qDAAqD;IACrD,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACjC,uEAAuE;IACvE,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,0DAA0D;IAC1D,IAAI,EAAE,YAAY,EAAE,CAAC;CACtB;AAuMD;;;;GAIG;AACH,wBAAgB,YAAY,CAAC,KAAK,EAAE,MAAM,EAAE,GAAG,MAAM,CAWpD;AAED;;;;;;;;GAQG;AACH,wBAAgB,sBAAsB,CACpC,UAAU,EAAE,eAAe,EAAE,EAC7B,OAAO,GAAE,qBAA0B,GAClC,iBAAiB,CA+JnB;AA4CD;;;GAGG;AACH,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,iBAAiB,GAAG,MAAM,CAE/D;AAED;;;;;GAKG;AACH,wBAAgB,sBAAsB,CACpC,UAAU,EAAE,eAAe,EAAE,EAC7B,OAAO,GAAE,qBAA0B,GAClC,oBAAoB,CAGtB"}
|
|
@@ -0,0 +1,481 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generate mode — component → GitHub Actions workflow YAML (#891, epic #885).
|
|
3
|
+
*
|
|
4
|
+
* The github counterpart to the gitlab generator (`../../gitlab/src/components/generate-pipeline.ts`,
|
|
5
|
+
* #563/#688): the same `chant build --components --generate <lexicon>` seam,
|
|
6
|
+
* synthesizing a **thin** `.github/workflows/*.yml` from the discovered
|
|
7
|
+
* component graph instead of interpreting it directly.
|
|
8
|
+
*
|
|
9
|
+
* The generated workflow is a trigger, not the deploy logic:
|
|
10
|
+
* - Ordering + parallel-safe waves are resolved once, generically, by
|
|
11
|
+
* `resolveComponentGraph` (core's driver) — the exact function the local
|
|
12
|
+
* interpret driver and the gitlab generator both use. This module does not
|
|
13
|
+
* re-derive or duplicate that graph logic.
|
|
14
|
+
* - GitHub Actions has no `stage` concept, so wave ordering is expressed
|
|
15
|
+
* entirely through `needs:` edges between jobs: one job per component,
|
|
16
|
+
* with `needs:` pointing at the jobs for its direct `dependsOn` entries.
|
|
17
|
+
* Independent components therefore run in parallel, and dependents wait
|
|
18
|
+
* on their dependencies via GitHub's own `needs:` scheduling.
|
|
19
|
+
* `ComponentPipelineResult.stages` is still populated with the wave-ordered
|
|
20
|
+
* names (one per wave) purely for parity with gitlab's machine-readable
|
|
21
|
+
* `--format json` view — GitHub's YAML itself has no `stages:` section.
|
|
22
|
+
* - Each job's trigger step is exactly one invocation that hands off to the
|
|
23
|
+
* component's own composition (`chant run --components <name> ...` by
|
|
24
|
+
* default) — never inlined build/publish/apply steps. The deploy logic
|
|
25
|
+
* lives in the component's `deploy` phases and the capabilities they
|
|
26
|
+
* reference, not in this YAML.
|
|
27
|
+
* - A component that something else depends on can't hand its resolved
|
|
28
|
+
* stack outputs to a dependent in-memory, since each job is a separate
|
|
29
|
+
* GitHub Actions runner. It dumps its outputs to a file and uploads that
|
|
30
|
+
* file as a workflow artifact (`actions/upload-artifact`); each direct
|
|
31
|
+
* dependent downloads it (`actions/download-artifact`) and seeds from it,
|
|
32
|
+
* so a `stackOutput()` / `@<dep>.publish.*` reference still resolves even
|
|
33
|
+
* though the producer ran in a different job. This mirrors the dump/seed
|
|
34
|
+
* model `cli-support.ts` (`runComponents`'s `componentOutputs`) already
|
|
35
|
+
* uses, and matches gitlab's artifact-passing 1:1 in intent — only the
|
|
36
|
+
* transport differs (explicit upload/download steps vs. GitLab's implicit
|
|
37
|
+
* `needs:` artifact passing).
|
|
38
|
+
* - With `options.promoteTo` (#2575), one more job needs every component
|
|
39
|
+
* job and runs `chant components promote`. A promote publishes from the
|
|
40
|
+
* build archive on disk, so each component job uploads the files its
|
|
41
|
+
* build steps wrote and the promote job downloads them to the same paths.
|
|
42
|
+
* Each component job with a publish step also writes the digest its run
|
|
43
|
+
* recorded (`--digest-file`) to a job output, and the promote job passes
|
|
44
|
+
* it back as `--digest <component>=<digest>` (#2602), so it promotes the
|
|
45
|
+
* release this run built rather than whatever is latest in the source
|
|
46
|
+
* environment.
|
|
47
|
+
* - For a workspace member (`options.member`, #2542) the workflow keeps its
|
|
48
|
+
* triggers, and its `run:` steps start in the member's directory. See
|
|
49
|
+
* `scopeToMember`.
|
|
50
|
+
*
|
|
51
|
+
* Cross-cutting changes (e.g. "sign every image before deploy") are made by
|
|
52
|
+
* editing `GenerateGithubOptions.extraScript`/`beforeScript` (or the
|
|
53
|
+
* component's own composition) ONCE here — never per generated job. See
|
|
54
|
+
* `generate-pipeline.test.ts`'s "cross-cutting change" cases for a
|
|
55
|
+
* demonstration: one generator-option edit reflects in every job without
|
|
56
|
+
* touching the component declarations.
|
|
57
|
+
*/
|
|
58
|
+
import { resolveComponentGraph } from "@intentius/chant/components/driver";
|
|
59
|
+
import { hasPublishStep, promoteArchivePaths } from "@intentius/chant/components/promote";
|
|
60
|
+
import { GATED_WAVE_RECORD, gatedWaveJobs } from "@intentius/chant/components/gated-wave-pipeline";
|
|
61
|
+
import { CI_IMAGE, ciJobName, dirnameOf, fillCommand, GITHUB_RUNNER } from "@intentius/chant/ci-jobs";
|
|
62
|
+
import { PR_APPLY_RECORD, PR_LOOP_REPORT_DIR, prApplyGroup, prRecordGroup, prApplyRecordKey, prApplyWaveJobName, prLoopJobs, prLoopSetup, prLoopWaveJobs, prLoopWithWaves, } from "@intentius/chant/components/pr-pipeline";
|
|
63
|
+
import { memberRepoPath } from "@intentius/chant/lexicon";
|
|
64
|
+
import { checkoutStep, concurrency, downloadArtifactStep, emitWorkflowYAML, keepRecordStep, runSteps, UPLOAD_ARTIFACT } from "./ci-jobs.js";
|
|
65
|
+
/** The workflow artifact the gated-wave jobs hand their attempt record through, one per wave. */
|
|
66
|
+
function waveRecordArtifact(wave) {
|
|
67
|
+
return `fan-out-record-wave-${wave}`;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* The gated-wave workflow (#3049): one job per wave, each running one wave of
|
|
71
|
+
* `chant components fan-out --wave-gate`, chained by `needs:`. Each job
|
|
72
|
+
* downloads the record the job before it uploaded and uploads its own, even
|
|
73
|
+
* when it stops at a gate (exit 3), so re-running it after `chant approve`
|
|
74
|
+
* reads the record. The checkout fetches full history for `--base`.
|
|
75
|
+
*/
|
|
76
|
+
function gatedWaveGithubDoc(components, env, image, beforeScript, extraScript, options) {
|
|
77
|
+
const waveJobs = gatedWaveJobs(components, env, options.gatedWaves);
|
|
78
|
+
const jobsDoc = {};
|
|
79
|
+
const jobs = [];
|
|
80
|
+
for (const job of waveJobs) {
|
|
81
|
+
jobs.push({ jobName: job.jobName, component: `wave ${job.wave}`, stage: job.jobName, needs: job.needs });
|
|
82
|
+
const steps = [checkoutStep(true)];
|
|
83
|
+
if (job.wave > 1) {
|
|
84
|
+
steps.push(downloadArtifactStep(`Download the record wave ${job.wave - 1} left`, waveRecordArtifact(job.wave - 1), dirnameOf(GATED_WAVE_RECORD)));
|
|
85
|
+
}
|
|
86
|
+
steps.push(...runSteps(prLoopSetup(image)), ...runSteps(beforeScript), { run: job.command.join(" ") }, ...runSteps(extraScript));
|
|
87
|
+
steps.push(keepRecordStep(`Upload the record for wave ${job.wave}`, waveRecordArtifact(job.wave), GATED_WAVE_RECORD));
|
|
88
|
+
jobsDoc[job.jobName] = {
|
|
89
|
+
"runs-on": GITHUB_RUNNER,
|
|
90
|
+
...(job.needs.length > 0 ? { needs: job.needs } : {}),
|
|
91
|
+
container: image,
|
|
92
|
+
steps,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
const doc = {
|
|
96
|
+
name: `chant-components-${env}`,
|
|
97
|
+
environment: env,
|
|
98
|
+
on: { workflow_dispatch: {} },
|
|
99
|
+
env: { ...options.variables, CHANT_ENV: env },
|
|
100
|
+
jobsDoc,
|
|
101
|
+
stages: waveJobs.map((j) => j.jobName),
|
|
102
|
+
jobs,
|
|
103
|
+
};
|
|
104
|
+
return options.member ? scopeToMember(doc, options.member) : doc;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* The pull-request workflow (#3183): `plan` on each pull request into the
|
|
108
|
+
* target branch, and on each push to it `record` (#4168), or `apply` with
|
|
109
|
+
* `applyOnMerge`. The record job plans on the merged commit and writes the
|
|
110
|
+
* merge record, applying nothing, so it pushes to `chant/lifecycle` and runs
|
|
111
|
+
* one at a time per environment as the apply does. The plan job can comment and set
|
|
112
|
+
* statuses but cannot push, since it runs the pull request's code; the apply
|
|
113
|
+
* job can push, to record a pending fact on `chant/lifecycle`, and runs one
|
|
114
|
+
* at a time per environment. Both keep their report as an artifact, and both
|
|
115
|
+
* check out the full history, since each measures the change with git.
|
|
116
|
+
* The apply job restores its attempt record from the cache before it runs
|
|
117
|
+
* and saves it afterwards, failed or not, so a re-run of a failed apply
|
|
118
|
+
* finishes it under the same approval (#3543).
|
|
119
|
+
*
|
|
120
|
+
* For a workspace member (#3465) the run steps start in the member's
|
|
121
|
+
* directory and the report artifact is kept from there. The triggers take no
|
|
122
|
+
* path filter, since a change outside the member can reach it; the plan
|
|
123
|
+
* selects from the whole change and passes `--member`, which keeps the
|
|
124
|
+
* member's gate, note and statuses apart from the other members'.
|
|
125
|
+
*
|
|
126
|
+
* With a gate per wave (`waveGate`, #4003) the one apply job becomes
|
|
127
|
+
* `apply-wave-1` to `apply-wave-<n>`, one per wave of the component graph,
|
|
128
|
+
* each needing the one before. Each saves the record under its own cache key
|
|
129
|
+
* (the run, the attempt and the wave), and each restores the newest record
|
|
130
|
+
* for the pushed commit, so wave k reads what wave k-1 left, and a re-run of
|
|
131
|
+
* a wave that stopped at its gate reads what it left itself. Each keeps its
|
|
132
|
+
* report as `chant-pr-apply-wave-<k>`.
|
|
133
|
+
*/
|
|
134
|
+
function prLoopGithubDoc(components, env, image, beforeScript, extraScript, options) {
|
|
135
|
+
const loop = options.prLoop;
|
|
136
|
+
const branch = loop.branch ?? "main";
|
|
137
|
+
const member = options.member;
|
|
138
|
+
const rooted = !member || member.dir === "." || member.dir === "";
|
|
139
|
+
const [plan, apply, record] = prLoopJobs(loop.forge ?? "github", env, loop, member?.name);
|
|
140
|
+
const reportPath = member ? memberRepoPath(member, PR_LOOP_REPORT_DIR) : PR_LOOP_REPORT_DIR;
|
|
141
|
+
// The apply's attempt record (#3543) lives in the cache under the pushed
|
|
142
|
+
// commit. Cache keys cannot be overwritten, so each attempt saves under
|
|
143
|
+
// its run and attempt, and a re-run restores the newest for the commit.
|
|
144
|
+
const recordPath = member ? memberRepoPath(member, PR_APPLY_RECORD) : PR_APPLY_RECORD;
|
|
145
|
+
const recordPrefix = `${prApplyRecordKey(env, "${{ github.sha }}", member?.name)}-`;
|
|
146
|
+
const recordKey = (wave) => `${recordPrefix}\${{ github.run_id }}-\${{ github.run_attempt }}${wave ? `-wave-${wave}` : ""}`;
|
|
147
|
+
const restoreRecord = (wave) => ({
|
|
148
|
+
name: wave ? "Restore the newest record of this commit's apply" : "Restore the record of an earlier attempt at this commit",
|
|
149
|
+
uses: "actions/cache/restore@v4",
|
|
150
|
+
with: { path: recordPath, key: recordKey(wave), "restore-keys": recordPrefix },
|
|
151
|
+
});
|
|
152
|
+
const saveRecord = (wave) => ({
|
|
153
|
+
name: wave ? "Keep the record for the next wave and a re-run" : "Keep the record for a re-run",
|
|
154
|
+
if: "always()",
|
|
155
|
+
uses: "actions/cache/save@v4",
|
|
156
|
+
with: { path: recordPath, key: recordKey(wave) },
|
|
157
|
+
});
|
|
158
|
+
const reportName = (job) => `chant-pr-${job.wave ? prApplyWaveJobName(job.wave) : job.jobName}`;
|
|
159
|
+
const steps = (job, name) => [
|
|
160
|
+
checkoutStep(true),
|
|
161
|
+
...runSteps(prLoopSetup(image)),
|
|
162
|
+
...runSteps(beforeScript),
|
|
163
|
+
...(job.build ? [{ name: "Build the templates", run: job.build[0] }, { name: "Lint the project", run: job.build[1] }] : []),
|
|
164
|
+
...(job.jobName === "apply" ? [restoreRecord(job.wave)] : []),
|
|
165
|
+
// The job's setup lines (#4004) run in the command's shell, so a value they settle reaches it.
|
|
166
|
+
// The command runs through chant ci run (#4156), which writes the job's run record, signed when the CHANT_RUNNER_KEY secret is set.
|
|
167
|
+
{ name, env: job.run.env, run: [...(job.setup ?? []), job.run.command].join("\n") },
|
|
168
|
+
...runSteps(extraScript),
|
|
169
|
+
...(job.jobName === "apply" ? [saveRecord(job.wave)] : []),
|
|
170
|
+
{
|
|
171
|
+
name: `Keep the ${job.wave ? `wave ${job.wave} ` : ""}${job.jobName} report`,
|
|
172
|
+
if: "always()",
|
|
173
|
+
uses: UPLOAD_ARTIFACT,
|
|
174
|
+
with: { name: reportName(job), path: reportPath, "if-no-files-found": "ignore", "include-hidden-files": true },
|
|
175
|
+
},
|
|
176
|
+
];
|
|
177
|
+
const applyJob = (job, needs, name) => ({
|
|
178
|
+
if: "github.event_name == 'push'",
|
|
179
|
+
"runs-on": GITHUB_RUNNER,
|
|
180
|
+
...(needs.length > 0 ? { needs } : {}),
|
|
181
|
+
container: image,
|
|
182
|
+
concurrency: concurrency(prApplyGroup(env, member?.name)),
|
|
183
|
+
permissions: { contents: "write", "pull-requests": "write", statuses: "write" },
|
|
184
|
+
steps: steps(job, name),
|
|
185
|
+
});
|
|
186
|
+
const waveJobs = loop.waveGate ? prLoopWaveJobs(components, apply) : [];
|
|
187
|
+
const applyJobs = loop.waveGate
|
|
188
|
+
? waveJobs.map((job) => [
|
|
189
|
+
prApplyWaveJobName(job.wave),
|
|
190
|
+
applyJob(job, job.wave > 1 ? [prApplyWaveJobName(job.wave - 1)] : [], `Apply wave ${job.wave}, under the approval its plan has`),
|
|
191
|
+
])
|
|
192
|
+
: loop.applyOnMerge
|
|
193
|
+
? [["apply", applyJob(apply, [], "Apply the plan a reviewer approved")]]
|
|
194
|
+
: [["record", { ...applyJob(record, [], "Record the plan on the merged commit; nothing applies"), concurrency: concurrency(prRecordGroup(env, member?.name)) }]];
|
|
195
|
+
const jobsDoc = {
|
|
196
|
+
plan: {
|
|
197
|
+
if: "github.event_name == 'pull_request'",
|
|
198
|
+
"runs-on": GITHUB_RUNNER,
|
|
199
|
+
container: image,
|
|
200
|
+
permissions: { contents: "read", "pull-requests": "write", statuses: "write" },
|
|
201
|
+
steps: steps(plan, "Plan the members this pull request reaches"),
|
|
202
|
+
},
|
|
203
|
+
...Object.fromEntries(applyJobs),
|
|
204
|
+
};
|
|
205
|
+
const applyNames = applyJobs.map(([name]) => name);
|
|
206
|
+
return {
|
|
207
|
+
name: member ? `chant-pr.${member.name}.${env}` : `chant-pr-${env}`,
|
|
208
|
+
environment: env,
|
|
209
|
+
on: { pull_request: { branches: [branch] }, push: { branches: [branch] } },
|
|
210
|
+
...(rooted ? {} : { defaults: { run: { "working-directory": member.dir } } }),
|
|
211
|
+
env: { ...options.variables, CHANT_ENV: env },
|
|
212
|
+
jobsDoc,
|
|
213
|
+
stages: ["plan", ...applyNames],
|
|
214
|
+
jobs: [
|
|
215
|
+
{ jobName: "plan", component: "pull request plan", stage: "plan", needs: [] },
|
|
216
|
+
...applyNames.map((name, i) => ({
|
|
217
|
+
jobName: name,
|
|
218
|
+
component: loop.waveGate ? `pull request apply, wave ${i + 1}` : loop.applyOnMerge ? "pull request apply" : "merge record",
|
|
219
|
+
stage: name,
|
|
220
|
+
needs: i > 0 ? [applyNames[i - 1]] : [],
|
|
221
|
+
})),
|
|
222
|
+
],
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
/** The file a producer dumps its resolved outputs to, and a dependent seeds from — one per component, matching gitlab's naming so both generators produce interchangeable artifacts. */
|
|
226
|
+
function outputsFile(name) {
|
|
227
|
+
return `${name}.outputs.json`;
|
|
228
|
+
}
|
|
229
|
+
/** The workflow artifact name a producer's dumped outputs are uploaded under. */
|
|
230
|
+
function artifactName(name) {
|
|
231
|
+
return `${name}-outputs`;
|
|
232
|
+
}
|
|
233
|
+
/** The file a component job writes the digest its run recorded to, for the promote job (#2602). */
|
|
234
|
+
function digestFile(name) {
|
|
235
|
+
return `${name}.digest`;
|
|
236
|
+
}
|
|
237
|
+
/** The workflow artifact name a component's build archive is uploaded under, for the promote job (#2575). */
|
|
238
|
+
function archiveArtifactName(name) {
|
|
239
|
+
return `${name}-archive`;
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* The directory `actions/upload-artifact` roots a set of files at: the
|
|
243
|
+
* deepest directory that holds all of them. Downloading into it puts each
|
|
244
|
+
* file back at the path it was uploaded from.
|
|
245
|
+
*/
|
|
246
|
+
export function artifactRoot(paths) {
|
|
247
|
+
const dirs = paths.map((p) => {
|
|
248
|
+
const parts = p.replace(/\/+$/, "").split("/");
|
|
249
|
+
parts.pop();
|
|
250
|
+
return parts.filter((part) => part !== "" && part !== ".");
|
|
251
|
+
});
|
|
252
|
+
const common = [];
|
|
253
|
+
for (let i = 0; dirs.every((d) => i < d.length && d[i] === dirs[0][i]); i++)
|
|
254
|
+
common.push(dirs[0][i]);
|
|
255
|
+
const absolute = paths.every((p) => p.startsWith("/"));
|
|
256
|
+
if (common.length === 0)
|
|
257
|
+
return absolute ? "/" : ".";
|
|
258
|
+
return (absolute ? "/" : "") + common.join("/");
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* Synthesize a `.github/workflows/*.yml` pipeline from a set of components:
|
|
262
|
+
* one job per component, `needs:` expressing the wave-ordered dependency DAG
|
|
263
|
+
* from `resolveComponentGraph`. Throws `DependencyCycleError`/
|
|
264
|
+
* `UnknownDependencyError` (from core's driver) exactly like the interpret
|
|
265
|
+
* driver and the gitlab generator do, since all three consume the same graph
|
|
266
|
+
* resolution. Wired into core's generate mode via the github lexicon plugin's
|
|
267
|
+
* `generateComponentPipeline` (../plugin.ts).
|
|
268
|
+
*/
|
|
269
|
+
export function buildGithubPipelineDoc(components, options = {}) {
|
|
270
|
+
const env = options.env ?? "production";
|
|
271
|
+
const image = options.image ?? CI_IMAGE;
|
|
272
|
+
const runCommand = options.runCommand ?? ["chant", "run", "--components", "{name}", "--env", env];
|
|
273
|
+
const beforeScript = options.beforeScript ?? [];
|
|
274
|
+
const extraScript = options.extraScript ?? [];
|
|
275
|
+
const { waves } = resolveComponentGraph(components);
|
|
276
|
+
const byName = new Map(components.map((c) => [c.name, c]));
|
|
277
|
+
if (options.prLoop) {
|
|
278
|
+
if (options.promoteTo !== undefined)
|
|
279
|
+
throw new Error("a pull-request pipeline has no promote job; drop --promote-to or --pr-loop");
|
|
280
|
+
// #4003: --wave-gate beside --pr-loop gives the apply a gate per wave.
|
|
281
|
+
const prLoop = prLoopWithWaves(options.prLoop, options.gatedWaves);
|
|
282
|
+
return prLoopGithubDoc(components, env, image, beforeScript, extraScript, { ...options, prLoop });
|
|
283
|
+
}
|
|
284
|
+
if (options.gatedWaves) {
|
|
285
|
+
if (options.promoteTo !== undefined)
|
|
286
|
+
throw new Error("a gated-wave pipeline has no promote job; drop --promote-to or --wave-gate");
|
|
287
|
+
return gatedWaveGithubDoc(components, env, image, beforeScript, extraScript, options);
|
|
288
|
+
}
|
|
289
|
+
// Components that something else depends on must hand their resolved outputs
|
|
290
|
+
// (stack outputs, published artifact refs) to their dependents, which run as
|
|
291
|
+
// separate jobs on separate runners. Each such producer dumps its outputs to
|
|
292
|
+
// a file and uploads it as a workflow artifact; each dependent downloads that
|
|
293
|
+
// artifact and seeds from it, so a `stackOutput()` / `@<dep>.publish.*`
|
|
294
|
+
// reference resolves even though the producer ran in a different job.
|
|
295
|
+
// Without this, a single-component job has no in-memory outputs for its
|
|
296
|
+
// dependencies — see epic #551 / the adopt-alb-services example.
|
|
297
|
+
const dependedUpon = new Set();
|
|
298
|
+
for (const c of components)
|
|
299
|
+
for (const dep of c.dependsOn ?? [])
|
|
300
|
+
dependedUpon.add(dep);
|
|
301
|
+
// A promote job (#2575) runs on its own runner, and a promote publishes from
|
|
302
|
+
// the build archive on disk, so each component job uploads the files its
|
|
303
|
+
// build steps wrote and the promote job downloads them.
|
|
304
|
+
const promoteTo = options.promoteTo;
|
|
305
|
+
const archives = new Map();
|
|
306
|
+
// The components the promote job pins to this run's digest (#2602): those
|
|
307
|
+
// with a publish step, the only ones whose deploy records a release.
|
|
308
|
+
const pinned = new Set();
|
|
309
|
+
if (promoteTo !== undefined) {
|
|
310
|
+
for (const c of components) {
|
|
311
|
+
const paths = promoteArchivePaths(c);
|
|
312
|
+
if (paths.length > 0)
|
|
313
|
+
archives.set(c.name, paths);
|
|
314
|
+
if (hasPublishStep(c))
|
|
315
|
+
pinned.add(c.name);
|
|
316
|
+
}
|
|
317
|
+
if (pinned.size === 0) {
|
|
318
|
+
throw new Error("no component has a publish step, so no deploy records a release for the promote job to promote");
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
const stages = waves.map((_, i) => `wave-${i + 1}`);
|
|
322
|
+
const jobs = [];
|
|
323
|
+
const jobNameByComponent = new Map();
|
|
324
|
+
for (const wave of waves) {
|
|
325
|
+
for (const name of wave)
|
|
326
|
+
jobNameByComponent.set(name, ciJobName(name));
|
|
327
|
+
}
|
|
328
|
+
const jobsDoc = {};
|
|
329
|
+
waves.forEach((wave, waveIndex) => {
|
|
330
|
+
const stage = stages[waveIndex];
|
|
331
|
+
for (const name of wave) {
|
|
332
|
+
const component = byName.get(name);
|
|
333
|
+
const jobName = jobNameByComponent.get(name);
|
|
334
|
+
const needs = (component.dependsOn ?? []).map((dep) => jobNameByComponent.get(dep)).sort();
|
|
335
|
+
jobs.push({ jobName, component: name, stage, needs });
|
|
336
|
+
// Build the run invocation, then append output-threading flags: seed from
|
|
337
|
+
// each dependency's dumped outputs (downloaded as an artifact below), and
|
|
338
|
+
// dump this component's own outputs if a dependent will need them.
|
|
339
|
+
const runParts = fillCommand(runCommand, name);
|
|
340
|
+
for (const dep of component.dependsOn ?? [])
|
|
341
|
+
runParts.push("--seed-outputs", outputsFile(dep));
|
|
342
|
+
if (dependedUpon.has(name))
|
|
343
|
+
runParts.push("--dump-outputs", outputsFile(name));
|
|
344
|
+
if (pinned.has(name))
|
|
345
|
+
runParts.push("--digest-file", digestFile(name));
|
|
346
|
+
// One step per script line — mirrors gitlab's `script:` array of
|
|
347
|
+
// discrete shell lines, rather than a single multi-line `run:` block, so
|
|
348
|
+
// each line is independently inspectable (and machine-parseable).
|
|
349
|
+
const steps = [checkoutStep()];
|
|
350
|
+
for (const dep of component.dependsOn ?? [])
|
|
351
|
+
steps.push(downloadArtifactStep(`Download ${dep} outputs`, artifactName(dep), "."));
|
|
352
|
+
steps.push(...runSteps(beforeScript), { run: runParts.join(" ") }, ...runSteps(extraScript));
|
|
353
|
+
// The file holds `<component>=<digest>`; the job output holds the digest.
|
|
354
|
+
if (pinned.has(name)) {
|
|
355
|
+
steps.push({
|
|
356
|
+
name: `Record ${name} digest`,
|
|
357
|
+
id: "digest",
|
|
358
|
+
run: `echo "digest=$(cut -d= -f2- ${digestFile(name)})" >> "$GITHUB_OUTPUT"`,
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
if (dependedUpon.has(name)) {
|
|
362
|
+
steps.push({
|
|
363
|
+
name: `Upload ${name} outputs`,
|
|
364
|
+
uses: UPLOAD_ARTIFACT,
|
|
365
|
+
with: { name: artifactName(name), path: outputsFile(name) },
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
const archive = archives.get(name);
|
|
369
|
+
if (archive) {
|
|
370
|
+
steps.push({
|
|
371
|
+
name: `Upload ${name} build archive`,
|
|
372
|
+
uses: UPLOAD_ARTIFACT,
|
|
373
|
+
with: {
|
|
374
|
+
name: archiveArtifactName(name),
|
|
375
|
+
path: archive.join("\n"),
|
|
376
|
+
"if-no-files-found": "error",
|
|
377
|
+
"include-hidden-files": true,
|
|
378
|
+
},
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
const jobProps = {
|
|
382
|
+
"runs-on": GITHUB_RUNNER,
|
|
383
|
+
...(needs.length > 0 ? { needs } : {}),
|
|
384
|
+
container: image,
|
|
385
|
+
...(pinned.has(name) ? { outputs: { digest: "${{ steps.digest.outputs.digest }}" } } : {}),
|
|
386
|
+
steps,
|
|
387
|
+
};
|
|
388
|
+
jobsDoc[jobName] = jobProps;
|
|
389
|
+
}
|
|
390
|
+
});
|
|
391
|
+
if (promoteTo !== undefined) {
|
|
392
|
+
const promoteJob = `promote-${ciJobName(promoteTo)}`;
|
|
393
|
+
if (promoteJob in jobsDoc) {
|
|
394
|
+
throw new Error(`the promote job "${promoteJob}" has the same name as a component job; rename the component`);
|
|
395
|
+
}
|
|
396
|
+
const command = [...(options.promoteCommand ?? ["chant", "components", "promote", "--from", env, "--to", promoteTo])];
|
|
397
|
+
for (const name of [...pinned].sort()) {
|
|
398
|
+
command.push("--digest", `"${name}=\${{ needs.${jobNameByComponent.get(name)}.outputs.digest }}"`);
|
|
399
|
+
}
|
|
400
|
+
const steps = [checkoutStep()];
|
|
401
|
+
for (const [name, paths] of archives) {
|
|
402
|
+
steps.push(downloadArtifactStep(`Download ${name} build archive`, archiveArtifactName(name), artifactRoot(paths)));
|
|
403
|
+
}
|
|
404
|
+
steps.push(...runSteps(beforeScript), { run: command.join(" ") }, ...runSteps(extraScript));
|
|
405
|
+
jobsDoc[promoteJob] = {
|
|
406
|
+
"runs-on": GITHUB_RUNNER,
|
|
407
|
+
needs: [...jobNameByComponent.values()].sort(),
|
|
408
|
+
container: image,
|
|
409
|
+
steps,
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
const doc = {
|
|
413
|
+
name: `chant-components-${env}`,
|
|
414
|
+
environment: env,
|
|
415
|
+
on: { workflow_dispatch: {} },
|
|
416
|
+
env: { ...options.variables, CHANT_ENV: env },
|
|
417
|
+
jobsDoc,
|
|
418
|
+
stages,
|
|
419
|
+
jobs,
|
|
420
|
+
};
|
|
421
|
+
return options.member ? scopeToMember(doc, options.member) : doc;
|
|
422
|
+
}
|
|
423
|
+
/**
|
|
424
|
+
* Scope a pipeline to one workspace member (#2542, #2524 D19). Its name
|
|
425
|
+
* carries the member's name, and every `run:` step starts in the member's
|
|
426
|
+
* directory through `defaults.run.working-directory`.
|
|
427
|
+
*
|
|
428
|
+
* The triggers stay exactly the plain pipeline's. A deploy pipeline gains no
|
|
429
|
+
* `push` or `pull_request` trigger for a member, and its only trigger,
|
|
430
|
+
* `workflow_dispatch`, takes no `paths:` filter.
|
|
431
|
+
*
|
|
432
|
+
* `working-directory` applies to `run:` steps only. The artifact actions
|
|
433
|
+
* resolve `path` against the repository root, so each upload and download
|
|
434
|
+
* path moves under the member's directory, where the `run:` steps read and
|
|
435
|
+
* write the files.
|
|
436
|
+
*/
|
|
437
|
+
function scopeToMember(doc, member) {
|
|
438
|
+
const rooted = member.dir === "." || member.dir === "";
|
|
439
|
+
const jobsDoc = {};
|
|
440
|
+
for (const [name, job] of Object.entries(doc.jobsDoc)) {
|
|
441
|
+
const props = job;
|
|
442
|
+
jobsDoc[name] = rooted || !props.steps ? job : { ...props, steps: props.steps.map((step) => memberArtifactStep(step, member)) };
|
|
443
|
+
}
|
|
444
|
+
return {
|
|
445
|
+
...doc,
|
|
446
|
+
name: `chant-components.${member.name}.${doc.environment}`,
|
|
447
|
+
...(rooted ? {} : { defaults: { run: { "working-directory": member.dir } } }),
|
|
448
|
+
jobsDoc,
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
/** An `actions/upload-artifact` or `download-artifact` step with its `path` moved under the member's directory. */
|
|
452
|
+
function memberArtifactStep(step, member) {
|
|
453
|
+
const uses = typeof step.uses === "string" ? step.uses : "";
|
|
454
|
+
if (!/^actions\/(upload|download)-artifact@/.test(uses))
|
|
455
|
+
return step;
|
|
456
|
+
const withProps = step.with;
|
|
457
|
+
if (!withProps || typeof withProps.path !== "string")
|
|
458
|
+
return step;
|
|
459
|
+
const path = withProps.path
|
|
460
|
+
.split("\n")
|
|
461
|
+
.map((line) => memberRepoPath(member, line))
|
|
462
|
+
.join("\n");
|
|
463
|
+
return { ...step, with: { ...withProps, path } };
|
|
464
|
+
}
|
|
465
|
+
/**
|
|
466
|
+
* Emit a `GithubPipelineDoc`'s `on`/`env`/`jobs` mappings as workflow YAML.
|
|
467
|
+
* Shared with the forgejo dialect (#969), which transforms the doc first.
|
|
468
|
+
*/
|
|
469
|
+
export function emitPipelineYAML(doc) {
|
|
470
|
+
return emitWorkflowYAML({ name: doc.name, on: doc.on, env: doc.env, defaults: doc.defaults, jobsDoc: doc.jobsDoc });
|
|
471
|
+
}
|
|
472
|
+
/**
|
|
473
|
+
* Synthesize a `.github/workflows/*.yml` pipeline from a set of components:
|
|
474
|
+
* one job per component, `needs:` expressing the wave-ordered dependency DAG
|
|
475
|
+
* from `resolveComponentGraph`. Thin wrapper over {@link buildGithubPipelineDoc}
|
|
476
|
+
* + {@link emitPipelineYAML}.
|
|
477
|
+
*/
|
|
478
|
+
export function generateGithubPipeline(components, options = {}) {
|
|
479
|
+
const doc = buildGithubPipelineDoc(components, options);
|
|
480
|
+
return { yaml: emitPipelineYAML(doc), stages: doc.stages, jobs: doc.jobs, env: doc.environment };
|
|
481
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { Composite, mergeDefaults } from "@intentius/chant";
|
|
2
|
+
import { Step } from "../generated/index.js";
|
|
3
|
+
export const CacheAction = Composite((props) => {
|
|
4
|
+
const { defaults } = props;
|
|
5
|
+
const withObj = {
|
|
6
|
+
path: props.path,
|
|
7
|
+
key: props.key,
|
|
8
|
+
};
|
|
9
|
+
if (props.restoreKeys !== undefined)
|
|
10
|
+
withObj["restore-keys"] = props.restoreKeys.join("\n");
|
|
11
|
+
const step = new Step(mergeDefaults({
|
|
12
|
+
name: "Cache",
|
|
13
|
+
uses: "actions/cache@v4",
|
|
14
|
+
with: withObj,
|
|
15
|
+
}, defaults?.step));
|
|
16
|
+
return { step };
|
|
17
|
+
}, "Cache");
|