@intentius/chant 0.124.40 → 0.124.41
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/__fixtures__/oidc-emulator.d.ts +49 -0
- package/dist/__fixtures__/oidc-emulator.d.ts.map +1 -0
- package/dist/__fixtures__/oidc-emulator.js +89 -0
- package/dist/ci-handover.d.ts +117 -0
- package/dist/ci-handover.d.ts.map +1 -0
- package/dist/ci-handover.js +217 -0
- package/dist/ci-identity.d.ts +190 -0
- package/dist/ci-identity.d.ts.map +1 -0
- package/dist/ci-identity.js +219 -0
- package/dist/cli/handlers/approve.d.ts +2 -1
- package/dist/cli/handlers/approve.d.ts.map +1 -1
- package/dist/cli/handlers/approve.js +20 -6
- package/dist/cli/handlers/build.d.ts.map +1 -1
- package/dist/cli/handlers/build.js +15 -1
- package/dist/cli/handlers/fan-out.js +16 -3
- package/dist/cli/handlers/gate-resume.d.ts +3 -0
- package/dist/cli/handlers/gate-resume.d.ts.map +1 -1
- package/dist/cli/handlers/gate-resume.js +15 -2
- package/dist/cli/handlers/lock.d.ts +8 -0
- package/dist/cli/handlers/lock.d.ts.map +1 -0
- package/dist/cli/handlers/lock.js +197 -0
- package/dist/cli/handlers/pr-env.d.ts +34 -0
- package/dist/cli/handlers/pr-env.d.ts.map +1 -0
- package/dist/cli/handlers/pr-env.js +436 -0
- package/dist/cli/handlers/pr.d.ts +74 -0
- package/dist/cli/handlers/pr.d.ts.map +1 -1
- package/dist/cli/handlers/pr.js +503 -53
- package/dist/cli/handlers/promote.d.ts.map +1 -1
- package/dist/cli/handlers/promote.js +9 -0
- package/dist/cli/handlers/query.d.ts +6 -0
- package/dist/cli/handlers/query.d.ts.map +1 -0
- package/dist/cli/handlers/query.js +80 -0
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/main.js +41 -2
- package/dist/cli/parse-args.d.ts.map +1 -1
- package/dist/cli/parse-args.js +31 -2
- package/dist/cli/registry.d.ts +12 -2
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/components/fan-out-run.d.ts +28 -0
- package/dist/components/fan-out-run.d.ts.map +1 -1
- package/dist/components/fan-out-run.js +27 -3
- package/dist/components/pr-environment.d.ts +298 -0
- package/dist/components/pr-environment.d.ts.map +1 -0
- package/dist/components/pr-environment.js +406 -0
- package/dist/components/pr-hooks.d.ts +66 -0
- package/dist/components/pr-hooks.d.ts.map +1 -0
- package/dist/components/pr-hooks.js +119 -0
- package/dist/components/pr-pipeline.d.ts +72 -2
- package/dist/components/pr-pipeline.d.ts.map +1 -1
- package/dist/components/pr-pipeline.js +96 -5
- package/dist/components/pr-policy.d.ts +109 -0
- package/dist/components/pr-policy.d.ts.map +1 -0
- package/dist/components/pr-policy.js +150 -0
- package/dist/components/pr-run.d.ts.map +1 -1
- package/dist/components/pr-run.js +5 -3
- package/dist/gate-decision.d.ts +15 -2
- package/dist/gate-decision.d.ts.map +1 -1
- package/dist/gate-decision.js +17 -2
- package/dist/gate-policy.d.ts +140 -0
- package/dist/gate-policy.d.ts.map +1 -0
- package/dist/gate-policy.js +242 -0
- package/dist/lifecycle/gate-checkpoint.d.ts +1 -1
- package/dist/lifecycle/gate-checkpoint.js +1 -1
- package/dist/{workspace/trust → lifecycle}/gate-seal.d.ts +1 -1
- package/dist/lifecycle/gate-seal.d.ts.map +1 -0
- package/dist/lifecycle/query.d.ts +68 -0
- package/dist/lifecycle/query.d.ts.map +1 -0
- package/dist/lifecycle/query.js +246 -0
- package/dist/lifecycle/run-ledger.d.ts +13 -0
- package/dist/lifecycle/run-ledger.d.ts.map +1 -1
- package/dist/lifecycle/run-ledger.js +16 -0
- package/dist/lifecycle/target-lock.d.ts +97 -0
- package/dist/lifecycle/target-lock.d.ts.map +1 -0
- package/dist/lifecycle/target-lock.js +286 -0
- package/dist/op/activities/activity-contracts.d.ts +30 -0
- package/dist/op/activities/activity-contracts.d.ts.map +1 -1
- package/dist/op/activities/activity-contracts.js +26 -0
- package/dist/op/activities/index.d.ts +2 -0
- package/dist/op/activities/index.d.ts.map +1 -1
- package/dist/op/activities/index.js +4 -0
- package/dist/op/activities/review-agent.d.ts +137 -0
- package/dist/op/activities/review-agent.d.ts.map +1 -0
- package/dist/op/activities/review-agent.js +266 -0
- package/dist/op/builders.d.ts +9 -0
- package/dist/op/builders.d.ts.map +1 -1
- package/dist/op/builders.js +11 -0
- package/dist/op/cron.d.ts +8 -0
- package/dist/op/cron.d.ts.map +1 -1
- package/dist/op/cron.js +51 -0
- package/dist/op/gate-approval.d.ts +20 -13
- package/dist/op/gate-approval.d.ts.map +1 -1
- package/dist/op/gate-approval.js +13 -18
- package/dist/op/gate-quorum.d.ts +131 -0
- package/dist/op/gate-quorum.d.ts.map +1 -0
- package/dist/op/gate-quorum.js +183 -0
- package/dist/op/gate-resume.d.ts +4 -2
- package/dist/op/gate-resume.d.ts.map +1 -1
- package/dist/op/gate-resume.js +5 -4
- package/dist/op/gate-review.js +1 -1
- package/dist/op/gate.d.ts +32 -4
- package/dist/op/gate.d.ts.map +1 -1
- package/dist/op/gate.js +40 -24
- package/dist/op/index.d.ts +6 -4
- package/dist/op/index.d.ts.map +1 -1
- package/dist/op/index.js +4 -3
- package/dist/op/local-executor.d.ts.map +1 -1
- package/dist/op/local-executor.js +9 -3
- package/dist/op/op-waves-run.d.ts +62 -1
- package/dist/op/op-waves-run.d.ts.map +1 -1
- package/dist/op/op-waves-run.js +190 -11
- package/dist/op/op-waves.d.ts +55 -3
- package/dist/op/op-waves.d.ts.map +1 -1
- package/dist/op/op-waves.js +63 -8
- package/dist/op/schedule-overdue.d.ts +57 -0
- package/dist/op/schedule-overdue.d.ts.map +1 -0
- package/dist/op/schedule-overdue.js +138 -0
- package/dist/override-gate.d.ts +2 -2
- package/dist/override-gate.d.ts.map +1 -1
- package/dist/override-gate.js +1 -1
- package/dist/pr-forge.d.ts +36 -0
- package/dist/pr-forge.d.ts.map +1 -1
- package/dist/pr-forge.js +82 -0
- package/dist/pr-loop.d.ts +90 -1
- package/dist/pr-loop.d.ts.map +1 -1
- package/dist/pr-loop.js +77 -3
- package/dist/pr-post.d.ts +72 -0
- package/dist/pr-post.d.ts.map +1 -0
- package/dist/pr-post.js +134 -0
- package/dist/wave-blocked.d.ts +79 -0
- package/dist/wave-blocked.d.ts.map +1 -0
- package/dist/wave-blocked.js +245 -0
- package/dist/wave-engine.d.ts +6 -0
- package/dist/wave-engine.d.ts.map +1 -1
- package/dist/wave-engine.js +2 -0
- package/dist/wave-runtime.mjs +242 -242
- package/dist/workspace/__fixtures__/two-member-workspace.d.ts +1 -0
- package/dist/workspace/__fixtures__/two-member-workspace.d.ts.map +1 -1
- package/dist/workspace/__fixtures__/two-member-workspace.js +1 -1
- package/dist/workspace/ci-branches.d.ts +51 -0
- package/dist/workspace/ci-branches.d.ts.map +1 -0
- package/dist/workspace/ci-branches.js +69 -0
- package/dist/workspace/ci-cloud-identity.d.ts +11 -0
- package/dist/workspace/ci-cloud-identity.d.ts.map +1 -0
- package/dist/workspace/ci-cloud-identity.js +24 -0
- package/dist/workspace/ci-locks.d.ts +12 -0
- package/dist/workspace/ci-locks.d.ts.map +1 -0
- package/dist/workspace/ci-locks.js +6 -0
- package/dist/workspace/ci-run.d.ts +17 -1
- package/dist/workspace/ci-run.d.ts.map +1 -1
- package/dist/workspace/ci-run.js +64 -3
- package/dist/workspace/declaration.d.ts +63 -0
- package/dist/workspace/declaration.d.ts.map +1 -1
- package/dist/workspace/declaration.js +72 -1
- package/dist/workspace/declaration.schema.json +147 -0
- package/dist/workspace/identity-gate-rule.d.ts +1 -1
- package/dist/workspace/identity-gate-rule.d.ts.map +1 -1
- package/dist/workspace/identity-gate-rule.js +1 -1
- package/dist/workspace/identity.d.ts +1 -1
- package/dist/workspace/identity.d.ts.map +1 -1
- package/dist/workspace/lineage-upgrade.d.ts.map +1 -1
- package/dist/workspace/lineage-upgrade.js +8 -5
- package/dist/workspace/member-pipeline.d.ts +6 -0
- package/dist/workspace/member-pipeline.d.ts.map +1 -1
- package/dist/workspace/member-pipeline.js +23 -0
- package/dist/workspace/pr-report.schema.json +73 -2
- package/dist/workspace/security.d.ts.map +1 -1
- package/dist/workspace/security.js +4 -0
- package/dist/workspace/status-gates.d.ts +3 -2
- package/dist/workspace/status-gates.d.ts.map +1 -1
- package/dist/workspace/status-gates.js +8 -7
- package/dist/workspace/status.d.ts.map +1 -1
- package/dist/workspace/status.js +4 -1
- package/dist/workspace/trust/seal.d.ts +2 -2
- package/dist/workspace/trust/seal.d.ts.map +1 -1
- package/dist/workspace/trust/seal.js +2 -2
- package/package.json +1 -1
- package/src/__fixtures__/oidc-emulator.ts +118 -0
- package/src/ci-handover.test.ts +209 -0
- package/src/ci-handover.ts +237 -0
- package/src/ci-identity.test.ts +201 -0
- package/src/ci-identity.ts +310 -0
- package/src/cli/handlers/approve.ts +18 -5
- package/src/cli/handlers/build.ts +15 -1
- package/src/cli/handlers/fan-out.ts +17 -4
- package/src/cli/handlers/gate-resume.ts +16 -2
- package/src/cli/handlers/lock.test.ts +98 -0
- package/src/cli/handlers/lock.ts +202 -0
- package/src/cli/handlers/operator.test.ts +3 -1
- package/src/cli/handlers/pr-env.ts +462 -0
- package/src/cli/handlers/pr-waves-blocked.test.ts +247 -0
- package/src/cli/handlers/pr.test.ts +184 -0
- package/src/cli/handlers/pr.ts +496 -59
- package/src/cli/handlers/promote.ts +9 -0
- package/src/cli/handlers/query.ts +78 -0
- package/src/cli/main.ts +41 -2
- package/src/cli/parse-args.ts +23 -2
- package/src/cli/registry.ts +12 -2
- package/src/components/fan-out-run.ts +39 -2
- package/src/components/fan-out-waves.test.ts +45 -1
- package/src/components/pr-environment.test.ts +249 -0
- package/src/components/pr-environment.ts +538 -0
- package/src/components/pr-hooks.test.ts +118 -0
- package/src/components/pr-hooks.ts +157 -0
- package/src/components/pr-pipeline.ts +130 -5
- package/src/components/pr-policy.test.ts +145 -0
- package/src/components/pr-policy.ts +211 -0
- package/src/components/pr-run.ts +5 -2
- package/src/gate-decision-bundle.test.ts +4 -3
- package/src/gate-decision.ts +39 -2
- package/src/gate-policy-bundle.test.ts +28 -0
- package/src/gate-policy.test.ts +162 -0
- package/src/gate-policy.ts +298 -0
- package/src/lifecycle/gate-checkpoint.ts +1 -1
- package/src/{workspace/trust → lifecycle}/gate-seal.ts +1 -1
- package/src/lifecycle/query.test.ts +91 -0
- package/src/lifecycle/query.ts +315 -0
- package/src/lifecycle/run-ledger.ts +18 -0
- package/src/lifecycle/target-lock.test.ts +195 -0
- package/src/lifecycle/target-lock.ts +347 -0
- package/src/op/activities/activity-contracts.ts +31 -0
- package/src/op/activities/index.ts +6 -0
- package/src/op/activities/review-agent.test.ts +240 -0
- package/src/op/activities/review-agent.ts +355 -0
- package/src/op/builders.ts +13 -0
- package/src/op/cron.ts +44 -0
- package/src/op/gate-alias.test.ts +2 -2
- package/src/op/gate-approval.test.ts +30 -9
- package/src/op/gate-approval.ts +23 -26
- package/src/op/gate-quorum.test.ts +139 -0
- package/src/op/gate-quorum.ts +248 -0
- package/src/op/gate-resume.ts +6 -3
- package/src/op/gate-review.ts +1 -1
- package/src/op/gate.ts +64 -23
- package/src/op/index.ts +13 -1
- package/src/op/local-executor.ts +9 -2
- package/src/op/op-waves-hook-targets.test.ts +126 -0
- package/src/op/op-waves-policy-hold.test.ts +66 -0
- package/src/op/op-waves-quorum.test.ts +101 -0
- package/src/op/op-waves-run.ts +233 -11
- package/src/op/op-waves.ts +83 -10
- package/src/op/schedule-overdue.test.ts +220 -0
- package/src/op/schedule-overdue.ts +161 -0
- package/src/override-gate-bundle.test.ts +1 -1
- package/src/override-gate.ts +2 -2
- package/src/pr-forge-bundle.test.ts +2 -2
- package/src/pr-forge.test.ts +33 -0
- package/src/pr-forge.ts +114 -0
- package/src/pr-loop-bundle.test.ts +2 -2
- package/src/pr-loop.test.ts +58 -0
- package/src/pr-loop.ts +146 -4
- package/src/pr-post.test.ts +141 -0
- package/src/pr-post.ts +159 -0
- package/src/wave-blocked-bundle.test.ts +25 -0
- package/src/wave-blocked.test.ts +146 -0
- package/src/wave-blocked.ts +271 -0
- package/src/wave-engine.ts +8 -0
- package/src/workspace/__fixtures__/two-member-workspace.ts +2 -2
- package/src/workspace/ci-branches.test.ts +62 -0
- package/src/workspace/ci-branches.ts +79 -0
- package/src/workspace/ci-cloud-identity.test.ts +53 -0
- package/src/workspace/ci-cloud-identity.ts +23 -0
- package/src/workspace/ci-locks.ts +16 -0
- package/src/workspace/ci-run-index.test.ts +2 -1
- package/src/workspace/ci-run.test.ts +107 -8
- package/src/workspace/ci-run.ts +72 -4
- package/src/workspace/declaration.schema.json +147 -0
- package/src/workspace/declaration.test.ts +76 -0
- package/src/workspace/declaration.ts +134 -2
- package/src/workspace/identity-gate-rule.ts +1 -1
- package/src/workspace/identity.ts +1 -1
- package/src/workspace/lineage-upgrade.ts +11 -8
- package/src/workspace/member-pipeline.test.ts +29 -0
- package/src/workspace/member-pipeline.ts +30 -0
- package/src/workspace/pr-report.schema.json +73 -2
- package/src/workspace/security.ts +4 -0
- package/src/workspace/status-gates.ts +9 -6
- package/src/workspace/status.ts +4 -1
- package/src/workspace/trust/gate-seal.test.ts +1 -1
- package/src/workspace/trust/seal.ts +2 -2
- package/dist/workspace/trust/gate-seal.d.ts.map +0 -1
- /package/dist/{workspace/trust → lifecycle}/gate-seal.js +0 -0
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The cloud identities a generated pull-request pipeline takes (#4296): the
|
|
3
|
+
* workspace declaration's `ci.identity.cloud`, resolved for the pipeline's
|
|
4
|
+
* environment and member (../ci-identity.ts). Read from the working tree of
|
|
5
|
+
* the workspace that holds `cwd`, as `ci.reportUrl` is (./ci-report-url.ts).
|
|
6
|
+
* A directory outside a workspace reads nothing.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { findWorkspaceRoot } from "../project-root";
|
|
10
|
+
import { resolveCloudIdentity, type PipelineCloudIdentity } from "../ci-identity";
|
|
11
|
+
|
|
12
|
+
/** The plan and apply identities of the pipeline for `env` and `member`, or undefined. */
|
|
13
|
+
export async function declaredCloudIdentity(cwd: string, env: string, member?: string): Promise<PipelineCloudIdentity | undefined> {
|
|
14
|
+
const found = findWorkspaceRoot(cwd);
|
|
15
|
+
if (!found) return undefined;
|
|
16
|
+
const [{ readDeclaration, WorkspaceReadError }, { workingTree }] = await Promise.all([import("./declaration"), import("./tree")]);
|
|
17
|
+
try {
|
|
18
|
+
return resolveCloudIdentity(readDeclaration(workingTree(found.dir)).ci?.identity?.cloud, env, member);
|
|
19
|
+
} catch (err) {
|
|
20
|
+
if (!(err instanceof WorkspaceReadError)) throw err;
|
|
21
|
+
throw Object.assign(new Error(`${err.describe()} [${err.code}]`), { code: err.code });
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `ci.locks` (#4293): when a pull request takes its target locks
|
|
3
|
+
* (../lifecycle/target-lock.ts). Read from the declaration at base, the
|
|
4
|
+
* target branch's tip, as the security groups are (./security.ts), so a
|
|
5
|
+
* pull request cannot turn its own locks off. Null outside a workspace and
|
|
6
|
+
* when the declaration sets none.
|
|
7
|
+
*/
|
|
8
|
+
import type { CiLocks } from "./declaration";
|
|
9
|
+
import { scopeSource } from "./write-scope";
|
|
10
|
+
|
|
11
|
+
export const CI_LOCKS = ["apply", "plan"] as const satisfies readonly CiLocks[];
|
|
12
|
+
|
|
13
|
+
/** The `ci.locks` of the workspace holding `cwd`, read at `at` when given, else at base, else from the working tree. */
|
|
14
|
+
export function ciLocksAt(cwd: string, at?: string): CiLocks | null {
|
|
15
|
+
return scopeSource(cwd, at).declaration?.ci?.locks ?? null;
|
|
16
|
+
}
|
|
@@ -31,7 +31,8 @@ import schema from "./ci-run-index.schema.json";
|
|
|
31
31
|
afterAll(cleanScratch);
|
|
32
32
|
|
|
33
33
|
const JOB_IMAGE = "chant-ci-run-index-test:local";
|
|
34
|
-
|
|
34
|
+
// No forge token: a plan job given one posts its note from the report (#4295), which these tests do not look at.
|
|
35
|
+
const HOST = { PATH: process.env.PATH, HOME: process.env.HOME, GITHUB_REPOSITORY: "acme/estate" };
|
|
35
36
|
|
|
36
37
|
/** The repository every test starts from, built once: one member, and two commits. */
|
|
37
38
|
const repoTemplate = repoFixture(() => {
|
|
@@ -18,7 +18,8 @@ import { afterAll, beforeAll, describe, expect, test } from "vitest";
|
|
|
18
18
|
import { cleanScratch, declaration, git, repoFixture, scratchDir, writeFiles } from "./__fixtures__/contract-repo";
|
|
19
19
|
import { checkRunEventLines } from "./__fixtures__/run-event-check";
|
|
20
20
|
import { ciJobCommand, ciJobEnv, ciJobFailure, ciJobOp, ciLoopOptions, ciRun, CiRunError, ciStatusContext, ciStatusFromEnv, ciStatusUrl, formatCiRun } from "./ci-run";
|
|
21
|
-
import type { CommitStatus } from "../pr-forge";
|
|
21
|
+
import type { CommitStatus, ForgeStatus, PrForge } from "../pr-forge";
|
|
22
|
+
import { FORGE_TOKEN_VARS } from "../ci-handover";
|
|
22
23
|
import { ciList, ciTick, evaluateCommit, readOpRunSource, revokedAnnotation } from "./ci-green";
|
|
23
24
|
import type { StepRecord } from "../op/local-executor";
|
|
24
25
|
import { sshFromEd25519 } from "./trust/dsse";
|
|
@@ -136,6 +137,28 @@ const HOST = {
|
|
|
136
137
|
GITHUB_RUN_ID: "999",
|
|
137
138
|
};
|
|
138
139
|
|
|
140
|
+
/** A plan report for pull request 7 at `sha` in member `app`, which the fake chant writes with FAKE_REPORT (#4295). */
|
|
141
|
+
const PLAN_FIXTURE = JSON.parse(readFileSync(join(import.meta.dirname, "..", "__fixtures__", "pr-loop", "pr-plan.golden.json"), "utf-8")) as Record<string, unknown>;
|
|
142
|
+
const planReport = (sha: string): string => JSON.stringify({ ...PLAN_FIXTURE, pr: 7, head: sha, member: "app" });
|
|
143
|
+
|
|
144
|
+
/** A forge client that records the plan's status and note the runner posts from the report (#4295). */
|
|
145
|
+
function recordingForge(): { forge: PrForge; statuses: Array<{ sha: string; status: ForgeStatus }>; notes: Array<{ pr: number; marker: string }> } {
|
|
146
|
+
const statuses: Array<{ sha: string; status: ForgeStatus }> = [];
|
|
147
|
+
const notes: Array<{ pr: number; marker: string }> = [];
|
|
148
|
+
const forge = {
|
|
149
|
+
kind: "forgejo",
|
|
150
|
+
host: "forgejo.localhost",
|
|
151
|
+
async setStatus(sha: string, status: ForgeStatus) {
|
|
152
|
+
statuses.push({ sha, status });
|
|
153
|
+
},
|
|
154
|
+
async upsertNote(pr: number, marker: string) {
|
|
155
|
+
notes.push({ pr, marker });
|
|
156
|
+
return "1";
|
|
157
|
+
},
|
|
158
|
+
} as unknown as PrForge;
|
|
159
|
+
return { forge, statuses, notes };
|
|
160
|
+
}
|
|
161
|
+
|
|
139
162
|
const envFile = (dir: string, stage: string): Record<string, string> =>
|
|
140
163
|
Object.fromEntries(
|
|
141
164
|
readFileSync(join(dir, `${stage}.env`), "utf-8")
|
|
@@ -235,6 +258,7 @@ describe("chant ci run, then ci tick from runs (#4094)", () => {
|
|
|
235
258
|
let events: string[];
|
|
236
259
|
let plan: Awaited<ReturnType<typeof ciRun>>;
|
|
237
260
|
let apply: Awaited<ReturnType<typeof ciRun>>;
|
|
261
|
+
const posted = recordingForge();
|
|
238
262
|
|
|
239
263
|
beforeAll(async () => {
|
|
240
264
|
w = workspace();
|
|
@@ -250,8 +274,9 @@ describe("chant ci run, then ci tick from runs (#4094)", () => {
|
|
|
250
274
|
base: w.base,
|
|
251
275
|
pr: 7,
|
|
252
276
|
chant,
|
|
253
|
-
hostEnv: HOST,
|
|
254
|
-
passEnv: ["AWS_ACCESS_KEY_ID"],
|
|
277
|
+
hostEnv: { ...HOST, FAKE_REPORT: planReport(w.sha) },
|
|
278
|
+
passEnv: ["AWS_ACCESS_KEY_ID", "FAKE_REPORT"],
|
|
279
|
+
planForge: posted.forge,
|
|
255
280
|
runnerKey: BOT.pem,
|
|
256
281
|
output: join(reports, "plan"),
|
|
257
282
|
runId: "chant-plan-1",
|
|
@@ -271,20 +296,24 @@ describe("chant ci run, then ci tick from runs (#4094)", () => {
|
|
|
271
296
|
const out = join(reports, "plan");
|
|
272
297
|
const [job] = prLoopJobs("forgejo", "prod", {}, "app");
|
|
273
298
|
const argv = readFileSync(join(out, "pr-plan.args"), "utf-8").trimEnd().split("\n");
|
|
274
|
-
expect(argv).toEqual(["components", "pr-plan", "--base", base, "--pr", "7", "--env", "prod", "--gate", "pr-apply", "--output", ".chant/pr", "--forge", "forgejo", "--member", "app"]);
|
|
299
|
+
expect(argv).toEqual(["components", "pr-plan", "--base", base, "--pr", "7", "--env", "prod", "--gate", "pr-apply", "--output", ".chant/pr", "--forge", "forgejo", "--member", "app", "--no-status"]);
|
|
275
300
|
expect(job!.command.split(" ").slice(1).map((a) => a.replace(/"/g, ""))).toEqual(argv.map((a) => (a === base ? "$BASE_SHA" : a === "7" ? "$PR_NUMBER" : a)));
|
|
276
301
|
const pwd = readFileSync(join(out, "pr-plan.pwd"), "utf-8").trim();
|
|
277
302
|
expect(pwd).toBe(join(plan.checkout, "app"));
|
|
278
303
|
expect(pwd.startsWith(realpathSync(root))).toBe(false);
|
|
279
304
|
expect(existsSync(plan.checkout)).toBe(false);
|
|
280
305
|
expect(git(root, "worktree", "list", "--porcelain")).toBe(worktrees);
|
|
281
|
-
expect(JSON.parse(readFileSync(join(out, "pr-plan.json"), "utf-8"))).
|
|
306
|
+
expect(JSON.parse(readFileSync(join(out, "pr-plan.json"), "utf-8"))).toMatchObject({ stage: "plan", pr: 7, head: sha });
|
|
282
307
|
|
|
283
308
|
// The environment is clean: the job's variables and the few the runner hands on. The copied
|
|
284
309
|
// report shows the values of the variables whose names look secret as *** (#4096).
|
|
285
310
|
const env = envFile(out, "pr-plan");
|
|
286
|
-
expect(env).toMatchObject({ BASE_SHA: base, PR_NUMBER: "7", GITHUB_SHA: sha, CHANT_ENV: "prod",
|
|
311
|
+
expect(env).toMatchObject({ BASE_SHA: base, PR_NUMBER: "7", GITHUB_SHA: sha, CHANT_ENV: "prod", AWS_ACCESS_KEY_ID: "***" });
|
|
287
312
|
for (const name of ["NOT_FOR_JOBS", "CHANT_RUNNER_KEY", "GITHUB_RUN_ID"]) expect(env).not.toHaveProperty(name);
|
|
313
|
+
// #4295: the plan runs the pull request's code, so its step got no forge token; the runner posted from its report.
|
|
314
|
+
for (const name of FORGE_TOKEN_VARS) expect(env).not.toHaveProperty(name);
|
|
315
|
+
expect(posted.statuses).toEqual([{ sha, status: expect.objectContaining({ context: "chant/plan/app", state: "success" }) }]);
|
|
316
|
+
expect(posted.notes).toEqual([{ pr: 7, marker: "<!-- chant-pr:prod:app -->" }]);
|
|
288
317
|
});
|
|
289
318
|
|
|
290
319
|
test("the plan and the apply are recorded signed, with a pipeline run's events", async () => {
|
|
@@ -508,7 +537,9 @@ describe("a commit status per job (#4097)", () => {
|
|
|
508
537
|
|
|
509
538
|
async function job(over: Record<string, unknown>) {
|
|
510
539
|
const { root, app, base, sha } = workspace();
|
|
511
|
-
|
|
540
|
+
// #4295: a plan job's report is a real plan report, and the runner posts it to a recording forge.
|
|
541
|
+
const plan = { hostEnv: { ...HOST, FAKE_REPORT: planReport(sha) }, passEnv: ["FAKE_REPORT"], planForge: recordingForge().forge };
|
|
542
|
+
const r = await ciRun({ image: JOB_IMAGE, cwd: app, job: "plan", env: "prod", forge: "forgejo", base, pr: 7, chant: fakeChant(), push: false, output: join(scratchDir("chant-ci-status-"), "pr"), ...plan, ...over } as Parameters<typeof ciRun>[0]);
|
|
512
543
|
return { r, sha };
|
|
513
544
|
}
|
|
514
545
|
|
|
@@ -818,7 +849,8 @@ describe("a generated forge job runs ci run --in-place (#4156)", () => {
|
|
|
818
849
|
test("with no key the job runs, warns of nothing, and its unsigned record counts in ci list and the run index", async () => {
|
|
819
850
|
const { app, base, sha } = workspace();
|
|
820
851
|
const reports = scratchDir("chant-ci-run-index-");
|
|
821
|
-
|
|
852
|
+
// #4295: a plan job generated now holds no forge token; its plan-note job posts.
|
|
853
|
+
const { CHANT_RUNNER_KEY: _key, GITHUB_TOKEN: _token, ...keyless } = HOST;
|
|
822
854
|
const r = await ciRun({ cwd: app, job: "plan", env: "prod", forge: "forgejo", base, pr: 7, chant: fakeChant(), hostEnv: { ...keyless, ...FORGE, GITHUB_JOB: "plan" }, inPlace: true, push: false, runId: "forge-plan-1", loop: { reportUrl: `file://${reports}` } });
|
|
823
855
|
expect(r.status).toBe("ok");
|
|
824
856
|
expect(r.warnings).toEqual([]);
|
|
@@ -940,3 +972,70 @@ describe("ci run --local (#4161)", () => {
|
|
|
940
972
|
expect(parseArgs(["ci", "run", "plan", "--local"]).local).toBe(true);
|
|
941
973
|
});
|
|
942
974
|
});
|
|
975
|
+
|
|
976
|
+
describe("the plan's steps hold no forge token (#4295)", () => {
|
|
977
|
+
test("on the runner: no token reaches a step from the runner, --pass-env or a forge name, and the runner posts the note from the report", async () => {
|
|
978
|
+
const { app, base, sha } = workspace();
|
|
979
|
+
const output = join(scratchDir("chant-ci-handover-"), "pr");
|
|
980
|
+
const forge = recordingForge();
|
|
981
|
+
const tokens = { GH_TOKEN: "gh-token-4295", FORGEJO_TOKEN: "fj-token-4295", CHANT_FORGE_TOKEN: "chant-token-4295", ACTIONS_RUNTIME_TOKEN: "rt-token-4295" };
|
|
982
|
+
const r = await ciRun({
|
|
983
|
+
image: JOB_IMAGE,
|
|
984
|
+
cwd: app,
|
|
985
|
+
job: "plan",
|
|
986
|
+
env: "prod",
|
|
987
|
+
forge: "forgejo",
|
|
988
|
+
base,
|
|
989
|
+
pr: 7,
|
|
990
|
+
chant: fakeChant(),
|
|
991
|
+
hostEnv: { ...HOST, ...tokens, FAKE_REPORT: planReport(sha) },
|
|
992
|
+
passEnv: ["FAKE_REPORT", ...Object.keys(tokens)],
|
|
993
|
+
beforeScript: ["mkdir -p .chant/pr && env | sort > .chant/pr/before.env"],
|
|
994
|
+
planForge: forge.forge,
|
|
995
|
+
push: false,
|
|
996
|
+
output,
|
|
997
|
+
});
|
|
998
|
+
expect(r.status).toBe("ok");
|
|
999
|
+
expect(r.warnings).toEqual([]);
|
|
1000
|
+
const env = readFileSync(join(output, "pr-plan.env"), "utf-8");
|
|
1001
|
+
for (const value of ["forge-token", ...Object.values(tokens)]) expect(env).not.toContain(value);
|
|
1002
|
+
// The build phase's lines run the change's code too.
|
|
1003
|
+
expect(r.record.phases.map((p) => p.name)).toEqual(["build", "plan"]);
|
|
1004
|
+
const before = readFileSync(join(output, "before.env"), "utf-8");
|
|
1005
|
+
for (const value of ["forge-token", ...Object.values(tokens)]) expect(before).not.toContain(value);
|
|
1006
|
+
for (const name of FORGE_TOKEN_VARS) expect(env).not.toMatch(new RegExp(`^${name}=`, "m"));
|
|
1007
|
+
expect(readFileSync(join(output, "pr-plan.args"), "utf-8").trimEnd().split("\n").at(-1)).toBe("--no-status");
|
|
1008
|
+
expect(forge.statuses.map((p) => [p.sha, p.status.context, p.status.state])).toEqual([[sha, "chant/plan/app", "success"]]);
|
|
1009
|
+
expect(forge.notes).toEqual([{ pr: 7, marker: "<!-- chant-pr:prod:app -->" }]);
|
|
1010
|
+
});
|
|
1011
|
+
|
|
1012
|
+
test("a report the change forged for another head posts a failed status and no note", async () => {
|
|
1013
|
+
const { app, base } = workspace();
|
|
1014
|
+
const forge = recordingForge();
|
|
1015
|
+
const r = await ciRun({ image: JOB_IMAGE, cwd: app, job: "plan", env: "prod", forge: "forgejo", base, pr: 7, chant: fakeChant(), hostEnv: { ...HOST, FAKE_REPORT: planReport("3".repeat(40)) }, passEnv: ["FAKE_REPORT"], planForge: forge.forge, push: false, output: join(scratchDir("chant-ci-handover-"), "pr") });
|
|
1016
|
+
expect(r.warnings).toEqual([expect.stringMatching(/the plan report was not posted: the report is for head/)]);
|
|
1017
|
+
expect(forge.statuses.map((p) => p.status.state)).toEqual(["failure"]);
|
|
1018
|
+
expect(forge.notes).toEqual([]);
|
|
1019
|
+
});
|
|
1020
|
+
|
|
1021
|
+
test("in place, a job generated before #4295 still holds a token: its step gets none, the runner posts, and says to regenerate", async () => {
|
|
1022
|
+
const { app, base, sha } = workspace();
|
|
1023
|
+
const forge = recordingForge();
|
|
1024
|
+
const r = await ciRun({ cwd: app, job: "plan", env: "prod", forge: "forgejo", base, pr: 7, chant: fakeChant(), hostEnv: { ...HOST, GITHUB_JOB: "plan", FAKE_REPORT: planReport(sha) }, inPlace: true, push: false, planForge: forge.forge });
|
|
1025
|
+
expect(r.warnings).toEqual([expect.stringMatching(/regenerate the pipeline, whose plan-note job posts the note/)]);
|
|
1026
|
+
const env = envFile(join(app, ".chant/pr"), "pr-plan");
|
|
1027
|
+
expect(env).not.toHaveProperty("GITHUB_TOKEN");
|
|
1028
|
+
expect(env.NOT_FOR_JOBS).toBe("leak");
|
|
1029
|
+
expect(forge.notes).toHaveLength(1);
|
|
1030
|
+
});
|
|
1031
|
+
|
|
1032
|
+
test("the apply and the record keep their token: they run the target branch's code and post themselves", () => {
|
|
1033
|
+
const [, apply, record] = prLoopJobs("forgejo", "prod");
|
|
1034
|
+
expect(apply!.env.GITHUB_TOKEN).toBe("${{ github.token }}");
|
|
1035
|
+
expect(record!.env.GITHUB_TOKEN).toBe("${{ github.token }}");
|
|
1036
|
+
const [plan] = prLoopJobs("forgejo", "prod");
|
|
1037
|
+
expect(plan!.env).not.toHaveProperty("GITHUB_TOKEN");
|
|
1038
|
+
expect(plan!.run.env).not.toHaveProperty("GITHUB_TOKEN");
|
|
1039
|
+
expect(plan!.command.endsWith(" --no-status")).toBe(true);
|
|
1040
|
+
});
|
|
1041
|
+
});
|
package/src/workspace/ci-run.ts
CHANGED
|
@@ -45,6 +45,16 @@
|
|
|
45
45
|
* commit: `pending` when it starts, `success` or `failure` when it settles.
|
|
46
46
|
* A status the forge refuses is a warning and never fails the job.
|
|
47
47
|
*
|
|
48
|
+
* The plan runs the pull request's code, so no step of the plan job gets a
|
|
49
|
+
* forge token (#4295, ../ci-handover.ts): every name in `FORGE_TOKEN_VARS`
|
|
50
|
+
* is left out of its steps' environment, from the runner, a secret or the
|
|
51
|
+
* forge job alike, and `pr-plan` runs with `--no-status`. Once the steps
|
|
52
|
+
* settle, the runner posts the note and the `chant/plan` status from the
|
|
53
|
+
* report with its own token (`postPlanReport`, ../pr-post.ts). In place, a
|
|
54
|
+
* generated job holds no token and its `plan-note` job posts; a job that
|
|
55
|
+
* still holds one was generated before #4295, and the runner posts for it
|
|
56
|
+
* with a warning to regenerate.
|
|
57
|
+
*
|
|
48
58
|
* Signing is opt-in (#4156). With no runner key the record is unsigned, and
|
|
49
59
|
* a reader counts it with no signer. A key `.chant/trust.json` does not list
|
|
50
60
|
* leaves it unsigned with a note `--verbose` prints, unless the declaration
|
|
@@ -75,7 +85,9 @@ import { copyFileSync, cpSync, existsSync, mkdirSync, mkdtempSync, readdirSync,
|
|
|
75
85
|
import { tmpdir } from "node:os";
|
|
76
86
|
import { dirname, join, relative, resolve, sep } from "node:path";
|
|
77
87
|
import { PR_APPLY_RECORD, PR_LOOP_REPORT_DIR, prLoopJobs, prLoopSetup, PR_LOOP_IMAGE, type PrLoopJob, type PrLoopPipelineOptions } from "../components/pr-pipeline";
|
|
78
|
-
import { detectForge, ForgeEnvironmentError, forgeFromEnv, FORGE_KINDS, type CommitStatus, type ForgeKind } from "../pr-forge";
|
|
88
|
+
import { detectForge, ForgeEnvironmentError, forgeFromEnv, FORGE_KINDS, type CommitStatus, type ForgeKind, type PrForge } from "../pr-forge";
|
|
89
|
+
import { holdsForgeToken, withoutForgeTokens } from "../ci-handover";
|
|
90
|
+
import { postPlanReport } from "../pr-post";
|
|
79
91
|
import { PR_APPLY_GATE } from "../pr-loop";
|
|
80
92
|
import type { CommandContext } from "../cli/registry";
|
|
81
93
|
import { formatError, formatWarning } from "../cli/format";
|
|
@@ -458,6 +470,12 @@ export interface CiRunRequest {
|
|
|
458
470
|
secrets?: CiSecrets;
|
|
459
471
|
/** `--output`: where the job's report is copied, from `cwd`. Default `.chant/pr`. */
|
|
460
472
|
output?: string;
|
|
473
|
+
/**
|
|
474
|
+
* The forge client that posts the plan's note and `chant/plan` status from
|
|
475
|
+
* its report once the steps settle (#4295). Default: one built from the
|
|
476
|
+
* runner's environment when it holds a forge token.
|
|
477
|
+
*/
|
|
478
|
+
planForge?: PrForge;
|
|
461
479
|
/** `--run-index-limit`: how many runs the index at `loop.reportUrl` keeps (#4098). Default 200. */
|
|
462
480
|
runIndexLimit?: number;
|
|
463
481
|
/** A runner key, PEM, that signs the run's record. */
|
|
@@ -675,7 +693,8 @@ export async function ciRun(req: CiRunRequest): Promise<CiRunResult> {
|
|
|
675
693
|
const started = req.requireRunnerStart || isolated ? runnerStartFor(req, { top, commit, hostEnv }) : undefined;
|
|
676
694
|
// A generated wave job's command, as prLoopWaveJobs writes it. #4161: a local job's command posts no status or note.
|
|
677
695
|
const forgeCommand = req.noForge ? found.command.replace(` --forge ${req.forge}`, "") : found.command;
|
|
678
|
-
const
|
|
696
|
+
const noStatus = req.local && !/ --no-status(?: |$)/.test(forgeCommand);
|
|
697
|
+
const command = `${forgeCommand}${req.wave !== undefined ? ` --wave ${req.wave}` : ""}${noStatus ? " --no-status" : ""}`;
|
|
679
698
|
const job = { ...found, command, ...(req.wave !== undefined ? { wave: req.wave } : {}) };
|
|
680
699
|
const own = ciJobEnv(job, {
|
|
681
700
|
forge: req.forge,
|
|
@@ -686,14 +705,19 @@ export async function ciRun(req: CiRunRequest): Promise<CiRunResult> {
|
|
|
686
705
|
secrets: ciSecretsFor(req.secrets, req.job),
|
|
687
706
|
});
|
|
688
707
|
// #4156: in place, the forge job's environment is the job's, less the runner key.
|
|
689
|
-
|
|
708
|
+
// #4295: the plan runs the pull request's code, so no step of it gets a forge token, whatever gave it one.
|
|
709
|
+
const merged = req.inPlace ? { ...definedEnv(hostEnv), ...own } : own;
|
|
710
|
+
const jobEnv = req.job === "plan" ? withoutForgeTokens(merged) : merged;
|
|
690
711
|
delete jobEnv[RUNNER_KEY_ENV];
|
|
712
|
+
const poster = req.job === "plan" ? planPoster(req, hostEnv) : undefined;
|
|
691
713
|
// #4096: the secrets and the job's secret-looking variables, redacted from everything the run writes and returns.
|
|
692
714
|
// In place the forge's own variables come too; one whose value is short, such as a flag, is left out, so it can't blank common words.
|
|
693
715
|
const redact = ciRedactor(req.inPlace ? { ...longValues(jobEnv), ...own } : jobEnv, req.secrets);
|
|
694
716
|
try {
|
|
695
717
|
const ran = await runCiJob(req, { cwd, top, commit, place, job, jobEnv, ...(redact ? { redact } : {}) });
|
|
696
|
-
const
|
|
718
|
+
const posted = poster ? await postFromReport(req, poster, ran, { commit, ...(place.member ? { member: place.member } : {}) }) : [];
|
|
719
|
+
const withPosts = posted.length > 0 ? { ...ran, warnings: [...ran.warnings, ...posted] } : ran;
|
|
720
|
+
const result = started ? { ...withPosts, notes: [started, ...withPosts.notes] } : withPosts;
|
|
697
721
|
return redact ? { ...result, warnings: result.warnings.map(redact), notes: result.notes.map(redact), ...(result.pushWarning ? { pushWarning: redact(result.pushWarning) } : {}) } : result;
|
|
698
722
|
} catch (err) {
|
|
699
723
|
if (redact && err instanceof Error) err.message = redact(err.message);
|
|
@@ -701,6 +725,50 @@ export async function ciRun(req: CiRunRequest): Promise<CiRunResult> {
|
|
|
701
725
|
}
|
|
702
726
|
}
|
|
703
727
|
|
|
728
|
+
/**
|
|
729
|
+
* Who posts the plan's note and `chant/plan` status (#4295): the runner,
|
|
730
|
+
* from the report, once the plan's steps settled, with the forge token the
|
|
731
|
+
* steps never got. None on `--local`, for a plain git server (`noForge`), or
|
|
732
|
+
* without a token. In place, a token means the job was generated before the
|
|
733
|
+
* plan-note job existed: the runner still posts, and says to regenerate.
|
|
734
|
+
*/
|
|
735
|
+
function planPoster(req: CiRunRequest, hostEnv: Record<string, string | undefined>): { forge: PrForge; warning?: string } | { error: string } | undefined {
|
|
736
|
+
if (req.local || req.noForge || req.pr === undefined) return undefined;
|
|
737
|
+
const warning = req.inPlace
|
|
738
|
+
? "this plan job holds a forge token, which the pull request's code runs beside in the same job; regenerate the pipeline, whose plan-note job posts the note from a fresh job (#4295)"
|
|
739
|
+
: undefined;
|
|
740
|
+
if (req.planForge) return { forge: req.planForge, ...(warning ? { warning } : {}) };
|
|
741
|
+
if (!holdsForgeToken(hostEnv)) return undefined;
|
|
742
|
+
try {
|
|
743
|
+
return { forge: forgeFromEnv(req.forge, hostEnv), ...(warning ? { warning } : {}) };
|
|
744
|
+
} catch (err) {
|
|
745
|
+
return { error: `the plan's note and status will not be posted: ${message(err)}` };
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
/** Post the plan's note and status from the report the job wrote (#4295), and return the warnings. */
|
|
750
|
+
async function postFromReport(
|
|
751
|
+
req: CiRunRequest,
|
|
752
|
+
poster: NonNullable<ReturnType<typeof planPoster>>,
|
|
753
|
+
ran: CiRunResult,
|
|
754
|
+
at: { commit: string; member?: string },
|
|
755
|
+
): Promise<string[]> {
|
|
756
|
+
if ("error" in poster) return [poster.error];
|
|
757
|
+
const warnings = poster.warning ? [poster.warning] : [];
|
|
758
|
+
const dir = ran.report ?? resolve(req.cwd, req.output ?? PR_LOOP_REPORT_DIR);
|
|
759
|
+
const posted = await postPlanReport({
|
|
760
|
+
forge: poster.forge,
|
|
761
|
+
dir,
|
|
762
|
+
pr: req.pr!,
|
|
763
|
+
head: at.commit,
|
|
764
|
+
env: req.env,
|
|
765
|
+
...(at.member ? { member: at.member } : {}),
|
|
766
|
+
result: ran.status === "ok" ? "success" : "failure",
|
|
767
|
+
});
|
|
768
|
+
if (posted.refused) warnings.push(`the plan report was not posted: ${posted.refused}`);
|
|
769
|
+
return [...warnings, ...posted.warnings];
|
|
770
|
+
}
|
|
771
|
+
|
|
704
772
|
/**
|
|
705
773
|
* `--require-runner-start` (#4147): the runner-start statement for this
|
|
706
774
|
* forge job, checked against `.chant/trust.json` at `--base` (the commit
|
|
@@ -181,10 +181,116 @@
|
|
|
181
181
|
"workflow": {
|
|
182
182
|
"$ref": "#/$defs/ciWorkflow"
|
|
183
183
|
},
|
|
184
|
+
"hooks": {
|
|
185
|
+
"$ref": "#/$defs/ciHooks"
|
|
186
|
+
},
|
|
187
|
+
"locks": {
|
|
188
|
+
"enum": ["apply", "plan"],
|
|
189
|
+
"description": "When a pull request takes its target locks, _locks/<gate>.json on chant/lifecycle (#4293). apply: the pull request's apply takes them, and chant lock takes them by hand. plan: its plan takes them too, through chant lock --stage plan in a job that runs the target branch's code. Under either, chant components pr-apply refuses a target that another open pull request holds, and a lock whose pull request merged or closed reads as released. Absent: nothing is locked. Read from the declaration at base. Added in schema 1 by chant 0.124.41, so a declaration that uses it sets minReader to 0.124.41 or newer."
|
|
190
|
+
},
|
|
191
|
+
"policy": {
|
|
192
|
+
"$ref": "#/$defs/ciPolicy"
|
|
193
|
+
},
|
|
184
194
|
"reportUrl": {
|
|
185
195
|
"type": "string",
|
|
186
196
|
"minLength": 1,
|
|
187
197
|
"description": "Where the pull-request jobs copy their reports and events, and where chant ci run keeps the run index, <reportUrl>/runs/index.json (#4160): an s3://<bucket>/<prefix> or file://<directory> URL, as --report-url takes. chant build --pr-loop, chant ci run, chant ci serve and chant ci runs use it when --report-url is not given, and the flag wins. Readers find it in chant workspace ls --json as ci.reportUrl. Added in schema 1 by chant 0.124.32, so a declaration that uses it sets minReader to 0.124.32 or newer."
|
|
198
|
+
},
|
|
199
|
+
"branches": {
|
|
200
|
+
"type": "object",
|
|
201
|
+
"description": "Branch routing (#4292): a branch name to member-name globs. A member a glob matches applies from that branch alone: chant build --pr-loop targets its pipeline at the branch, and its record and apply jobs plan nothing on a push to any other branch. A member no glob matches applies from every branch the map does not name. One member may not be matched by two branches. Added in schema 1 by chant 0.124.41, so a declaration that uses it sets minReader to 0.124.41 or newer.",
|
|
202
|
+
"patternProperties": {
|
|
203
|
+
"^x-": true,
|
|
204
|
+
"^(?!x-)[^\\s]+$": { "$ref": "#/$defs/ciMemberGlobs" }
|
|
205
|
+
},
|
|
206
|
+
"additionalProperties": false
|
|
207
|
+
},
|
|
208
|
+
"labels": {
|
|
209
|
+
"type": "object",
|
|
210
|
+
"description": "Label filters (#4292): a pull request label to member-name globs. When a pull request carries one of these labels, its plan jobs plan only the members those labels match. A label never gates: the record and apply jobs never read labels, and an apply still needs an approval of the digest it plans. Added in schema 1 by chant 0.124.41, so a declaration that uses it sets minReader to 0.124.41 or newer.",
|
|
211
|
+
"patternProperties": {
|
|
212
|
+
"^x-": true,
|
|
213
|
+
"^(?!x-).+$": { "$ref": "#/$defs/ciMemberGlobs" }
|
|
214
|
+
},
|
|
215
|
+
"additionalProperties": false
|
|
216
|
+
},
|
|
217
|
+
"identity": {
|
|
218
|
+
"type": "object",
|
|
219
|
+
"additionalProperties": false,
|
|
220
|
+
"patternProperties": { "^x-": true },
|
|
221
|
+
"description": "Cloud identities for the pull-request jobs (#4296). Added in schema 1 by chant 0.124.41, so a declaration that uses it sets minReader to 0.124.41 or newer.",
|
|
222
|
+
"properties": {
|
|
223
|
+
"cloud": {
|
|
224
|
+
"type": "object",
|
|
225
|
+
"additionalProperties": false,
|
|
226
|
+
"patternProperties": { "^x-": true },
|
|
227
|
+
"description": "Each cloud's plan and apply identities, as rules over environment and member globs. chant build --pr-loop takes the first rule that matches the pipeline's environment and member. The plan job trades its OIDC token for the plan identity before the change is checked out, the record job takes the plan identity, and the apply jobs the apply identity. A rule may not plan as an identity any rule applies as.",
|
|
228
|
+
"properties": {
|
|
229
|
+
"aws": {
|
|
230
|
+
"type": "object",
|
|
231
|
+
"additionalProperties": false,
|
|
232
|
+
"patternProperties": { "^x-": true },
|
|
233
|
+
"required": ["rules"],
|
|
234
|
+
"description": "AWS roles, assumed through STS AssumeRoleWithWebIdentity.",
|
|
235
|
+
"properties": {
|
|
236
|
+
"region": { "type": "string", "minLength": 1, "description": "The STS region. Default us-east-1." },
|
|
237
|
+
"audience": { "type": "string", "minLength": 1, "description": "The OIDC token's audience. Default sts.amazonaws.com." },
|
|
238
|
+
"endpoint": { "type": "string", "pattern": "^https?://", "description": "The STS endpoint, for an emulator. Default the region's, or AWS_ENDPOINT_URL_STS in the job." },
|
|
239
|
+
"rules": { "$ref": "#/$defs/ciCloudIdentityRules" }
|
|
240
|
+
}
|
|
241
|
+
},
|
|
242
|
+
"gcp": {
|
|
243
|
+
"type": "object",
|
|
244
|
+
"additionalProperties": false,
|
|
245
|
+
"patternProperties": { "^x-": true },
|
|
246
|
+
"required": ["provider", "rules"],
|
|
247
|
+
"description": "GCP service accounts, reached through a workload identity provider and impersonated with IAM generateAccessToken.",
|
|
248
|
+
"properties": {
|
|
249
|
+
"provider": { "type": "string", "pattern": "^(//iam\\.googleapis\\.com/)?projects/[^/]+/locations/global/workloadIdentityPools/[^/]+/providers/[^/]+$", "description": "The workload identity provider: projects/<number>/locations/global/workloadIdentityPools/<pool>/providers/<provider>." },
|
|
250
|
+
"audience": { "type": "string", "minLength": 1, "description": "The OIDC token's audience. Default https://iam.googleapis.com/<provider>." },
|
|
251
|
+
"stsEndpoint": { "type": "string", "pattern": "^https?://", "description": "Google's STS, for an emulator. Default https://sts.googleapis.com." },
|
|
252
|
+
"iamEndpoint": { "type": "string", "pattern": "^https?://", "description": "IAM credentials, for an emulator. Default https://iamcredentials.googleapis.com." },
|
|
253
|
+
"rules": { "$ref": "#/$defs/ciCloudIdentityRules" }
|
|
254
|
+
}
|
|
255
|
+
},
|
|
256
|
+
"azure": {
|
|
257
|
+
"type": "object",
|
|
258
|
+
"additionalProperties": false,
|
|
259
|
+
"patternProperties": { "^x-": true },
|
|
260
|
+
"required": ["tenantId", "rules"],
|
|
261
|
+
"description": "Entra application clients, each with a federated credential for the forge's tokens. The rules' identities are client ids.",
|
|
262
|
+
"properties": {
|
|
263
|
+
"tenantId": { "type": "string", "minLength": 1, "description": "The Entra tenant." },
|
|
264
|
+
"subscriptionId": { "type": "string", "minLength": 1, "description": "Set as AZURE_SUBSCRIPTION_ID and ARM_SUBSCRIPTION_ID when given." },
|
|
265
|
+
"audience": { "type": "string", "minLength": 1, "description": "The OIDC token's audience. Default api://AzureADTokenExchange." },
|
|
266
|
+
"authority": { "type": "string", "pattern": "^https?://", "description": "The Entra authority host, for an emulator. Default https://login.microsoftonline.com." },
|
|
267
|
+
"rules": { "$ref": "#/$defs/ciCloudIdentityRules" }
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
},
|
|
274
|
+
"prEnvironment": {
|
|
275
|
+
"type": "object",
|
|
276
|
+
"description": "An environment per open pull request (#4298): chant components pr-env up applies the pull request's components to pr-<number>, which takes the template environment's declared endpoint, and pr-env down tears down only what carries that environment's ownership marker. A pull request's environment is never a release target. Read from the declaration at base, so a pull request cannot change its own template or TTL. Added in schema 1 by chant 0.124.42, so a declaration that uses it sets minReader to 0.124.42 or newer.",
|
|
277
|
+
"required": ["template"],
|
|
278
|
+
"additionalProperties": false,
|
|
279
|
+
"patternProperties": {
|
|
280
|
+
"^x-": true
|
|
281
|
+
},
|
|
282
|
+
"properties": {
|
|
283
|
+
"template": {
|
|
284
|
+
"type": "string",
|
|
285
|
+
"pattern": "^[a-z0-9][a-z0-9-]*$",
|
|
286
|
+
"description": "The declared environment a pull request's environment is derived from: pr-<number> takes its endpoint unless a pr-* entry declares one."
|
|
287
|
+
},
|
|
288
|
+
"ttl": {
|
|
289
|
+
"type": "string",
|
|
290
|
+
"pattern": "^[1-9][0-9]*(m|h|d)$",
|
|
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
|
+
}
|
|
188
294
|
}
|
|
189
295
|
}
|
|
190
296
|
},
|
|
@@ -254,6 +360,36 @@
|
|
|
254
360
|
},
|
|
255
361
|
"additionalProperties": false,
|
|
256
362
|
"$defs": {
|
|
363
|
+
"ciCloudIdentityRules": {
|
|
364
|
+
"type": "array",
|
|
365
|
+
"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, and one with no members every member and a pipeline outside a member.",
|
|
367
|
+
"items": {
|
|
368
|
+
"type": "object",
|
|
369
|
+
"additionalProperties": false,
|
|
370
|
+
"patternProperties": { "^x-": true },
|
|
371
|
+
"required": ["plan", "apply"],
|
|
372
|
+
"properties": {
|
|
373
|
+
"environments": {
|
|
374
|
+
"type": "array",
|
|
375
|
+
"minItems": 1,
|
|
376
|
+
"uniqueItems": true,
|
|
377
|
+
"items": { "type": "string", "minLength": 1 },
|
|
378
|
+
"description": "Environment-name globs, such as prod or staging-*."
|
|
379
|
+
},
|
|
380
|
+
"members": { "$ref": "#/$defs/ciMemberGlobs" },
|
|
381
|
+
"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
|
+
"apply": { "type": "string", "minLength": 1, "description": "The identity the apply jobs take." }
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
},
|
|
386
|
+
"ciMemberGlobs": {
|
|
387
|
+
"type": "array",
|
|
388
|
+
"minItems": 1,
|
|
389
|
+
"uniqueItems": true,
|
|
390
|
+
"items": { "type": "string", "minLength": 1, "pattern": "^[a-z0-9*?\\[\\]{},!-]+$" },
|
|
391
|
+
"description": "Globs over member names, such as prod-* or [\"api\", \"web\"]: * and ? within the name, [...] classes, {a,b} alternatives."
|
|
392
|
+
},
|
|
257
393
|
"retentionDays": {
|
|
258
394
|
"type": "integer",
|
|
259
395
|
"minimum": 0,
|
|
@@ -504,6 +640,9 @@
|
|
|
504
640
|
"properties": {
|
|
505
641
|
"workflow": {
|
|
506
642
|
"$ref": "#/$defs/ciWorkflow"
|
|
643
|
+
},
|
|
644
|
+
"hooks": {
|
|
645
|
+
"$ref": "#/$defs/ciHooks"
|
|
507
646
|
}
|
|
508
647
|
}
|
|
509
648
|
},
|
|
@@ -1582,6 +1721,14 @@
|
|
|
1582
1721
|
"enum": ["trunk-curated", "trunk-curated+apply-on-merge"],
|
|
1583
1722
|
"description": "A prescribed delivery workflow (#4168). trunk-curated: a merge records the plan and deploys nothing; releases are cut from green commits and promoted through each member's environments, gated in the last. trunk-curated+apply-on-merge: the same, with the apply on merge turned back on. chant workspace check reports what a member under one still lacks (WSP151), and chant ci init writes it. At the top level for every member, or on a member, which wins. Added in schema 1 by chant 0.124.34, so a declaration that uses it sets minReader to 0.124.34 or newer."
|
|
1584
1723
|
},
|
|
1724
|
+
"ciHooks": {
|
|
1725
|
+
"type": "object",
|
|
1726
|
+
"description": "Stage hooks the pull-request jobs run (#4288), in the shape an Op waves spec's hooks take: before.observe, before.plan, after.plan, before.apply and after.apply, each a list of { command, name?, onFailure?, perRun?, targets? }. targets are component names or patterns. chant components pr-plan, pr-record and pr-apply read them from the declaration at base, the top level's first and then the member's. after.gate and apply.runBy are refused. Added in schema 1 by chant 0.124.41, so a declaration that uses it sets minReader to 0.124.41 or newer."
|
|
1727
|
+
},
|
|
1728
|
+
"ciPolicy": {
|
|
1729
|
+
"type": "object",
|
|
1730
|
+
"description": "Gate-time policy on the pull-request jobs (#4294): a policy engine run over the change set, with the policy read at base. engine names the result shape the command prints, conftest (the default) or opa. path is the policy directory, relative to the declaration (default policy). namespace is the package to read. command replaces the engine's own argv, with {policy} for the policy directory exported from base and {input} for the input file. deny rules refuse the apply unless an override counts; hold rules make the plan wait for a person's approval; warn rules are advice. There is no unattended outcome. Added in schema 1 by chant 0.124.41, so a declaration that uses it sets minReader to 0.124.41 or newer."
|
|
1731
|
+
},
|
|
1585
1732
|
"ciPhaseName": {
|
|
1586
1733
|
"type": "string",
|
|
1587
1734
|
"pattern": "^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$"
|
|
@@ -436,6 +436,15 @@ describe("retention (#4036)", () => {
|
|
|
436
436
|
});
|
|
437
437
|
});
|
|
438
438
|
|
|
439
|
+
describe("ci.locks (#4293)", () => {
|
|
440
|
+
test("apply or plan is read; absent leaves it out; anything else is declaration-invalid", () => {
|
|
441
|
+
expect(parse(base([], { ci: { locks: "plan" } })).ci).toEqual({ green: null, locks: "plan", reportUrl: null });
|
|
442
|
+
expect(parse(base([], { ci: { locks: "apply" } })).ci?.locks).toBe("apply");
|
|
443
|
+
expect(parse(base([], { ci: {} })).ci?.locks).toBeUndefined();
|
|
444
|
+
for (const locks of ["merge", true, ""]) expect(failure(base([], { ci: { locks } })).code).toBe("declaration-invalid");
|
|
445
|
+
});
|
|
446
|
+
});
|
|
447
|
+
|
|
439
448
|
describe("ci.reportUrl (#4160)", () => {
|
|
440
449
|
test("an s3:// or file:// URL is read without its trailing slash, beside green or alone, and is null when absent", () => {
|
|
441
450
|
expect(parse(base([])).ci).toBeNull();
|
|
@@ -489,3 +498,70 @@ describe("metrics (#4039)", () => {
|
|
|
489
498
|
}
|
|
490
499
|
});
|
|
491
500
|
});
|
|
501
|
+
|
|
502
|
+
describe("ci.branches and ci.labels (#4292)", () => {
|
|
503
|
+
const members = [
|
|
504
|
+
{ name: "prod-eu", dir: "prod-eu", kind: "chant" },
|
|
505
|
+
{ name: "api", dir: "api", kind: "chant" },
|
|
506
|
+
];
|
|
507
|
+
|
|
508
|
+
test("both maps are read without their x- keys, and stay absent when unset", () => {
|
|
509
|
+
const d = parse(base(members, { ci: { branches: { release: ["prod-*"], "x-note": "ops" }, labels: { "plan:prod": ["prod-*"] } } }));
|
|
510
|
+
expect(d.ci?.branches).toEqual({ release: ["prod-*"] });
|
|
511
|
+
expect(d.ci?.labels).toEqual({ "plan:prod": ["prod-*"] });
|
|
512
|
+
expect(parse(base(members, { ci: {} })).ci).toEqual({ green: null, reportUrl: null });
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
test("a member two branches route is declaration-invalid at the second branch", () => {
|
|
516
|
+
const err = failure(base(members, { ci: { branches: { release: ["prod-*"], hotfix: ["prod-eu"] } } }));
|
|
517
|
+
expect(err.code).toBe("declaration-invalid");
|
|
518
|
+
expect(err.message).toBe("ci.branches routes member prod-eu to both release and hotfix; a member applies from one branch");
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
test("an empty glob list or a glob that is not a member-name glob is refused", () => {
|
|
522
|
+
expect(failure(base(members, { ci: { branches: { release: [] } } })).code).toBe("declaration-invalid");
|
|
523
|
+
expect(failure(base(members, { ci: { branches: { release: ["prod/eu"] } } })).code).toBe("declaration-invalid");
|
|
524
|
+
expect(failure(base(members, { ci: { labels: { "plan:prod": "prod-*" } } })).code).toBe("declaration-invalid");
|
|
525
|
+
});
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
describe("ci.identity.cloud (#4296)", () => {
|
|
529
|
+
const members = [{ name: "net", dir: "net", kind: "chant" }];
|
|
530
|
+
const aws = { region: "eu-west-1", "x-note": "ops", rules: [{ environments: ["prod"], members: ["net"], plan: "arn:aws:iam::1:role/plan", apply: "arn:aws:iam::1:role/apply", "x-why": "split" }] };
|
|
531
|
+
|
|
532
|
+
test("each cloud is read without its x- keys", () => {
|
|
533
|
+
const d = parse(base(members, { ci: { identity: { cloud: { aws, azure: { tenantId: "t", rules: [{ plan: "p", apply: "a" }] } } } } }));
|
|
534
|
+
expect(d.ci?.identity).toEqual({
|
|
535
|
+
cloud: {
|
|
536
|
+
aws: { region: "eu-west-1", rules: [{ environments: ["prod"], members: ["net"], plan: "arn:aws:iam::1:role/plan", apply: "arn:aws:iam::1:role/apply" }] },
|
|
537
|
+
azure: { tenantId: "t", rules: [{ plan: "p", apply: "a" }] },
|
|
538
|
+
},
|
|
539
|
+
});
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
test("a rule that plans as an identity some rule applies as is declaration-invalid at that rule", () => {
|
|
543
|
+
const err = failure(base(members, { ci: { identity: { cloud: { aws: { rules: [{ plan: "r", apply: "r" }] } } } } }));
|
|
544
|
+
expect(err.code).toBe("declaration-invalid");
|
|
545
|
+
expect(err.message).toContain("ci.identity.cloud.aws.rules[0] plans and applies as r");
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
test("the schema refuses a rule with no apply, an unknown cloud and a GCP provider that is not a provider's name", () => {
|
|
549
|
+
expect(failure(base(members, { ci: { identity: { cloud: { aws: { rules: [{ plan: "r" }] } } } } })).code).toBe("declaration-invalid");
|
|
550
|
+
expect(failure(base(members, { ci: { identity: { cloud: { oci: { rules: [] } } } } })).code).toBe("declaration-invalid");
|
|
551
|
+
expect(failure(base(members, { ci: { identity: { cloud: { gcp: { provider: "forge", rules: [{ plan: "p", apply: "a" }] } } } } })).code).toBe("declaration-invalid");
|
|
552
|
+
});
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
describe("ci.prEnvironment (#4298)", () => {
|
|
556
|
+
const members = [{ name: "api", dir: "api", kind: "chant" }];
|
|
557
|
+
|
|
558
|
+
test("is read with its template and a 72h TTL by default", () => {
|
|
559
|
+
expect(parse(base(members, { ci: { prEnvironment: { template: "staging" } } })).ci?.prEnvironment).toEqual({ template: "staging", ttl: "72h" });
|
|
560
|
+
expect(parse(base(members, { ci: { prEnvironment: { template: "staging", ttl: "7d" } } })).ci?.prEnvironment).toEqual({ template: "staging", ttl: "7d" });
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
test("a block with no template, or a TTL in another form, is declaration-invalid", () => {
|
|
564
|
+
expect(failure(base(members, { ci: { prEnvironment: {} } })).code).toBe("declaration-invalid");
|
|
565
|
+
expect(failure(base(members, { ci: { prEnvironment: { template: "staging", ttl: "1w" } } })).code).toBe("declaration-invalid");
|
|
566
|
+
});
|
|
567
|
+
});
|