@intentius/chant 0.124.32 → 0.124.34
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/ci-jobs.d.ts +4 -0
- package/dist/ci-jobs.d.ts.map +1 -1
- package/dist/ci-jobs.js +9 -2
- package/dist/cli/handlers/build.d.ts.map +1 -1
- package/dist/cli/handlers/build.js +18 -3
- package/dist/cli/handlers/lifecycle-health.d.ts +16 -0
- package/dist/cli/handlers/lifecycle-health.d.ts.map +1 -0
- package/dist/cli/handlers/lifecycle-health.js +150 -0
- package/dist/cli/handlers/lifecycle.d.ts +26 -0
- package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
- package/dist/cli/handlers/lifecycle.js +1 -1
- package/dist/cli/handlers/pr.d.ts +2 -0
- package/dist/cli/handlers/pr.d.ts.map +1 -1
- package/dist/cli/handlers/pr.js +124 -2
- package/dist/cli/handlers/promote.d.ts.map +1 -1
- package/dist/cli/handlers/promote.js +277 -74
- package/dist/cli/handlers/release.d.ts +16 -1
- package/dist/cli/handlers/release.d.ts.map +1 -1
- package/dist/cli/handlers/release.js +83 -11
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/main.js +99 -22
- package/dist/cli/parse-args.d.ts.map +1 -1
- package/dist/cli/parse-args.js +30 -1
- package/dist/cli/registry.d.ts +21 -5
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/components/deploy-inputs.d.ts +91 -0
- package/dist/components/deploy-inputs.d.ts.map +1 -0
- package/dist/components/deploy-inputs.js +184 -0
- package/dist/components/driver.d.ts.map +1 -1
- package/dist/components/driver.js +9 -0
- package/dist/components/gate-plan.d.ts +8 -0
- package/dist/components/gate-plan.d.ts.map +1 -1
- package/dist/components/gate-plan.js +6 -0
- package/dist/components/pr-pipeline.d.ts +33 -10
- package/dist/components/pr-pipeline.d.ts.map +1 -1
- package/dist/components/pr-pipeline.js +42 -11
- package/dist/components/pr-run.d.ts +1 -1
- package/dist/components/pr-run.d.ts.map +1 -1
- package/dist/components/promote-rounds.d.ts +102 -0
- package/dist/components/promote-rounds.d.ts.map +1 -0
- package/dist/components/promote-rounds.js +174 -0
- package/dist/components/release-compile.d.ts +37 -2
- package/dist/components/release-compile.d.ts.map +1 -1
- package/dist/components/release-compile.js +64 -8
- package/dist/components/release-gate.d.ts +30 -0
- package/dist/components/release-gate.d.ts.map +1 -1
- package/dist/components/release-gate.js +92 -42
- package/dist/components/release-promote.d.ts +17 -3
- package/dist/components/release-promote.d.ts.map +1 -1
- package/dist/components/release-promote.js +79 -18
- package/dist/components/verbs/bom-writer.d.ts +1 -1
- package/dist/components/verbs/bom-writer.d.ts.map +1 -1
- package/dist/components/verbs/lockfile-sbom-generator.d.ts +20 -0
- package/dist/components/verbs/lockfile-sbom-generator.d.ts.map +1 -1
- package/dist/components/verbs/lockfile-sbom-generator.js +41 -4
- package/dist/components/wave-plan.d.ts +10 -0
- package/dist/components/wave-plan.d.ts.map +1 -1
- package/dist/components/wave-plan.js +32 -4
- package/dist/graph-declared.d.ts +4 -0
- package/dist/graph-declared.d.ts.map +1 -1
- package/dist/graph-declared.js +11 -1
- package/dist/graph-detail.d.ts.map +1 -1
- package/dist/graph-detail.js +32 -7
- package/dist/health.d.ts +110 -0
- package/dist/health.d.ts.map +1 -0
- package/dist/health.js +105 -0
- package/dist/health.schema.json +191 -0
- package/dist/lexicon.d.ts +27 -0
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/lifecycle/merge-record.d.ts +104 -0
- package/dist/lifecycle/merge-record.d.ts.map +1 -0
- package/dist/lifecycle/merge-record.js +102 -0
- package/dist/lifecycle/publish-attachments.d.ts +8 -5
- package/dist/lifecycle/publish-attachments.d.ts.map +1 -1
- package/dist/lifecycle/publish-attachments.js +27 -13
- package/dist/lifecycle/publish-npm.d.ts +3 -2
- package/dist/lifecycle/publish-npm.d.ts.map +1 -1
- package/dist/lifecycle/publish-npm.js +4 -3
- package/dist/lifecycle/release-at.d.ts +40 -0
- package/dist/lifecycle/release-at.d.ts.map +1 -0
- package/dist/lifecycle/release-at.js +83 -0
- package/dist/lifecycle/release-cut.d.ts.map +1 -1
- package/dist/lifecycle/release-cut.js +23 -12
- package/dist/lifecycle/release-publish.d.ts +28 -12
- package/dist/lifecycle/release-publish.d.ts.map +1 -1
- package/dist/lifecycle/release-publish.js +29 -17
- package/dist/lifecycle/release-store.d.ts +25 -2
- package/dist/lifecycle/release-store.d.ts.map +1 -1
- package/dist/lifecycle/release-store.js +40 -13
- package/dist/lifecycle/run-ledger.d.ts.map +1 -1
- package/dist/lifecycle/run-ledger.js +1 -0
- package/dist/lint/rules/op/index.d.ts +2 -1
- package/dist/lint/rules/op/index.d.ts.map +1 -1
- package/dist/lint/rules/op/index.js +4 -2
- package/dist/lint/rules/op/ops018-shell-image-digest.d.ts +10 -0
- package/dist/lint/rules/op/ops018-shell-image-digest.d.ts.map +1 -0
- package/dist/lint/rules/op/ops018-shell-image-digest.js +38 -0
- package/dist/op/activities/activity-contracts.d.ts +5 -0
- package/dist/op/activities/activity-contracts.d.ts.map +1 -1
- package/dist/op/activities/activity-contracts.js +3 -0
- package/dist/op/activities/shell.d.ts +38 -0
- package/dist/op/activities/shell.d.ts.map +1 -1
- package/dist/op/activities/shell.js +89 -3
- package/dist/op/builders.d.ts +7 -0
- package/dist/op/builders.d.ts.map +1 -1
- package/dist/op/builders.js +7 -0
- package/dist/op/runtime.d.ts +2 -0
- package/dist/op/runtime.d.ts.map +1 -1
- package/dist/pr-loop.d.ts +14 -3
- package/dist/pr-loop.d.ts.map +1 -1
- package/dist/pr-loop.js +15 -5
- package/dist/pr-report-url.d.ts +2 -2
- package/dist/pr-report-url.d.ts.map +1 -1
- package/dist/runner-start.d.ts +179 -0
- package/dist/runner-start.d.ts.map +1 -0
- package/dist/runner-start.js +277 -0
- package/dist/wave-runtime.mjs +236 -229
- package/dist/workspace/__fixtures__/contract-repo.d.ts.map +1 -1
- package/dist/workspace/__fixtures__/contract-repo.js +13 -2
- package/dist/workspace/check.schema.json +88 -2
- package/dist/workspace/checks/ci-workflow.d.ts +17 -0
- package/dist/workspace/checks/ci-workflow.d.ts.map +1 -0
- package/dist/workspace/checks/ci-workflow.js +123 -0
- package/dist/workspace/checks/security.d.ts +50 -0
- package/dist/workspace/checks/security.d.ts.map +1 -0
- package/dist/workspace/checks/security.js +305 -0
- package/dist/workspace/checks.d.ts +2 -0
- package/dist/workspace/checks.d.ts.map +1 -1
- package/dist/workspace/checks.js +8 -0
- package/dist/workspace/ci-builds.d.ts +163 -0
- package/dist/workspace/ci-builds.d.ts.map +1 -0
- package/dist/workspace/ci-builds.js +315 -0
- package/dist/workspace/ci-green-cli.d.ts +2 -1
- package/dist/workspace/ci-green-cli.d.ts.map +1 -1
- package/dist/workspace/ci-green-cli.js +25 -5
- package/dist/workspace/ci-green-forge.d.ts +12 -0
- package/dist/workspace/ci-green-forge.d.ts.map +1 -1
- package/dist/workspace/ci-green-forge.js +12 -0
- package/dist/workspace/ci-green-workflow.d.ts +55 -1
- package/dist/workspace/ci-green-workflow.d.ts.map +1 -1
- package/dist/workspace/ci-green-workflow.js +128 -1
- package/dist/workspace/ci-green.d.ts +89 -4
- package/dist/workspace/ci-green.d.ts.map +1 -1
- package/dist/workspace/ci-green.js +310 -23
- package/dist/workspace/ci-init.d.ts +100 -0
- package/dist/workspace/ci-init.d.ts.map +1 -0
- package/dist/workspace/ci-init.js +434 -0
- package/dist/workspace/ci-last-green.schema.json +49 -0
- package/dist/workspace/ci-list.schema.json +35 -1
- package/dist/workspace/ci-run-index.d.ts +1 -1
- package/dist/workspace/ci-run-index.d.ts.map +1 -1
- package/dist/workspace/ci-run-index.schema.json +1 -1
- package/dist/workspace/ci-run.d.ts +12 -6
- package/dist/workspace/ci-run.d.ts.map +1 -1
- package/dist/workspace/ci-run.js +72 -16
- package/dist/workspace/ci-runner-start.d.ts +77 -0
- package/dist/workspace/ci-runner-start.d.ts.map +1 -0
- package/dist/workspace/ci-runner-start.js +185 -0
- package/dist/workspace/ci-serve.d.ts +76 -7
- package/dist/workspace/ci-serve.d.ts.map +1 -1
- package/dist/workspace/ci-serve.js +153 -19
- package/dist/workspace/ci-workflow.d.ts +91 -0
- package/dist/workspace/ci-workflow.d.ts.map +1 -0
- package/dist/workspace/ci-workflow.js +33 -0
- package/dist/workspace/declaration.d.ts +35 -0
- package/dist/workspace/declaration.d.ts.map +1 -1
- package/dist/workspace/declaration.js +12 -2
- package/dist/workspace/declaration.schema.json +51 -0
- package/dist/workspace/init.js +2 -2
- package/dist/workspace/ls.d.ts +2 -0
- package/dist/workspace/ls.d.ts.map +1 -1
- package/dist/workspace/ls.js +1 -1
- package/dist/workspace/ls.schema.json +2 -1
- package/dist/workspace/member-pipeline.d.ts +1 -0
- package/dist/workspace/member-pipeline.d.ts.map +1 -1
- package/dist/workspace/member-pipeline.js +2 -0
- package/dist/workspace/merge-record.schema.json +49 -0
- package/dist/workspace/pr-report.schema.json +3 -3
- package/dist/workspace/reason-codes.d.ts +26 -0
- package/dist/workspace/reason-codes.d.ts.map +1 -1
- package/dist/workspace/reason-codes.js +28 -0
- package/dist/workspace/release-candidates.d.ts +129 -0
- package/dist/workspace/release-candidates.d.ts.map +1 -0
- package/dist/workspace/release-candidates.js +233 -0
- package/dist/workspace/release-candidates.schema.json +353 -0
- package/dist/workspace/release-workflow.d.ts +31 -0
- package/dist/workspace/release-workflow.d.ts.map +1 -0
- package/dist/workspace/release-workflow.js +150 -0
- package/dist/workspace/security.d.ts +40 -0
- package/dist/workspace/security.d.ts.map +1 -0
- package/dist/workspace/security.js +99 -0
- package/package.json +8 -1
- package/src/__fixtures__/pr-loop/pr-plan.note.golden.md +1 -1
- package/src/ci-jobs.ts +11 -2
- package/src/cli/handlers/build.test.ts +36 -0
- package/src/cli/handlers/build.ts +18 -3
- package/src/cli/handlers/lifecycle-health.test.ts +123 -0
- package/src/cli/handlers/lifecycle-health.ts +157 -0
- package/src/cli/handlers/lifecycle.ts +1 -1
- package/src/cli/handlers/pr-deploy-inputs.test.ts +209 -0
- package/src/cli/handlers/pr-record.test.ts +201 -0
- package/src/cli/handlers/pr.test.ts +22 -0
- package/src/cli/handlers/pr.ts +127 -4
- package/src/cli/handlers/promote.test.ts +17 -0
- package/src/cli/handlers/promote.ts +296 -84
- package/src/cli/handlers/release-cut-at.e2e.test.ts +115 -0
- package/src/cli/handlers/release-publish-targets.e2e.test.ts +2 -2
- package/src/cli/handlers/release-verify.e2e.test.ts +12 -10
- package/src/cli/handlers/release.e2e.test.ts +19 -28
- package/src/cli/handlers/release.ts +81 -11
- package/src/cli/main.ts +99 -22
- package/src/cli/parse-args.ts +22 -1
- package/src/cli/registry.ts +21 -5
- package/src/components/deploy-inputs.test.ts +108 -0
- package/src/components/deploy-inputs.ts +225 -0
- package/src/components/driver.ts +11 -0
- package/src/components/gate-plan.ts +9 -0
- package/src/components/pr-pipeline.ts +56 -15
- package/src/components/pr-run.ts +1 -1
- package/src/components/promote-rounds.test.ts +220 -0
- package/src/components/promote-rounds.ts +232 -0
- package/src/components/release-compile.test.ts +34 -0
- package/src/components/release-compile.ts +89 -8
- package/src/components/release-gate.test.ts +47 -0
- package/src/components/release-gate.ts +111 -46
- package/src/components/release-promote.test.ts +55 -2
- package/src/components/release-promote.ts +93 -23
- package/src/components/verbs/bom-writer.ts +1 -1
- package/src/components/verbs/lockfile-sbom-generator.test.ts +36 -0
- package/src/components/verbs/lockfile-sbom-generator.ts +43 -5
- package/src/components/wave-plan.ts +32 -4
- package/src/graph-declared-exports.test.ts +40 -0
- package/src/graph-declared.ts +9 -1
- package/src/graph-detail.test.ts +57 -0
- package/src/graph-detail.ts +33 -7
- package/src/health.schema.json +191 -0
- package/src/health.test.ts +62 -0
- package/src/health.ts +178 -0
- package/src/lexicon.ts +41 -1
- package/src/lifecycle/merge-record.test.ts +111 -0
- package/src/lifecycle/merge-record.ts +154 -0
- package/src/lifecycle/publish-attachments.ts +25 -13
- package/src/lifecycle/publish-npm.ts +4 -3
- package/src/lifecycle/publish-targets.test.ts +34 -4
- package/src/lifecycle/release-at.test.ts +48 -0
- package/src/lifecycle/release-at.ts +100 -0
- package/src/lifecycle/release-cut.test.ts +41 -0
- package/src/lifecycle/release-cut.ts +22 -11
- package/src/lifecycle/release-publish.test.ts +43 -13
- package/src/lifecycle/release-publish.ts +38 -18
- package/src/lifecycle/release-store.ts +57 -13
- package/src/lifecycle/run-ledger.test.ts +13 -0
- package/src/lifecycle/run-ledger.ts +1 -0
- package/src/lint/rules/op/index.ts +4 -2
- package/src/lint/rules/op/ops018-shell-image-digest.test.ts +60 -0
- package/src/lint/rules/op/ops018-shell-image-digest.ts +48 -0
- package/src/op/activities/activity-contracts.ts +3 -0
- package/src/op/activities/shell-container.e2e.test.ts +67 -0
- package/src/op/activities/shell.test.ts +44 -1
- package/src/op/activities/shell.ts +110 -3
- package/src/op/builders.ts +7 -0
- package/src/op/runtime.ts +2 -0
- package/src/op/step-console.test.ts +4 -2
- package/src/pr-loop.test.ts +29 -0
- package/src/pr-loop.ts +26 -7
- package/src/pr-report-url.ts +2 -2
- package/src/runner-start-bundle.test.ts +29 -0
- package/src/runner-start.test.ts +179 -0
- package/src/runner-start.ts +329 -0
- package/src/workspace/__fixtures__/contract-repo.ts +11 -2
- package/src/workspace/check-contract.test.ts +4 -2
- package/src/workspace/check.schema.json +88 -2
- package/src/workspace/checks/ci-workflow.test.ts +150 -0
- package/src/workspace/checks/ci-workflow.ts +129 -0
- package/src/workspace/checks/security.test.ts +138 -0
- package/src/workspace/checks/security.ts +305 -0
- package/src/workspace/checks.ts +8 -0
- package/src/workspace/ci-builds.e2e.test.ts +208 -0
- package/src/workspace/ci-builds.test.ts +174 -0
- package/src/workspace/ci-builds.ts +385 -0
- package/src/workspace/ci-green-builds.test.ts +180 -0
- package/src/workspace/ci-green-cli.test.ts +54 -0
- package/src/workspace/ci-green-cli.ts +26 -5
- package/src/workspace/ci-green-forge.ts +24 -0
- package/src/workspace/ci-green-workflow.test.ts +84 -1
- package/src/workspace/ci-green-workflow.ts +139 -2
- package/src/workspace/ci-green.test.ts +5 -2
- package/src/workspace/ci-green.ts +359 -27
- package/src/workspace/ci-init.test.ts +159 -0
- package/src/workspace/ci-init.ts +445 -0
- package/src/workspace/ci-last-green.schema.json +49 -0
- package/src/workspace/ci-list.schema.json +35 -1
- package/src/workspace/ci-report-url.test.ts +6 -1
- package/src/workspace/ci-run-index.schema.json +1 -1
- package/src/workspace/ci-run-index.ts +1 -1
- package/src/workspace/ci-run.test.ts +24 -7
- package/src/workspace/ci-run.ts +78 -16
- package/src/workspace/ci-runner-start.test.ts +151 -0
- package/src/workspace/ci-runner-start.ts +193 -0
- package/src/workspace/ci-serve-builds.test.ts +86 -0
- package/src/workspace/ci-serve.test.ts +59 -0
- package/src/workspace/ci-serve.ts +177 -22
- package/src/workspace/ci-workflow.ts +71 -0
- package/src/workspace/declaration.schema.json +51 -0
- package/src/workspace/declaration.test.ts +17 -0
- package/src/workspace/declaration.ts +53 -3
- package/src/workspace/init.ts +2 -2
- package/src/workspace/ls.schema.json +2 -1
- package/src/workspace/ls.ts +3 -2
- package/src/workspace/member-pipeline.ts +2 -1
- package/src/workspace/merge-record.schema.json +49 -0
- package/src/workspace/pr-report.schema.json +3 -3
- package/src/workspace/reason-codes.test.ts +4 -0
- package/src/workspace/reason-codes.ts +28 -0
- package/src/workspace/release-candidates.schema.json +353 -0
- package/src/workspace/release-candidates.test.ts +106 -0
- package/src/workspace/release-candidates.ts +315 -0
- package/src/workspace/release-workflow.ts +167 -0
- package/src/workspace/security.test.ts +70 -0
- package/src/workspace/security.ts +118 -0
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `chant ci init` (#4168 step 6): the proposal, its diff and the write, and
|
|
3
|
+
* `chant release workflow`'s rendering.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { execFileSync } from "node:child_process";
|
|
7
|
+
import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { tmpdir } from "node:os";
|
|
9
|
+
import { dirname, join } from "node:path";
|
|
10
|
+
import { afterAll, describe, expect, test } from "vitest";
|
|
11
|
+
import { applyCiInit, componentStub, editDeclarationText, inferGreen, lineDiff, proposeCiInit, withEnvironments } from "./ci-init";
|
|
12
|
+
import { publishFlags, renderReleaseWorkflow } from "./release-workflow";
|
|
13
|
+
import { runDeclarationChecks } from "./checks";
|
|
14
|
+
|
|
15
|
+
const scratch: string[] = [];
|
|
16
|
+
afterAll(() => {
|
|
17
|
+
for (const d of scratch) rmSync(d, { recursive: true, force: true });
|
|
18
|
+
});
|
|
19
|
+
function repo(files: Record<string, string>): string {
|
|
20
|
+
const root = realpathSync(mkdtempSync(join(tmpdir(), "chant-ci-init-")));
|
|
21
|
+
scratch.push(root);
|
|
22
|
+
execFileSync("git", ["init", "-q", "-b", "main"], { cwd: root });
|
|
23
|
+
for (const [path, text] of Object.entries(files)) {
|
|
24
|
+
mkdirSync(dirname(join(root, path)), { recursive: true });
|
|
25
|
+
writeFileSync(join(root, path), text);
|
|
26
|
+
}
|
|
27
|
+
return root;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const DECLARATION = `${JSON.stringify({ name: "acme", schema: 1, members: [{ name: "api", dir: "api", kind: "other", because: "the service" }] }, null, 2)}\n`;
|
|
31
|
+
const CHECK_YML = "name: check\non:\n pull_request:\n push:\n branches: [main]\njobs:\n lint:\n runs-on: ubuntu-latest\n steps: []\n test:\n runs-on: ubuntu-latest\n steps: []\n";
|
|
32
|
+
const SUITES_YML = "name: suites\non:\n push:\n branches: [main]\njobs:\n e2e:\n runs-on: ubuntu-latest\n steps: []\n";
|
|
33
|
+
|
|
34
|
+
const fresh = () =>
|
|
35
|
+
repo({
|
|
36
|
+
"chant.workspace.json": DECLARATION,
|
|
37
|
+
"api/chant.config.ts": 'export default {\n lexicons: ["aws"],\n};\n',
|
|
38
|
+
".github/workflows/check.yml": CHECK_YML,
|
|
39
|
+
".github/workflows/suites.yml": SUITES_YML,
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe("proposeCiInit", () => {
|
|
43
|
+
test("a fresh workspace gets ci.workflow, an inferred ci.green, environments, a component stub and the generators", () => {
|
|
44
|
+
const root = fresh();
|
|
45
|
+
const p = proposeCiInit(root, { workflow: "trunk-curated" });
|
|
46
|
+
expect(p.files.map((f) => [f.path, f.before === null ? "new" : "edit"])).toEqual([
|
|
47
|
+
["chant.workspace.json", "edit"],
|
|
48
|
+
["api/chant.config.ts", "edit"],
|
|
49
|
+
["api/api.component.ts", "new"],
|
|
50
|
+
]);
|
|
51
|
+
const decl = JSON.parse(p.files[0]!.after);
|
|
52
|
+
expect(decl.ci).toEqual({
|
|
53
|
+
workflow: "trunk-curated",
|
|
54
|
+
green: { branch: "main", phases: { check: ["lint", "test"], suites: ["e2e"] }, require: ["check", "suites"], preMerge: ["check"] },
|
|
55
|
+
});
|
|
56
|
+
expect(decl.security).toBeUndefined();
|
|
57
|
+
expect(p.files[1]!.after).toContain('environments: ["staging", "prod"],');
|
|
58
|
+
expect(p.files[2]!.after).toContain("TODO(chant ci init)");
|
|
59
|
+
expect(p.commands.map((c) => [c.cwd, c.args.join(" ")])).toEqual([
|
|
60
|
+
["api", "build --components --generate github --pr-loop --env staging"],
|
|
61
|
+
[".", "ci workflow"],
|
|
62
|
+
[".", "release workflow --forge github"],
|
|
63
|
+
]);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("apply on merge passes --apply-on-merge, and --security adds the groups", () => {
|
|
67
|
+
const root = fresh();
|
|
68
|
+
const p = proposeCiInit(root, { workflow: "trunk-curated+apply-on-merge", security: ["supply-chain"] });
|
|
69
|
+
expect(JSON.parse(p.files[0]!.after).security).toEqual(["supply-chain"]);
|
|
70
|
+
expect(p.commands[0]!.args).toContain("--apply-on-merge");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("a rerun after the writes and the generators proposes only what is still missing", async () => {
|
|
74
|
+
const root = fresh();
|
|
75
|
+
applyCiInit(root, proposeCiInit(root, { workflow: "trunk-curated" }));
|
|
76
|
+
// What the generators would have written.
|
|
77
|
+
writeFileSync(join(root, ".github/workflows/chant-pr.yml"), "jobs:\n plan:\n steps:\n - run: chant ci run plan --env staging\n record:\n steps:\n - run: chant ci run record --env staging\n");
|
|
78
|
+
writeFileSync(join(root, ".github/workflows/chant-ci-green.yml"), "jobs:\n tick:\n steps:\n - run: npx chant ci tick\n");
|
|
79
|
+
writeFileSync(join(root, ".github/workflows/chant-release.yml"), renderReleaseWorkflow({ forge: "github", groups: new Set() }));
|
|
80
|
+
const again = proposeCiInit(root, { workflow: "trunk-curated" });
|
|
81
|
+
expect(again.files).toEqual([]);
|
|
82
|
+
expect(again.commands).toEqual([]);
|
|
83
|
+
// The stub keeps WSP151 asking.
|
|
84
|
+
const report = await runDeclarationChecks(root, (f) => f, { gather: false });
|
|
85
|
+
expect(report.diagnostics.filter((d) => d.ruleId === "WSP151").map((d) => d.code)).toEqual(["ci-workflow-no-components"]);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("a component someone wrote is never edited; a missing gate is a note", () => {
|
|
89
|
+
const root = fresh();
|
|
90
|
+
writeFileSync(join(root, "api/api.component.ts"), 'export const api = { name: "api", deploy: [] };\n');
|
|
91
|
+
const p = proposeCiInit(root, { workflow: "trunk-curated" });
|
|
92
|
+
expect(p.files.map((f) => f.path)).not.toContain("api/api.component.ts");
|
|
93
|
+
expect(p.notes.some((n) => n.startsWith("api/api.component.ts: add a gate()"))).toBe(true);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("a JSONC declaration is refused, since its comments would be lost", () => {
|
|
97
|
+
const root = repo({ "chant.workspace.jsonc": `// ours\n${DECLARATION}` });
|
|
98
|
+
expect(() => proposeCiInit(root, { workflow: "trunk-curated" })).toThrow(/JSONC/);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("a file that appeared after the proposal is never overwritten", () => {
|
|
102
|
+
const root = fresh();
|
|
103
|
+
const p = proposeCiInit(root, { workflow: "trunk-curated" });
|
|
104
|
+
writeFileSync(join(root, "api/api.component.ts"), "// mine\n");
|
|
105
|
+
expect(() => applyCiInit(root, p)).toThrow(/appeared after the proposal/);
|
|
106
|
+
expect(readFileSync(join(root, "chant.workspace.json"), "utf-8")).toBe(DECLARATION);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe("the pieces", () => {
|
|
111
|
+
test("inferGreen falls back to one check phase with no workflows", () => {
|
|
112
|
+
expect(inferGreen(repo({}))).toEqual({ branch: "main", phases: { check: ["check"] }, require: ["check"], preMerge: ["check"] });
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("withEnvironments adds the order after export default {, and leaves anything else alone", () => {
|
|
116
|
+
expect(withEnvironments("export default {\n lexicons: [],\n};\n")).toBe('export default {\n environments: ["staging", "prod"],\n lexicons: [],\n};\n');
|
|
117
|
+
expect(withEnvironments("export default defineConfig({});\n")).toBeNull();
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("the stub names a gate and the last environment", () => {
|
|
121
|
+
expect(componentStub("api", "prod")).toMatch(/gate\("release", \{ description: "Approve the release for prod\." \}\)/);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("editDeclarationText adds a missing key as text, and rewrites a changed one", () => {
|
|
125
|
+
const text = '{\n "name": "a",\n "members": [{ "name": "x" }]\n}\n';
|
|
126
|
+
const raw = JSON.parse(text);
|
|
127
|
+
expect(editDeclarationText(text, raw, { ...raw, ci: { workflow: "trunk-curated" } })).toBe('{\n "name": "a",\n "members": [{ "name": "x" }],\n "ci": {\n "workflow": "trunk-curated"\n }\n}\n');
|
|
128
|
+
expect(editDeclarationText(text, raw, { ...raw, name: "b" })).toBe(`${JSON.stringify({ ...raw, name: "b" }, null, 2)}\n`);
|
|
129
|
+
expect(editDeclarationText(text, raw, { ...raw })).toBe(text);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("lineDiff marks added and removed lines", () => {
|
|
133
|
+
expect(lineDiff("f", "a\nb\nc\n", "a\nx\nc\n")).toBe("--- a/f\n+++ b/f\n a\n- b\n+ x\n c");
|
|
134
|
+
expect(lineDiff("g", null, "new\n")).toBe("--- /dev/null\n+++ b/g\n+ new");
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
describe("release workflow", () => {
|
|
139
|
+
test("no group: publish with --yes only, and no verify step", () => {
|
|
140
|
+
const text = renderReleaseWorkflow({ forge: "github", groups: new Set() });
|
|
141
|
+
expect(text).toContain('run: npx chant release publish "${GITHUB_REF_NAME#release/}" --yes');
|
|
142
|
+
expect(text).not.toContain("release verify");
|
|
143
|
+
expect(text).toContain("id-token: write");
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("supply-chain requires provenance, verified-releases verifies first", () => {
|
|
147
|
+
const text = renderReleaseWorkflow({ forge: "github", groups: new Set(["supply-chain", "verified-releases"]) });
|
|
148
|
+
expect(text).toContain("--require-provenance --yes");
|
|
149
|
+
expect(text.indexOf("release verify")).toBeLessThan(text.indexOf("chant release publish \""));
|
|
150
|
+
expect(publishFlags(new Set())).toEqual(["--yes"]);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("gitlab writes a job with an ID token, and a nested workspace root cds into it", () => {
|
|
154
|
+
const text = renderReleaseWorkflow({ forge: "gitlab", groups: new Set(), workspaceRoot: "infra", build: null });
|
|
155
|
+
expect(text).toContain("SIGSTORE_ID_TOKEN");
|
|
156
|
+
expect(text).toContain(" - cd infra");
|
|
157
|
+
expect(text).not.toContain("npm run build");
|
|
158
|
+
});
|
|
159
|
+
});
|
|
@@ -0,0 +1,445 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `chant ci init --workflow <name> [--forge <forge>] [--security <group>]...
|
|
3
|
+
* [--yes]` (#4168 step 6, section 9): set a workspace up for a prescribed
|
|
4
|
+
* delivery workflow (../ci-workflow.ts).
|
|
5
|
+
*
|
|
6
|
+
* It proposes, prints the proposal as a diff, and writes only on `--yes` or a
|
|
7
|
+
* yes at the prompt. What it proposes comes from what `workspace check`'s
|
|
8
|
+
* WSP151 reports for the workspace as it would be with `ci.workflow` set, so
|
|
9
|
+
* a second run proposes only what is still missing, and nothing once the
|
|
10
|
+
* workspace has it all.
|
|
11
|
+
*
|
|
12
|
+
* - The declaration gains `ci.workflow`, a `ci.green` block when it has none
|
|
13
|
+
* (one phase per GitHub workflow, its jobs as the check runs, and the
|
|
14
|
+
* workflows a pull request runs as `preMerge`), and `preMerge` when the
|
|
15
|
+
* block has none. `--security` adds groups to the top-level `security`;
|
|
16
|
+
* without it no group is turned on, and the groups are listed.
|
|
17
|
+
* - A member that builds with chant and declares no component gets a
|
|
18
|
+
* `<member>.component.ts` stub with a gate and a TODO line, which WSP151
|
|
19
|
+
* keeps reporting until someone fills the stub in. A `chant.config.ts`
|
|
20
|
+
* that declares no environments gains `environments: ["staging", "prod"]`
|
|
21
|
+
* when its default export is a plain object literal.
|
|
22
|
+
* - The generated files are written by the generators that own them, run
|
|
23
|
+
* after the edits: `chant build --components --generate <forge> --pr-loop`
|
|
24
|
+
* in each member, `chant ci workflow` for the tick, and `chant release
|
|
25
|
+
* workflow` for the publish job. Each is named in the proposal before it
|
|
26
|
+
* runs.
|
|
27
|
+
*
|
|
28
|
+
* It never edits a file the diff did not show, and never overwrites a file
|
|
29
|
+
* it proposed to create that appeared in the meantime.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { spawnSync } from "node:child_process";
|
|
33
|
+
import { existsSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
|
|
34
|
+
import { basename, join } from "node:path";
|
|
35
|
+
import type { CommandContext } from "../cli/registry";
|
|
36
|
+
import { formatError, formatSuccess } from "../cli/format";
|
|
37
|
+
import { parseYAMLDocument } from "../yaml";
|
|
38
|
+
import { CI_WORKFLOW_CHECKS } from "./checks/ci-workflow";
|
|
39
|
+
import type { WorkspaceDiagnostic } from "./checks";
|
|
40
|
+
import { CI_WORKFLOWS, STUB_MARKER, appliesOnMerge } from "./ci-workflow";
|
|
41
|
+
|
|
42
|
+
export { STUB_MARKER };
|
|
43
|
+
import { CI_GREEN_WORKFLOW_FILE, jobCheckRuns, readWorkflowSources, workflowsHolding } from "./ci-green-workflow";
|
|
44
|
+
import { findWorkspaceRoot } from "../project-root";
|
|
45
|
+
import { parseDeclaration, readDeclaration, WorkspaceReadError, type CiWorkflow, type Declaration } from "./declaration";
|
|
46
|
+
import { staticPromotionOrder } from "./promotion-order";
|
|
47
|
+
import { SECURITY_GROUPS, type SecurityGroup } from "./security";
|
|
48
|
+
import { gitTop, workingTree, type WorkspaceTree } from "./tree";
|
|
49
|
+
import { RELEASE_WORKFLOW_FORGES, type ReleaseWorkflowForge } from "./release-workflow";
|
|
50
|
+
|
|
51
|
+
const USAGE = "chant ci init --workflow trunk-curated|trunk-curated+apply-on-merge [--forge github|gitlab|forgejo] [--security <group>]... [--yes]";
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
/** The environments a config with none is given. */
|
|
55
|
+
export const DEFAULT_ENVIRONMENTS = ["staging", "prod"] as const;
|
|
56
|
+
|
|
57
|
+
/** One file the proposal writes: created when `before` is null. */
|
|
58
|
+
export interface ProposedFile {
|
|
59
|
+
path: string;
|
|
60
|
+
before: string | null;
|
|
61
|
+
after: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** A generator the proposal runs after the edits, in `cwd` (relative to the workspace root). */
|
|
65
|
+
export interface ProposedCommand {
|
|
66
|
+
cwd: string;
|
|
67
|
+
args: string[];
|
|
68
|
+
why: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface CiInitProposal {
|
|
72
|
+
files: ProposedFile[];
|
|
73
|
+
commands: ProposedCommand[];
|
|
74
|
+
/** What the proposal leaves to a person, such as a component with no gate. */
|
|
75
|
+
notes: string[];
|
|
76
|
+
/** The WSP151 findings the proposal answers. */
|
|
77
|
+
findings: WorkspaceDiagnostic[];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface CiInitOptions {
|
|
81
|
+
workflow: CiWorkflow;
|
|
82
|
+
forge?: ReleaseWorkflowForge;
|
|
83
|
+
security?: readonly SecurityGroup[];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const memberDir = (dir: string) => (dir === "." ? "" : dir);
|
|
87
|
+
const join2 = (dir: string, file: string) => (dir === "" ? file : `${dir}/${file}`);
|
|
88
|
+
const isObject = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
89
|
+
|
|
90
|
+
/** The forge whose pipeline files the repository has, or null. */
|
|
91
|
+
export function inferForge(repoRoot: string): ReleaseWorkflowForge | null {
|
|
92
|
+
if (existsSync(join(repoRoot, ".github", "workflows"))) return "github";
|
|
93
|
+
if (existsSync(join(repoRoot, ".forgejo", "workflows"))) return "forgejo";
|
|
94
|
+
if (existsSync(join(repoRoot, ".gitlab-ci.yml"))) return "gitlab";
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** The branch `origin/HEAD` names, else `main`. */
|
|
99
|
+
export function inferTrunk(repoRoot: string): string {
|
|
100
|
+
const r = spawnSync("git", ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"], { cwd: repoRoot, encoding: "utf-8" });
|
|
101
|
+
const ref = r.status === 0 ? r.stdout.trim() : "";
|
|
102
|
+
return ref.startsWith("origin/") ? ref.slice("origin/".length) : "main";
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Whether a workflow document runs on a pull request. */
|
|
106
|
+
function onPullRequest(doc: unknown): boolean {
|
|
107
|
+
if (!isObject(doc)) return false;
|
|
108
|
+
const on = doc.on ?? (doc as Record<string, unknown>)["true"];
|
|
109
|
+
if (typeof on === "string") return on === "pull_request" || on === "pull_request_target";
|
|
110
|
+
if (Array.isArray(on)) return on.includes("pull_request") || on.includes("pull_request_target");
|
|
111
|
+
return isObject(on) && ("pull_request" in on || "pull_request_target" in on);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* A `ci.green` block from the repository's GitHub workflows: a phase per
|
|
116
|
+
* workflow holding its jobs' check runs, all required, and those a pull
|
|
117
|
+
* request runs as `preMerge`. chant's own generated workflows are left out.
|
|
118
|
+
*/
|
|
119
|
+
export function inferGreen(repoRoot: string): { branch: string; phases: Record<string, string[]>; require: string[]; preMerge: string[] } {
|
|
120
|
+
const phases: Record<string, string[]> = {};
|
|
121
|
+
const preMerge: string[] = [];
|
|
122
|
+
for (const s of readWorkflowSources(repoRoot, "")) {
|
|
123
|
+
if (/\/chant-[^/]*\.ya?ml$/.test(s.path)) continue;
|
|
124
|
+
let doc: unknown;
|
|
125
|
+
try {
|
|
126
|
+
doc = parseYAMLDocument(s.text);
|
|
127
|
+
} catch {
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
const runs = jobCheckRuns(doc);
|
|
131
|
+
if (runs.length === 0) continue;
|
|
132
|
+
const name = basename(s.path).replace(/\.ya?ml$/, "").replace(/[^A-Za-z0-9_.-]/g, "-").slice(0, 64) || "check";
|
|
133
|
+
phases[name] = runs;
|
|
134
|
+
if (onPullRequest(doc)) preMerge.push(name);
|
|
135
|
+
}
|
|
136
|
+
if (Object.keys(phases).length === 0) {
|
|
137
|
+
phases.check = ["check"];
|
|
138
|
+
preMerge.push("check");
|
|
139
|
+
}
|
|
140
|
+
return { branch: inferTrunk(repoRoot), phases, require: Object.keys(phases), preMerge: preMerge.length > 0 ? preMerge : [Object.keys(phases)[0]!] };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* The declaration's text with `next`'s top-level keys. A key the file lacks
|
|
145
|
+
* is added as text before the closing brace, so the rest keeps its layout; a
|
|
146
|
+
* key it has whose value changes rewrites the file, two-space indented.
|
|
147
|
+
*/
|
|
148
|
+
export function editDeclarationText(text: string, raw: Record<string, unknown>, next: Record<string, unknown>): string {
|
|
149
|
+
const whole = `${JSON.stringify(next, null, 2)}\n`;
|
|
150
|
+
const changed = Object.keys(next).filter((k) => JSON.stringify(raw[k]) !== JSON.stringify(next[k]));
|
|
151
|
+
if (changed.length === 0) return text;
|
|
152
|
+
if (changed.some((k) => k in raw)) return whole;
|
|
153
|
+
const close = text.lastIndexOf("}");
|
|
154
|
+
if (close < 0) return whole;
|
|
155
|
+
const head = text.slice(0, close).replace(/\s*$/, "");
|
|
156
|
+
const added = changed.map((k) => ` ${JSON.stringify(k)}: ${JSON.stringify(next[k], null, 2).replace(/\n/g, "\n ")}`).join(",\n");
|
|
157
|
+
const out = `${head}${head.endsWith("{") ? "" : ","}\n${added}\n${text.slice(close)}`;
|
|
158
|
+
try {
|
|
159
|
+
return JSON.stringify(JSON.parse(out)) === JSON.stringify(next) ? out : whole;
|
|
160
|
+
} catch {
|
|
161
|
+
return whole;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** An identifier for a member's stub export. */
|
|
166
|
+
function exportName(member: string): string {
|
|
167
|
+
const parts = member.split(/[^A-Za-z0-9]+/).filter(Boolean);
|
|
168
|
+
const id = parts.map((p, i) => (i === 0 ? p.toLowerCase() : p[0]!.toUpperCase() + p.slice(1))).join("");
|
|
169
|
+
return /^[A-Za-z_]/.test(id) ? id : `component${id}`;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** The component stub for a member that declares none: a gate and a TODO, so WSP151 keeps asking until it is filled in. */
|
|
173
|
+
export function componentStub(member: string, lastEnvironment: string): string {
|
|
174
|
+
return [
|
|
175
|
+
`// ${STUB_MARKER}: say how ${member} is built and deployed, then delete this line.`,
|
|
176
|
+
"// The gate makes a promote wait on an approval; a release gate decides it",
|
|
177
|
+
`// for each environment, ${lastEnvironment} among them.`,
|
|
178
|
+
"// chant-disable COMP004 -- the release gate: the person promoting approves each environment with chant approve",
|
|
179
|
+
'import { gate, phase, type Component } from "@intentius/chant/components/component";',
|
|
180
|
+
"",
|
|
181
|
+
`export const ${exportName(member)}: Component = {`,
|
|
182
|
+
` name: ${JSON.stringify(member)},`,
|
|
183
|
+
" deploy: [",
|
|
184
|
+
` phase("Release", [gate("release", { description: "Approve the release for ${lastEnvironment}." })]),`,
|
|
185
|
+
" ],",
|
|
186
|
+
"};",
|
|
187
|
+
"",
|
|
188
|
+
].join("\n");
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** The config with `environments` added after `export default {`, or null when its default export is not a plain object literal. */
|
|
192
|
+
export function withEnvironments(config: string, environments: readonly string[] = DEFAULT_ENVIRONMENTS): string | null {
|
|
193
|
+
const m = /export\s+default\s*\{/.exec(config);
|
|
194
|
+
if (!m) return null;
|
|
195
|
+
const at = m.index + m[0].length;
|
|
196
|
+
return `${config.slice(0, at)}\n environments: ${JSON.stringify(environments).replace(/,/g, ", ")},${config.slice(at)}`;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function read(tree: WorkspaceTree, path: string): string | null {
|
|
200
|
+
if (tree.stat(path) !== "file") return null;
|
|
201
|
+
try {
|
|
202
|
+
return tree.read(path);
|
|
203
|
+
} catch {
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Run WSP151 over `declaration` and the tree as it is. */
|
|
209
|
+
function check(declaration: Declaration, tree: WorkspaceTree): WorkspaceDiagnostic[] {
|
|
210
|
+
return CI_WORKFLOW_CHECKS.flatMap((c) => c.check({ declaration, tree, groups: [], kinds: new Map() as never, kindProblems: [] }));
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* What `chant ci init` would write for the workspace at `root`, and the
|
|
215
|
+
* generators it would run. Reads only; {@link applyCiInit} writes.
|
|
216
|
+
*/
|
|
217
|
+
export function proposeCiInit(root: string, options: CiInitOptions): CiInitProposal {
|
|
218
|
+
const tree = workingTree(root);
|
|
219
|
+
const current = readDeclaration(tree);
|
|
220
|
+
if (current.file.endsWith(".jsonc")) {
|
|
221
|
+
throw new WorkspaceReadError("declaration-invalid", `${current.file} is JSONC, which chant ci init does not rewrite, since its comments would be lost; add ci.workflow by hand, then rerun chant ci init for the rest`);
|
|
222
|
+
}
|
|
223
|
+
const repoRoot = gitTop(root) ?? root;
|
|
224
|
+
const text = readFileSync(join(root, current.file), "utf-8");
|
|
225
|
+
const raw = JSON.parse(text) as Record<string, unknown>;
|
|
226
|
+
const ci = isObject(raw.ci) ? { ...raw.ci } : {};
|
|
227
|
+
ci.workflow = options.workflow;
|
|
228
|
+
if (!isObject(ci.green)) ci.green = inferGreen(repoRoot);
|
|
229
|
+
else if (!Array.isArray(ci.green.preMerge) || ci.green.preMerge.length === 0) {
|
|
230
|
+
const inferred = inferGreen(repoRoot).preMerge;
|
|
231
|
+
const declared = Object.keys((ci.green.phases as Record<string, unknown> | undefined) ?? {});
|
|
232
|
+
const pick = inferred.filter((p) => declared.includes(p));
|
|
233
|
+
ci.green = { ...ci.green, preMerge: pick.length > 0 ? pick : declared.slice(0, 1) };
|
|
234
|
+
}
|
|
235
|
+
const next: Record<string, unknown> = { ...raw, ci };
|
|
236
|
+
const security = [...new Set([...((raw.security as SecurityGroup[] | undefined) ?? []), ...(options.security ?? [])])];
|
|
237
|
+
if (security.length > 0) next.security = security;
|
|
238
|
+
const after = editDeclarationText(text, raw, next);
|
|
239
|
+
const proposed = parseDeclaration(after, current.file);
|
|
240
|
+
|
|
241
|
+
const files: ProposedFile[] = [];
|
|
242
|
+
if (after !== text) files.push({ path: current.file, before: text, after });
|
|
243
|
+
const notes: string[] = [];
|
|
244
|
+
const findings = check(proposed, tree);
|
|
245
|
+
const reported = (code: string, entity?: string) => findings.some((f) => f.code === code && (entity === undefined || f.entity === entity));
|
|
246
|
+
|
|
247
|
+
const forge = options.forge ?? inferForge(repoRoot);
|
|
248
|
+
const releasing = proposed.members.filter((m) => (m.ciWorkflow ?? proposed.ci?.workflow) && (m.ciWorkflow !== null || tree.stat(join2(memberDir(m.dir), "chant.config.ts")) === "file"));
|
|
249
|
+
const firstEnv = new Map<string, string>();
|
|
250
|
+
for (const m of releasing) {
|
|
251
|
+
const dir = memberDir(m.dir);
|
|
252
|
+
const configPath = join2(dir, "chant.config.ts");
|
|
253
|
+
const config = read(tree, configPath);
|
|
254
|
+
let order = config === null ? [] : (staticPromotionOrder(config) ?? null);
|
|
255
|
+
if (reported("ci-workflow-no-environments", m.name) && config !== null) {
|
|
256
|
+
const edited = withEnvironments(config);
|
|
257
|
+
if (edited) {
|
|
258
|
+
files.push({ path: configPath, before: config, after: edited });
|
|
259
|
+
order = DEFAULT_ENVIRONMENTS.map((name) => ({ name, canary: false }));
|
|
260
|
+
notes.push(`${configPath}: ${DEFAULT_ENVIRONMENTS.join(", ")} is a guess at the promotion order; edit it if yours differs.`);
|
|
261
|
+
} else {
|
|
262
|
+
notes.push(`${configPath}: add environments, the promotion order; its default export is not a plain object, so chant ci init leaves it alone.`);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
const names = (order ?? []).map((s) => s.name);
|
|
266
|
+
if (names.length > 0) firstEnv.set(m.name, names[0]!);
|
|
267
|
+
if (reported("ci-workflow-no-components", m.name)) {
|
|
268
|
+
const stubPath = join2(dir, `${m.name}.component.ts`);
|
|
269
|
+
if (!tree.stat(stubPath)) files.push({ path: stubPath, before: null, after: componentStub(m.name, names[names.length - 1] ?? "the last environment") });
|
|
270
|
+
}
|
|
271
|
+
for (const f of findings.filter((f) => f.code === "ci-workflow-ungated-env" && f.entity === m.name)) {
|
|
272
|
+
notes.push(`${f.treeFile}: add a gate() step, so a promote to the last environment waits on an approval; chant ci init does not edit a component you wrote.`);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const commands: ProposedCommand[] = [];
|
|
277
|
+
if (forge === null && (reported("ci-workflow-pipeline-missing") || reported("ci-workflow-pipeline-differs") || reported("ci-workflow-publish-missing"))) {
|
|
278
|
+
notes.push("No .github/workflows, .forgejo/workflows or .gitlab-ci.yml was found, so no pipeline is generated; rerun with --forge github|gitlab|forgejo.");
|
|
279
|
+
} else if (forge !== null) {
|
|
280
|
+
if (reported("ci-workflow-pipeline-missing") || reported("ci-workflow-pipeline-differs")) {
|
|
281
|
+
for (const m of releasing) {
|
|
282
|
+
const env = firstEnv.get(m.name);
|
|
283
|
+
commands.push({
|
|
284
|
+
cwd: memberDir(m.dir) || ".",
|
|
285
|
+
// The root member builds alone: at a workspace root, chant build refuses without --root-only (WSP000).
|
|
286
|
+
args: ["build", "--components", "--generate", forge, "--pr-loop", ...(m.dir === "." ? ["--root-only"] : []), ...(env ? ["--env", env] : []), ...(appliesOnMerge(m.ciWorkflow ?? proposed.ci!.workflow!) ? ["--apply-on-merge"] : [])],
|
|
287
|
+
why: `the pull-request pipeline for ${m.name}: plan on a pull request, ${appliesOnMerge(m.ciWorkflow ?? proposed.ci!.workflow!) ? "apply" : "record"} on merge`,
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
if (forge === "github") {
|
|
291
|
+
// ci workflow refuses when no workflow holds a required phase's check run, so say so instead of running it.
|
|
292
|
+
const green = proposed.ci?.green;
|
|
293
|
+
const holding = green ? workflowsHolding(green, readWorkflowSources(repoRoot, CI_GREEN_WORKFLOW_FILE)).workflows : [];
|
|
294
|
+
if (holding.length > 0) commands.push({ cwd: ".", args: ["ci", "workflow"], why: "the tick that tags green commits" });
|
|
295
|
+
else notes.push("No workflow in .github/workflows runs a job of a required ci.green phase on the branch yet, so the tick is not generated; add your CI, name its jobs in ci.green.phases, then rerun chant ci init.");
|
|
296
|
+
}
|
|
297
|
+
else notes.push(`${forge} has no check runs for ci tick to read; set ci.green.source to runs and run chant ci tick from a scheduled job (see the green-commits guide).`);
|
|
298
|
+
}
|
|
299
|
+
if (reported("ci-workflow-publish-missing")) commands.push({ cwd: ".", args: ["release", "workflow", "--forge", forge], why: "the publish job for release/** tags" });
|
|
300
|
+
}
|
|
301
|
+
return { files, commands, notes, findings };
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** A unified-style diff of two texts, for the proposal. Line-based, from their longest common subsequence. */
|
|
305
|
+
export function lineDiff(path: string, before: string | null, after: string): string {
|
|
306
|
+
const a = before === null ? [] : before.replace(/\n$/, "").split("\n");
|
|
307
|
+
const b = after.replace(/\n$/, "").split("\n");
|
|
308
|
+
const n = a.length;
|
|
309
|
+
const m = b.length;
|
|
310
|
+
const lcs: number[][] = Array.from({ length: n + 1 }, () => new Array<number>(m + 1).fill(0));
|
|
311
|
+
for (let i = n - 1; i >= 0; i--) for (let j = m - 1; j >= 0; j--) lcs[i]![j] = a[i] === b[j] ? lcs[i + 1]![j + 1]! + 1 : Math.max(lcs[i + 1]![j]!, lcs[i]![j + 1]!);
|
|
312
|
+
const out = [`--- ${before === null ? "/dev/null" : `a/${path}`}`, `+++ b/${path}`];
|
|
313
|
+
let i = 0;
|
|
314
|
+
let j = 0;
|
|
315
|
+
while (i < n || j < m) {
|
|
316
|
+
if (i < n && j < m && a[i] === b[j]) {
|
|
317
|
+
out.push(` ${a[i]}`);
|
|
318
|
+
i++;
|
|
319
|
+
j++;
|
|
320
|
+
} else if (i < n && (j >= m || lcs[i + 1]![j]! >= lcs[i]![j + 1]!)) out.push(`- ${a[i++]}`);
|
|
321
|
+
else out.push(`+ ${b[j++]}`);
|
|
322
|
+
}
|
|
323
|
+
// Keep three lines of context around each change.
|
|
324
|
+
const keep = new Set<number>();
|
|
325
|
+
out.forEach((l, k) => {
|
|
326
|
+
if (k < 2 || l.startsWith("+ ") || l.startsWith("- ")) for (let d = -3; d <= 3; d++) keep.add(k + d);
|
|
327
|
+
});
|
|
328
|
+
const shown: string[] = [];
|
|
329
|
+
let skipped = false;
|
|
330
|
+
out.forEach((l, k) => {
|
|
331
|
+
if (keep.has(k)) {
|
|
332
|
+
shown.push(l);
|
|
333
|
+
skipped = false;
|
|
334
|
+
} else if (!skipped) {
|
|
335
|
+
shown.push(" ...");
|
|
336
|
+
skipped = true;
|
|
337
|
+
}
|
|
338
|
+
});
|
|
339
|
+
return shown.join("\n");
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/** Write the proposal's files. A file it would create that now exists is refused, and nothing is written. */
|
|
343
|
+
export function applyCiInit(root: string, proposal: CiInitProposal): string[] {
|
|
344
|
+
for (const f of proposal.files) {
|
|
345
|
+
if (f.before === null && existsSync(join(root, f.path))) throw new Error(`${f.path} appeared after the proposal was made; nothing written`);
|
|
346
|
+
if (f.before !== null && readFileSync(join(root, f.path), "utf-8") !== f.before) throw new Error(`${f.path} changed after the proposal was made; nothing written`);
|
|
347
|
+
}
|
|
348
|
+
for (const f of proposal.files) writeFileSync(join(root, f.path), f.after, f.before === null ? { flag: "wx" } : {});
|
|
349
|
+
return proposal.files.map((f) => f.path);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
async function confirm(question: string): Promise<boolean> {
|
|
353
|
+
const { createInterface } = await import("node:readline");
|
|
354
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
355
|
+
return new Promise((resolveAnswer) => {
|
|
356
|
+
rl.question(question, (answer) => {
|
|
357
|
+
resolveAnswer(/^y(es)?$/i.test(answer.trim()));
|
|
358
|
+
rl.close();
|
|
359
|
+
});
|
|
360
|
+
rl.on("close", () => resolveAnswer(false));
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** Run a chant command the way this process was run, so `npx chant` and a source checkout both work. */
|
|
365
|
+
function runChant(cwd: string, args: string[]): number {
|
|
366
|
+
const r = spawnSync(process.execPath, [...process.execArgv, process.argv[1]!, ...args], { cwd, stdio: "inherit" });
|
|
367
|
+
return r.status ?? 1;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
export async function runCiInit(ctx: CommandContext): Promise<number> {
|
|
371
|
+
const { args } = ctx;
|
|
372
|
+
const workflow = (args.ciWorkflows ?? [])[0];
|
|
373
|
+
if (!workflow || !(CI_WORKFLOWS as readonly string[]).includes(workflow) || (args.ciWorkflows ?? []).length > 1) {
|
|
374
|
+
console.error(formatError({ message: `--workflow names one of ${CI_WORKFLOWS.join(", ")}`, hint: USAGE }));
|
|
375
|
+
return 1;
|
|
376
|
+
}
|
|
377
|
+
if (args.forge !== undefined && !(RELEASE_WORKFLOW_FORGES as readonly string[]).includes(args.forge)) {
|
|
378
|
+
console.error(formatError({ message: `--forge ${JSON.stringify(args.forge)} is not one of ${RELEASE_WORKFLOW_FORGES.join(", ")}`, hint: USAGE }));
|
|
379
|
+
return 1;
|
|
380
|
+
}
|
|
381
|
+
const security = args.securityGroups ?? [];
|
|
382
|
+
const unknown = security.filter((g) => !(SECURITY_GROUPS as readonly string[]).includes(g));
|
|
383
|
+
if (unknown.length > 0) {
|
|
384
|
+
console.error(formatError({ message: `--security ${unknown.join(", ")} is not a group; the groups are ${SECURITY_GROUPS.join(", ")}`, hint: USAGE }));
|
|
385
|
+
return 1;
|
|
386
|
+
}
|
|
387
|
+
const found = findWorkspaceRoot(process.cwd());
|
|
388
|
+
if (!found) {
|
|
389
|
+
console.error(formatError({ message: "no chant.workspace.json between this directory and the git root", hint: "Run chant workspace init first, then chant ci init." }));
|
|
390
|
+
return 1;
|
|
391
|
+
}
|
|
392
|
+
const root = realpathSync(found.dir);
|
|
393
|
+
let proposal: CiInitProposal;
|
|
394
|
+
try {
|
|
395
|
+
proposal = proposeCiInit(root, { workflow: workflow as CiWorkflow, ...(args.forge ? { forge: args.forge as ReleaseWorkflowForge } : {}), security: security as SecurityGroup[] });
|
|
396
|
+
} catch (err) {
|
|
397
|
+
console.error(formatError({ message: `chant ci init: ${err instanceof WorkspaceReadError ? err.describe() : (err as Error).message}`, hint: USAGE }));
|
|
398
|
+
return 1;
|
|
399
|
+
}
|
|
400
|
+
if (proposal.files.length === 0 && proposal.commands.length === 0) {
|
|
401
|
+
for (const n of proposal.notes) console.log(n);
|
|
402
|
+
console.log(formatSuccess(`The workspace has what ${workflow} needs; chant workspace check reports anything left.`));
|
|
403
|
+
return 0;
|
|
404
|
+
}
|
|
405
|
+
for (const f of proposal.files) {
|
|
406
|
+
console.log(lineDiff(f.path, f.before, f.after));
|
|
407
|
+
console.log("");
|
|
408
|
+
}
|
|
409
|
+
if (proposal.commands.length > 0) {
|
|
410
|
+
console.log("Then runs:");
|
|
411
|
+
for (const c of proposal.commands) console.log(` (in ${c.cwd}) chant ${c.args.join(" ")} # ${c.why}`);
|
|
412
|
+
console.log("");
|
|
413
|
+
}
|
|
414
|
+
for (const n of proposal.notes) console.log(`Note: ${n}`);
|
|
415
|
+
if (security.length === 0) console.log(`No security group is turned on. Add one later with chant ci init --workflow ${workflow} --security <group>: ${SECURITY_GROUPS.join(", ")}.`);
|
|
416
|
+
console.log("");
|
|
417
|
+
|
|
418
|
+
let write = !!args.yes;
|
|
419
|
+
if (!write) {
|
|
420
|
+
if (!process.stdin.isTTY) {
|
|
421
|
+
console.log("Nothing written. Re-run with --yes to write it.");
|
|
422
|
+
return 0;
|
|
423
|
+
}
|
|
424
|
+
write = await confirm("Write these files and run the generators? [y/N] ");
|
|
425
|
+
}
|
|
426
|
+
if (!write) {
|
|
427
|
+
console.log("Nothing written.");
|
|
428
|
+
return 0;
|
|
429
|
+
}
|
|
430
|
+
try {
|
|
431
|
+
for (const path of applyCiInit(root, proposal)) console.log(`Wrote ${path}`);
|
|
432
|
+
} catch (err) {
|
|
433
|
+
console.error(formatError({ message: `chant ci init: ${(err as Error).message}`, hint: "Rerun chant ci init to see a fresh proposal." }));
|
|
434
|
+
return 1;
|
|
435
|
+
}
|
|
436
|
+
for (const c of proposal.commands) {
|
|
437
|
+
const status = runChant(join(root, c.cwd), c.args);
|
|
438
|
+
if (status !== 0) {
|
|
439
|
+
console.error(formatError({ message: `chant ${c.args.join(" ")} failed in ${c.cwd}`, hint: "Fix what it reports and rerun chant ci init; it proposes only what is still missing." }));
|
|
440
|
+
return 1;
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
console.log(formatSuccess("Done. chant workspace check reports what the workflow still lacks, such as the component stubs to fill in."));
|
|
444
|
+
return 0;
|
|
445
|
+
}
|
|
@@ -104,6 +104,55 @@
|
|
|
104
104
|
}
|
|
105
105
|
]
|
|
106
106
|
},
|
|
107
|
+
"newer": {
|
|
108
|
+
"type": "array",
|
|
109
|
+
"description": "Added within contract version 1 by #4169, written by chant 0.124.33 and newer. The first-parent commits walked past, newest first and above the one found, each with what its _ci/<sha>.json record on the local chant/lifecycle says (null when it has none), so a reader can say why the newest commits are not green. At most 50.",
|
|
110
|
+
"items": {
|
|
111
|
+
"type": "object",
|
|
112
|
+
"required": [
|
|
113
|
+
"sha",
|
|
114
|
+
"record"
|
|
115
|
+
],
|
|
116
|
+
"properties": {
|
|
117
|
+
"sha": {
|
|
118
|
+
"type": "string",
|
|
119
|
+
"pattern": "^[0-9a-f]{40,64}$"
|
|
120
|
+
},
|
|
121
|
+
"record": {
|
|
122
|
+
"oneOf": [
|
|
123
|
+
{
|
|
124
|
+
"type": "null"
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
"type": "object",
|
|
128
|
+
"required": [
|
|
129
|
+
"state"
|
|
130
|
+
],
|
|
131
|
+
"properties": {
|
|
132
|
+
"state": {
|
|
133
|
+
"enum": [
|
|
134
|
+
"green",
|
|
135
|
+
"red",
|
|
136
|
+
"unbuilt",
|
|
137
|
+
"pending"
|
|
138
|
+
]
|
|
139
|
+
},
|
|
140
|
+
"reason": {
|
|
141
|
+
"type": "string"
|
|
142
|
+
},
|
|
143
|
+
"coveredBy": {
|
|
144
|
+
"type": "string"
|
|
145
|
+
},
|
|
146
|
+
"culprit": {
|
|
147
|
+
"type": "string"
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
]
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
},
|
|
107
156
|
"ignored": {
|
|
108
157
|
"type": "array",
|
|
109
158
|
"description": "Added within contract version 1 by #4031, written by chant 0.124.28 and newer. The tags on the commits walked, newest first and down to the one found, that do not count for want of a valid statement. Empty when ci.green.signedSince is not set.",
|