@intentius/chant 0.124.34 → 0.124.36
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/cli/handlers/graph.js +2 -2
- package/dist/cli/handlers/pr.d.ts.map +1 -1
- package/dist/cli/handlers/pr.js +147 -51
- package/dist/cli/handlers/promote.d.ts.map +1 -1
- package/dist/cli/handlers/promote.js +23 -8
- package/dist/cli/handlers/release.d.ts +12 -0
- package/dist/cli/handlers/release.d.ts.map +1 -1
- package/dist/cli/handlers/release.js +125 -10
- package/dist/cli/handlers/run.d.ts +3 -1
- package/dist/cli/handlers/run.d.ts.map +1 -1
- package/dist/cli/handlers/run.js +12 -2
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/main.js +19 -1
- package/dist/cli/parse-args.d.ts.map +1 -1
- package/dist/cli/parse-args.js +17 -0
- package/dist/cli/registry.d.ts +14 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/components/auto-release.d.ts +8 -1
- package/dist/components/auto-release.d.ts.map +1 -1
- package/dist/components/auto-release.js +3 -2
- package/dist/components/green-cover.d.ts +77 -0
- package/dist/components/green-cover.d.ts.map +1 -0
- package/dist/components/green-cover.js +153 -0
- package/dist/components/pr-pipeline.d.ts +34 -0
- package/dist/components/pr-pipeline.d.ts.map +1 -1
- package/dist/components/pr-pipeline.js +39 -5
- package/dist/components/promote.d.ts +4 -1
- package/dist/components/promote.d.ts.map +1 -1
- package/dist/components/promote.js +6 -3
- package/dist/components/release-promote.d.ts +9 -0
- package/dist/components/release-promote.d.ts.map +1 -1
- package/dist/components/release-promote.js +15 -0
- package/dist/config.d.ts +3 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/deep-observation.d.ts +11 -0
- package/dist/deep-observation.d.ts.map +1 -1
- package/dist/graph-declared.d.ts +5 -1
- package/dist/graph-declared.d.ts.map +1 -1
- package/dist/graph-declared.js +7 -2
- package/dist/graph-detail.d.ts.map +1 -1
- package/dist/graph-detail.js +30 -1
- package/dist/graph-ir.d.ts.map +1 -1
- package/dist/graph-ir.js +13 -2
- package/dist/lifecycle/__fixtures__/publish-fakes.d.ts +5 -0
- package/dist/lifecycle/__fixtures__/publish-fakes.d.ts.map +1 -1
- package/dist/lifecycle/__fixtures__/publish-fakes.js +20 -2
- package/dist/lifecycle/compose-refs.d.ts +52 -0
- package/dist/lifecycle/compose-refs.d.ts.map +1 -0
- package/dist/lifecycle/compose-refs.js +115 -0
- package/dist/lifecycle/deep-observe.d.ts.map +1 -1
- package/dist/lifecycle/deep-observe.js +3 -2
- package/dist/lifecycle/green-apply.d.ts +39 -0
- package/dist/lifecycle/green-apply.d.ts.map +1 -0
- package/dist/lifecycle/green-apply.js +51 -0
- package/dist/lifecycle/publish-declaration.d.ts +6 -0
- package/dist/lifecycle/publish-declaration.d.ts.map +1 -1
- package/dist/lifecycle/publish-declaration.js +21 -0
- package/dist/lifecycle/publish-npm.d.ts +11 -4
- package/dist/lifecycle/publish-npm.d.ts.map +1 -1
- package/dist/lifecycle/publish-npm.js +104 -14
- package/dist/lifecycle/release-compose.d.ts +91 -0
- package/dist/lifecycle/release-compose.d.ts.map +1 -0
- package/dist/lifecycle/release-compose.js +235 -0
- package/dist/lifecycle/release-cut.d.ts +8 -0
- package/dist/lifecycle/release-cut.d.ts.map +1 -1
- package/dist/lifecycle/release-cut.js +2 -0
- package/dist/lifecycle/release-publish.d.ts +6 -0
- package/dist/lifecycle/release-publish.d.ts.map +1 -1
- package/dist/lifecycle/release-publish.js +2 -1
- package/dist/lifecycle/release-store.d.ts +15 -0
- package/dist/lifecycle/release-store.d.ts.map +1 -1
- package/dist/lifecycle/release-store.js +5 -1
- package/dist/wave-runtime.mjs +225 -223
- package/dist/workspace/checks/ci-workflow.d.ts.map +1 -1
- package/dist/workspace/checks/ci-workflow.js +8 -1
- package/dist/workspace/ci-green-cli.d.ts.map +1 -1
- package/dist/workspace/ci-green-cli.js +5 -3
- package/dist/workspace/ci-green-workflow.d.ts +18 -2
- package/dist/workspace/ci-green-workflow.d.ts.map +1 -1
- package/dist/workspace/ci-green-workflow.js +27 -5
- package/dist/workspace/ci-green.d.ts +31 -0
- package/dist/workspace/ci-green.d.ts.map +1 -1
- package/dist/workspace/ci-green.js +78 -4
- package/dist/workspace/ci-run.d.ts +6 -0
- package/dist/workspace/ci-run.d.ts.map +1 -1
- package/dist/workspace/ci-run.js +2 -1
- package/dist/workspace/ci-serve.d.ts +94 -13
- package/dist/workspace/ci-serve.d.ts.map +1 -1
- package/dist/workspace/ci-serve.js +248 -30
- package/dist/workspace/ci-workflow.d.ts +4 -4
- package/dist/workspace/ci-workflow.d.ts.map +1 -1
- package/dist/workspace/ci-workflow.js +2 -2
- package/dist/workspace/delivery-metrics.d.ts +3 -1
- package/dist/workspace/delivery-metrics.d.ts.map +1 -1
- package/dist/workspace/delivery-metrics.js +12 -3
- package/dist/workspace/member-commands.d.ts.map +1 -1
- package/dist/workspace/member-commands.js +4 -0
- package/dist/workspace/nested-graph.d.ts.map +1 -1
- package/dist/workspace/nested-graph.js +1 -0
- package/dist/workspace/release-candidates.d.ts +28 -0
- package/dist/workspace/release-candidates.d.ts.map +1 -1
- package/dist/workspace/release-candidates.js +38 -2
- package/dist/workspace/release-candidates.schema.json +51 -0
- package/dist/workspace/releases.schema.json +30 -0
- package/package.json +1 -1
- package/src/cli/handlers/graph.ts +2 -2
- package/src/cli/handlers/pr-green.test.ts +230 -0
- package/src/cli/handlers/pr.ts +148 -66
- package/src/cli/handlers/promote.test.ts +34 -0
- package/src/cli/handlers/promote.ts +22 -8
- package/src/cli/handlers/release-compose.e2e.test.ts +133 -0
- package/src/cli/handlers/release-publish-targets.e2e.test.ts +4 -1
- package/src/cli/handlers/release.ts +112 -10
- package/src/cli/handlers/run.ts +12 -1
- package/src/cli/main.ts +19 -1
- package/src/cli/parse-args.ts +13 -0
- package/src/cli/registry.ts +14 -0
- package/src/codegen/release-wiring.test.ts +56 -9
- package/src/components/auto-release.test.ts +22 -0
- package/src/components/auto-release.ts +9 -2
- package/src/components/green-cover.test.ts +170 -0
- package/src/components/green-cover.ts +186 -0
- package/src/components/pr-pipeline.test.ts +14 -4
- package/src/components/pr-pipeline.ts +56 -5
- package/src/components/promote.test.ts +7 -0
- package/src/components/promote.ts +9 -4
- package/src/components/release-promote.test.ts +24 -1
- package/src/components/release-promote.ts +15 -0
- package/src/deep-observation.ts +8 -0
- package/src/graph-declared.test.ts +18 -0
- package/src/graph-declared.ts +8 -1
- package/src/graph-detail.test.ts +68 -1
- package/src/graph-detail.ts +29 -1
- package/src/graph-ir.ts +12 -4
- package/src/lifecycle/__fixtures__/publish-fakes.ts +20 -2
- package/src/lifecycle/compose-refs.ts +125 -0
- package/src/lifecycle/deep-observe.test.ts +20 -0
- package/src/lifecycle/deep-observe.ts +4 -2
- package/src/lifecycle/green-apply.ts +71 -0
- package/src/lifecycle/publish-declaration.ts +21 -0
- package/src/lifecycle/publish-npm.ts +100 -14
- package/src/lifecycle/publish-targets.test.ts +108 -0
- package/src/lifecycle/release-compose.test.ts +125 -0
- package/src/lifecycle/release-compose.ts +312 -0
- package/src/lifecycle/release-cut.ts +6 -0
- package/src/lifecycle/release-publish.ts +8 -1
- package/src/lifecycle/release-store.ts +16 -1
- package/src/workspace/checks/ci-workflow.test.ts +12 -2
- package/src/workspace/checks/ci-workflow.ts +7 -1
- package/src/workspace/ci-green-cli.ts +5 -3
- package/src/workspace/ci-green-workflow.test.ts +52 -0
- package/src/workspace/ci-green-workflow.ts +34 -5
- package/src/workspace/ci-green.test.ts +33 -0
- package/src/workspace/ci-green.ts +85 -4
- package/src/workspace/ci-run.test.ts +18 -13
- package/src/workspace/ci-run.ts +8 -1
- package/src/workspace/ci-serve-land.e2e.test.ts +138 -0
- package/src/workspace/ci-serve-land.test.ts +176 -0
- package/src/workspace/ci-serve.test.ts +77 -43
- package/src/workspace/ci-serve.ts +268 -34
- package/src/workspace/ci-workflow.ts +4 -4
- package/src/workspace/delivery-metrics.ts +12 -3
- package/src/workspace/graph-contract.test.ts +3 -2
- package/src/workspace/member-commands.test.ts +11 -4
- package/src/workspace/member-commands.ts +3 -0
- package/src/workspace/nested-graph.test.ts +4 -0
- package/src/workspace/nested-graph.ts +1 -0
- package/src/workspace/release-candidates.schema.json +51 -0
- package/src/workspace/release-candidates.test.ts +30 -3
- package/src/workspace/release-candidates.ts +54 -2
- package/src/workspace/releases.schema.json +30 -0
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `release compose` (#4168 4.3): chosen trunk commits cherry-picked onto a
|
|
3
|
+
* base, each with a `Chant-Composed-From` trailer, on a pushed
|
|
4
|
+
* `chant/compose/<name>` branch, appended to and never rewritten.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { describe, test, expect } from "vitest";
|
|
8
|
+
import { withTestDir } from "@intentius/chant-test-utils";
|
|
9
|
+
import { spawnSync } from "node:child_process";
|
|
10
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import { composeRelease, ComposeError } from "./release-compose";
|
|
13
|
+
import { composeBranchContaining, composedFrom, composedIncluding, compositionOf } from "./compose-refs";
|
|
14
|
+
|
|
15
|
+
function git(dir: string, ...args: string[]): string {
|
|
16
|
+
const r = spawnSync("git", args, { cwd: dir, encoding: "utf-8" });
|
|
17
|
+
if (r.status !== 0) throw new Error(r.stderr);
|
|
18
|
+
return r.stdout.trim();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** A clone of a bare remote with trunk: base, then #1 (a.txt), #2 (b.txt, work w-2), #3 (a.txt again). */
|
|
22
|
+
function trunk(dir: string): { clone: string; remote: string; base: string; c1: string; c2: string; c3: string } {
|
|
23
|
+
const remote = join(dir, "remote.git");
|
|
24
|
+
const clone = join(dir, "clone");
|
|
25
|
+
git(dir, "init", "-q", "--bare", "-b", "main", remote);
|
|
26
|
+
mkdirSync(clone);
|
|
27
|
+
for (const args of [["init", "-q", "-b", "main"], ["config", "user.email", "t@chant.dev"], ["config", "user.name", "T"], ["remote", "add", "origin", remote]]) git(clone, ...args);
|
|
28
|
+
const commit = (file: string, text: string, message: string): string => {
|
|
29
|
+
writeFileSync(join(clone, file), text);
|
|
30
|
+
git(clone, "add", file);
|
|
31
|
+
git(clone, "commit", "-q", "-m", message);
|
|
32
|
+
return git(clone, "rev-parse", "HEAD");
|
|
33
|
+
};
|
|
34
|
+
const base = commit("a.txt", "a0\n", "base");
|
|
35
|
+
const c1 = commit("a.txt", "a1\n", "fix a (#1)");
|
|
36
|
+
const c2 = commit("b.txt", "b\n", "add b (#2)\n\nChant-Record: work:w-2");
|
|
37
|
+
const c3 = commit("a.txt", "a3\n", "rework a (#3)");
|
|
38
|
+
git(clone, "push", "-q", "origin", "main");
|
|
39
|
+
git(clone, "fetch", "-q", "origin");
|
|
40
|
+
return { clone, remote, base, c1, c2, c3 };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const TRUNK = { remote: "origin", branch: "main" };
|
|
44
|
+
|
|
45
|
+
describe("composeRelease", () => {
|
|
46
|
+
test("picks the chosen changes in trunk order onto the base, with trailers, and pushes the branch", async () => {
|
|
47
|
+
await withTestDir(async (dir) => {
|
|
48
|
+
const t = trunk(dir);
|
|
49
|
+
const dry = await composeRelease({ cwd: t.clone, name: "hotfix", base: t.base, prs: [], works: ["w-2"], yes: false, trunk: TRUNK });
|
|
50
|
+
expect(dry.written).toBe(false);
|
|
51
|
+
expect(dry.picked.map((s) => s.commit)).toEqual([t.c2]);
|
|
52
|
+
expect(git(t.clone, "branch", "--list", "chant/compose/hotfix")).toBe("");
|
|
53
|
+
|
|
54
|
+
const r = await composeRelease({ cwd: t.clone, name: "hotfix", base: t.base, prs: [2, 1], works: [], yes: true, trunk: TRUNK });
|
|
55
|
+
expect(r.picked.map((s) => s.commit)).toEqual([t.c1, t.c2]);
|
|
56
|
+
expect(r.picked[1].work).toEqual(["w-2"]);
|
|
57
|
+
expect(r.written).toBe(true);
|
|
58
|
+
expect(r.pushed).toBe(true);
|
|
59
|
+
expect(git(t.remote, "rev-parse", "refs/heads/chant/compose/hotfix")).toBe(r.tip);
|
|
60
|
+
expect(composedFrom(t.clone, r.tip)).toEqual([t.c2]);
|
|
61
|
+
expect(compositionOf(t.clone, r.tip)).toEqual({ branch: null, base: t.base, sources: [t.c1, t.c2] });
|
|
62
|
+
expect(composeBranchContaining(t.clone, r.tip)?.branch).toBe("chant/compose/hotfix");
|
|
63
|
+
expect(composedIncluding(t.clone, t.c1)).toEqual([{ branch: "chant/compose/hotfix", commit: git(t.clone, "rev-parse", `${r.tip}^`) }]);
|
|
64
|
+
expect(git(t.clone, "show", `${r.tip}:a.txt`)).toBe("a1");
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("appends to the branch, skips what it carries, and refuses another base", async () => {
|
|
69
|
+
await withTestDir(async (dir) => {
|
|
70
|
+
const t = trunk(dir);
|
|
71
|
+
const first = await composeRelease({ cwd: t.clone, name: "x", base: t.base, prs: [1], works: [], yes: true, trunk: TRUNK });
|
|
72
|
+
const again = await composeRelease({ cwd: t.clone, name: "x", prs: [1, 2], works: [], yes: true, trunk: TRUNK });
|
|
73
|
+
expect(again.from).toBe(first.tip);
|
|
74
|
+
expect(again.already.map((s) => s.commit)).toEqual([t.c1]);
|
|
75
|
+
expect(again.picked.map((s) => s.commit)).toEqual([t.c2]);
|
|
76
|
+
expect(git(t.clone, "rev-parse", `${again.tip}^`)).toBe(first.tip);
|
|
77
|
+
await expect(composeRelease({ cwd: t.clone, name: "x", base: t.c1, prs: [3], works: [], yes: true, trunk: TRUNK })).rejects.toMatchObject({ code: "compose-base-differs" });
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("stops on a conflict, names the change and the file, and writes nothing", async () => {
|
|
82
|
+
await withTestDir(async (dir) => {
|
|
83
|
+
const t = trunk(dir);
|
|
84
|
+
const err = await composeRelease({ cwd: t.clone, name: "y", base: t.base, prs: [3], works: [], yes: true, trunk: TRUNK }).catch((e: unknown) => e);
|
|
85
|
+
expect(err).toBeInstanceOf(ComposeError);
|
|
86
|
+
expect((err as ComposeError).code).toBe("compose-conflict");
|
|
87
|
+
expect((err as ComposeError).conflict).toMatchObject({ commit: t.c3, files: ["a.txt"] });
|
|
88
|
+
expect((err as ComposeError).message).toContain("#3");
|
|
89
|
+
expect(git(t.clone, "branch", "--list", "chant/compose/y")).toBe("");
|
|
90
|
+
expect(spawnSync("git", ["rev-parse", "--verify", "--quiet", "refs/heads/chant/compose/y"], { cwd: t.remote }).status).not.toBe(0);
|
|
91
|
+
expect(git(t.clone, "worktree", "list").split("\n")).toHaveLength(1);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("refuses a change trunk does not have, a bad name, and no base for a new branch", async () => {
|
|
96
|
+
await withTestDir(async (dir) => {
|
|
97
|
+
const t = trunk(dir);
|
|
98
|
+
await expect(composeRelease({ cwd: t.clone, name: "z", base: t.base, prs: [9], works: [], yes: false, trunk: TRUNK })).rejects.toMatchObject({ code: "compose-source-unknown" });
|
|
99
|
+
await expect(composeRelease({ cwd: t.clone, name: "../z", base: t.base, prs: [1], works: [], yes: false, trunk: TRUNK })).rejects.toMatchObject({ code: "compose-name-invalid" });
|
|
100
|
+
await expect(composeRelease({ cwd: t.clone, name: "z", prs: [1], works: [], yes: false, trunk: TRUNK })).rejects.toMatchObject({ code: "compose-base-unknown" });
|
|
101
|
+
await expect(composeRelease({ cwd: t.clone, name: "z", base: t.base, prs: [], works: [], yes: false, trunk: TRUNK })).rejects.toMatchObject({ code: "compose-nothing-chosen" });
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe("composeRelease after a hand-made commit", () => {
|
|
107
|
+
test("keeps the branch's base when its tip carries no trailer", async () => {
|
|
108
|
+
await withTestDir(async (dir) => {
|
|
109
|
+
const t = trunk(dir);
|
|
110
|
+
const first = await composeRelease({ cwd: t.clone, name: "h", base: t.base, prs: [1], works: [], yes: true, trunk: TRUNK });
|
|
111
|
+
// A person resolves something on the branch and pushes it.
|
|
112
|
+
git(t.clone, "checkout", "-q", "chant/compose/h");
|
|
113
|
+
writeFileSync(join(t.clone, "c.txt"), "by hand\n");
|
|
114
|
+
git(t.clone, "add", "c.txt");
|
|
115
|
+
git(t.clone, "commit", "-q", "-m", "by hand");
|
|
116
|
+
git(t.clone, "push", "-q", "origin", "chant/compose/h");
|
|
117
|
+
git(t.clone, "checkout", "-q", "main");
|
|
118
|
+
const next = await composeRelease({ cwd: t.clone, name: "h", base: t.base, prs: [1, 2], works: [], yes: true, trunk: TRUNK });
|
|
119
|
+
expect(next.base).toBe(t.base);
|
|
120
|
+
expect(next.already.map((s) => s.commit)).toEqual([t.c1]);
|
|
121
|
+
expect(next.picked.map((s) => s.commit)).toEqual([t.c2]);
|
|
122
|
+
expect(git(t.clone, "rev-parse", `${next.tip}~2`)).toBe(first.tip);
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
});
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `chant release compose` (#4168 4.3, mode b): a release of chosen changes
|
|
3
|
+
* on a base, when trunk carries work that is not ready to ship.
|
|
4
|
+
*
|
|
5
|
+
* The changes are named by pull request (`--pr <n>`) or work item
|
|
6
|
+
* (`--work <id>`) and found on trunk's first-parent line after the base:
|
|
7
|
+
* a pull request by the merge record `_merges/<sha>.json` on
|
|
8
|
+
* `chant/lifecycle` (./merge-record.ts) or the squash subject `... (#n)`,
|
|
9
|
+
* a work item by the commit's `Chant-Record: work:<id>` trailers or a merge
|
|
10
|
+
* record that names it. Each commit is cherry-picked in trunk order onto the
|
|
11
|
+
* base, in a temporary detached worktree, with a `Chant-Composed-From: <sha>`
|
|
12
|
+
* trailer, and the result goes on the branch `chant/compose/<name>`, pushed
|
|
13
|
+
* so the forge's CI builds it (decision 6).
|
|
14
|
+
*
|
|
15
|
+
* It only appends. A branch that exists is composed onto from its tip, with
|
|
16
|
+
* the base it already has; a change it already carries is skipped. On a
|
|
17
|
+
* conflict it stops, names the change and the files, and writes nothing: a
|
|
18
|
+
* person resolves it in a pull request to the compose branch.
|
|
19
|
+
*
|
|
20
|
+
* Without `yes` it is a dry run: the picks are made in the temporary
|
|
21
|
+
* worktree, so a conflict shows now, and no ref is moved or pushed.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { execFileSync, spawnSync } from "node:child_process";
|
|
25
|
+
import { mkdtempSync, realpathSync, rmSync } from "node:fs";
|
|
26
|
+
import { tmpdir } from "node:os";
|
|
27
|
+
import { join } from "node:path";
|
|
28
|
+
import { COMPOSE_BRANCH_PREFIX, COMPOSED_FROM_TRAILER, compositionOnTrunk } from "./compose-refs";
|
|
29
|
+
|
|
30
|
+
export type ComposeCode =
|
|
31
|
+
| "not-a-git-repository"
|
|
32
|
+
| "compose-name-invalid"
|
|
33
|
+
| "compose-nothing-chosen"
|
|
34
|
+
| "compose-base-unknown"
|
|
35
|
+
| "compose-base-differs"
|
|
36
|
+
| "compose-branch-diverged"
|
|
37
|
+
| "compose-source-unknown"
|
|
38
|
+
| "compose-conflict"
|
|
39
|
+
| "compose-push-refused";
|
|
40
|
+
|
|
41
|
+
export class ComposeError extends Error {
|
|
42
|
+
constructor(
|
|
43
|
+
readonly code: ComposeCode,
|
|
44
|
+
message: string,
|
|
45
|
+
readonly fix?: string,
|
|
46
|
+
/** With `compose-conflict`: the change that did not apply and the files it conflicts in. */
|
|
47
|
+
readonly conflict?: { commit: string; subject: string; files: string[] },
|
|
48
|
+
) {
|
|
49
|
+
super(message);
|
|
50
|
+
this.name = "ComposeError";
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface ComposeRequest {
|
|
55
|
+
cwd: string;
|
|
56
|
+
name: string;
|
|
57
|
+
/** A release label or digest, or a commit. Required when the branch does not exist yet. */
|
|
58
|
+
base?: string;
|
|
59
|
+
prs: readonly number[];
|
|
60
|
+
works: readonly string[];
|
|
61
|
+
/** Move the branch and push it. Otherwise a dry run. */
|
|
62
|
+
yes: boolean;
|
|
63
|
+
/** The trunk branch, `ci.green.branch` as the cut resolves it. */
|
|
64
|
+
trunk: { remote: string; branch: string };
|
|
65
|
+
/** Push the branch to the trunk's remote with `yes`. Default true. */
|
|
66
|
+
push?: boolean;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** One trunk commit a compose picks, and why. */
|
|
70
|
+
export interface ComposeSource {
|
|
71
|
+
commit: string;
|
|
72
|
+
subject: string;
|
|
73
|
+
pr: number | null;
|
|
74
|
+
work: string[];
|
|
75
|
+
/** The `--pr` and `--work` values that chose it. */
|
|
76
|
+
chosenBy: string[];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface ComposeResult {
|
|
80
|
+
name: string;
|
|
81
|
+
branch: string;
|
|
82
|
+
base: string;
|
|
83
|
+
/** The branch's tip before the compose, or null when it is new. */
|
|
84
|
+
from: string | null;
|
|
85
|
+
/** The commits picked now, in trunk order. */
|
|
86
|
+
picked: ComposeSource[];
|
|
87
|
+
/** Chosen commits the branch already carries. */
|
|
88
|
+
already: ComposeSource[];
|
|
89
|
+
/** The branch's tip after the picks. Under a dry run, the tip it would have. */
|
|
90
|
+
tip: string;
|
|
91
|
+
/** Whether the branch was moved here. False under a dry run. */
|
|
92
|
+
written: boolean;
|
|
93
|
+
/** Whether it was pushed. */
|
|
94
|
+
pushed: boolean;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const NAME = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
98
|
+
const PR_IN_SUBJECT = /\(#(\d+)\)\s*$/;
|
|
99
|
+
|
|
100
|
+
function git(cwd: string, args: string[], env?: NodeJS.ProcessEnv): string {
|
|
101
|
+
return execFileSync("git", args, { cwd, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"], maxBuffer: 64 * 1024 * 1024, ...(env ? { env } : {}) }).trim();
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function tryGit(cwd: string, args: string[]): string | null {
|
|
105
|
+
try {
|
|
106
|
+
return git(cwd, args);
|
|
107
|
+
} catch {
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const isAncestor = (top: string, a: string, b: string): boolean => spawnSync("git", ["merge-base", "--is-ancestor", a, b], { cwd: top, stdio: "ignore" }).status === 0;
|
|
113
|
+
|
|
114
|
+
/** The branch a compose name lives on. */
|
|
115
|
+
export function composeBranchName(name: string): string {
|
|
116
|
+
return `${COMPOSE_BRANCH_PREFIX}${name}`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function resolveBase(top: string, cwd: string, ref: string): Promise<string> {
|
|
120
|
+
const { resolveRelease } = await import("./release-store");
|
|
121
|
+
const found = await resolveRelease(ref, { cwd }).catch(() => null);
|
|
122
|
+
if (found) return found.release.commit;
|
|
123
|
+
const sha = tryGit(top, ["rev-parse", "--verify", "--quiet", `${ref}^{commit}`]);
|
|
124
|
+
if (!sha) {
|
|
125
|
+
throw new ComposeError("compose-base-unknown", `--base ${ref} names no release of this member and no commit this clone has`, "Name a release label (chant release list) or a commit, after fetching it.");
|
|
126
|
+
}
|
|
127
|
+
return sha;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** The merge record of `sha`, as stored, or null. */
|
|
131
|
+
async function mergeRecordOf(cwd: string, sha: string): Promise<Record<string, unknown> | null> {
|
|
132
|
+
const { readBlobFromPath } = await import("./git");
|
|
133
|
+
const text = await readBlobFromPath("_merges", `${sha}.json`, { cwd }).catch(() => null);
|
|
134
|
+
if (text === null) return null;
|
|
135
|
+
try {
|
|
136
|
+
const parsed = JSON.parse(text) as unknown;
|
|
137
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? (parsed as Record<string, unknown>) : null;
|
|
138
|
+
} catch {
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** The pull request and work items a merge record names. */
|
|
144
|
+
function fromMergeRecord(merge: Record<string, unknown> | null): { pr: number | null; work: string[] } {
|
|
145
|
+
if (!merge) return { pr: null, work: [] };
|
|
146
|
+
const source = merge.source as { kind?: unknown; pr?: unknown; records?: unknown } | undefined;
|
|
147
|
+
const pr = source?.kind === "pull-request" && typeof source.pr === "number" ? source.pr : typeof merge.pr === "number" ? merge.pr : null;
|
|
148
|
+
const work =
|
|
149
|
+
source?.kind === "work-item" && Array.isArray(source.records)
|
|
150
|
+
? source.records.filter((r): r is { kind: string; id: string } => !!r && typeof r === "object" && (r as { kind?: unknown }).kind === "work").map((r) => r.id)
|
|
151
|
+
: [];
|
|
152
|
+
return { pr, work };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Compose the chosen changes onto the base, on `chant/compose/<name>`.
|
|
157
|
+
* Throws {@link ComposeError} before any ref moves.
|
|
158
|
+
*/
|
|
159
|
+
export async function composeRelease(req: ComposeRequest): Promise<ComposeResult> {
|
|
160
|
+
const top = tryGit(req.cwd, ["rev-parse", "--show-toplevel"]);
|
|
161
|
+
if (!top) throw new ComposeError("not-a-git-repository", "a compose picks commits, and this directory is not in a git repository");
|
|
162
|
+
if (!NAME.test(req.name)) {
|
|
163
|
+
throw new ComposeError("compose-name-invalid", `"${req.name}" can't name a compose branch: use letters, digits, dots, dashes and underscores, starting with a letter or digit`);
|
|
164
|
+
}
|
|
165
|
+
if (req.prs.length === 0 && req.works.length === 0) {
|
|
166
|
+
throw new ComposeError("compose-nothing-chosen", "a compose needs the changes it carries", "Name them: --pr <n> or --work <id>, each as often as needed.");
|
|
167
|
+
}
|
|
168
|
+
const { remote } = req.trunk;
|
|
169
|
+
const branch = composeBranchName(req.name);
|
|
170
|
+
const remoteRef = `refs/remotes/${remote}/${branch}`;
|
|
171
|
+
const localRef = `refs/heads/${branch}`;
|
|
172
|
+
// The branch as the remote has it, when the remote is reachable; a new branch is absent there.
|
|
173
|
+
if (tryGit(top, ["remote", "get-url", remote]) !== null) tryGit(top, ["fetch", "--quiet", "--no-tags", remote, `+${localRef}:${remoteRef}`]);
|
|
174
|
+
const remoteTip = tryGit(top, ["rev-parse", "--verify", "--quiet", `${remoteRef}^{commit}`]);
|
|
175
|
+
const localTip = tryGit(top, ["rev-parse", "--verify", "--quiet", `${localRef}^{commit}`]);
|
|
176
|
+
if (remoteTip && localTip && !isAncestor(top, localTip, remoteTip) && !isAncestor(top, remoteTip, localTip)) {
|
|
177
|
+
throw new ComposeError(
|
|
178
|
+
"compose-branch-diverged",
|
|
179
|
+
`${branch} here and on ${remote} have diverged, and a compose only appends`,
|
|
180
|
+
`Keep the remote's: git branch -f ${branch} ${remote}/${branch}`,
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
const from = remoteTip && localTip ? (isAncestor(top, localTip, remoteTip) ? remoteTip : localTip) : (remoteTip ?? localTip);
|
|
184
|
+
const trunkRef =
|
|
185
|
+
tryGit(top, ["rev-parse", "--verify", "--quiet", `refs/remotes/${remote}/${req.trunk.branch}^{commit}`]) ??
|
|
186
|
+
tryGit(top, ["rev-parse", "--verify", "--quiet", `refs/heads/${req.trunk.branch}^{commit}`]);
|
|
187
|
+
if (!trunkRef) throw new ComposeError("compose-source-unknown", `this clone has neither ${remote}/${req.trunk.branch} nor ${req.trunk.branch}, so there is no trunk to pick from`);
|
|
188
|
+
// What the branch carries: back to trunk, past a commit a person made on it, such as a resolved conflict.
|
|
189
|
+
const existing = from ? compositionOnTrunk(top, from, trunkRef, branch) : null;
|
|
190
|
+
|
|
191
|
+
let base: string;
|
|
192
|
+
if (from) {
|
|
193
|
+
base = existing?.base ?? from;
|
|
194
|
+
if (req.base !== undefined) {
|
|
195
|
+
const asked = await resolveBase(top, req.cwd, req.base);
|
|
196
|
+
if (asked !== base) {
|
|
197
|
+
throw new ComposeError(
|
|
198
|
+
"compose-base-differs",
|
|
199
|
+
`${branch} is composed on ${base.slice(0, 12)}, and --base names ${asked.slice(0, 12)}; a compose only appends to its branch`,
|
|
200
|
+
`Drop --base to add to ${branch}, or compose under another name.`,
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
} else {
|
|
205
|
+
if (req.base === undefined) throw new ComposeError("compose-base-unknown", `${branch} does not exist yet, so the compose needs --base`, "Name the release or commit to compose on: --base <label|sha>");
|
|
206
|
+
base = await resolveBase(top, req.cwd, req.base);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Trunk's first-parent commits after the base, oldest first.
|
|
210
|
+
const line = (tryGit(top, ["rev-list", "--first-parent", "--reverse", trunkRef, `^${base}`]) ?? "").split("\n").filter(Boolean);
|
|
211
|
+
const { commitDetails } = await import("../workspace/intent");
|
|
212
|
+
const { recordTrailers } = await import("../workspace/release-candidates");
|
|
213
|
+
const details = commitDetails(top, line);
|
|
214
|
+
|
|
215
|
+
const chosen = new Map<string, ComposeSource>();
|
|
216
|
+
const choose = (sha: string, by: string, pr: number | null, work: string[]) => {
|
|
217
|
+
const entry = chosen.get(sha) ?? { commit: sha, subject: details.get(sha)?.subject ?? "", pr, work, chosenBy: [] };
|
|
218
|
+
if (!entry.chosenBy.includes(by)) entry.chosenBy.push(by);
|
|
219
|
+
chosen.set(sha, entry);
|
|
220
|
+
};
|
|
221
|
+
const unmatched = new Set<string>([...req.prs.map((n) => `--pr ${n}`), ...req.works.map((w) => `--work ${w}`)]);
|
|
222
|
+
for (const sha of line) {
|
|
223
|
+
const d = details.get(sha);
|
|
224
|
+
const merge = fromMergeRecord(await mergeRecordOf(req.cwd, sha));
|
|
225
|
+
const subjectPr = PR_IN_SUBJECT.exec(d?.subject ?? "");
|
|
226
|
+
const pr = merge.pr ?? (subjectPr ? Number(subjectPr[1]) : null);
|
|
227
|
+
const work = [...new Set([...recordTrailers(d?.trailers ?? {}).work, ...merge.work])];
|
|
228
|
+
for (const n of req.prs) {
|
|
229
|
+
if (pr === n) {
|
|
230
|
+
choose(sha, `--pr ${n}`, pr, work);
|
|
231
|
+
unmatched.delete(`--pr ${n}`);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
for (const w of req.works) {
|
|
235
|
+
if (work.includes(w)) {
|
|
236
|
+
choose(sha, `--work ${w}`, pr, work);
|
|
237
|
+
unmatched.delete(`--work ${w}`);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
if (unmatched.size > 0) {
|
|
242
|
+
throw new ComposeError(
|
|
243
|
+
"compose-source-unknown",
|
|
244
|
+
`${[...unmatched].join(", ")} names no commit on ${remote}/${req.trunk.branch} after ${base.slice(0, 12)}`,
|
|
245
|
+
"A pull request is found by its merge record or its squash subject (#n), and a work item by a Chant-Record: work:<id> trailer. Fetch trunk and chant/lifecycle if it merged elsewhere.",
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Trunk order, and what the branch already carries.
|
|
250
|
+
const carried = new Set(existing?.sources ?? []);
|
|
251
|
+
const ordered = line.filter((sha) => chosen.has(sha)).map((sha) => chosen.get(sha)!);
|
|
252
|
+
const already = ordered.filter((s) => carried.has(s.commit));
|
|
253
|
+
const picked = ordered.filter((s) => !carried.has(s.commit));
|
|
254
|
+
|
|
255
|
+
const tip = picked.length === 0 ? (from ?? base) : pickOnto(top, from ?? base, picked);
|
|
256
|
+
let written = false;
|
|
257
|
+
let pushed = false;
|
|
258
|
+
if (req.yes && picked.length > 0) {
|
|
259
|
+
git(top, ["update-ref", "-m", `chant release compose ${req.name}`, localRef, tip, localTip ?? ""]);
|
|
260
|
+
written = true;
|
|
261
|
+
}
|
|
262
|
+
if (req.yes && (req.push ?? true) && tryGit(top, ["remote", "get-url", remote]) !== null && tip !== remoteTip) {
|
|
263
|
+
try {
|
|
264
|
+
git(top, ["push", "--quiet", remote, `${tip}:${localRef}`]);
|
|
265
|
+
tryGit(top, ["update-ref", remoteRef, tip]);
|
|
266
|
+
pushed = true;
|
|
267
|
+
} catch (err) {
|
|
268
|
+
const stderr = (err as { stderr?: Buffer | string }).stderr?.toString().trim() ?? String(err);
|
|
269
|
+
throw new ComposeError("compose-push-refused", `${branch} was composed here at ${tip.slice(0, 12)} and ${remote} refused the push: ${stderr}`, `Push it by hand once the remote allows it: git push ${remote} ${branch}`);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
return { name: req.name, branch, base, from, picked, already, tip, written, pushed };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Cherry-pick `sources` in order onto `start` in a temporary detached
|
|
277
|
+
* worktree, each with its trailer, and return the last commit. Throws
|
|
278
|
+
* `compose-conflict` naming the change and its files when one does not
|
|
279
|
+
* apply. The worktree is removed either way.
|
|
280
|
+
*/
|
|
281
|
+
function pickOnto(top: string, start: string, sources: readonly ComposeSource[]): string {
|
|
282
|
+
const realTop = realpathSync(top);
|
|
283
|
+
const dir = mkdtempSync(join(tmpdir(), "chant-compose-"));
|
|
284
|
+
git(realTop, ["worktree", "add", "--detach", "--quiet", dir, start]);
|
|
285
|
+
try {
|
|
286
|
+
for (const s of sources) {
|
|
287
|
+
const parents = (tryGit(dir, ["rev-list", "--parents", "-n", "1", s.commit]) ?? "").split(" ").length - 1;
|
|
288
|
+
const args = ["cherry-pick", "--allow-empty", "--keep-redundant-commits", ...(parents > 1 ? ["-m", "1"] : []), s.commit];
|
|
289
|
+
const run = spawnSync("git", args, { cwd: dir, encoding: "utf-8" });
|
|
290
|
+
if (run.status !== 0) {
|
|
291
|
+
const files = (tryGit(dir, ["diff", "--name-only", "--diff-filter=U"]) ?? "").split("\n").filter(Boolean);
|
|
292
|
+
tryGit(dir, ["cherry-pick", "--abort"]);
|
|
293
|
+
const what = [s.pr !== null ? `#${s.pr}` : null, ...s.work.map((w) => `work:${w}`)].filter(Boolean).join(", ");
|
|
294
|
+
throw new ComposeError(
|
|
295
|
+
"compose-conflict",
|
|
296
|
+
`${s.commit.slice(0, 12)} (${s.subject}${what ? `; ${what}` : ""}) does not apply on the composed commits${files.length > 0 ? `: it conflicts in ${files.join(", ")}` : `: ${(run.stderr || run.stdout).trim()}`}. Nothing was written`,
|
|
297
|
+
"Resolve it by hand in a pull request to the compose branch, or compose without that change.",
|
|
298
|
+
{ commit: s.commit, subject: s.subject, files },
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
git(dir, ["commit", "--quiet", "--amend", "--no-edit", "--allow-empty", "--no-verify", "--trailer", `${COMPOSED_FROM_TRAILER}: ${s.commit}`]);
|
|
302
|
+
}
|
|
303
|
+
return git(dir, ["rev-parse", "HEAD"]);
|
|
304
|
+
} finally {
|
|
305
|
+
try {
|
|
306
|
+
git(realTop, ["worktree", "remove", "--force", dir]);
|
|
307
|
+
} catch {
|
|
308
|
+
rmSync(dir, { recursive: true, force: true });
|
|
309
|
+
tryGit(realTop, ["worktree", "prune"]);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
}
|
|
@@ -102,6 +102,10 @@ export interface CutRequest {
|
|
|
102
102
|
* must be the principal the signers file lists the key for.
|
|
103
103
|
*/
|
|
104
104
|
sign?: string | true;
|
|
105
|
+
/** The composition of a composed commit (#4168 4.3), recorded on the cut. */
|
|
106
|
+
composed?: { branch: string; base: string; sources: string[] };
|
|
107
|
+
/** The publish channel recorded on the cut (#4168 4.3), in place of `latest`. */
|
|
108
|
+
channel?: string;
|
|
105
109
|
}
|
|
106
110
|
|
|
107
111
|
export interface CutResult {
|
|
@@ -501,6 +505,8 @@ export async function cutRelease(req: CutRequest): Promise<CutResult> {
|
|
|
501
505
|
runId: ci?.runId ?? `local-${Date.parse(timestamp)}`,
|
|
502
506
|
runOrigin: ci?.runOrigin ?? { forge: "local" },
|
|
503
507
|
signature,
|
|
508
|
+
...(req.composed ? { composed: req.composed } : {}),
|
|
509
|
+
...(req.channel ? { channel: req.channel } : {}),
|
|
504
510
|
},
|
|
505
511
|
{ cwd },
|
|
506
512
|
);
|
|
@@ -129,6 +129,10 @@ export interface PublishDeps {
|
|
|
129
129
|
fetch?: PublishFetch;
|
|
130
130
|
/** The npm executable. Default: `npm` on `PATH`. */
|
|
131
131
|
npm?: string;
|
|
132
|
+
/** How a target waits between polls of a registry (#4244). Default: a timer. */
|
|
133
|
+
sleep?: (ms: number) => Promise<void>;
|
|
134
|
+
/** The clock a target's wait reads (#4244). Default: `Date.now`. */
|
|
135
|
+
now?: () => number;
|
|
132
136
|
}
|
|
133
137
|
|
|
134
138
|
/** The subset of the WHATWG `fetch` the targets use. */
|
|
@@ -278,6 +282,8 @@ export interface PublishPlan {
|
|
|
278
282
|
redactor: Redactor;
|
|
279
283
|
/** Whether a target with no SBOM source is refused (`publish-sbom-missing`): the workspace opts into `supply-chain` (#4168). Otherwise it publishes without one. */
|
|
280
284
|
requireSbom: boolean;
|
|
285
|
+
/** The channel a cut of the release names (#4168 4.3), the tag moved in place of `latest`, or null. */
|
|
286
|
+
channel: string | null;
|
|
281
287
|
}
|
|
282
288
|
|
|
283
289
|
/**
|
|
@@ -399,7 +405,8 @@ export async function preparePublish(req: {
|
|
|
399
405
|
const member = ledger.members.length > 0 ? ledger.members.join("/") : null;
|
|
400
406
|
const releaseTag = label !== null ? releaseTagName(member, label) : null;
|
|
401
407
|
const redactor = createRedactor(env, targets.flatMap((t) => t.credentialEnv));
|
|
402
|
-
|
|
408
|
+
const channel = [...cuts].reverse().find((c) => c.channel)?.channel ?? null;
|
|
409
|
+
return { release, label, signature, provenance, warning, signatureWarning, cut, releaseTag, targets, redactor, requireSbom: groups.has("supply-chain"), channel };
|
|
403
410
|
}
|
|
404
411
|
|
|
405
412
|
/**
|
|
@@ -149,6 +149,17 @@ export interface ReleaseCut {
|
|
|
149
149
|
* unsigned cut, and on every cut written before 0.124.28.
|
|
150
150
|
*/
|
|
151
151
|
signature: string | null;
|
|
152
|
+
/**
|
|
153
|
+
* A cut of a composed commit (#4168 4.3): its compose branch, the base it
|
|
154
|
+
* was composed on, and the trunk commits picked onto the base, oldest
|
|
155
|
+
* first. Absent on a cut of a trunk commit.
|
|
156
|
+
*/
|
|
157
|
+
composed?: { branch: string; base: string; sources: string[] };
|
|
158
|
+
/**
|
|
159
|
+
* The channel the release publishes to (#4168 4.3): the npm dist-tag or
|
|
160
|
+
* image tag a publish moves in place of `latest`. Absent for `latest`.
|
|
161
|
+
*/
|
|
162
|
+
channel?: string;
|
|
152
163
|
}
|
|
153
164
|
|
|
154
165
|
/** The parts of a release a digest is computed from. */
|
|
@@ -364,7 +375,9 @@ export function parseCuts(text: string | null): { cuts: ReleaseCut[]; malformed:
|
|
|
364
375
|
typeof c.timestamp === "string" &&
|
|
365
376
|
(c.runOrigin === null || (typeof c.runOrigin === "object" && typeof c.runOrigin?.forge === "string")) &&
|
|
366
377
|
(c.signature === null || c.signature === undefined || typeof c.signature === "string") &&
|
|
367
|
-
(c.runId === null || c.runId === undefined || typeof c.runId === "string")
|
|
378
|
+
(c.runId === null || c.runId === undefined || typeof c.runId === "string") &&
|
|
379
|
+
(c.channel === undefined || typeof c.channel === "string") &&
|
|
380
|
+
(c.composed === undefined || (typeof c.composed === "object" && c.composed !== null && typeof c.composed.base === "string" && Array.isArray(c.composed.sources)));
|
|
368
381
|
if (!ok) {
|
|
369
382
|
malformed++;
|
|
370
383
|
continue;
|
|
@@ -444,6 +457,8 @@ export async function appendCut(
|
|
|
444
457
|
runId: input.runId ?? null,
|
|
445
458
|
runOrigin: input.runOrigin,
|
|
446
459
|
signature: input.signature ?? null,
|
|
460
|
+
...(input.composed ? { composed: input.composed } : {}),
|
|
461
|
+
...(input.channel ? { channel: input.channel } : {}),
|
|
447
462
|
};
|
|
448
463
|
const content = `${existing ? `${existing.replace(/\n$/, "")}\n` : ""}${JSON.stringify(cut, sortedJsonReplacer)}\n`;
|
|
449
464
|
try {
|
|
@@ -47,6 +47,8 @@ const PIPELINE = [
|
|
|
47
47
|
" - run: chant ci run record --in-place --env prod --gate pr-apply --forge github",
|
|
48
48
|
"",
|
|
49
49
|
].join("\n");
|
|
50
|
+
/** #4168 step 9: under apply on merge a merge is recorded, and the apply applies the newest green commit, with no push base. */
|
|
51
|
+
const APPLY_PIPELINE = `${PIPELINE} apply:\n steps:\n - run: chant ci run apply --in-place --env prod --gate pr-apply --forge github\n`;
|
|
50
52
|
const TICK = "jobs:\n tick:\n steps:\n - run: npx --yes @intentius/chant@0.124.33 ci tick\n";
|
|
51
53
|
const PUBLISH = "jobs:\n publish:\n steps:\n - run: npx chant release publish \"$LABEL\"\n";
|
|
52
54
|
|
|
@@ -100,14 +102,22 @@ describe("ci.workflow (WSP151)", () => {
|
|
|
100
102
|
});
|
|
101
103
|
|
|
102
104
|
test("trunk-curated with an apply job on merge differs", async () => {
|
|
103
|
-
const root = repo(complete({ ".github/workflows/chant-pr.yml":
|
|
105
|
+
const root = repo(complete({ ".github/workflows/chant-pr.yml": APPLY_PIPELINE }));
|
|
104
106
|
expect(await found(root)).toEqual([["ci-workflow-pipeline-differs", null]]);
|
|
105
107
|
});
|
|
106
108
|
|
|
107
109
|
test("trunk-curated+apply-on-merge with only a record job differs, and with an apply job passes", async () => {
|
|
108
110
|
const decl = declaration({ ci: { workflow: "trunk-curated+apply-on-merge", green: green() } });
|
|
109
111
|
expect(await found(repo(complete({ "chant.workspace.json": decl })))).toEqual([["ci-workflow-pipeline-differs", null]]);
|
|
110
|
-
expect(await found(repo(complete({ "chant.workspace.json": decl, ".github/workflows/chant-pr.yml":
|
|
112
|
+
expect(await found(repo(complete({ "chant.workspace.json": decl, ".github/workflows/chant-pr.yml": APPLY_PIPELINE })))).toEqual([]);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("trunk-curated+apply-on-merge: an apply of each merge differs, and a pipeline with no record job is missing it (#4168 step 9)", async () => {
|
|
116
|
+
const decl = declaration({ ci: { workflow: "trunk-curated+apply-on-merge", green: green() } });
|
|
117
|
+
const perMerge = APPLY_PIPELINE.replace("ci run apply --in-place", 'ci run apply --in-place --base "$BASE_SHA"');
|
|
118
|
+
expect(await found(repo(complete({ "chant.workspace.json": decl, ".github/workflows/chant-pr.yml": perMerge })))).toEqual([["ci-workflow-pipeline-differs", null]]);
|
|
119
|
+
const noRecord = PIPELINE.replace("ci run record", "ci run apply");
|
|
120
|
+
expect(await found(repo(complete({ "chant.workspace.json": decl, ".github/workflows/chant-pr.yml": noRecord })))).toEqual([["ci-workflow-pipeline-missing", null]]);
|
|
111
121
|
});
|
|
112
122
|
|
|
113
123
|
test("a member's own workflow wins, and a top-level workflow skips members that don't build with chant", async () => {
|
|
@@ -106,7 +106,8 @@ export const CI_WORKFLOW_CHECKS: readonly WorkspaceCheck[] = [
|
|
|
106
106
|
const applyOnMerge = under.some((u) => appliesOnMerge(u.workflow)) || (d.ci?.workflow ? appliesOnMerge(d.ci.workflow) : false);
|
|
107
107
|
const missing = [
|
|
108
108
|
...(has(PLAN) ? [] : ["a plan job (ci run plan)"]),
|
|
109
|
-
|
|
109
|
+
// #4168 step 9: every merge is recorded, apply on merge or not, since the apply reads the merge records.
|
|
110
|
+
...(has(RECORD) ? [] : ["a record job (ci run record)"]),
|
|
110
111
|
...(has(TICK) ? [] : ["chant ci tick"]),
|
|
111
112
|
];
|
|
112
113
|
if (missing.length > 0) {
|
|
@@ -119,6 +120,11 @@ export const CI_WORKFLOW_CHECKS: readonly WorkspaceCheck[] = [
|
|
|
119
120
|
} else if (!has(APPLY) && has(RECORD)) {
|
|
120
121
|
const l = commandLines(ctx, RECORD)[0]!;
|
|
121
122
|
finding("ci-workflow-pipeline-differs", `${l.path} records on merge, and the workflow is trunk-curated+apply-on-merge; regenerate with --apply-on-merge`, { treeFile: l.path, line: l.line });
|
|
123
|
+
} else {
|
|
124
|
+
// #4168 step 9: an apply measured from a push's base applies each merge; the workflow applies green commits.
|
|
125
|
+
for (const l of commandLines(ctx, APPLY).filter((c) => /--base\b/.test(c.text) || (/\bcomponents\s+pr-apply\b/.test(c.text) && !/--green\b/.test(c.text)))) {
|
|
126
|
+
finding("ci-workflow-pipeline-differs", `${l.path} applies each merge, and under trunk-curated+apply-on-merge the apply applies the newest green commit; regenerate with --apply-on-merge`, { treeFile: l.path, line: l.line });
|
|
127
|
+
}
|
|
122
128
|
}
|
|
123
129
|
if (!has(PUBLISH)) {
|
|
124
130
|
finding("ci-workflow-publish-missing", "no pipeline file runs chant release publish, so a cut release is never published; generate one with chant release workflow");
|
|
@@ -154,7 +154,9 @@ export async function runCiWorkflow(ctx: CommandContext): Promise<number> {
|
|
|
154
154
|
const sources = readWorkflowSources(repoRoot, fromRepo);
|
|
155
155
|
const scan = workflowsHolding(green, sources);
|
|
156
156
|
// #4169: the triggers and concurrency of the workflows holding a phase that sets build. Refused before anything is written.
|
|
157
|
-
|
|
157
|
+
// #4220: under a ci.workflow, composed releases' branches run the phases and tick too.
|
|
158
|
+
const compose = declaration.ci?.workflow !== undefined || declaration.members.some((m) => m.ciWorkflow !== null);
|
|
159
|
+
const edits = buildTriggerEdits(green, sources, { compose });
|
|
158
160
|
const extra = ctx.args.ciWorkflows ?? [];
|
|
159
161
|
const workflows = [...new Set([...scan.workflows, ...extra])].sort();
|
|
160
162
|
for (const f of scan.unreadable) console.error(`warning: ${f} is not valid YAML, so its jobs were not read`);
|
|
@@ -180,10 +182,10 @@ export async function runCiWorkflow(ctx: CommandContext): Promise<number> {
|
|
|
180
182
|
const workspaceRoot = relative(repoRoot, root).split(sep).join("/") || ".";
|
|
181
183
|
// The install runs in the workspace root, so its lockfile there keys setup-node's npm cache.
|
|
182
184
|
const npmLock = ctx.args.ciInstall && existsSync(join(root, "package-lock.json")) ? (workspaceRoot === "." ? "package-lock.json" : `${workspaceRoot}/package-lock.json`) : undefined;
|
|
183
|
-
const yaml = renderCiGreenWorkflow({ green, workflows, chantVersion: readerVersion(), workspaceRoot, chant: ctx.args.ciChant, install: ctx.args.ciInstall, npmLock, push, ...(runnerKeySecret ? { runnerKeySecret } : {}) });
|
|
185
|
+
const yaml = renderCiGreenWorkflow({ green, workflows, chantVersion: readerVersion(), workspaceRoot, chant: ctx.args.ciChant, install: ctx.args.ciInstall, npmLock, push, ...(runnerKeySecret ? { runnerKeySecret } : {}), ...(compose ? { compose } : {}) });
|
|
184
186
|
const written = writeWorkflowFile(target, ciGreenHeader(command, member ? "member" : "root") + yaml);
|
|
185
187
|
if (member) recordGeneratedFiles(member.memberRoot, [{ path: fromRepo, command }]);
|
|
186
|
-
console.log(`Wrote ${written}: ticks when ${workflows.join(", ")} complete${workflows.length === 1 ? "s" : ""} on ${green.branch}, and every 15 minutes.`);
|
|
188
|
+
console.log(`Wrote ${written}: ticks when ${workflows.join(", ")} complete${workflows.length === 1 ? "s" : ""} on ${green.branch}${compose ? " or a chant/compose/ branch" : ""}, and every 15 minutes.`);
|
|
187
189
|
for (const e of edits) {
|
|
188
190
|
if (!e.changed) continue;
|
|
189
191
|
const path = writeWorkflowFile(join(repoRoot, e.path), e.text);
|