@intentius/chant 0.124.43 → 0.124.45
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/ci-identity.d.ts +183 -12
- package/dist/ci-identity.d.ts.map +1 -1
- package/dist/ci-identity.js +362 -53
- package/dist/cli/handlers/build.d.ts.map +1 -1
- package/dist/cli/handlers/build.js +17 -1
- package/dist/cli/handlers/pr-env.d.ts +8 -2
- package/dist/cli/handlers/pr-env.d.ts.map +1 -1
- package/dist/cli/handlers/pr-env.js +126 -225
- package/dist/cli/handlers/pr.d.ts +3 -0
- package/dist/cli/handlers/pr.d.ts.map +1 -1
- package/dist/cli/handlers/pr.js +1 -0
- package/dist/components/cli-support.d.ts +2 -0
- package/dist/components/cli-support.d.ts.map +1 -1
- package/dist/components/cli-support.js +4 -2
- package/dist/components/fan-out-support.d.ts +3 -0
- package/dist/components/fan-out-support.d.ts.map +1 -1
- package/dist/components/fan-out-support.js +1 -1
- package/dist/components/pr-env-run.d.ts +270 -0
- package/dist/components/pr-env-run.d.ts.map +1 -0
- package/dist/components/pr-env-run.js +440 -0
- package/dist/components/pr-environment.d.ts +89 -14
- package/dist/components/pr-environment.d.ts.map +1 -1
- package/dist/components/pr-environment.js +94 -8
- package/dist/components/pr-pipeline.d.ts +10 -1
- package/dist/components/pr-pipeline.d.ts.map +1 -1
- package/dist/op/op-waves.d.ts +17 -0
- package/dist/op/op-waves.d.ts.map +1 -1
- package/dist/op/op-waves.js +23 -1
- package/dist/pr-forge.d.ts +15 -1
- package/dist/pr-forge.d.ts.map +1 -1
- package/dist/pr-forge.js +28 -16
- package/dist/pr-requires.d.ts +5 -1
- package/dist/pr-requires.d.ts.map +1 -1
- package/dist/wave-runtime.mjs +179 -179
- package/dist/workspace/ci-cloud-identity.d.ts +3 -1
- package/dist/workspace/ci-cloud-identity.d.ts.map +1 -1
- package/dist/workspace/ci-cloud-identity.js +16 -1
- package/dist/workspace/ci-run.d.ts +35 -8
- package/dist/workspace/ci-run.d.ts.map +1 -1
- package/dist/workspace/ci-run.js +95 -21
- package/dist/workspace/ci-secrets.d.ts.map +1 -1
- package/dist/workspace/ci-secrets.js +4 -3
- package/dist/workspace/declaration.d.ts +10 -0
- package/dist/workspace/declaration.d.ts.map +1 -1
- package/dist/workspace/declaration.js +17 -1
- package/dist/workspace/declaration.schema.json +36 -1
- package/package.json +1 -1
- package/src/ci-identity-targets.test.ts +275 -0
- package/src/ci-identity.ts +394 -61
- package/src/cli/handlers/build.ts +15 -1
- package/src/cli/handlers/pr-env.ts +136 -227
- package/src/cli/handlers/pr.ts +4 -0
- package/src/components/cli-support.ts +6 -1
- package/src/components/fan-out-support.ts +4 -1
- package/src/components/pr-env-run.test.ts +290 -0
- package/src/components/pr-env-run.ts +634 -0
- package/src/components/pr-environment.ts +159 -12
- package/src/components/pr-pipeline.ts +10 -1
- package/src/op/op-waves.ts +38 -1
- package/src/pr-forge-bundle.test.ts +1 -1
- package/src/pr-forge.ts +44 -17
- package/src/pr-requires.test.ts +43 -6
- package/src/pr-requires.ts +5 -1
- package/src/workspace/ci-cloud-identity.test.ts +20 -0
- package/src/workspace/ci-cloud-identity.ts +14 -1
- package/src/workspace/ci-run.test.ts +74 -1
- package/src/workspace/ci-run.ts +122 -29
- package/src/workspace/ci-secrets.test.ts +1 -1
- package/src/workspace/ci-secrets.ts +4 -3
- package/src/workspace/declaration.schema.json +36 -1
- package/src/workspace/declaration.test.ts +7 -0
- package/src/workspace/declaration.ts +24 -2
package/src/pr-requires.ts
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* Like ./pr-forge.ts this imports nothing beyond it, so a product bundles it
|
|
14
14
|
* without the TypeScript toolchain.
|
|
15
15
|
*/
|
|
16
|
-
import type { PrApproverFilter, PrForge, PrRequireResult } from "./pr-forge";
|
|
16
|
+
import type { PrApproverFilter, PrExcludedApprover, PrForge, PrRequireResult } from "./pr-forge";
|
|
17
17
|
|
|
18
18
|
/** The four checks, in the order they run and the report lists them. */
|
|
19
19
|
export const PR_APPLY_REQUIRES = ["approved", "mergeable", "undiverged", "checks"] as const;
|
|
@@ -23,6 +23,10 @@ export type PrApplyRequire = (typeof PR_APPLY_REQUIRES)[number];
|
|
|
23
23
|
/** One check as the pull-request report's `beforeMerge.requires` holds it. */
|
|
24
24
|
export interface PrRequireOutcome extends PrRequireResult {
|
|
25
25
|
name: PrApplyRequire;
|
|
26
|
+
/** `approved` only (#4287): the logins that counted, as {@link PrApprovalResult} gives them. */
|
|
27
|
+
approvers?: string[];
|
|
28
|
+
/** `approved` only (#4287): the logins left out, each with why. */
|
|
29
|
+
excluded?: PrExcludedApprover[];
|
|
26
30
|
}
|
|
27
31
|
|
|
28
32
|
export interface PrRequiresOptions {
|
|
@@ -50,4 +50,24 @@ describe("declaredCloudIdentity (#4296)", () => {
|
|
|
50
50
|
const root = workspace({ identity: { cloud: { aws: { rules: [{ plan: "r", apply: "r" }] } } } });
|
|
51
51
|
await expect(declaredCloudIdentity(root, "production")).rejects.toThrow(/plans and applies as r.*\[declaration-invalid\]/);
|
|
52
52
|
});
|
|
53
|
+
|
|
54
|
+
test("rules with targets and a name read from the declaration, and a target glob list may not be empty (#4332)", async () => {
|
|
55
|
+
const root = workspace({
|
|
56
|
+
identity: {
|
|
57
|
+
cloud: {
|
|
58
|
+
aws: {
|
|
59
|
+
rules: [
|
|
60
|
+
{ members: ["net"], targets: ["envs/prod/**"], name: "prod", plan: "arn:aws:iam::1:role/prod-plan", apply: "arn:aws:iam::1:role/prod-apply" },
|
|
61
|
+
{ plan: "arn:aws:iam::1:role/plan", apply: "arn:aws:iam::1:role/apply" },
|
|
62
|
+
],
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
});
|
|
67
|
+
const net = (await declaredCloudIdentity(join(root, "net"), "production", "net"))!;
|
|
68
|
+
expect(net.plan.aws).toMatchObject({ role: "arn:aws:iam::1:role/plan", targets: [{ targets: ["envs/prod/**"], role: "arn:aws:iam::1:role/prod-plan" }] });
|
|
69
|
+
expect((await declaredCloudIdentity(join(root, "app"), "production", "app"))!.plan.aws!.targets).toBeUndefined();
|
|
70
|
+
const empty = workspace({ identity: { cloud: { aws: { rules: [{ targets: [], plan: "p", apply: "a" }] } } } });
|
|
71
|
+
await expect(declaredCloudIdentity(empty, "production")).rejects.toThrow(/\[declaration-invalid\]/);
|
|
72
|
+
});
|
|
53
73
|
});
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { findWorkspaceRoot } from "../project-root";
|
|
10
|
-
import { resolveCloudIdentity, type PipelineCloudIdentity } from "../ci-identity";
|
|
10
|
+
import { resolveCloudIdentity, resolvePrEnvCloudIdentity, type CloudTradeSpec, type PipelineCloudIdentity } from "../ci-identity";
|
|
11
11
|
|
|
12
12
|
/** The plan and apply identities of the pipeline for `env` and `member`, or undefined. */
|
|
13
13
|
export async function declaredCloudIdentity(cwd: string, env: string, member?: string): Promise<PipelineCloudIdentity | undefined> {
|
|
@@ -21,3 +21,16 @@ export async function declaredCloudIdentity(cwd: string, env: string, member?: s
|
|
|
21
21
|
throw Object.assign(new Error(`${err.describe()} [${err.code}]`), { code: err.code });
|
|
22
22
|
}
|
|
23
23
|
}
|
|
24
|
+
|
|
25
|
+
/** The trade a pull request environment's jobs take for `member` (#4330, `resolvePrEnvCloudIdentity`), or undefined. */
|
|
26
|
+
export async function declaredPrEnvCloudIdentity(cwd: string, member?: string): Promise<CloudTradeSpec | undefined> {
|
|
27
|
+
const found = findWorkspaceRoot(cwd);
|
|
28
|
+
if (!found) return undefined;
|
|
29
|
+
const [{ readDeclaration, WorkspaceReadError }, { workingTree }] = await Promise.all([import("./declaration"), import("./tree")]);
|
|
30
|
+
try {
|
|
31
|
+
return resolvePrEnvCloudIdentity(readDeclaration(workingTree(found.dir)).ci?.identity?.cloud, member);
|
|
32
|
+
} catch (err) {
|
|
33
|
+
if (!(err instanceof WorkspaceReadError)) throw err;
|
|
34
|
+
throw Object.assign(new Error(`${err.describe()} [${err.code}]`), { code: err.code });
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -26,7 +26,7 @@ import { sshFromEd25519 } from "./trust/dsse";
|
|
|
26
26
|
import { verifyOpRunStatement } from "./trust/op-run-statement";
|
|
27
27
|
import { readRunLedger } from "../lifecycle/run-ledger";
|
|
28
28
|
import { resolveRunId } from "../lifecycle/release-ledger";
|
|
29
|
-
import { prLoopJobs, PR_LOOP_SETUP } from "../components/pr-pipeline";
|
|
29
|
+
import { prEnvJobs, prLoopJobs, PR_LOOP_SETUP } from "../components/pr-pipeline";
|
|
30
30
|
import { parseArgs } from "../cli/parse-args";
|
|
31
31
|
import { ciSecretsFor, readCiSecrets } from "./ci-secrets";
|
|
32
32
|
import { runEventFileName, runEventsDir } from "../run-event-store";
|
|
@@ -1039,3 +1039,76 @@ describe("the plan's steps hold no forge token (#4295)", () => {
|
|
|
1039
1039
|
expect(plan!.command.endsWith(" --no-status")).toBe(true);
|
|
1040
1040
|
});
|
|
1041
1041
|
});
|
|
1042
|
+
|
|
1043
|
+
/**
|
|
1044
|
+
* #4330: a pull request environment's jobs run through `ci run` too. The
|
|
1045
|
+
* stand-in chant keeps each action's arguments, environment, directory and
|
|
1046
|
+
* commit under `.chant/pr-env`, and `up` writes a report and a record there.
|
|
1047
|
+
*/
|
|
1048
|
+
function fakeEnvChant(): string {
|
|
1049
|
+
const file = join(scratchDir("chant-ci-run-env-bin-"), "chant");
|
|
1050
|
+
writeFileSync(
|
|
1051
|
+
file,
|
|
1052
|
+
[
|
|
1053
|
+
"#!/bin/sh",
|
|
1054
|
+
'action="$3"',
|
|
1055
|
+
"mkdir -p .chant/pr-env",
|
|
1056
|
+
'printf "%s\\n" "$@" > ".chant/pr-env/$action.args"',
|
|
1057
|
+
'env | sort > ".chant/pr-env/$action.env"',
|
|
1058
|
+
'git rev-parse HEAD > ".chant/pr-env/$action.commit"',
|
|
1059
|
+
'ls .chant/pr-env > ".chant/pr-env/$action.found"',
|
|
1060
|
+
'if [ "$action" = up ]; then echo "{\\"stage\\": \\"up\\"}" > .chant/pr-env/pr-env-up.json; echo "{}" > .chant/pr-env/pr-env-record.json; fi',
|
|
1061
|
+
'exit "${FAKE_EXIT:-0}"',
|
|
1062
|
+
"",
|
|
1063
|
+
].join("\n"),
|
|
1064
|
+
);
|
|
1065
|
+
chmodSync(file, 0o755);
|
|
1066
|
+
return file;
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
describe("a pull request environment's jobs (#4330)", () => {
|
|
1070
|
+
test("env-up runs with no forge token, then env-post runs at the base commit with it and the report env-up handed on", async () => {
|
|
1071
|
+
const { app, base, sha } = workspace();
|
|
1072
|
+
const out = join(scratchDir("chant-ci-run-env-out-"), "pr-env");
|
|
1073
|
+
const r = await ciRun({ image: JOB_IMAGE, cwd: app, job: "env-up", env: "pr-7", forge: "github", base, pr: 7, chant: fakeEnvChant(), hostEnv: HOST, output: out, push: false });
|
|
1074
|
+
expect(r.op).toBe("pr-env-up");
|
|
1075
|
+
expect(r.status).toBe("ok");
|
|
1076
|
+
const upEnv = envFile(out, "up");
|
|
1077
|
+
for (const name of FORGE_TOKEN_VARS) expect(upEnv[name]).toBeUndefined();
|
|
1078
|
+
expect(upEnv.CHANT_ENV).toBe("pr-7");
|
|
1079
|
+
expect(upEnv.PR_NUMBER).toBe("7");
|
|
1080
|
+
expect(readFileSync(join(out, "up.commit"), "utf-8").trim()).toBe(sha);
|
|
1081
|
+
expect(readFileSync(join(out, "up.args"), "utf-8")).not.toContain("--forge");
|
|
1082
|
+
|
|
1083
|
+
expect(r.post?.op).toBe("pr-env-post");
|
|
1084
|
+
const postEnv = envFile(out, "post");
|
|
1085
|
+
// The token reached env-post, and its copied report is redacted.
|
|
1086
|
+
expect(postEnv.GITHUB_TOKEN).toBe("***");
|
|
1087
|
+
expect(postEnv.HEAD_SHA).toBe(sha);
|
|
1088
|
+
expect(postEnv.UP_RESULT).toBe("success");
|
|
1089
|
+
expect(readFileSync(join(out, "post.commit"), "utf-8").trim()).toBe(base);
|
|
1090
|
+
expect(readFileSync(join(out, "post.found"), "utf-8")).toContain("pr-env-record.json");
|
|
1091
|
+
});
|
|
1092
|
+
|
|
1093
|
+
test("env-down runs with the token and writes its report under .chant/pr-env, and a local env-up posts nothing", async () => {
|
|
1094
|
+
const { app, base } = workspace();
|
|
1095
|
+
const out = join(scratchDir("chant-ci-run-env-out-"), "pr-env");
|
|
1096
|
+
const down = await ciRun({ image: JOB_IMAGE, cwd: app, job: "env-down", env: "pr-7", forge: "github", base, pr: 7, chant: fakeEnvChant(), hostEnv: HOST, output: out, push: false });
|
|
1097
|
+
expect(down.op).toBe("pr-env-down");
|
|
1098
|
+
expect(envFile(out, "down").GITHUB_TOKEN).toBe("***");
|
|
1099
|
+
expect(readFileSync(join(out, "down.args"), "utf-8")).toContain("closed");
|
|
1100
|
+
|
|
1101
|
+
const local = await ciRun({ image: JOB_IMAGE, cwd: app, job: "env-up", env: "pr-7", forge: "github", base, pr: 7, chant: fakeEnvChant(), hostEnv: HOST, output: join(scratchDir("chant-ci-run-env-out-"), "pr-env"), local: true });
|
|
1102
|
+
expect(local.post).toBeUndefined();
|
|
1103
|
+
});
|
|
1104
|
+
|
|
1105
|
+
test("env-post reads the head and the up job's result from flags", () => {
|
|
1106
|
+
expect(() => ciRunEnvJobEnv({})).toThrow(/pass --head <sha>/);
|
|
1107
|
+
expect(ciRunEnvJobEnv({ head: "h".repeat(40), upResult: "failure" })).toMatchObject({ HEAD_SHA: "h".repeat(40), UP_RESULT: "failure", PR_NUMBER: "7" });
|
|
1108
|
+
});
|
|
1109
|
+
});
|
|
1110
|
+
|
|
1111
|
+
function ciRunEnvJobEnv(values: { head?: string; upResult?: string }): Record<string, string> {
|
|
1112
|
+
const post = prEnvJobs("github", {}, "app").find((j) => j.jobName === "env-post")!;
|
|
1113
|
+
return ciJobEnv(post, { forge: "github", env: "pr-7", values: { pr: 7, base: "b".repeat(40), commit: "b".repeat(40), ...values }, hostEnv: HOST });
|
|
1114
|
+
}
|
package/src/workspace/ci-run.ts
CHANGED
|
@@ -84,7 +84,7 @@ import { execFileSync } from "node:child_process";
|
|
|
84
84
|
import { copyFileSync, cpSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
|
85
85
|
import { tmpdir } from "node:os";
|
|
86
86
|
import { dirname, join, relative, resolve, sep } from "node:path";
|
|
87
|
-
import { PR_APPLY_RECORD, PR_LOOP_REPORT_DIR, prLoopJobs, prLoopSetup, PR_LOOP_IMAGE, type PrLoopJob, type PrLoopPipelineOptions } from "../components/pr-pipeline";
|
|
87
|
+
import { PR_APPLY_RECORD, PR_ENV_POST_JOB, PR_ENV_REPORT_DIR, PR_LOOP_REPORT_DIR, prEnvJobs, prLoopJobs, prLoopSetup, PR_LOOP_IMAGE, type PrEnvJob, type PrLoopJob, type PrLoopPipelineOptions } from "../components/pr-pipeline";
|
|
88
88
|
import { detectForge, ForgeEnvironmentError, forgeFromEnv, FORGE_KINDS, type CommitStatus, type ForgeKind, type PrForge } from "../pr-forge";
|
|
89
89
|
import { holdsForgeToken, withoutForgeTokens } from "../ci-handover";
|
|
90
90
|
import { postPlanReport } from "../pr-post";
|
|
@@ -114,20 +114,42 @@ import { hostRunnerKey } from "./ci-runner-key";
|
|
|
114
114
|
import { requireRunnerStart } from "./ci-runner-start";
|
|
115
115
|
import { securityGroupsAt } from "./security";
|
|
116
116
|
|
|
117
|
-
/**
|
|
118
|
-
|
|
119
|
-
|
|
117
|
+
/**
|
|
118
|
+
* The jobs a pull-request pipeline has: plan, and on merge record (#4168) or,
|
|
119
|
+
* opted in, apply; and with `--pr-env` (#4298, #4330) the jobs of a pull
|
|
120
|
+
* request's environment.
|
|
121
|
+
*/
|
|
122
|
+
export type CiJobName = PrLoopJob["jobName"] | PrEnvJob["jobName"];
|
|
123
|
+
export const CI_JOB_NAMES: readonly CiJobName[] = ["plan", "apply", "record", "env-up", "env-post", "env-down", "env-sweep"];
|
|
124
|
+
|
|
125
|
+
/** The jobs of a pull request's environment (#4330). */
|
|
126
|
+
export const CI_ENV_JOB_NAMES: readonly PrEnvJob["jobName"][] = ["env-up", "env-post", "env-down", "env-sweep"];
|
|
120
127
|
|
|
121
|
-
/**
|
|
122
|
-
export const
|
|
128
|
+
/** Whether `job` is one of a pull request environment's jobs. */
|
|
129
|
+
export const isCiEnvJob = (job: CiJobName): job is PrEnvJob["jobName"] => (CI_ENV_JOB_NAMES as readonly string[]).includes(job);
|
|
130
|
+
|
|
131
|
+
/** The Op each job is recorded as, which `ci.green` phases match. An environment job's is its report's stem too. */
|
|
132
|
+
export const CI_JOB_OPS: Record<CiJobName, string> = { plan: "pr-plan", apply: "pr-apply", record: "pr-record", "env-up": "pr-env-up", "env-post": "pr-env-post", "env-down": "pr-env-down", "env-sweep": "pr-env-sweep" };
|
|
123
133
|
|
|
124
134
|
/** What each job's Op says it does. */
|
|
125
135
|
const CI_JOB_OVERVIEWS: Record<CiJobName, string> = {
|
|
126
136
|
plan: "Plan the members this pull request reaches",
|
|
127
137
|
apply: "Apply the plan a reviewer approved",
|
|
128
138
|
record: "Record the plan on the merged commit; nothing applies",
|
|
139
|
+
"env-up": "Plan this pull request's environment and apply it once its plan is approved",
|
|
140
|
+
"env-post": "Keep the environment's record and post its note",
|
|
141
|
+
"env-down": "Tear down what this pull request's environment owns",
|
|
142
|
+
"env-sweep": "Tear down the environments whose TTL passed or whose pull request closed",
|
|
129
143
|
};
|
|
130
144
|
|
|
145
|
+
/** One job as `ci run` runs it: a pull-request job, or an environment job (#4330). */
|
|
146
|
+
export type CiJob = Pick<PrLoopJob, "command" | "env" | "setup" | "build" | "wave"> & { jobName: CiJobName };
|
|
147
|
+
|
|
148
|
+
/** Where a job writes its report, inside its checkout. */
|
|
149
|
+
export function ciJobReportDir(job: CiJobName): string {
|
|
150
|
+
return isCiEnvJob(job) ? PR_ENV_REPORT_DIR : PR_LOOP_REPORT_DIR;
|
|
151
|
+
}
|
|
152
|
+
|
|
131
153
|
/** Where a job the chant runner ran says it ran (#4094). */
|
|
132
154
|
export const CHANT_RUNNER_ORIGIN: RunOrigin = { forge: "chant" };
|
|
133
155
|
|
|
@@ -167,6 +189,10 @@ export interface CiRunValues {
|
|
|
167
189
|
pr?: number;
|
|
168
190
|
/** The full commit the job runs at. */
|
|
169
191
|
commit: string;
|
|
192
|
+
/** `--head`: the pull request's head, `HEAD_SHA`, which `env-post` reads (#4330). */
|
|
193
|
+
head?: string;
|
|
194
|
+
/** `--result`: the env-up job's result, `UP_RESULT`, which `env-post` reads on GitHub and Forgejo (#4330). */
|
|
195
|
+
upResult?: string;
|
|
170
196
|
}
|
|
171
197
|
|
|
172
198
|
export class CiRunError extends Error {
|
|
@@ -188,7 +214,7 @@ export class CiRunError extends Error {
|
|
|
188
214
|
* runner sets for the job. `CHANT_RUNNER_KEY` is never in it.
|
|
189
215
|
*/
|
|
190
216
|
export function ciJobEnv(
|
|
191
|
-
job:
|
|
217
|
+
job: CiJob,
|
|
192
218
|
input: {
|
|
193
219
|
forge: ForgeKind;
|
|
194
220
|
env: string;
|
|
@@ -211,12 +237,18 @@ export function ciJobEnv(
|
|
|
211
237
|
const flags: Record<string, { value: string | undefined; flag: string }> = {
|
|
212
238
|
BASE_SHA: { value: input.values.base, flag: "--base <sha>" },
|
|
213
239
|
PR_NUMBER: { value: input.values.pr !== undefined ? String(input.values.pr) : undefined, flag: "--pr <number>" },
|
|
240
|
+
HEAD_SHA: { value: input.values.head, flag: "--head <sha>" },
|
|
241
|
+
UP_RESULT: { value: input.values.upResult, flag: "--result success|failure" },
|
|
214
242
|
};
|
|
243
|
+
// #4330: an environment job's CHANT_ENV is pr-<n>, which a forge fills from the event.
|
|
244
|
+
const prEnv = isCiEnvJob(job.jobName) && input.values.pr !== undefined ? `pr-${input.values.pr}` : undefined;
|
|
215
245
|
for (const [name, value] of Object.entries(job.env)) {
|
|
216
246
|
const given = flags[name];
|
|
217
247
|
if (given) {
|
|
218
248
|
if (given.value === undefined) throw new CiRunError(`the ${job.jobName} job reads ${name}, which a forge fills from the event; pass ${given.flag}`);
|
|
219
249
|
out[name] = given.value;
|
|
250
|
+
} else if (name === "CHANT_ENV" && prEnv) {
|
|
251
|
+
out[name] = prEnv;
|
|
220
252
|
} else if (!value.includes("$")) {
|
|
221
253
|
out[name] = value;
|
|
222
254
|
} else if (input.secrets?.[name] !== undefined) {
|
|
@@ -231,7 +263,7 @@ export function ciJobEnv(
|
|
|
231
263
|
|
|
232
264
|
/** What {@link ciJobOp} builds the Op from. */
|
|
233
265
|
export interface CiJobOpInput {
|
|
234
|
-
job:
|
|
266
|
+
job: CiJob;
|
|
235
267
|
env: string;
|
|
236
268
|
/** The directory the steps run in, inside the fresh checkout. */
|
|
237
269
|
cwd: string;
|
|
@@ -251,7 +283,7 @@ export function withChant(line: string, chant?: string): string {
|
|
|
251
283
|
}
|
|
252
284
|
|
|
253
285
|
/** The job's own setup lines and its command, as one shell script, with `chant` at its start replaced by the command the caller names. */
|
|
254
|
-
export function ciJobCommand(job:
|
|
286
|
+
export function ciJobCommand(job: CiJob, chant?: string): string {
|
|
255
287
|
return [...(job.setup ?? []), withChant(job.command, chant)].join("\n");
|
|
256
288
|
}
|
|
257
289
|
|
|
@@ -283,7 +315,7 @@ export function ciJobOp(input: CiJobOpInput): OpConfig {
|
|
|
283
315
|
}
|
|
284
316
|
|
|
285
317
|
/** The pull-request report statuses that mean the job did not do its work, which a failed job's `failure.code` takes. */
|
|
286
|
-
const FAILED_REPORT_STATUSES = new Set(["plan-failed", "failed", "refused"]);
|
|
318
|
+
const FAILED_REPORT_STATUSES = new Set(["plan-failed", "failed", "refused", "incomplete"]);
|
|
287
319
|
|
|
288
320
|
/** The exit code a failed shell step's error names (`command exited <n>`), when it names one. */
|
|
289
321
|
function exitCodeOf(error: string | undefined): number | undefined {
|
|
@@ -441,10 +473,14 @@ export interface CiRunRequest {
|
|
|
441
473
|
* `forge` then only shapes the job's variables.
|
|
442
474
|
*/
|
|
443
475
|
noForge?: boolean;
|
|
444
|
-
/** `--base`, `--pr` and `--commit` (default `HEAD`). */
|
|
476
|
+
/** `--base`, `--pr` and `--commit` (default `HEAD`; for `env-post`, `--base`). */
|
|
445
477
|
base?: string;
|
|
446
478
|
pr?: number;
|
|
447
479
|
commit?: string;
|
|
480
|
+
/** `--head`: for `env-post` (#4330), the pull request's head its environment was applied from. */
|
|
481
|
+
head?: string;
|
|
482
|
+
/** `--result`: for `env-post` (#4330), the env-up job's result. */
|
|
483
|
+
upResult?: string;
|
|
448
484
|
/**
|
|
449
485
|
* The generator's options: `--gate`, `--branch`, `--events-json`,
|
|
450
486
|
* `--report-url`, `--pr-build` and `--pr-build-command`, and `--wave-gate`
|
|
@@ -553,6 +589,11 @@ export interface CiRunResult {
|
|
|
553
589
|
checkout: string;
|
|
554
590
|
/** The job ran with `local` (#4161): its record stays on this machine. */
|
|
555
591
|
local?: true;
|
|
592
|
+
/**
|
|
593
|
+
* For `env-up` (#4330): the `env-post` job the runner ran after it at
|
|
594
|
+
* `--base`, with its own token, which kept the record and posted the note.
|
|
595
|
+
*/
|
|
596
|
+
post?: CiRunResult;
|
|
556
597
|
}
|
|
557
598
|
|
|
558
599
|
const message = (err: unknown): string => (err instanceof Error ? err.message : String(err));
|
|
@@ -619,7 +660,7 @@ function freshCheckout(top: string, commit: string): { dir: string; remove(): vo
|
|
|
619
660
|
*/
|
|
620
661
|
async function recordInIndex(input: {
|
|
621
662
|
url: string;
|
|
622
|
-
job:
|
|
663
|
+
job: PrLoopJob["jobName"];
|
|
623
664
|
commit: string;
|
|
624
665
|
member?: string;
|
|
625
666
|
pr?: number;
|
|
@@ -679,11 +720,14 @@ export async function ciRun(req: CiRunRequest): Promise<CiRunResult> {
|
|
|
679
720
|
const cwd = realpathSync(resolve(req.cwd));
|
|
680
721
|
const top = gitTop(cwd);
|
|
681
722
|
if (!top) throw new CiRunError("a job runs in a fresh checkout of a commit, and this directory is not in a git repository");
|
|
682
|
-
|
|
723
|
+
// #4330: env-post runs at the target branch's commit, none of the change's code.
|
|
724
|
+
const commit = resolveJobCommit(top, req.commit ?? (req.job === PR_ENV_POST_JOB && req.base ? req.base : "HEAD"));
|
|
683
725
|
if (req.inPlace && commit !== resolveJobCommit(top, "HEAD")) throw new CiRunError(`--in-place runs the checkout's HEAD, and --commit names ${commit.slice(0, 12)}`);
|
|
684
726
|
if (req.local && req.inPlace) throw new CiRunError("--in-place runs a forge job in the forge's checkout, and --local runs one on this machine; pass one of them");
|
|
685
727
|
const place = ciJobPlace(cwd, top);
|
|
686
|
-
const
|
|
728
|
+
const loop = req.local ? localLoop(req.loop) : (req.loop ?? {});
|
|
729
|
+
// #4330: the jobs of a pull request's environment, as the generators wrap them with --pr-env.
|
|
730
|
+
const jobs: CiJob[] = isCiEnvJob(req.job) ? prEnvJobs(req.forge, loop, place.member) : prLoopJobs(req.forge, req.env, loop, place.member);
|
|
687
731
|
const found = jobs.find((j) => j.jobName === req.job);
|
|
688
732
|
if (!found) throw new CiRunError(`the pull-request pipeline has no ${req.job} job; it has ${jobs.map((j) => j.jobName).join(" and ")}`);
|
|
689
733
|
if (req.wave !== undefined && (req.job !== "apply" || !req.loop?.waveGate)) throw new CiRunError("--wave names the one wave an apply runs, and takes --wave-gate: ci run apply --wave-gate <gate> --wave <k>");
|
|
@@ -699,15 +743,15 @@ export async function ciRun(req: CiRunRequest): Promise<CiRunResult> {
|
|
|
699
743
|
const own = ciJobEnv(job, {
|
|
700
744
|
forge: req.forge,
|
|
701
745
|
env: req.env,
|
|
702
|
-
values: { base: req.base, pr: req.pr, commit },
|
|
746
|
+
values: { base: req.base, pr: req.pr, commit, ...(req.head ? { head: req.head } : {}), ...(req.upResult ? { upResult: req.upResult } : {}) },
|
|
703
747
|
hostEnv,
|
|
704
748
|
passEnv: req.passEnv,
|
|
705
749
|
secrets: ciSecretsFor(req.secrets, req.job),
|
|
706
750
|
});
|
|
707
751
|
// #4156: in place, the forge job's environment is the job's, less the runner key.
|
|
708
|
-
// #4295: the plan
|
|
752
|
+
// #4295: the plan and env-up run the pull request's code, so no step of them gets a forge token, whatever gave it one.
|
|
709
753
|
const merged = req.inPlace ? { ...definedEnv(hostEnv), ...own } : own;
|
|
710
|
-
const jobEnv = req.job === "plan" ? withoutForgeTokens(merged) : merged;
|
|
754
|
+
const jobEnv = req.job === "plan" || req.job === "env-up" ? withoutForgeTokens(merged) : merged;
|
|
711
755
|
delete jobEnv[RUNNER_KEY_ENV];
|
|
712
756
|
const poster = req.job === "plan" ? planPoster(req, hostEnv) : undefined;
|
|
713
757
|
// #4096: the secrets and the job's secret-looking variables, redacted from everything the run writes and returns.
|
|
@@ -716,7 +760,9 @@ export async function ciRun(req: CiRunRequest): Promise<CiRunResult> {
|
|
|
716
760
|
try {
|
|
717
761
|
const ran = await runCiJob(req, { cwd, top, commit, place, job, jobEnv, ...(redact ? { redact } : {}) });
|
|
718
762
|
const posted = poster ? await postFromReport(req, poster, ran, { commit, ...(place.member ? { member: place.member } : {}) }) : [];
|
|
719
|
-
const
|
|
763
|
+
const envPost = req.job === "env-up" ? await envPostAfterUp(req, { cwd, top, head: commit, place, jobs, hostEnv, ran }) : undefined;
|
|
764
|
+
const withPost = envPost ? ("result" in envPost ? { ...ran, post: envPost.result } : { ...ran, warnings: [...ran.warnings, envPost.warning] }) : ran;
|
|
765
|
+
const withPosts = posted.length > 0 ? { ...withPost, warnings: [...withPost.warnings, ...posted] } : withPost;
|
|
720
766
|
const result = started ? { ...withPosts, notes: [started, ...withPosts.notes] } : withPosts;
|
|
721
767
|
return redact ? { ...result, warnings: result.warnings.map(redact), notes: result.notes.map(redact), ...(result.pushWarning ? { pushWarning: redact(result.pushWarning) } : {}) } : result;
|
|
722
768
|
} catch (err) {
|
|
@@ -725,6 +771,40 @@ export async function ciRun(req: CiRunRequest): Promise<CiRunResult> {
|
|
|
725
771
|
}
|
|
726
772
|
}
|
|
727
773
|
|
|
774
|
+
/**
|
|
775
|
+
* The env-post job after an env-up (#4330), as a generated pipeline runs it
|
|
776
|
+
* in a job of its own: at `--base`, the target branch's commit, with the
|
|
777
|
+
* forge token env-up's steps never got, taking the report and record env-up
|
|
778
|
+
* handed on. None on `--local`, in place (the forge's env-post job posts),
|
|
779
|
+
* for a plain git server, without `--base` and `--pr`, or without a token.
|
|
780
|
+
*/
|
|
781
|
+
async function envPostAfterUp(
|
|
782
|
+
req: CiRunRequest,
|
|
783
|
+
at: { cwd: string; top: string; head: string; place: { member?: string; dir: string }; jobs: CiJob[]; hostEnv: Record<string, string | undefined>; ran: CiRunResult },
|
|
784
|
+
): Promise<{ result: CiRunResult } | { warning: string } | undefined> {
|
|
785
|
+
if (req.local || req.inPlace || req.noForge || req.pr === undefined) return undefined;
|
|
786
|
+
if (!req.base) return { warning: "env-post did not run: it runs at the target branch's commit, so pass --base <sha>" };
|
|
787
|
+
if (!holdsForgeToken(at.hostEnv)) return { warning: "env-post did not run: there is no forge token, so the environment's record is in the report and nothing kept it" };
|
|
788
|
+
const found = at.jobs.find((j) => j.jobName === PR_ENV_POST_JOB);
|
|
789
|
+
if (!found) return undefined;
|
|
790
|
+
const base = resolveJobCommit(at.top, req.base);
|
|
791
|
+
const job = { ...found, command: req.noForge ? found.command.replace(` --forge ${req.forge}`, "") : found.command };
|
|
792
|
+
const postReq: CiRunRequest = { ...req, job: PR_ENV_POST_JOB, commit: base, head: at.head, upResult: at.ran.status === "ok" ? "success" : "failure" };
|
|
793
|
+
delete postReq.status;
|
|
794
|
+
delete postReq.resumeRecord;
|
|
795
|
+
delete postReq.wave;
|
|
796
|
+
const jobEnv = ciJobEnv(job, { forge: req.forge, env: req.env, values: { base: req.base, pr: req.pr, commit: base, head: at.head, upResult: postReq.upResult! }, hostEnv: at.hostEnv, passEnv: req.passEnv, secrets: ciSecretsFor(req.secrets, PR_ENV_POST_JOB) });
|
|
797
|
+
delete jobEnv[RUNNER_KEY_ENV];
|
|
798
|
+
// Its own redactor: env-post holds the token env-up's steps never got.
|
|
799
|
+
const redact = ciRedactor(jobEnv, req.secrets);
|
|
800
|
+
try {
|
|
801
|
+
const result = await runCiJob(postReq, { cwd: at.cwd, top: at.top, commit: base, place: at.place, job, jobEnv, ...(redact ? { redact } : {}), ...(at.ran.report ? { seed: at.ran.report } : {}) });
|
|
802
|
+
return { result: redact ? { ...result, warnings: result.warnings.map(redact), notes: result.notes.map(redact), ...(result.pushWarning ? { pushWarning: redact(result.pushWarning) } : {}) } : result };
|
|
803
|
+
} catch (err) {
|
|
804
|
+
return { warning: `env-post did not run: ${message(err)}` };
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
|
|
728
808
|
/**
|
|
729
809
|
* Who posts the plan's note and `chant/plan` status (#4295): the runner,
|
|
730
810
|
* from the report, once the plan's steps settled, with the forge token the
|
|
@@ -854,7 +934,7 @@ function jobCheckout(top: string, commit: string, inPlace?: boolean): { dir: str
|
|
|
854
934
|
/** {@link ciRun} once the job and its environment are known. */
|
|
855
935
|
async function runCiJob(
|
|
856
936
|
req: CiRunRequest,
|
|
857
|
-
at: { cwd: string; top: string; commit: string; place: { member?: string; dir: string }; job:
|
|
937
|
+
at: { cwd: string; top: string; commit: string; place: { member?: string; dir: string }; job: CiJob; jobEnv: Record<string, string>; redact?: Redact; seed?: string },
|
|
858
938
|
): Promise<CiRunResult> {
|
|
859
939
|
const { cwd, top, commit, place, job, jobEnv, redact } = at;
|
|
860
940
|
const local = req.local === true;
|
|
@@ -884,6 +964,8 @@ async function runCiJob(
|
|
|
884
964
|
...(req.chant ? { chant: req.chant } : {}),
|
|
885
965
|
});
|
|
886
966
|
const warnings: string[] = [...early];
|
|
967
|
+
// #4330: env-post takes the report env-up wrote, as a forge job takes it from the up job's outputs.
|
|
968
|
+
if (at.seed && existsSync(at.seed)) cpSync(at.seed, join(jobDir, ciJobReportDir(req.job)), { recursive: true });
|
|
887
969
|
const resume = req.job === "apply" && req.resumeRecord ? { host: resolve(req.resumeRecord), job: join(jobDir, PR_APPLY_RECORD) } : undefined;
|
|
888
970
|
if (resume && existsSync(resume.host)) {
|
|
889
971
|
mkdirSync(dirname(resume.job), { recursive: true });
|
|
@@ -913,7 +995,7 @@ async function runCiJob(
|
|
|
913
995
|
commit,
|
|
914
996
|
ledger: { cwd: jobDir },
|
|
915
997
|
runner: { kind: "pipeline", runtime: local ? "local" : "ci", runOrigin: origin },
|
|
916
|
-
describeFailure: (records) => ciJobFailure({ job: req.job, op, records, reportDir: join(jobDir,
|
|
998
|
+
describeFailure: (records) => ciJobFailure({ job: req.job, op, records, reportDir: join(jobDir, ciJobReportDir(req.job)) }),
|
|
917
999
|
...(req.runEvents ? { runEvents: { write: req.runEvents } } : {}),
|
|
918
1000
|
...(req.stepOutput ? { stepOutput: req.stepOutput } : {}),
|
|
919
1001
|
...(signer ? { signRecord: (record: OpRunRecord) => signOpRunRecord(record, signer!) } : {}),
|
|
@@ -946,10 +1028,10 @@ async function runCiJob(
|
|
|
946
1028
|
warnings.push(`the resume record could not be kept at ${resume.host}: ${message(err)}`);
|
|
947
1029
|
}
|
|
948
1030
|
}
|
|
949
|
-
const written = join(jobDir,
|
|
1031
|
+
const written = join(jobDir, ciJobReportDir(req.job));
|
|
950
1032
|
let report: string | null = null;
|
|
951
1033
|
if (existsSync(written)) {
|
|
952
|
-
report = resolve(cwd, req.output ??
|
|
1034
|
+
report = resolve(cwd, req.output ?? ciJobReportDir(req.job));
|
|
953
1035
|
// In place the report is already where the forge keeps it, and stays as the command wrote it.
|
|
954
1036
|
if (!(existsSync(report) && realpathSync(report) === realpathSync(written))) {
|
|
955
1037
|
cpSync(written, report, { recursive: true });
|
|
@@ -958,7 +1040,8 @@ async function runCiJob(
|
|
|
958
1040
|
}
|
|
959
1041
|
const failed = result.records.find((r) => r.status === "fail");
|
|
960
1042
|
const exitCode = failed?.phase === req.job ? exitCodeOf(failed.error) : undefined;
|
|
961
|
-
|
|
1043
|
+
// The run index lists the pull-request jobs; an environment job's run is in its record only (#4330).
|
|
1044
|
+
if (reportUrl && !isCiEnvJob(req.job)) {
|
|
962
1045
|
await recordInIndex({ url: reportUrl, job: req.job, commit, member: place.member, pr: req.pr, record: result.record, runOrigin: origin, signer: result.record.statement && signer ? signer.principal : null, exitCode: ciJobExitCode({ status: result.status, ...(exitCode !== undefined ? { exitCode } : {}) }), reportDir: written, limit: req.runIndexLimit, ...(redact ? { redact } : {}), warn: (w) => warnings.push(w) });
|
|
963
1046
|
}
|
|
964
1047
|
// In place the forge job's outcome is the command's, so a record that could not be written is a warning there.
|
|
@@ -1045,7 +1128,7 @@ export function ciLoopOptions(args: CommandContext["args"]): PrLoopPipelineOptio
|
|
|
1045
1128
|
}
|
|
1046
1129
|
|
|
1047
1130
|
const USAGE =
|
|
1048
|
-
"chant ci run plan|record|apply --env <env> --base <sha> [--pr <n>] [--commit <sha>] [--forge github|gitlab|forgejo] " +
|
|
1131
|
+
"chant ci run plan|record|apply|env-up|env-post|env-down|env-sweep --env <env> --base <sha> [--pr <n>] [--commit <sha>] [--head <sha>] [--result <r>] [--forge github|gitlab|forgejo] " +
|
|
1049
1132
|
"[--gate <name>] [--wave-gate <name>] [--wave <k>] [--branch <name>] [--events-json] [--report-url <url>] [--run-index-limit <n>] [--image <image>] [--before-script <line>]... " +
|
|
1050
1133
|
"[--pr-build] [--pr-build-command <line>] [--require-review] [--apply-before-merge] [--no-status] [--status-url <url template>] " +
|
|
1051
1134
|
"[--chant <command>] [--pass-env <NAME>]... [--secrets <file>] [--key <runner key.pem>] [-o <dir>] [--in-place | --local] [--no-push] [--verbose] | chant ci run --at <sha> | chant ci run --setup-key [--trust [--principal <name>]] [--state <dir>] [--branch <name>]";
|
|
@@ -1101,9 +1184,11 @@ export async function runCiRun(ctx: CommandContext): Promise<number> {
|
|
|
1101
1184
|
try {
|
|
1102
1185
|
if (args.setupKey) return (await import("./ci-serve")).runnerKeySetupCommand(ctx);
|
|
1103
1186
|
if (args.at !== undefined && args.extraPositional === undefined) return await requestBuildCommand(args.at);
|
|
1104
|
-
const job = args.extraPositional;
|
|
1105
|
-
if (job
|
|
1106
|
-
|
|
1187
|
+
const job = args.extraPositional as CiJobName | undefined;
|
|
1188
|
+
if (!job || !CI_JOB_NAMES.includes(job)) throw new CiRunError(`ci run takes the job to run, ${CI_JOB_NAMES.join(", ")}${job ? `, not ${JSON.stringify(job)}` : ""}`);
|
|
1189
|
+
// #4330: an environment job's environment is pr-<n>; the sweep's run is recorded under --env, else local.
|
|
1190
|
+
const env = args.env ?? (isCiEnvJob(job) ? (args.pr !== undefined && job !== "env-sweep" ? `pr-${args.pr}` : "local") : undefined);
|
|
1191
|
+
if (!env) throw new CiRunError("--env names the environment the pipeline is for, as chant build --components --generate <lexicon> --pr-loop took it: --env <env>");
|
|
1107
1192
|
const forge = args.forge ?? detectForge(process.env);
|
|
1108
1193
|
if (!forge) throw new CiRunError("--forge names the forge the job posts to: --forge github|gitlab|forgejo");
|
|
1109
1194
|
if (!(FORGE_KINDS as readonly string[]).includes(forge)) throw new CiRunError(`--forge takes github, gitlab or forgejo, not ${JSON.stringify(forge)}`);
|
|
@@ -1124,11 +1209,13 @@ export async function runCiRun(ctx: CommandContext): Promise<number> {
|
|
|
1124
1209
|
cwd: process.cwd(),
|
|
1125
1210
|
...(status ? { status } : {}),
|
|
1126
1211
|
job,
|
|
1127
|
-
env
|
|
1212
|
+
env,
|
|
1128
1213
|
forge: forge as ForgeKind,
|
|
1129
1214
|
...(args.base ? { base: args.base } : {}),
|
|
1130
1215
|
...(args.pr !== undefined ? { pr: args.pr } : {}),
|
|
1131
1216
|
...(args.commit ? { commit: args.commit } : {}),
|
|
1217
|
+
...(args.head ? { head: args.head } : {}),
|
|
1218
|
+
...(args.planResult ? { upResult: args.planResult } : {}),
|
|
1132
1219
|
loop: ciLoopOptions(reportUrl ? { ...args, reportUrl } : args),
|
|
1133
1220
|
...(args.ciInPlace ? { inPlace: true } : {}),
|
|
1134
1221
|
...(args.local ? { local: true } : {}),
|
|
@@ -1149,8 +1236,14 @@ export async function runCiRun(ctx: CommandContext): Promise<number> {
|
|
|
1149
1236
|
for (const w of result.warnings) console.error(formatWarning({ message: w }));
|
|
1150
1237
|
if (args.verbose) for (const n of result.notes) console.error(`ci run: ${n}`);
|
|
1151
1238
|
console.error(formatCiRun(result));
|
|
1239
|
+
if (result.post) {
|
|
1240
|
+
for (const w of result.post.warnings) console.error(formatWarning({ message: w }));
|
|
1241
|
+
console.error(formatCiRun(result.post));
|
|
1242
|
+
}
|
|
1152
1243
|
// #4156: a failed command's own exit code, such as 3 for a moved plan, is the job's, as it was before the job ran through ci run.
|
|
1153
|
-
|
|
1244
|
+
// #4330: an env-up whose env-post failed fails too, since its record was not kept.
|
|
1245
|
+
const code = ciJobExitCode(result);
|
|
1246
|
+
return code === 0 && result.post ? ciJobExitCode(result.post) : code;
|
|
1154
1247
|
} catch (err) {
|
|
1155
1248
|
console.error(formatError({ message: `chant ci run: ${message(err)}`, hint: USAGE }));
|
|
1156
1249
|
return 1;
|
|
@@ -69,7 +69,7 @@ describe("the secrets file (#4096)", () => {
|
|
|
69
69
|
expect(ciSecretsFor(undefined, "plan")).toEqual({});
|
|
70
70
|
expect(ciSecretsFor({ ...s, jobs: { plan: [] } }, "plan")).toEqual({});
|
|
71
71
|
expect(() => parseCiSecrets(JSON.stringify({ secrets: { A: "aaaa" }, jobs: { plan: ["C"] } }), "s.json")).toThrow(/jobs\.plan names C, which the file does not set/);
|
|
72
|
-
expect(() => parseCiSecrets(JSON.stringify({ secrets: { A: "aaaa" }, jobs: { deploy: [] } }), "s.json")).toThrow(/the jobs are plan
|
|
72
|
+
expect(() => parseCiSecrets(JSON.stringify({ secrets: { A: "aaaa" }, jobs: { deploy: [] } }), "s.json")).toThrow(/the jobs are plan, apply, env-up/);
|
|
73
73
|
expect(() => parseCiSecrets('{ "A": 1 }', "s.json")).toThrow(/must be a string/);
|
|
74
74
|
// A parse error does not quote the text, which may hold a secret.
|
|
75
75
|
expect(() => parseCiSecrets('{ "A": "hunter2', "s.json")).toThrow(/^s\.json starts with \{, and is not valid JSON$/);
|
|
@@ -37,7 +37,8 @@ export const SECRET_NAME = /TOKEN|SECRET|KEY|PASSWORD/i;
|
|
|
37
37
|
|
|
38
38
|
const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
39
39
|
|
|
40
|
-
|
|
40
|
+
/** The jobs a secrets file can give a list of its own: the pull-request jobs, and a pull request environment's (#4330). */
|
|
41
|
+
const JOBS: readonly CiJobName[] = ["plan", "apply", "env-up", "env-post", "env-down", "env-sweep"];
|
|
41
42
|
|
|
42
43
|
/** The secrets file, read. */
|
|
43
44
|
export interface CiSecrets {
|
|
@@ -176,9 +177,9 @@ export function parseCiSecrets(text: string, file: string): Omit<CiSecrets, "fil
|
|
|
176
177
|
if (structured) {
|
|
177
178
|
for (const key of Object.keys(doc)) if (key !== "secrets" && key !== "jobs") throw new CiSecretsError(`${file} has ${JSON.stringify(key)} beside secrets and jobs`);
|
|
178
179
|
const given = doc.jobs ?? {};
|
|
179
|
-
if (given === null || typeof given !== "object" || Array.isArray(given)) throw new CiSecretsError(`${file}: jobs must map
|
|
180
|
+
if (given === null || typeof given !== "object" || Array.isArray(given)) throw new CiSecretsError(`${file}: jobs must map ${JOBS.join(", ")} to lists of secret names`);
|
|
180
181
|
for (const [job, names] of Object.entries(given as Record<string, unknown>)) {
|
|
181
|
-
if (!(JOBS as readonly string[]).includes(job)) throw new CiSecretsError(`${file}: jobs names ${JSON.stringify(job)}, and the jobs are
|
|
182
|
+
if (!(JOBS as readonly string[]).includes(job)) throw new CiSecretsError(`${file}: jobs names ${JSON.stringify(job)}, and the jobs are ${JOBS.join(", ")}`);
|
|
182
183
|
if (!Array.isArray(names) || names.some((n) => typeof n !== "string")) throw new CiSecretsError(`${file}: jobs.${job} must be a list of secret names`);
|
|
183
184
|
for (const n of names as string[]) if (!Object.prototype.hasOwnProperty.call(values, n)) throw new CiSecretsError(`${file}: jobs.${job} names ${n}, which the file does not set`);
|
|
184
185
|
jobs[job as CiJobName] = [...(names as string[])];
|
|
@@ -289,6 +289,33 @@
|
|
|
289
289
|
"type": "string",
|
|
290
290
|
"pattern": "^[1-9][0-9]*(m|h|d)$",
|
|
291
291
|
"description": "How long a pull request's environment lives after its last apply, such as 72h, 90m or 7d. chant components pr-env sweep tears down one whose TTL passed. 72h when absent."
|
|
292
|
+
},
|
|
293
|
+
"teardown": {
|
|
294
|
+
"type": "object",
|
|
295
|
+
"description": "A command that tears down what no lexicon does, such as a Terraform root's copy of the environment (#4330). pr-env down and pr-env sweep run it after the ownership check, twice: CHANT_PR_ENV_STAGE=owned writes the candidates with their read-back markers to CHANT_PR_ENV_RESULT, and CHANT_PR_ENV_STAGE=teardown deletes the candidates in CHANT_PR_ENV_CANDIDATES and writes their outcomes. It runs without the forge token variables. Added in schema 1 by chant 0.124.45, so a declaration that uses it sets minReader to 0.124.45 or newer.",
|
|
296
|
+
"required": ["command"],
|
|
297
|
+
"additionalProperties": false,
|
|
298
|
+
"patternProperties": {
|
|
299
|
+
"^x-": true
|
|
300
|
+
},
|
|
301
|
+
"properties": {
|
|
302
|
+
"name": {
|
|
303
|
+
"type": "string",
|
|
304
|
+
"pattern": "^[a-z0-9][a-z0-9-]*$",
|
|
305
|
+
"description": "The name the teardown's outcomes are attributed to, as a lexicon's are. teardown when absent."
|
|
306
|
+
},
|
|
307
|
+
"command": {
|
|
308
|
+
"type": "array",
|
|
309
|
+
"minItems": 1,
|
|
310
|
+
"items": { "type": "string", "minLength": 1 },
|
|
311
|
+
"description": "Argv, run in the member's directory at the commit the job runs at. {env}, {pr} and {stage} are filled in."
|
|
312
|
+
},
|
|
313
|
+
"lexicons": {
|
|
314
|
+
"type": "array",
|
|
315
|
+
"items": { "type": "string", "minLength": 1 },
|
|
316
|
+
"description": "The lexicons the command tears down in place of, such as terraform. Their own teardown is left out, so one with no teardown execution does not leave the environment incomplete."
|
|
317
|
+
}
|
|
318
|
+
}
|
|
292
319
|
}
|
|
293
320
|
}
|
|
294
321
|
}
|
|
@@ -363,7 +390,7 @@
|
|
|
363
390
|
"ciCloudIdentityRules": {
|
|
364
391
|
"type": "array",
|
|
365
392
|
"minItems": 1,
|
|
366
|
-
"description": "Rules, first match wins: a rule matches a pipeline when its environments match the pipeline's environment and its members match the pipeline's member. A rule with no environments matches every environment,
|
|
393
|
+
"description": "Rules, first match wins: a rule matches a pipeline when its environments match the pipeline's environment and its members match the pipeline's member, and a target inside the member when its targets match the target too. A rule with no environments matches every environment, one with no members every member and a pipeline outside a member, and one with no targets every target.",
|
|
367
394
|
"items": {
|
|
368
395
|
"type": "object",
|
|
369
396
|
"additionalProperties": false,
|
|
@@ -378,6 +405,14 @@
|
|
|
378
405
|
"description": "Environment-name globs, such as prod or staging-*."
|
|
379
406
|
},
|
|
380
407
|
"members": { "$ref": "#/$defs/ciMemberGlobs" },
|
|
408
|
+
"targets": {
|
|
409
|
+
"type": "array",
|
|
410
|
+
"minItems": 1,
|
|
411
|
+
"uniqueItems": true,
|
|
412
|
+
"items": { "type": "string", "minLength": 1 },
|
|
413
|
+
"description": "Target globs inside the member (#4332), such as Terraform roots or Op wave run targets: * within one / segment, ** across segments, ? one character. The plan job trades for every plan identity the member's targets take, and an apply splits into one job per apply identity. Added in schema 1 by chant 0.124.45, so a declaration that uses it sets minReader to 0.124.45 or newer."
|
|
414
|
+
},
|
|
415
|
+
"name": { "type": "string", "pattern": "^[a-z0-9_-]+$", "description": "The name of the apply job group the rule's targets form (#4332). Default: from its first target glob. Added by chant 0.124.45." },
|
|
381
416
|
"plan": { "type": "string", "minLength": 1, "description": "The identity the plan and record jobs take: a role ARN, a service account email or a client id." },
|
|
382
417
|
"apply": { "type": "string", "minLength": 1, "description": "The identity the apply jobs take." }
|
|
383
418
|
}
|
|
@@ -564,4 +564,11 @@ describe("ci.prEnvironment (#4298)", () => {
|
|
|
564
564
|
expect(failure(base(members, { ci: { prEnvironment: {} } })).code).toBe("declaration-invalid");
|
|
565
565
|
expect(failure(base(members, { ci: { prEnvironment: { template: "staging", ttl: "1w" } } })).code).toBe("declaration-invalid");
|
|
566
566
|
});
|
|
567
|
+
|
|
568
|
+
test("a teardown command is read with its argv and the lexicons it covers (#4330)", () => {
|
|
569
|
+
const teardown = { name: "terragucci", command: ["terragucci", "pr-env", "teardown", "{stage}"], lexicons: ["terraform"] };
|
|
570
|
+
expect(parse(base(members, { ci: { prEnvironment: { template: "staging", teardown } } })).ci?.prEnvironment).toEqual({ template: "staging", ttl: "72h", teardown });
|
|
571
|
+
expect(failure(base(members, { ci: { prEnvironment: { template: "staging", teardown: { command: [] } } } })).code).toBe("declaration-invalid");
|
|
572
|
+
expect(failure(base(members, { ci: { prEnvironment: { template: "staging", teardown: { command: ["x"], runs: "y" } } } })).code).toBe("declaration-invalid");
|
|
573
|
+
});
|
|
567
574
|
});
|