@intentius/chant 0.124.34 → 0.124.35
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 +2 -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.map +1 -1
- package/dist/lifecycle/__fixtures__/publish-fakes.js +5 -1
- 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 +4 -0
- package/dist/lifecycle/publish-declaration.d.ts.map +1 -1
- package/dist/lifecycle/publish-declaration.js +15 -0
- package/dist/lifecycle/publish-npm.d.ts +7 -4
- package/dist/lifecycle/publish-npm.d.ts.map +1 -1
- package/dist/lifecycle/publish-npm.js +60 -13
- 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 +2 -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.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 +5 -1
- 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 +15 -0
- package/src/lifecycle/publish-npm.ts +55 -13
- package/src/lifecycle/publish-targets.test.ts +45 -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 +4 -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
|
@@ -531,3 +531,36 @@ describe("chant ci list (#4021)", () => {
|
|
|
531
531
|
}
|
|
532
532
|
});
|
|
533
533
|
});
|
|
534
|
+
|
|
535
|
+
describe("chant ci tick walks compose branches (#4168 4.3)", () => {
|
|
536
|
+
test("judges composed commits, names their branch in the tag, and warns when a source they carry is revoked", async () => {
|
|
537
|
+
const { root, origin } = workspace();
|
|
538
|
+
const a = commitAt(root, 5, "app/a.txt");
|
|
539
|
+
const b = commitAt(root, 4, "app/b.txt");
|
|
540
|
+
push(root);
|
|
541
|
+
// A composed commit on a, picking b.
|
|
542
|
+
git(root, "checkout", "-q", "-b", "chant/compose/hf", a);
|
|
543
|
+
writeFiles(root, { "app/b.txt": "app/b.txt 4\n" });
|
|
544
|
+
gitAt(root, hoursAgo(2), "add", "-A");
|
|
545
|
+
gitAt(root, hoursAgo(2), "commit", "-q", "-m", `app/b.txt\n\nChant-Composed-From: ${b}`);
|
|
546
|
+
const c = git(root, "rev-parse", "HEAD");
|
|
547
|
+
git(root, "push", "-q", "origin", "chant/compose/hf");
|
|
548
|
+
git(root, "checkout", "-q", "main");
|
|
549
|
+
|
|
550
|
+
const runs = new Map([
|
|
551
|
+
[a, PASSING],
|
|
552
|
+
[b, PASSING],
|
|
553
|
+
[c, PASSING],
|
|
554
|
+
]);
|
|
555
|
+
const first = await ciTick({ cwd: root, forge: fakeForge(runs), now: NOW });
|
|
556
|
+
expect(first.composed.map((x) => [x.branch, x.commits.map((k) => k.sha)])).toEqual([["chant/compose/hf", [c]]]);
|
|
557
|
+
expect(first.made.sort()).toEqual([`ci/green/${a}`, `ci/green/${b}`, `ci/green/${c}`].sort());
|
|
558
|
+
const note = git(origin, "tag", "-l", "--format=%(contents)", `ci/green/${c}`);
|
|
559
|
+
expect(JSON.parse(note.slice(note.indexOf("{")))).toMatchObject({ tag: "green", commit: c, branch: "chant/compose/hf" });
|
|
560
|
+
|
|
561
|
+
runs.set(b, [...PASSING, run(9, "lint", "failure")]);
|
|
562
|
+
const revoke = await ciTick({ cwd: root, forge: fakeForge(runs), now: NOW });
|
|
563
|
+
expect(revoke.made).toEqual([`ci/revoked/${b}`]);
|
|
564
|
+
expect(revoke.warnings.join("\n")).toContain(`chant/compose/hf carries it as ${c.slice(0, 12)}`);
|
|
565
|
+
});
|
|
566
|
+
});
|
|
@@ -71,6 +71,7 @@ import { gitRevisionSource } from "./record-source";
|
|
|
71
71
|
import { emptyPolicy, readTrustPolicy } from "./trust/policy";
|
|
72
72
|
import { loadRunnerKey } from "./trust/dsse";
|
|
73
73
|
import { signCiTagRecord, verifyCiTagStatement, CI_TAG_SIGNER_ROLE, type CiTagFailure, type CiTagRecord, type CiTagSigner } from "./trust/ci-tag-statement";
|
|
74
|
+
import { COMPOSE_BRANCH_PREFIX, composedIncluding, isComposeBranch, remoteComposeBranches } from "../lifecycle/compose-refs";
|
|
74
75
|
|
|
75
76
|
/** The environment variable a tick (#4031) and `chant run` (#4044) read a runner key from, as PEM, when `--key` is not given. */
|
|
76
77
|
export const RUNNER_KEY_ENV = "CHANT_RUNNER_KEY";
|
|
@@ -478,7 +479,9 @@ export function readCiTags(top: string, green: CiGreen | null = null, base: stri
|
|
|
478
479
|
continue;
|
|
479
480
|
}
|
|
480
481
|
const p = policyAtBase();
|
|
481
|
-
|
|
482
|
+
// A tag the tick made walking a compose branch (#4168 4.3) is checked against that branch.
|
|
483
|
+
const walked = isComposeBranch(t.record?.branch) ? (t.record!.branch as string) : green!.branch;
|
|
484
|
+
const v = verifyCiTagStatement(t.record, { sha: t.sha, kind: t.kind, branch: walked }, p.runners, p.roles);
|
|
482
485
|
if (v.status === "signed") counted(v.signer);
|
|
483
486
|
else ignore(v.status, `${v.reason}; ci.green.signedSince is ${green!.signedSince}, so a tag on a commit that is not a strict first-parent ancestor of it needs one`);
|
|
484
487
|
}
|
|
@@ -501,7 +504,7 @@ export function signedRunsRequired(cwd: string): string | null {
|
|
|
501
504
|
}
|
|
502
505
|
|
|
503
506
|
/** The declaration's `ci.green`, read where `cwd` is, and the git top. */
|
|
504
|
-
function readGreen(cwd: string): { green: CiGreen; top: string; workspace: { name: string; root: string; file: string } } {
|
|
507
|
+
export function readGreen(cwd: string): { green: CiGreen; top: string; workspace: { name: string; root: string; file: string } } {
|
|
505
508
|
const located = locateWorkspace(cwd);
|
|
506
509
|
const declaration = readDeclaration(located.tree);
|
|
507
510
|
const green = declaration.ci?.green ?? null;
|
|
@@ -535,12 +538,21 @@ export interface TickRequest {
|
|
|
535
538
|
runnerKey?: string;
|
|
536
539
|
}
|
|
537
540
|
|
|
541
|
+
/** One compose branch a tick walked (#4168 4.3): its composed commits within the window. */
|
|
542
|
+
export interface ComposedBranchTick {
|
|
543
|
+
branch: string;
|
|
544
|
+
ref: string;
|
|
545
|
+
commits: TickCommit[];
|
|
546
|
+
}
|
|
547
|
+
|
|
538
548
|
export interface TickResult {
|
|
539
549
|
branch: string;
|
|
540
550
|
window: string;
|
|
541
551
|
/** The ref the commits were read from. */
|
|
542
552
|
ref: string;
|
|
543
553
|
commits: TickCommit[];
|
|
554
|
+
/** The compose branches walked (#4168 4.3), each with its composed commits judged. Their tags are in `made`. */
|
|
555
|
+
composed: ComposedBranchTick[];
|
|
544
556
|
/** The tags made and pushed, or that would be under `dryRun`. */
|
|
545
557
|
made: string[];
|
|
546
558
|
/** The builds asked for (#4169), as `ci/build/<sha>` tags made and pushed, or that would be under `dryRun`. */
|
|
@@ -596,7 +608,16 @@ export async function ciTick(req: TickRequest): Promise<TickResult> {
|
|
|
596
608
|
const now = req.now ?? new Date();
|
|
597
609
|
const tracking = `refs/remotes/${remote}/${green.branch}`;
|
|
598
610
|
try {
|
|
599
|
-
git(top, [
|
|
611
|
+
git(top, [
|
|
612
|
+
"fetch",
|
|
613
|
+
"--quiet",
|
|
614
|
+
"--no-tags",
|
|
615
|
+
"--prune",
|
|
616
|
+
remote,
|
|
617
|
+
`+refs/heads/${green.branch}:${tracking}`,
|
|
618
|
+
`+refs/heads/${COMPOSE_BRANCH_PREFIX}*:refs/remotes/${remote}/${COMPOSE_BRANCH_PREFIX}*`,
|
|
619
|
+
"+refs/tags/ci/*:refs/tags/ci/*",
|
|
620
|
+
]);
|
|
600
621
|
} catch (err) {
|
|
601
622
|
throw new Error(`could not fetch ${green.branch} and the ci tags from ${remote}: ${stderrOf(err)}`);
|
|
602
623
|
}
|
|
@@ -648,6 +669,28 @@ export async function ciTick(req: TickRequest): Promise<TickResult> {
|
|
|
648
669
|
signer,
|
|
649
670
|
});
|
|
650
671
|
if (links) for (const c of planned) c.work = links.get(c.sha);
|
|
672
|
+
// Compose branches (#4168 4.3): each composed commit within the window is judged as trunk's are, and its tags name its branch.
|
|
673
|
+
const composed: ComposedBranchTick[] = [];
|
|
674
|
+
for (const b of remoteComposeBranches(top, remote)) {
|
|
675
|
+
const own = git(top, ["log", "--first-parent", `--max-age=${cutoff}`, "--format=%H %ct", b.ref, `^${tracking}`])
|
|
676
|
+
.split("\n")
|
|
677
|
+
.filter(Boolean)
|
|
678
|
+
.map((line) => line.split(" "))
|
|
679
|
+
.filter(([, ct]) => Number(ct) >= cutoff)
|
|
680
|
+
.map(([sha, ct]) => ({ sha, committed: iso(new Date(Number(ct) * 1000)) }));
|
|
681
|
+
if (own.length === 0) continue;
|
|
682
|
+
const judged = await planTick({
|
|
683
|
+
green: { ...green, branch: b.branch },
|
|
684
|
+
commits: own,
|
|
685
|
+
greenTags: new Set(tags.green.keys()),
|
|
686
|
+
revokedTags: new Set(tags.revoked.keys()),
|
|
687
|
+
ignored: tags.ignored,
|
|
688
|
+
checkRuns: async (sha) => (runs ? runs.checkRuns(sha) : await forge!.checkRuns(sha)),
|
|
689
|
+
now,
|
|
690
|
+
signer,
|
|
691
|
+
});
|
|
692
|
+
composed.push({ branch: b.branch, ref: b.ref, commits: judged });
|
|
693
|
+
}
|
|
651
694
|
if (runs) {
|
|
652
695
|
for (const c of planned) {
|
|
653
696
|
const ignored = runs.ignored.get(c.sha);
|
|
@@ -658,7 +701,14 @@ export async function ciTick(req: TickRequest): Promise<TickResult> {
|
|
|
658
701
|
const requests = readBuildRequests(top);
|
|
659
702
|
const retryVia = green.source === "runs" ? "request" : forge?.rerun ? "forge" : null;
|
|
660
703
|
const builds = planBuilds({ green, commits: planned, runs: runsBy, requests, records, now, retryVia });
|
|
661
|
-
const due = planned.filter((c) => c.made !== null);
|
|
704
|
+
const due = [...planned, ...composed.flatMap((b) => b.commits)].filter((c) => c.made !== null);
|
|
705
|
+
// Decision 4: revoking a trunk commit says which composed commits carry it; their own CI decides their releases.
|
|
706
|
+
for (const c of planned) {
|
|
707
|
+
if (!c.made?.tag.startsWith(CI_REVOKED_TAG_PREFIX)) continue;
|
|
708
|
+
for (const k of composedIncluding(top, c.sha, remote)) {
|
|
709
|
+
warnings.push(`${c.sha.slice(0, 12)} is revoked, and ${k.branch} carries it as ${k.commit.slice(0, 12)}; that composed commit's own CI decides whether its releases stand`);
|
|
710
|
+
}
|
|
711
|
+
}
|
|
662
712
|
if (!req.dryRun && due.length > 0) {
|
|
663
713
|
const ident = taggerConfig(top);
|
|
664
714
|
for (const c of due) git(top, [...ident, "tag", "-a", "-m", c.made!.message, c.made!.tag, c.sha]);
|
|
@@ -683,6 +733,7 @@ export async function ciTick(req: TickRequest): Promise<TickResult> {
|
|
|
683
733
|
ref: tracking,
|
|
684
734
|
commits: planned,
|
|
685
735
|
made: due.map((c) => c.made!.tag),
|
|
736
|
+
composed,
|
|
686
737
|
requested: builds.requests,
|
|
687
738
|
retried,
|
|
688
739
|
recorded,
|
|
@@ -964,6 +1015,23 @@ export async function readOpRunSource(cwd: string, top: string, base: string, si
|
|
|
964
1015
|
return { checkRuns: (sha) => byCommit.get(sha) ?? [], ignored };
|
|
965
1016
|
}
|
|
966
1017
|
|
|
1018
|
+
/**
|
|
1019
|
+
* Judge `ci.green.preMerge` on `commit` from the Op run records on the ledger
|
|
1020
|
+
* (#4168 4.4), as `ci serve --land` does before it moves the target. The
|
|
1021
|
+
* trust policy is read at `base`, the target's head. An unsigned run counts
|
|
1022
|
+
* as the tick counts one: always, unless `ci.green.signedSince` asks for a
|
|
1023
|
+
* signature. Throws {@link CiGreenError} when the declaration has no
|
|
1024
|
+
* `ci.green` or names no pre-merge phase.
|
|
1025
|
+
*/
|
|
1026
|
+
export async function judgePreMerge(req: { cwd: string; commit: string; base: string }): Promise<CommitVerdict> {
|
|
1027
|
+
const { green, top } = readGreen(req.cwd);
|
|
1028
|
+
const pre = green.preMerge ?? [];
|
|
1029
|
+
if (pre.length === 0) throw new CiGreenError("ci-green-undeclared", "ci.green.preMerge names no phase, so nothing says which checks must pass before the branch moves");
|
|
1030
|
+
const runs = await readOpRunSource(req.cwd, top, req.base, green.signedSince);
|
|
1031
|
+
const latest = latestAttempts(runs.checkRuns(req.commit));
|
|
1032
|
+
return commitVerdictOf(pre.map((name) => evaluatePhase(green.phases.find((p) => p.name === name)!, latest)));
|
|
1033
|
+
}
|
|
1034
|
+
|
|
967
1035
|
/**
|
|
968
1036
|
* What `chant run <op> --key` signs a run's record with (#4044): the runner
|
|
969
1037
|
* key `.chant/trust.json` at the checkout's `HEAD` lists for a principal
|
|
@@ -1063,6 +1131,19 @@ export function formatTick(r: TickResult, dryRun = false): string {
|
|
|
1063
1131
|
for (const r of c.ignoredRuns ?? []) lines.push(` ignored run ${r.run} of ${r.op} in ${r.env} (${r.status}): ${r.reason}`);
|
|
1064
1132
|
if (c.blocked) lines.push(` did not make ${c.blocked}: an ignored tag holds the name. Delete it to let the tick tag the commit: git push origin :refs/tags/${c.blocked}`);
|
|
1065
1133
|
}
|
|
1134
|
+
for (const b of r.composed ?? []) {
|
|
1135
|
+
lines.push(`${b.branch}, composed commits within ${r.window}: ${b.commits.length}`);
|
|
1136
|
+
for (const c of b.commits) {
|
|
1137
|
+
const what = c.made
|
|
1138
|
+
? `${dryRun ? "would make" : "made"} ${c.made.tag.slice(0, c.made.tag.lastIndexOf("/"))}`
|
|
1139
|
+
: c.before !== "none"
|
|
1140
|
+
? c.before
|
|
1141
|
+
: c.verdict?.verdict === "fail"
|
|
1142
|
+
? `failed: phase ${c.verdict.failure!.phase}`
|
|
1143
|
+
: "undecided";
|
|
1144
|
+
lines.push(` ${c.sha.slice(0, 12)} ${what}`);
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1066
1147
|
if (r.signer && r.made.length > 0) lines.push(`signed as ${r.signer}`);
|
|
1067
1148
|
for (const q of r.requested) lines.push(`${dryRun ? "would ask" : "asked"} for ${q.phases.join(", ")} at ${q.commit.slice(0, 12)} (${q.reason}): ${q.tag}`);
|
|
1068
1149
|
for (const x of r.retried) lines.push(`${dryRun ? "would run" : "ran"} ${x.run} at ${x.sha.slice(0, 12)} again`);
|
|
@@ -145,7 +145,7 @@ const envFile = (dir: string, stage: string): Record<string, string> =>
|
|
|
145
145
|
|
|
146
146
|
describe("the job as an Op (#4094)", () => {
|
|
147
147
|
test("the job's variables come from flags, literals and the runner's tokens, and nothing else of the runner's", () => {
|
|
148
|
-
const [plan, apply] = prLoopJobs("forgejo", "prod", { events: true });
|
|
148
|
+
const [plan, apply, record] = prLoopJobs("forgejo", "prod", { events: true });
|
|
149
149
|
const env = ciJobEnv(plan!, { forge: "forgejo", env: "prod", values: { base: "b".repeat(40), pr: 7, commit: "c".repeat(40) }, hostEnv: HOST, passEnv: ["AWS_ACCESS_KEY_ID"] });
|
|
150
150
|
expect(env).toMatchObject({
|
|
151
151
|
BASE_SHA: "b".repeat(40),
|
|
@@ -162,10 +162,12 @@ describe("the job as an Op (#4094)", () => {
|
|
|
162
162
|
expect(Object.values(env).some((v) => v.includes("${{"))).toBe(false);
|
|
163
163
|
|
|
164
164
|
expect(() => ciJobEnv(plan!, { forge: "forgejo", env: "prod", values: { base: "b".repeat(40), commit: "c".repeat(40) }, hostEnv: {} })).toThrow(CiRunError);
|
|
165
|
-
expect(() => ciJobEnv(
|
|
165
|
+
expect(() => ciJobEnv(record!, { forge: "forgejo", env: "prod", values: { commit: "c".repeat(40) }, hostEnv: {} })).toThrow(/pass --base <sha>/);
|
|
166
|
+
// #4168 step 9: the apply measures from the last applied green commit, so it needs no --base.
|
|
167
|
+
expect(ciJobEnv(apply!, { forge: "forgejo", env: "prod", values: { commit: "c".repeat(40) }, hostEnv: {} })).not.toHaveProperty("BASE_SHA");
|
|
166
168
|
|
|
167
|
-
const [,
|
|
168
|
-
const gl = ciJobEnv(
|
|
169
|
+
const [, , gitlabRecord] = prLoopJobs("gitlab", "prod");
|
|
170
|
+
const gl = ciJobEnv(gitlabRecord!, { forge: "gitlab", env: "prod", values: { base: "b".repeat(40), commit: "c".repeat(40) }, hostEnv: {} });
|
|
169
171
|
expect(gl).toEqual({ BASE_SHA: "b".repeat(40), CHANT_ENV: "prod", CI_COMMIT_SHA: "c".repeat(40) });
|
|
170
172
|
});
|
|
171
173
|
|
|
@@ -180,11 +182,13 @@ describe("the job as an Op (#4094)", () => {
|
|
|
180
182
|
expect(last.args).toEqual({ cmd: `npx chant${plan!.command.slice("chant".length)}`, cwd: "/w/app", env: { BASE_SHA: "b" }, inheritEnv: false });
|
|
181
183
|
expect(plan!.command).toContain("--member app");
|
|
182
184
|
|
|
183
|
-
// No setup or build lines: the job's phase alone. GitLab's
|
|
184
|
-
const [, gitlabApply] = prLoopJobs("gitlab", "prod");
|
|
185
|
-
const bare = ciJobOp({ job:
|
|
186
|
-
expect(bare.phases.map((p) => p.name)).toEqual(["
|
|
187
|
-
expect(ciJobCommand(
|
|
185
|
+
// No setup or build lines: the job's phase alone. GitLab's record keeps its base fallback in the command's shell.
|
|
186
|
+
const [, gitlabApply, gitlabRecord] = prLoopJobs("gitlab", "prod");
|
|
187
|
+
const bare = ciJobOp({ job: gitlabRecord!, env: "prod", cwd: "/w", jobEnv: {}, setup: [], build: [] });
|
|
188
|
+
expect(bare.phases.map((p) => p.name)).toEqual(["record"]);
|
|
189
|
+
expect(ciJobCommand(gitlabRecord!)).toBe(`${gitlabRecord!.setup![0]}\n${gitlabRecord!.command}`);
|
|
190
|
+
// #4168 step 9: the apply's green checkout is the generated job's, never ci run's: it runs at the commit it was given.
|
|
191
|
+
expect(ciJobCommand(gitlabApply!)).toBe(gitlabApply!.command);
|
|
188
192
|
|
|
189
193
|
// #4088's build and lint lines follow --before-script, with chant replaced in each.
|
|
190
194
|
const [built] = prLoopJobs("forgejo", "prod", { build: true });
|
|
@@ -298,7 +302,7 @@ describe("chant ci run, then ci tick from runs (#4094)", () => {
|
|
|
298
302
|
|
|
299
303
|
expect(apply.status).toBe("ok");
|
|
300
304
|
const applyArgs = readFileSync(join(reports, "apply", "pr-apply.args"), "utf-8").trimEnd().split("\n");
|
|
301
|
-
expect(applyArgs).toEqual(["components", "pr-apply", "--
|
|
305
|
+
expect(applyArgs).toEqual(["components", "pr-apply", "--green", "--env", "prod", "--gate", "pr-apply", "--output", ".chant/pr", "--forge", "forgejo", "--member", "app", "--resume", ".chant/pr-resume/pr-apply.json"]);
|
|
302
306
|
expect(formatCiRun(apply)).toContain("signed by chant-runner");
|
|
303
307
|
});
|
|
304
308
|
|
|
@@ -671,7 +675,8 @@ describe("the job's secrets from the host (#4096)", () => {
|
|
|
671
675
|
const applyEnv = ciJobEnv(apply!, { forge: "forgejo", env: "prod", values: { base, commit: base }, hostEnv: HOST, secrets: ciSecretsFor(secrets, "apply") });
|
|
672
676
|
expect(applyEnv).toMatchObject({ DB_PASSWORD: "hunter22-db", DEPLOY_KEY: "deploy-key-value" });
|
|
673
677
|
// A secret can replace a forge token, and never the variables the runner sets.
|
|
674
|
-
const
|
|
678
|
+
const [, , record] = prLoopJobs("forgejo", "prod");
|
|
679
|
+
const over = ciJobEnv(record!, { forge: "forgejo", env: "prod", values: { base, commit: base }, hostEnv: HOST, secrets: { GITHUB_TOKEN: "from-file", BASE_SHA: "x", CHANT_RUNNER_KEY: "k" } });
|
|
675
680
|
expect(over).toMatchObject({ GITHUB_TOKEN: "from-file", BASE_SHA: base });
|
|
676
681
|
expect(over).not.toHaveProperty("CHANT_RUNNER_KEY");
|
|
677
682
|
expect(() => ciJobEnv(apply!, { forge: "forgejo", env: "prod", values: { base, commit: base }, hostEnv: HOST, passEnv: ["CHANT_RUNNER_KEY"] })).toThrow(/never passed to a job/);
|
|
@@ -791,10 +796,10 @@ describe("a generated forge job runs ci run --in-place (#4156)", () => {
|
|
|
791
796
|
expect(r.report).toBe(join(realpathSync(app), ".chant/pr"));
|
|
792
797
|
expect(r.record.phases.map((p) => p.name)).toEqual(["apply"]);
|
|
793
798
|
const argv = readFileSync(join(app, ".chant/pr/pr-apply.args"), "utf-8").trimEnd().split("\n");
|
|
794
|
-
expect(argv).toEqual(["components", "pr-apply", "--
|
|
799
|
+
expect(argv).toEqual(["components", "pr-apply", "--green", "--env", "prod", "--gate", "pr-apply", "--output", ".chant/pr", "--forge", "forgejo", "--member", "app", "--resume", ".chant/pr-resume/pr-apply.json"]);
|
|
795
800
|
// The forge job's whole environment reaches the step, but never the runner key.
|
|
796
801
|
const env = envFile(join(app, ".chant/pr"), "pr-apply");
|
|
797
|
-
expect(env).toMatchObject({ NOT_FOR_JOBS: "leak", GITHUB_RUN_ID: "4242",
|
|
802
|
+
expect(env).toMatchObject({ NOT_FOR_JOBS: "leak", GITHUB_RUN_ID: "4242", GITHUB_SHA: sha });
|
|
798
803
|
expect(env).not.toHaveProperty("CHANT_RUNNER_KEY");
|
|
799
804
|
|
|
800
805
|
// The record: the forge's run, job and attempt as runOrigin, signed, and it verifies against trust.json.
|
package/src/workspace/ci-run.ts
CHANGED
|
@@ -423,6 +423,12 @@ export interface CiRunRequest {
|
|
|
423
423
|
/** `--env`: the environment the pipeline is for. */
|
|
424
424
|
env: string;
|
|
425
425
|
forge: ForgeKind;
|
|
426
|
+
/**
|
|
427
|
+
* Run the job's command without `--forge` (#4016 gap 6): `ci serve --forge
|
|
428
|
+
* git` reads a plain git server, which has no API for the command to ask.
|
|
429
|
+
* `forge` then only shapes the job's variables.
|
|
430
|
+
*/
|
|
431
|
+
noForge?: boolean;
|
|
426
432
|
/** `--base`, `--pr` and `--commit` (default `HEAD`). */
|
|
427
433
|
base?: string;
|
|
428
434
|
pr?: number;
|
|
@@ -668,7 +674,8 @@ export async function ciRun(req: CiRunRequest): Promise<CiRunResult> {
|
|
|
668
674
|
const isolated = !req.requireRunnerStart && !req.local && req.job === "apply" && securityGroupsAt(cwd).has("isolated-runners");
|
|
669
675
|
const started = req.requireRunnerStart || isolated ? runnerStartFor(req, { top, commit, hostEnv }) : undefined;
|
|
670
676
|
// A generated wave job's command, as prLoopWaveJobs writes it. #4161: a local job's command posts no status or note.
|
|
671
|
-
const
|
|
677
|
+
const forgeCommand = req.noForge ? found.command.replace(` --forge ${req.forge}`, "") : found.command;
|
|
678
|
+
const command = `${forgeCommand}${req.wave !== undefined ? ` --wave ${req.wave}` : ""}${req.local ? " --no-status" : ""}`;
|
|
672
679
|
const job = { ...found, command, ...(req.wave !== undefined ? { wave: req.wave } : {}) };
|
|
673
680
|
const own = ciJobEnv(job, {
|
|
674
681
|
forge: req.forge,
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #4168 4.4 and #4016 gap 6 end to end: `chant ci serve --forge git --land`,
|
|
3
|
+
* as a process, against a bare git remote with no forge API, no runner key
|
|
4
|
+
* and no signers.
|
|
5
|
+
*
|
|
6
|
+
* The build command records an Op run named `check` on the land branch's
|
|
7
|
+
* head, which passes unless the head holds `red.txt`. The serve judges
|
|
8
|
+
* `ci.green.preMerge` from that record: a green land moves main and the land
|
|
9
|
+
* branch goes; a red one leaves both. Each serve gets a fresh state
|
|
10
|
+
* directory, so its first poll only notes main's head and runs no job.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { spawn } from "node:child_process";
|
|
14
|
+
import { writeFileSync } from "node:fs";
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
import { pathToFileURL } from "node:url";
|
|
17
|
+
import { afterAll, beforeAll, describe, expect, test } from "vitest";
|
|
18
|
+
import { cleanScratch, declaration, git, REPO, scratchDir, writeFiles } from "./__fixtures__/contract-repo";
|
|
19
|
+
|
|
20
|
+
afterAll(cleanScratch);
|
|
21
|
+
|
|
22
|
+
const LOADER = pathToFileURL(join(REPO, "node_modules/tsx/dist/loader.mjs")).href;
|
|
23
|
+
const GREEN = { branch: "main", phases: { check: ["check"], suites: ["suites"] }, require: ["check", "suites"], preMerge: ["check"], source: "runs" };
|
|
24
|
+
|
|
25
|
+
/** Records an unsigned `check` run on the checkout's HEAD: it fails when `red.txt` is there. */
|
|
26
|
+
const RECORD_CHECK = `
|
|
27
|
+
import { existsSync } from "node:fs";
|
|
28
|
+
import { runOpLocally, OpRunFailure } from ${JSON.stringify(join(REPO, "packages/core/src/op/local-executor.ts"))};
|
|
29
|
+
const red = existsSync("red.txt");
|
|
30
|
+
const op = { name: "check", overview: "check", labels: { Env: "ci" }, phases: [{ name: "Run", steps: [{ kind: "activity", fn: "step", args: {} }] }] };
|
|
31
|
+
const fns = new Map([["step", async () => { if (red) throw new Error("red"); return {}; }]]);
|
|
32
|
+
try {
|
|
33
|
+
await runOpLocally(op, fns, {}, undefined, { cwd: process.cwd(), ledger: { cwd: process.cwd() } });
|
|
34
|
+
} catch (err) {
|
|
35
|
+
if (!(err instanceof OpRunFailure)) throw err;
|
|
36
|
+
}
|
|
37
|
+
`;
|
|
38
|
+
|
|
39
|
+
function chant(cwd: string, ...args: string[]): Promise<{ code: number; out: string; err: string }> {
|
|
40
|
+
return new Promise((done) => {
|
|
41
|
+
const env: Record<string, string | undefined> = { ...process.env, GIT_TERMINAL_PROMPT: "0", NO_COLOR: "1" };
|
|
42
|
+
// No forge and no key: nothing here may lean on either.
|
|
43
|
+
for (const name of ["GITHUB_ACTIONS", "GITHUB_TOKEN", "GH_TOKEN", "CHANT_FORGE_TOKEN", "CHANT_RUNNER_KEY", "GITHUB_REPOSITORY"]) delete env[name];
|
|
44
|
+
const child = spawn(process.execPath, ["--import", LOADER, join(REPO, "packages/core/src/cli/main.ts"), ...args], { cwd, env, stdio: ["ignore", "pipe", "pipe"] });
|
|
45
|
+
let out = "";
|
|
46
|
+
let err = "";
|
|
47
|
+
child.stdout.on("data", (c) => (out += c));
|
|
48
|
+
child.stderr.on("data", (c) => (err += c));
|
|
49
|
+
child.on("close", (code) => done({ code: code ?? 1, out, err }));
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function commit(root: string, file: string): string {
|
|
54
|
+
writeFiles(root, { [file]: `${file}\n` });
|
|
55
|
+
git(root, "add", "-A");
|
|
56
|
+
git(root, "commit", "-q", "-m", file);
|
|
57
|
+
return git(root, "rev-parse", "HEAD");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function pushLand(root: string, name: string, files: string[]): string {
|
|
61
|
+
git(root, "checkout", "-q", "-b", `land-${name}`, "main");
|
|
62
|
+
let sha = "";
|
|
63
|
+
for (const f of files) sha = commit(root, f);
|
|
64
|
+
git(root, "push", "-q", "origin", `${sha}:refs/heads/chant/land/${name}`);
|
|
65
|
+
git(root, "checkout", "-q", "main");
|
|
66
|
+
return sha;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const remote = (origin: string, ref: string): string | null => {
|
|
70
|
+
try {
|
|
71
|
+
return git(origin, "rev-parse", "--verify", "--quiet", ref);
|
|
72
|
+
} catch {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
describe("ci serve --forge git --land, end to end (#4168 4.4, #4016 gap 6)", () => {
|
|
78
|
+
let root: string;
|
|
79
|
+
let origin: string;
|
|
80
|
+
let main: string;
|
|
81
|
+
let build: string;
|
|
82
|
+
|
|
83
|
+
beforeAll(() => {
|
|
84
|
+
origin = scratchDir("chant-land-e2e-origin-");
|
|
85
|
+
git(origin, "init", "-q", "--bare");
|
|
86
|
+
root = scratchDir("chant-land-e2e-work-");
|
|
87
|
+
git(root, "init", "-q");
|
|
88
|
+
git(root, "symbolic-ref", "HEAD", "refs/heads/main");
|
|
89
|
+
git(root, "config", "user.name", "t");
|
|
90
|
+
git(root, "config", "user.email", "t@example.com");
|
|
91
|
+
git(root, "remote", "add", "origin", origin);
|
|
92
|
+
writeFiles(root, {
|
|
93
|
+
"chant.workspace.json": declaration([{ name: "app", dir: "app", kind: "other", because: "the app" }], { ci: { green: GREEN } }),
|
|
94
|
+
"app/a.txt": "a\n",
|
|
95
|
+
});
|
|
96
|
+
main = commit(root, "init.txt");
|
|
97
|
+
git(root, "push", "-q", "origin", "main");
|
|
98
|
+
const script = join(scratchDir("chant-land-e2e-bin-"), "record-check.mts");
|
|
99
|
+
writeFileSync(script, RECORD_CHECK);
|
|
100
|
+
build = `${JSON.stringify(process.execPath)} --import ${JSON.stringify(LOADER)} ${JSON.stringify(script)}`;
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
const serve = (state: string) => chant(root, "ci", "serve", "--forge", "git", "--env", "prod", "--once", "--land", "--build-command", build, "--state", join(root, ".git", state));
|
|
104
|
+
|
|
105
|
+
test("a land whose pre-merge phase passes moves main to it, and the land branch is deleted", async () => {
|
|
106
|
+
const head = pushLand(root, "fix", ["fix-1.txt", "fix-2.txt"]);
|
|
107
|
+
const r = await serve("serve-green");
|
|
108
|
+
expect(r.err).toContain("no commit status is set");
|
|
109
|
+
expect(r.err).toContain(`chant/land/fix at ${head.slice(0, 12)} landed, and the land branch was deleted`);
|
|
110
|
+
expect(r.code).toBe(0);
|
|
111
|
+
expect(remote(origin, "refs/heads/main")).toBe(head);
|
|
112
|
+
expect(remote(origin, "refs/heads/chant/land/fix")).toBeNull();
|
|
113
|
+
main = head;
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("a land whose pre-merge phase fails leaves main and the land branch", async () => {
|
|
117
|
+
git(root, "fetch", "-q", "origin", "main");
|
|
118
|
+
git(root, "reset", "-q", "--hard", "origin/main");
|
|
119
|
+
const head = pushLand(root, "red", ["red.txt"]);
|
|
120
|
+
const r = await serve("serve-red");
|
|
121
|
+
expect(r.err).toContain(`chant/land/red at ${head.slice(0, 12)} was refused and left on the remote: the pre-merge phase check failed`);
|
|
122
|
+
expect(remote(origin, "refs/heads/main")).toBe(main);
|
|
123
|
+
expect(remote(origin, "refs/heads/chant/land/red")).toBe(head);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("--land without a build command, or without ci.green.preMerge, is refused before the first poll", async () => {
|
|
127
|
+
const noBuild = await chant(root, "ci", "serve", "--forge", "git", "--env", "prod", "--once", "--land");
|
|
128
|
+
expect(noBuild.code).toBe(1);
|
|
129
|
+
expect(noBuild.err).toContain("--land runs each land branch's pre-merge phases with --build-command");
|
|
130
|
+
|
|
131
|
+
const bare = scratchDir("chant-land-e2e-nopre-");
|
|
132
|
+
git(bare, "init", "-q");
|
|
133
|
+
writeFiles(bare, { "chant.workspace.json": declaration([{ name: "app", dir: "app", kind: "other", because: "the app" }], { ci: { green: { ...GREEN, preMerge: undefined } } }) });
|
|
134
|
+
const noPre = await chant(bare, "ci", "serve", "--forge", "git", "--env", "prod", "--once", "--land", "--build-command", "true");
|
|
135
|
+
expect(noPre.code).toBe(1);
|
|
136
|
+
expect(noPre.err).toContain("the declaration names none");
|
|
137
|
+
});
|
|
138
|
+
});
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #4168 4.4 — `ci serve --land` fast-forwards the target to a pushed
|
|
3
|
+
* `chant/land/<name>` branch once its pre-merge phases pass on it, and
|
|
4
|
+
* #4016 gap 6 — `ci serve --forge git` reads a plain git server's refs.
|
|
5
|
+
*
|
|
6
|
+
* Each test works in its own clone with a bare origin. The build and the
|
|
7
|
+
* judge are fakes; `ci-serve-land.e2e.test.ts` runs the real ones.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { afterAll, describe, expect, test } from "vitest";
|
|
12
|
+
import { cleanScratch, git, scratchDir, writeFiles } from "./__fixtures__/contract-repo";
|
|
13
|
+
import { ciServeLands, formatCiServeEvent, gitCiServeForge, newCiServeRuntime, readCiServeState, type CiServeEvent, type CiServeOptions } from "./ci-serve";
|
|
14
|
+
import type { CommitVerdict } from "./ci-green";
|
|
15
|
+
|
|
16
|
+
afterAll(cleanScratch);
|
|
17
|
+
|
|
18
|
+
function commit(root: string, file: string): string {
|
|
19
|
+
writeFiles(root, { [file]: `${file}\n` });
|
|
20
|
+
git(root, "add", "-A");
|
|
21
|
+
git(root, "commit", "-q", "-m", file);
|
|
22
|
+
return git(root, "rev-parse", "HEAD");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** A clone on main with a bare origin. */
|
|
26
|
+
function clone(): { root: string; origin: string; main: string } {
|
|
27
|
+
const origin = scratchDir("chant-serve-land-origin-");
|
|
28
|
+
git(origin, "init", "-q", "--bare");
|
|
29
|
+
const root = scratchDir("chant-serve-land-");
|
|
30
|
+
git(root, "init", "-q");
|
|
31
|
+
git(root, "symbolic-ref", "HEAD", "refs/heads/main");
|
|
32
|
+
git(root, "config", "user.name", "t");
|
|
33
|
+
git(root, "config", "user.email", "t@example.com");
|
|
34
|
+
git(root, "remote", "add", "origin", origin);
|
|
35
|
+
const main = commit(root, "a.txt");
|
|
36
|
+
git(root, "push", "-q", "origin", "main");
|
|
37
|
+
return { root, origin, main };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** A commit on top of `from`, pushed as `chant/land/<name>`. */
|
|
41
|
+
function pushLand(root: string, name: string, from: string, file: string): string {
|
|
42
|
+
git(root, "checkout", "-q", "--detach", from);
|
|
43
|
+
const sha = commit(root, file);
|
|
44
|
+
git(root, "push", "-q", "--force", "origin", `${sha}:refs/heads/chant/land/${name}`);
|
|
45
|
+
git(root, "checkout", "-q", "main");
|
|
46
|
+
return sha;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const verdict = (phases: Record<string, "pass" | "fail" | "pending">): CommitVerdict => {
|
|
50
|
+
const list = Object.entries(phases).map(([phase, v]) => ({ phase, verdict: v, runs: [], unmatched: [] }));
|
|
51
|
+
return { verdict: list.some((p) => p.verdict === "fail") ? "fail" : list.some((p) => p.verdict === "pending") ? "pending" : "pass", phases: list, failure: null };
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
function options(root: string, log: CiServeEvent[], extra: Partial<CiServeOptions> = {}): CiServeOptions & { built: { commit: string; phases: string[]; reason: string }[] } {
|
|
55
|
+
const built: { commit: string; phases: string[]; reason: string }[] = [];
|
|
56
|
+
return {
|
|
57
|
+
cwd: root,
|
|
58
|
+
forge: "git",
|
|
59
|
+
repo: "origin",
|
|
60
|
+
env: "prod",
|
|
61
|
+
client: gitCiServeForge(root),
|
|
62
|
+
stateDir: join(root, ".git", "serve"),
|
|
63
|
+
buildCommand: "true",
|
|
64
|
+
build: async ({ request }) => (built.push({ ...request }), 0),
|
|
65
|
+
land: { phases: ["check"] },
|
|
66
|
+
judgeLand: async () => verdict({ check: "pass" }),
|
|
67
|
+
log: (e) => log.push(e),
|
|
68
|
+
built,
|
|
69
|
+
...extra,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const remoteHead = (origin: string, ref: string): string | undefined => {
|
|
74
|
+
try {
|
|
75
|
+
return git(origin, "rev-parse", "--verify", "--quiet", ref);
|
|
76
|
+
} catch {
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
describe("the git forge kind (#4016 gap 6)", () => {
|
|
82
|
+
test("reads the branch head through ls-remote, lists no pull requests, and says when the branch is missing", async () => {
|
|
83
|
+
const { root, main } = clone();
|
|
84
|
+
const forge = gitCiServeForge(root);
|
|
85
|
+
expect(await forge.branchHead("main")).toBe(main);
|
|
86
|
+
expect(await forge.branchHead("nope")).toBeUndefined();
|
|
87
|
+
expect(await forge.openPullRequests("main")).toEqual([]);
|
|
88
|
+
expect(forge.pullRequestFor).toBeUndefined();
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe("ci serve --land (#4168 4.4)", () => {
|
|
93
|
+
test("a land whose pre-merge phases pass moves the target to it and deletes the land branch, once", async () => {
|
|
94
|
+
const { root, origin, main } = clone();
|
|
95
|
+
const head = pushLand(root, "fix", main, "b.txt");
|
|
96
|
+
const log: CiServeEvent[] = [];
|
|
97
|
+
const opts = options(root, log);
|
|
98
|
+
const rt = newCiServeRuntime();
|
|
99
|
+
const pass = await ciServeLands(opts, rt);
|
|
100
|
+
await Promise.all(pass.started);
|
|
101
|
+
|
|
102
|
+
expect(opts.built).toEqual([{ commit: head, phases: ["check"], reason: "land" }]);
|
|
103
|
+
expect(remoteHead(origin, "refs/heads/main")).toBe(head);
|
|
104
|
+
expect(remoteHead(origin, "refs/heads/chant/land/fix")).toBeUndefined();
|
|
105
|
+
const state = readCiServeState(join(root, ".git", "serve", "chant-apply-prod.json"), { forge: "git", repo: "origin", env: "prod", branch: "main" });
|
|
106
|
+
expect(state.lands?.[`land:fix:${head}`]).toMatchObject({ branch: "chant/land/fix", commit: head, status: "landed" });
|
|
107
|
+
expect(log.map(formatCiServeEvent)).toContain(`chant/land/fix at ${head.slice(0, 12)} landed, and the land branch was deleted`);
|
|
108
|
+
|
|
109
|
+
// Nothing is left to serve.
|
|
110
|
+
expect((await ciServeLands(opts, rt)).started).toEqual([]);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("a red pre-merge phase refuses the land and leaves the target and the land branch", async () => {
|
|
114
|
+
const { root, origin, main } = clone();
|
|
115
|
+
const head = pushLand(root, "red", main, "b.txt");
|
|
116
|
+
const log: CiServeEvent[] = [];
|
|
117
|
+
const opts = options(root, log, { judgeLand: async () => verdict({ check: "fail" }) });
|
|
118
|
+
await Promise.all((await ciServeLands(opts, newCiServeRuntime())).started);
|
|
119
|
+
|
|
120
|
+
expect(remoteHead(origin, "refs/heads/main")).toBe(main);
|
|
121
|
+
expect(remoteHead(origin, "refs/heads/chant/land/red")).toBe(head);
|
|
122
|
+
expect(log.map(formatCiServeEvent)).toContain(`chant/land/red at ${head.slice(0, 12)} was refused and left on the remote: the pre-merge phase check failed`);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("a phase with no finished run, or a build that fails, refuses the land", async () => {
|
|
126
|
+
const { root, origin, main } = clone();
|
|
127
|
+
const a = pushLand(root, "norun", main, "b.txt");
|
|
128
|
+
const b = pushLand(root, "broken", main, "c.txt");
|
|
129
|
+
const log: CiServeEvent[] = [];
|
|
130
|
+
const opts = options(root, log, {
|
|
131
|
+
maxJobs: 2,
|
|
132
|
+
judgeLand: async () => verdict({ check: "pending" }),
|
|
133
|
+
build: async ({ request }) => (request.commit === b ? 3 : 0),
|
|
134
|
+
});
|
|
135
|
+
await Promise.all((await ciServeLands(opts, newCiServeRuntime())).started);
|
|
136
|
+
|
|
137
|
+
expect(remoteHead(origin, "refs/heads/main")).toBe(main);
|
|
138
|
+
const lines = log.map(formatCiServeEvent);
|
|
139
|
+
expect(lines).toContain(`chant/land/norun at ${a.slice(0, 12)} was refused and left on the remote: the build recorded no finished run for check`);
|
|
140
|
+
expect(lines).toContain(`chant/land/broken at ${b.slice(0, 12)} was refused and left on the remote: the build of check exited 3`);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("a land that does not descend from the target is refused without a build, and a new push to it is tried again", async () => {
|
|
144
|
+
const { root, origin, main } = clone();
|
|
145
|
+
const stale = pushLand(root, "old", main, "b.txt");
|
|
146
|
+
const moved = commit(root, "c.txt");
|
|
147
|
+
git(root, "push", "-q", "origin", "main");
|
|
148
|
+
const log: CiServeEvent[] = [];
|
|
149
|
+
const opts = options(root, log);
|
|
150
|
+
const rt = newCiServeRuntime();
|
|
151
|
+
await Promise.all((await ciServeLands(opts, rt)).started);
|
|
152
|
+
|
|
153
|
+
expect(opts.built).toEqual([]);
|
|
154
|
+
expect(remoteHead(origin, "refs/heads/main")).toBe(moved);
|
|
155
|
+
expect(log.map(formatCiServeEvent)).toContain(
|
|
156
|
+
`chant/land/old at ${stale.slice(0, 12)} was refused and left on the remote: ${stale.slice(0, 12)} does not descend from main at ${moved.slice(0, 12)}; rebase chant/land/old onto it and push again`,
|
|
157
|
+
);
|
|
158
|
+
// The same head is not tried twice.
|
|
159
|
+
expect((await ciServeLands(opts, rt)).started).toEqual([]);
|
|
160
|
+
|
|
161
|
+
// Rebased and pushed again, it lands.
|
|
162
|
+
const rebased = pushLand(root, "old", moved, "b.txt");
|
|
163
|
+
await Promise.all((await ciServeLands(opts, rt)).started);
|
|
164
|
+
expect(remoteHead(origin, "refs/heads/main")).toBe(rebased);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("lands wait for capacity", async () => {
|
|
168
|
+
const { root, main } = clone();
|
|
169
|
+
const head = pushLand(root, "fix", main, "b.txt");
|
|
170
|
+
const log: CiServeEvent[] = [];
|
|
171
|
+
const rt = newCiServeRuntime();
|
|
172
|
+
rt.inflight.set("busy", new Promise(() => {}));
|
|
173
|
+
expect((await ciServeLands(options(root, log), rt)).started).toEqual([]);
|
|
174
|
+
expect(log.map(formatCiServeEvent)).toContain(`chant/land/fix at ${head.slice(0, 12)} waits: --max-jobs jobs run`);
|
|
175
|
+
});
|
|
176
|
+
});
|