@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
|
@@ -19,6 +19,8 @@
|
|
|
19
19
|
import { RETENTION_KINDS, type RetentionOverride } from "../lifecycle/retention-policy";
|
|
20
20
|
import { parseReportUrl } from "../pr-report-url";
|
|
21
21
|
import schema from "./declaration.schema.json" with { type: "json" };
|
|
22
|
+
import { routeConflicts } from "./ci-branches";
|
|
23
|
+
import { cloudIdentityProblems, CLOUDS, type CloudIdentityDeclaration } from "../ci-identity";
|
|
22
24
|
import { expandGlob } from "./glob";
|
|
23
25
|
import { EXAMPLES_KIND, holdsChantProject } from "./kinds";
|
|
24
26
|
import { parseJsonText, pointerToken, type TextLocation } from "./jsonc";
|
|
@@ -27,6 +29,8 @@ import type { SecurityGroup } from "./security";
|
|
|
27
29
|
import { joinPath, type WorkspaceTree } from "./tree";
|
|
28
30
|
import { loadAjv2020 } from "./ajv2020";
|
|
29
31
|
import { readChantPackageVersion } from "../package-version";
|
|
32
|
+
import { assertPrLoopHooks, type OpWaveHooks } from "../op/op-waves";
|
|
33
|
+
import { assertGatePolicySettings, type GatePolicySettings } from "../gate-policy";
|
|
30
34
|
|
|
31
35
|
export const DECLARATION_SCHEMA_ID = schema.$id;
|
|
32
36
|
export const DECLARATION_FILES = ["chant.workspace.json", "chant.workspace.jsonc"] as const;
|
|
@@ -259,11 +263,22 @@ export type CiSource = "checks" | "runs";
|
|
|
259
263
|
*/
|
|
260
264
|
export type CiWorkflow = "trunk-curated" | "trunk-curated+apply-on-merge";
|
|
261
265
|
|
|
266
|
+
/**
|
|
267
|
+
* When a pull request takes its target locks (#4293, ../lifecycle/target-lock.ts).
|
|
268
|
+
* `apply`: its apply takes them, and `chant lock` does by hand. `plan`: its
|
|
269
|
+
* plan takes them too, through `chant lock --stage plan` in a job that runs
|
|
270
|
+
* the target branch's code. Under either, an apply refuses a target another
|
|
271
|
+
* open pull request holds.
|
|
272
|
+
*/
|
|
273
|
+
export type CiLocks = "apply" | "plan";
|
|
274
|
+
|
|
262
275
|
/** The declaration's `ci` block (#3573). */
|
|
263
276
|
export interface CiDeclaration {
|
|
264
277
|
green: CiGreen | null;
|
|
265
278
|
/** `ci.workflow` (#4168 step 6): the workflow every member follows unless it names its own; absent when the declaration sets none. */
|
|
266
279
|
workflow?: CiWorkflow;
|
|
280
|
+
/** `ci.locks` (#4293): when a pull request takes its target locks; absent when the declaration sets none, and then nothing is locked. */
|
|
281
|
+
locks?: CiLocks;
|
|
267
282
|
/**
|
|
268
283
|
* `ci.reportUrl` (#4160): where the pull-request jobs copy their reports
|
|
269
284
|
* and `chant ci run` keeps the run index, `<reportUrl>/runs/index.json`.
|
|
@@ -272,8 +287,56 @@ export interface CiDeclaration {
|
|
|
272
287
|
* the declaration sets none.
|
|
273
288
|
*/
|
|
274
289
|
reportUrl: string | null;
|
|
290
|
+
/**
|
|
291
|
+
* `ci.hooks` (#4288): the stage hooks the pull-request jobs run, in the
|
|
292
|
+
* shape an Op waves spec's `hooks` takes. `chant components pr-plan`,
|
|
293
|
+
* `pr-record` and `pr-apply` read them from the declaration at base.
|
|
294
|
+
* Absent when the declaration sets none.
|
|
295
|
+
*/
|
|
296
|
+
hooks?: OpWaveHooks;
|
|
297
|
+
/**
|
|
298
|
+
* `ci.branches` (#4292): branch name to member-name globs. A member a glob
|
|
299
|
+
* matches applies from that branch alone; the others apply from every
|
|
300
|
+
* branch the map does not name (./ci-branches.ts). Absent when unset.
|
|
301
|
+
*/
|
|
302
|
+
branches?: Record<string, string[]>;
|
|
303
|
+
/**
|
|
304
|
+
* `ci.labels` (#4292): pull request label to member-name globs. A labelled
|
|
305
|
+
* pull request plans only the members its labels match. Never a gate.
|
|
306
|
+
* Absent when unset.
|
|
307
|
+
*/
|
|
308
|
+
labels?: Record<string, string[]>;
|
|
309
|
+
/**
|
|
310
|
+
* `ci.policy` (#4294): the policy engine the pull-request jobs run over
|
|
311
|
+
* the change set, read with the policy from the declaration at base.
|
|
312
|
+
* Absent when the declaration sets none.
|
|
313
|
+
*/
|
|
314
|
+
policy?: GatePolicySettings;
|
|
315
|
+
/**
|
|
316
|
+
* `ci.identity` (#4296): `cloud` gives each cloud's plan and apply
|
|
317
|
+
* identities by environment and member globs (../ci-identity.ts).
|
|
318
|
+
* Absent when unset.
|
|
319
|
+
*/
|
|
320
|
+
identity?: { cloud: CloudIdentityDeclaration };
|
|
321
|
+
/**
|
|
322
|
+
* `ci.prEnvironment` (#4298): an environment per open pull request,
|
|
323
|
+
* derived from `template`, torn down on close or after `ttl`
|
|
324
|
+
* (../components/pr-environment.ts). Absent when unset.
|
|
325
|
+
*/
|
|
326
|
+
prEnvironment?: CiPrEnvironment;
|
|
275
327
|
}
|
|
276
328
|
|
|
329
|
+
/** The `ci.prEnvironment` block (#4298), with its default TTL. */
|
|
330
|
+
export interface CiPrEnvironment {
|
|
331
|
+
/** The declared environment `pr-<n>` takes its endpoint from. */
|
|
332
|
+
template: string;
|
|
333
|
+
/** How long the environment lives after its last apply: `72h` when absent. */
|
|
334
|
+
ttl: string;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** The default `ci.prEnvironment.ttl`. */
|
|
338
|
+
export const DEFAULT_PR_ENVIRONMENT_TTL = "72h";
|
|
339
|
+
|
|
277
340
|
export interface MemberRole {
|
|
278
341
|
name: string;
|
|
279
342
|
/** Relative to the member's directory, or null for the whole member. */
|
|
@@ -592,6 +655,8 @@ export interface Member {
|
|
|
592
655
|
security: SecurityGroup[];
|
|
593
656
|
/** The member's own `ci.workflow` (#4168 step 6), which wins over the top level's; null when it names none. */
|
|
594
657
|
ciWorkflow: CiWorkflow | null;
|
|
658
|
+
/** The member's own `ci.hooks` (#4288), run after the top level's; null when it names none. */
|
|
659
|
+
ciHooks: OpWaveHooks | null;
|
|
595
660
|
suppress: Suppression[];
|
|
596
661
|
/** The entry's JSON Pointer in the file, for messages. */
|
|
597
662
|
pointer: string;
|
|
@@ -889,6 +954,7 @@ export function parseDeclaration(text: string, file: string, reader: string = re
|
|
|
889
954
|
travel: e.travel === true,
|
|
890
955
|
security: [...((e.security as SecurityGroup[] | undefined) ?? [])],
|
|
891
956
|
ciWorkflow: ((e.ci as { workflow?: CiWorkflow } | undefined)?.workflow) ?? null,
|
|
957
|
+
ciHooks: ciHooksOf((e.ci as { hooks?: unknown } | undefined)?.hooks, `${pointer}/ci/hooks`, at),
|
|
892
958
|
suppress,
|
|
893
959
|
pointer,
|
|
894
960
|
};
|
|
@@ -1013,6 +1079,15 @@ export function parseDeclaration(text: string, file: string, reader: string = re
|
|
|
1013
1079
|
const tierSession = factoryTierSessionProblem(members, agents);
|
|
1014
1080
|
if (tierSession) throw new WorkspaceReadError("declaration-invalid", tierSession.message, at(tierSession.pointer));
|
|
1015
1081
|
const ci = ciOf(obj.ci, at);
|
|
1082
|
+
// #4292: a member applies from one branch, so two branches may not both route it.
|
|
1083
|
+
const [conflict] = routeConflicts(ci?.branches, members.map((m) => m.name));
|
|
1084
|
+
if (conflict) {
|
|
1085
|
+
throw new WorkspaceReadError(
|
|
1086
|
+
"declaration-invalid",
|
|
1087
|
+
`ci.branches routes member ${conflict.member} to both ${conflict.branches.join(" and ")}; a member applies from one branch`,
|
|
1088
|
+
at(`/ci/branches/${pointerToken(conflict.branches[1]!)}`),
|
|
1089
|
+
);
|
|
1090
|
+
}
|
|
1016
1091
|
|
|
1017
1092
|
const pins = ((obj.pins as Record<string, string>[] | undefined) ?? []).map((p) => ({
|
|
1018
1093
|
package: p.package ?? null,
|
|
@@ -1134,10 +1209,67 @@ function retentionOf(raw: unknown): RetentionOverride | null {
|
|
|
1134
1209
|
return out;
|
|
1135
1210
|
}
|
|
1136
1211
|
|
|
1212
|
+
/** A `ci.hooks` block (#4288), refused when a pull-request job could not run it. Null when absent. */
|
|
1213
|
+
function ciHooksOf(raw: unknown, pointer: string, at: (pointer: string, key?: boolean) => ErrorLocation): OpWaveHooks | null {
|
|
1214
|
+
if (raw === undefined) return null;
|
|
1215
|
+
try {
|
|
1216
|
+
assertPrLoopHooks(pointer.slice(1).replace(/\//g, "."), raw as OpWaveHooks);
|
|
1217
|
+
} catch (err) {
|
|
1218
|
+
throw new WorkspaceReadError("declaration-invalid", err instanceof Error ? err.message : String(err), at(pointer));
|
|
1219
|
+
}
|
|
1220
|
+
return structuredClone(raw as OpWaveHooks);
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
/** A `ci.branches` or `ci.labels` map, copied without its `x-` keys. */
|
|
1224
|
+
function globMap(raw: Record<string, string[]>): Record<string, string[]> {
|
|
1225
|
+
return Object.fromEntries(Object.entries(raw).filter(([k]) => !k.startsWith("x-")).map(([k, v]) => [k, [...v]]));
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
/** A `ci.policy` block (#4294), refused when the pull-request jobs could not run it. Null when absent. */
|
|
1229
|
+
function ciPolicyOf(raw: unknown, at: (pointer: string, key?: boolean) => ErrorLocation): GatePolicySettings | null {
|
|
1230
|
+
if (raw === undefined) return null;
|
|
1231
|
+
try {
|
|
1232
|
+
assertGatePolicySettings("ci.policy", raw);
|
|
1233
|
+
} catch (err) {
|
|
1234
|
+
throw new WorkspaceReadError("declaration-invalid", err instanceof Error ? err.message : String(err), at("/ci/policy"));
|
|
1235
|
+
}
|
|
1236
|
+
return structuredClone(raw);
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
/**
|
|
1240
|
+
* `ci.identity.cloud` (#4296), copied without its `x-` keys. A rule that
|
|
1241
|
+
* plans as an identity some rule applies as is refused: the plan runs the
|
|
1242
|
+
* pull request's code.
|
|
1243
|
+
*/
|
|
1244
|
+
function cloudIdentityOf(raw: CloudIdentityDeclaration, at: (pointer: string, key?: boolean) => ErrorLocation): CloudIdentityDeclaration {
|
|
1245
|
+
const out: CloudIdentityDeclaration = {};
|
|
1246
|
+
for (const cloud of CLOUDS) {
|
|
1247
|
+
const c = raw[cloud];
|
|
1248
|
+
if (c === undefined) continue;
|
|
1249
|
+
const kept = Object.fromEntries(Object.entries(c).filter(([k]) => !k.startsWith("x-")));
|
|
1250
|
+
const rules = c.rules.map((r) => Object.fromEntries(Object.entries(r).filter(([k]) => !k.startsWith("x-")).map(([k, v]) => [k, Array.isArray(v) ? [...v] : v])));
|
|
1251
|
+
(out as Record<string, unknown>)[cloud] = { ...kept, rules };
|
|
1252
|
+
}
|
|
1253
|
+
const [problem] = cloudIdentityProblems(out);
|
|
1254
|
+
if (problem) throw new WorkspaceReadError("declaration-invalid", problem.message, at(problem.pointer));
|
|
1255
|
+
return out;
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1137
1258
|
function ciOf(raw: unknown, at: (pointer: string, key?: boolean) => ErrorLocation): CiDeclaration | null {
|
|
1138
1259
|
if (raw === undefined) return null;
|
|
1139
|
-
const block = raw as { reportUrl?: string; workflow?: CiWorkflow; green?: { branch: string; window?: string; phases: Record<string, string[] | { runs: string[]; skipped?: CiSkipped; build?: CiBuild }>; require: string[]; preMerge?: string[]; signedSince?: string; source?: CiSource } };
|
|
1140
|
-
const
|
|
1260
|
+
const block = raw as { reportUrl?: string; workflow?: CiWorkflow; hooks?: unknown; identity?: { cloud?: CloudIdentityDeclaration }; branches?: Record<string, string[]>; labels?: Record<string, string[]>; locks?: CiLocks; policy?: unknown; prEnvironment?: { template: string; ttl?: string }; green?: { branch: string; window?: string; phases: Record<string, string[] | { runs: string[]; skipped?: CiSkipped; build?: CiBuild }>; require: string[]; preMerge?: string[]; signedSince?: string; source?: CiSource } };
|
|
1261
|
+
const hooks = ciHooksOf(block.hooks, "/ci/hooks", at);
|
|
1262
|
+
const policy = ciPolicyOf(block.policy, at);
|
|
1263
|
+
const workflow = {
|
|
1264
|
+
...(block.workflow !== undefined ? { workflow: block.workflow } : {}),
|
|
1265
|
+
...(hooks ? { hooks } : {}),
|
|
1266
|
+
...(block.branches !== undefined ? { branches: globMap(block.branches) } : {}),
|
|
1267
|
+
...(block.labels !== undefined ? { labels: globMap(block.labels) } : {}),
|
|
1268
|
+
...(block.locks !== undefined ? { locks: block.locks } : {}),
|
|
1269
|
+
...(policy ? { policy } : {}),
|
|
1270
|
+
...(block.identity?.cloud !== undefined ? { identity: { cloud: cloudIdentityOf(block.identity.cloud, at) } } : {}),
|
|
1271
|
+
...(block.prEnvironment !== undefined ? { prEnvironment: { template: block.prEnvironment.template, ttl: block.prEnvironment.ttl ?? DEFAULT_PR_ENVIRONMENT_TTL } } : {}),
|
|
1272
|
+
};
|
|
1141
1273
|
let reportUrl: string | null = null;
|
|
1142
1274
|
if (block.reportUrl !== undefined) {
|
|
1143
1275
|
// #4160: refused here as pr-plan and ci run refuse the flag, so a reader never gets a URL no job can write to.
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* else: no zod, no ajv, no package loading, so it bundles small.
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
-
import { GATE_SEAL_NAMESPACE, gateSealPayload, namespaceAllowed, sshKeyFingerprint, sshSignatureKey, verifySshSignatureWith, type SealedGateApproval } from "
|
|
17
|
+
import { GATE_SEAL_NAMESPACE, gateSealPayload, namespaceAllowed, sshKeyFingerprint, sshSignatureKey, verifySshSignatureWith, type SealedGateApproval } from "../lifecycle/gate-seal";
|
|
18
18
|
import type { Signer } from "./trust/trust-files";
|
|
19
19
|
|
|
20
20
|
/** The core classes, in the order a principal's roles are tried; `human` holds no role and is the rest. */
|
|
@@ -31,7 +31,7 @@ import { classesOf, coreClassRegistry, roleGrants, type ClassRegistry } from "./
|
|
|
31
31
|
import type { ReasonCode } from "./reason-codes";
|
|
32
32
|
import { normalisePrincipal } from "./records";
|
|
33
33
|
import type { TrustPolicy } from "./trust/policy";
|
|
34
|
-
import type { SealedGateApproval } from "
|
|
34
|
+
import type { SealedGateApproval } from "../lifecycle/gate-seal";
|
|
35
35
|
import { scopeSource } from "./write-scope";
|
|
36
36
|
|
|
37
37
|
/** The forges a forge identity can name. */
|
|
@@ -42,6 +42,7 @@ import { tmpdir } from "node:os";
|
|
|
42
42
|
import { basename, dirname, join, relative, resolve, sep } from "node:path";
|
|
43
43
|
import { computePlanDigest } from "../lifecycle/plan-digest";
|
|
44
44
|
import type { ResolvedGateApproval } from "../op/gate-approval";
|
|
45
|
+
import { quorumRules, type GateQuorumSpec } from "../op/gate-quorum";
|
|
45
46
|
import { checkLineage, findingKey, type CheckFinding } from "./lineage-check";
|
|
46
47
|
import { dirLabel, git, parseDirSpec, parseTemplateSource, portableUrl, readTemplateDir, readTemplateTree, recordedDirPath } from "./lineage-init";
|
|
47
48
|
import {
|
|
@@ -589,11 +590,13 @@ async function rulesAtHead(repo: string, paths: string[]): Promise<ResolvedGateA
|
|
|
589
590
|
for (const { config, filePath } of ops.values()) {
|
|
590
591
|
const rel = relative(repo, filePath).split(sep).join("/");
|
|
591
592
|
if (!opFiles.has(rel)) continue;
|
|
593
|
+
// #4290: a quorum may be a list of rules; the strictest count is asked for.
|
|
592
594
|
for (const approval of gateApprovals(config)) {
|
|
593
|
-
const
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
595
|
+
for (const rule of quorumRules(approval.quorum)) {
|
|
596
|
+
if (rule.count > count) {
|
|
597
|
+
count = rule.count;
|
|
598
|
+
roles = rule.roles;
|
|
599
|
+
}
|
|
597
600
|
}
|
|
598
601
|
}
|
|
599
602
|
}
|
|
@@ -602,10 +605,10 @@ async function rulesAtHead(repo: string, paths: string[]): Promise<ResolvedGateA
|
|
|
602
605
|
return { mode: "log-only", quorum: { count, ...(roles && roles.length > 0 ? { roles } : {}) } };
|
|
603
606
|
}
|
|
604
607
|
|
|
605
|
-
type StepLike = { kind: string; approval?: { quorum?:
|
|
608
|
+
type StepLike = { kind: string; approval?: { quorum?: GateQuorumSpec }; steps?: StepLike[] };
|
|
606
609
|
|
|
607
|
-
function gateApprovals(config: { phases?: Array<{ steps: StepLike[] }>; onFailure?: Array<{ steps: StepLike[] }> }): Array<{ quorum?:
|
|
608
|
-
const out: Array<{ quorum?:
|
|
610
|
+
function gateApprovals(config: { phases?: Array<{ steps: StepLike[] }>; onFailure?: Array<{ steps: StepLike[] }> }): Array<{ quorum?: GateQuorumSpec }> {
|
|
611
|
+
const out: Array<{ quorum?: GateQuorumSpec }> = [];
|
|
609
612
|
const visit = (step: StepLike): void => {
|
|
610
613
|
if (step.kind === "gate") out.push(step.approval ?? {});
|
|
611
614
|
if (step.kind === "effect") (step.steps ?? []).forEach(visit);
|
|
@@ -1042,7 +1045,7 @@ export function describeStaged(staged: StagedUpgrade): string[] {
|
|
|
1042
1045
|
}
|
|
1043
1046
|
for (const s of staged.manualSteps) lines.push(` manual step: ${at(s.path)} (${s.reason})`);
|
|
1044
1047
|
if (staged.governance) {
|
|
1045
|
-
const q = staged.governance.approval.quorum!;
|
|
1048
|
+
const q = quorumRules(staged.governance.approval.quorum)[0]!;
|
|
1046
1049
|
lines.push(
|
|
1047
1050
|
` governance: ${staged.governance.paths.join(", ")}; needs ${q.count} human approval(s)${q.roles ? ` with a role in ${q.roles.join(", ")}` : ""}, from the rules at ${staged.governance.rules.slice(0, 12)}`,
|
|
1048
1051
|
);
|
|
@@ -178,3 +178,32 @@ describe("the generated-file record", () => {
|
|
|
178
178
|
expect(readGeneratedRecord(dir).files.map((f) => f.path)).toEqual(["a.yml", "b.yml"]);
|
|
179
179
|
});
|
|
180
180
|
});
|
|
181
|
+
|
|
182
|
+
describe("ci.branches routes a member's pull-request pipeline (#4292)", () => {
|
|
183
|
+
const routed = () => {
|
|
184
|
+
rmSync(repo, { recursive: true, force: true });
|
|
185
|
+
repo = twoMemberWorkspace({ extra: { ci: { branches: { release: ["api"] } } } });
|
|
186
|
+
};
|
|
187
|
+
const plan = (dir: string, branch?: string) =>
|
|
188
|
+
planMemberComponentPipeline({ projectDir: dir, found: findWorkspaceRoot(dir)!, lexicon: "github", env: "staging", prLoop: branch ? { branch } : {} })!;
|
|
189
|
+
|
|
190
|
+
test("a routed member's pipeline targets its branch, with or without --branch naming it", () => {
|
|
191
|
+
routed();
|
|
192
|
+
const dir = join(repo, "services/api");
|
|
193
|
+
expect(plan(dir).branch).toBe("release");
|
|
194
|
+
expect(plan(dir, "release").branch).toBe("release");
|
|
195
|
+
expect(plan(dir).command).toBe("chant build --components --generate github --env staging --pr-loop");
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test("--branch may not move a routed member, nor put another member on a routed branch", () => {
|
|
199
|
+
routed();
|
|
200
|
+
expect(() => plan(join(repo, "services/api"), "main")).toThrow("--branch main: ci.branches routes member api to release, so its pull-request pipeline targets release");
|
|
201
|
+
expect(() => plan(join(repo, "apps/web"), "release")).toThrow("--branch release: ci.branches applies only the members its globs match from release, and member web is not one");
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test("an unrouted member keeps --branch, or the generator's default", () => {
|
|
205
|
+
routed();
|
|
206
|
+
expect(plan(join(repo, "apps/web")).branch).toBeUndefined();
|
|
207
|
+
expect(plan(join(repo, "apps/web"), "trunk").branch).toBe("trunk");
|
|
208
|
+
});
|
|
209
|
+
});
|
|
@@ -26,6 +26,7 @@ import { GENERATED_MARKER } from "../discovery/files";
|
|
|
26
26
|
import type { PipelineMember } from "../lexicon";
|
|
27
27
|
import type { WorkspaceRootSearch } from "../project-root";
|
|
28
28
|
import { ownerOf, readDeclaration, resolveGroups, rootExclusions, WorkspaceReadError, type Declaration, type Member } from "./declaration";
|
|
29
|
+
import { routedBranch } from "./ci-branches";
|
|
29
30
|
import { gitTop, workingTree } from "./tree";
|
|
30
31
|
|
|
31
32
|
/**
|
|
@@ -196,6 +197,12 @@ export interface MemberComponentPlan {
|
|
|
196
197
|
/** The command that regenerates it, run in the member's directory. */
|
|
197
198
|
command: string;
|
|
198
199
|
env: string;
|
|
200
|
+
/**
|
|
201
|
+
* With `--pr-loop`, the branch the pipeline targets: `--branch`, else the
|
|
202
|
+
* branch `ci.branches` routes the member to (#4292). Undefined for the
|
|
203
|
+
* generator's default.
|
|
204
|
+
*/
|
|
205
|
+
branch?: string;
|
|
199
206
|
}
|
|
200
207
|
|
|
201
208
|
export interface ComponentPlanInput {
|
|
@@ -234,6 +241,7 @@ export function planMemberComponentPipeline(input: ComponentPlanInput): MemberCo
|
|
|
234
241
|
const ctx = resolveMemberContext(input.projectDir, input.found);
|
|
235
242
|
if (!ctx) return undefined;
|
|
236
243
|
const env = input.env ?? DEFAULT_ENV;
|
|
244
|
+
const branch = input.prLoop ? prLoopBranch(ctx, input.prLoop.branch) : undefined;
|
|
237
245
|
let target: string;
|
|
238
246
|
if (input.output) {
|
|
239
247
|
target = resolve(input.output);
|
|
@@ -273,9 +281,31 @@ export function planMemberComponentPipeline(input: ComponentPlanInput): MemberCo
|
|
|
273
281
|
target,
|
|
274
282
|
command: parts.map(shellArg).join(" "),
|
|
275
283
|
env,
|
|
284
|
+
...(branch !== undefined ? { branch } : {}),
|
|
276
285
|
};
|
|
277
286
|
}
|
|
278
287
|
|
|
288
|
+
/**
|
|
289
|
+
* The branch a member's pull-request pipeline targets (#4292): the one
|
|
290
|
+
* `ci.branches` routes it to, which `--branch` may repeat but not change.
|
|
291
|
+
* Without a route, `--branch` as given, which may not name a routed branch
|
|
292
|
+
* whose globs leave the member out.
|
|
293
|
+
*/
|
|
294
|
+
function prLoopBranch(ctx: MemberContext, flag: string | undefined): string | undefined {
|
|
295
|
+
const branches = ctx.declaration.ci?.branches;
|
|
296
|
+
const own = routedBranch(branches, ctx.member.name);
|
|
297
|
+
if (own !== null) {
|
|
298
|
+
if (flag !== undefined && flag !== own) {
|
|
299
|
+
throw new Error(`--branch ${flag}: ci.branches routes member ${ctx.member.name} to ${own}, so its pull-request pipeline targets ${own}`);
|
|
300
|
+
}
|
|
301
|
+
return own;
|
|
302
|
+
}
|
|
303
|
+
if (flag !== undefined && Object.prototype.hasOwnProperty.call(branches ?? {}, flag)) {
|
|
304
|
+
throw new Error(`--branch ${flag}: ci.branches applies only the members its globs match from ${flag}, and member ${ctx.member.name} is not one`);
|
|
305
|
+
}
|
|
306
|
+
return flag;
|
|
307
|
+
}
|
|
308
|
+
|
|
279
309
|
/** Write a member's pipeline with its header and record it as the member's generated file. */
|
|
280
310
|
export function writeMemberComponentPipeline(plan: MemberComponentPlan, yaml: string): void {
|
|
281
311
|
mkdirSync(dirname(plan.target), { recursive: true });
|
|
@@ -37,8 +37,8 @@
|
|
|
37
37
|
"description": "planned: every member planned (plan stage). recorded (#4168, chant 0.124.33 and newer): every member planned on the merged commit and the merge record was written (record stage); approval says whether the plan is the approved one, and nothing applied. plan-failed: a member failed to plan, so nothing can apply. nothing: no component deploys what the change touches. applied: every member applied. failed: a member failed to apply and its dependents did not run. wave-applied (#4003, chant 0.124.32 and newer): with --wave-gate and --wave, the wave applied and a later wave is still to run. refused: nothing applied, see refusal. waiting (#3999): the approved plans applied except members held back after an upstream applied, which wait on an approval of the plan the last of rounds made."
|
|
38
38
|
},
|
|
39
39
|
"refusal": {
|
|
40
|
-
"enum": ["not-approved", "plan-changed", "not-a-reviewer", "author-approved", "no-pull-request", "wave-not-approved", "wave-plan-changed", "earlier-wave"],
|
|
41
|
-
"description": "Why an apply refused. plan-changed: an approval stands for another digest, so the plan changed after review. author-approved (#4000): under --require-review, an approval names the pull request's author or an author or committer of one of its commits. With --wave-gate (#4003, chant 0.124.32 and newer): wave-not-approved, a wave's new plan is not covered by the approved one and no approval of its gate stands for it; wave-plan-changed, an approval of its gate stands for another digest; earlier-wave, an earlier wave has work left or no record says it applied. The waves that applied before a wave refused stay applied; waves says which."
|
|
40
|
+
"enum": ["not-approved", "plan-changed", "not-a-reviewer", "author-approved", "no-pull-request", "wave-not-approved", "wave-plan-changed", "earlier-wave", "locked", "policy-denied", "policy-error"],
|
|
41
|
+
"description": "Why an apply refused. plan-changed: an approval stands for another digest, so the plan changed after review. author-approved (#4000): under --require-review, an approval names the pull request's author or an author or committer of one of its commits. With --wave-gate (#4003, chant 0.124.32 and newer): wave-not-approved, a wave's new plan is not covered by the approved one and no approval of its gate stands for it; wave-plan-changed, an approval of its gate stands for another digest; earlier-wave, an earlier wave has work left or no record says it applied. The waves that applied before a wave refused stay applied; waves says which. locked (#4293, chant 0.124.41 and newer): under ci.locks, another open pull request holds a lock on a target the apply reaches, and nothing applied. With ci.policy (#4294, chant 0.124.41 and newer): policy-denied, the policy at base denied the plan and no override counts; policy-error, the policy could not be run or gave no readable answer."
|
|
42
42
|
},
|
|
43
43
|
"message": { "type": "string", "description": "The refusal or failure as printed." },
|
|
44
44
|
"resumed": {
|
|
@@ -77,6 +77,21 @@
|
|
|
77
77
|
}
|
|
78
78
|
}
|
|
79
79
|
},
|
|
80
|
+
"overdue": {
|
|
81
|
+
"type": "array",
|
|
82
|
+
"description": "Added within contract version 1 by #4289, written by chant 0.124.41 and newer. Plan stage: the scheduled watch and reconcile Ops whose cron has fired at least twice since their job last ran, or since their pipeline file was added when no run is on record. Absent when none is overdue or the forge was not asked.",
|
|
83
|
+
"items": {
|
|
84
|
+
"type": "object",
|
|
85
|
+
"required": ["op", "cron", "since", "message"],
|
|
86
|
+
"properties": {
|
|
87
|
+
"op": { "type": "string" },
|
|
88
|
+
"cron": { "type": "string" },
|
|
89
|
+
"since": { "type": "string", "description": "The job's last run, or the pipeline file's first commit when neverRan, ISO 8601." },
|
|
90
|
+
"neverRan": { "type": "boolean" },
|
|
91
|
+
"message": { "type": "string", "description": "The warning as the plan note prints it." }
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
},
|
|
80
95
|
"plannedOutputs": {
|
|
81
96
|
"type": "object",
|
|
82
97
|
"description": "Added within contract version 1 by #3999, written by chant 0.124.32 and newer. Per dependency in the change that a member was planned against, the names of the outputs its plan knows, sorted. An output a member reads that is not listed is known only after apply, and the member lists it under unknownInputs. Absent when no member read one.",
|
|
@@ -175,6 +190,62 @@
|
|
|
175
190
|
"changeSet": {
|
|
176
191
|
"type": "object",
|
|
177
192
|
"description": "The change-set document over every member (change-set.schema.json). Its digest equals digest."
|
|
193
|
+
},
|
|
194
|
+
"hooks": {
|
|
195
|
+
"type": "array",
|
|
196
|
+
"description": "Added within contract version 1 by #4288, written by chant 0.124.41 and newer. The stage hooks the job ran, in order, from ci.hooks in the workspace declaration at base. Absent when it ran none.",
|
|
197
|
+
"items": {
|
|
198
|
+
"type": "object",
|
|
199
|
+
"required": ["moment", "name", "exit", "outcome"],
|
|
200
|
+
"additionalProperties": false,
|
|
201
|
+
"properties": {
|
|
202
|
+
"moment": { "enum": ["before-observe", "before-plan", "after-plan", "before-apply", "after-apply"] },
|
|
203
|
+
"name": { "type": "string" },
|
|
204
|
+
"target": { "type": "string", "description": "The component, for a perRun hook." },
|
|
205
|
+
"exit": { "type": "integer" },
|
|
206
|
+
"outcome": { "enum": ["passed", "failed", "held"], "description": "held: an onFailure approve hook failed, which the pull request's gate answers." }
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
},
|
|
210
|
+
"policy": {
|
|
211
|
+
"type": "object",
|
|
212
|
+
"description": "Added within contract version 1 by #4294, written by chant 0.124.41 and newer. What ci.policy in the workspace declaration at base said about the change set. Absent when the base sets no policy, or the stage reached no plan to judge.",
|
|
213
|
+
"required": ["result", "engine", "base", "denials", "holds", "warnings"],
|
|
214
|
+
"additionalProperties": false,
|
|
215
|
+
"properties": {
|
|
216
|
+
"result": { "enum": ["passed", "denied", "held", "error"], "description": "denied wins over held. error: the policy did not run or gave no readable answer, and nothing applies." },
|
|
217
|
+
"engine": { "enum": ["conftest", "opa"], "description": "The result shape read." },
|
|
218
|
+
"base": { "type": "string", "description": "The commit the settings and the policy were read at." },
|
|
219
|
+
"denials": { "type": "array", "items": { "$ref": "#/$defs/policyFinding" } },
|
|
220
|
+
"rules": { "type": "array", "items": { "type": "string" }, "description": "The ids of the rules that denied, sorted: what an override names." },
|
|
221
|
+
"holds": { "type": "array", "items": { "$ref": "#/$defs/policyFinding" }, "description": "Rules that hold the plan for a person's approval of its digest." },
|
|
222
|
+
"warnings": { "type": "array", "items": { "type": "string" } },
|
|
223
|
+
"error": { "type": "string" },
|
|
224
|
+
"override": {
|
|
225
|
+
"type": "object",
|
|
226
|
+
"required": ["by", "at", "reason", "digest"],
|
|
227
|
+
"additionalProperties": false,
|
|
228
|
+
"description": "The override on _gates/policy-override.jsonl that lets the denied plan through.",
|
|
229
|
+
"properties": {
|
|
230
|
+
"by": { "type": "string" },
|
|
231
|
+
"at": { "type": "string" },
|
|
232
|
+
"reason": { "type": "string" },
|
|
233
|
+
"digest": { "type": "string", "description": "The override digest: the op, the plan digest and the rules." }
|
|
234
|
+
}
|
|
235
|
+
},
|
|
236
|
+
"overrideCommand": { "type": "string", "description": "While denied: the command that overrides the denial." }
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
},
|
|
240
|
+
"$defs": {
|
|
241
|
+
"policyFinding": {
|
|
242
|
+
"type": "object",
|
|
243
|
+
"required": ["rule", "message"],
|
|
244
|
+
"additionalProperties": false,
|
|
245
|
+
"properties": {
|
|
246
|
+
"rule": { "type": "string", "description": "<package>.<rule>, such as main.deny_public_bucket." },
|
|
247
|
+
"message": { "type": "string" }
|
|
248
|
+
}
|
|
178
249
|
}
|
|
179
250
|
}
|
|
180
251
|
}
|
|
@@ -109,6 +109,10 @@ export const SECURITY_FEATURES: readonly SecurityFeature[] = [
|
|
|
109
109
|
{ feature: "Apply jobs require a runner-start statement", byDefault: "off", setting: "`--require-runner-start`", group: "isolated-runners", codes: ["security-runner-start-no-key", "security-runner-start-no-role", "security-trust-unreadable"] },
|
|
110
110
|
{ feature: "Secrets file", byDefault: "off", setting: "`--secrets`", group: null, codes: [] },
|
|
111
111
|
{ feature: "Env allowlist and log redaction", byDefault: "on: `ci run`'s contract, a listed exception", setting: "`--pass-env`", group: null, codes: [] },
|
|
112
|
+
// #4295: the plan job's steps never hold a forge token; the plan-note job posts. Part of the generated pipeline, so nothing turns it on.
|
|
113
|
+
{ feature: "Plan steps hold no forge token", byDefault: "on: the plan job's contract, a listed exception", setting: "regenerate the pipeline", group: null, codes: [] },
|
|
114
|
+
// #4296: the plan job trades its OIDC token for the plan identity before the checkout, and the apply jobs take the apply identity.
|
|
115
|
+
{ feature: "Separate plan and apply cloud identities", byDefault: "off: no job requests an OIDC token", setting: "`ci.identity.cloud`", group: null, codes: [] },
|
|
112
116
|
];
|
|
113
117
|
|
|
114
118
|
/** The reference page's table of {@link SECURITY_FEATURES}, as markdown. */
|
|
@@ -24,6 +24,7 @@ import { samePlanDigest } from "../lifecycle/plan-digest";
|
|
|
24
24
|
import type { GateResolutionRecord, PendingGateRecord } from "../lifecycle/gate-ledger";
|
|
25
25
|
import { isPendingGateExpired, latestPendingGate, latestResolutionForPlan, parseGateLedger } from "../lifecycle/gate-ledger";
|
|
26
26
|
import { humanQuorumSince, tallyGateApprovals, type GateApprovalRule } from "../op/gate";
|
|
27
|
+
import type { GateQuorumJudge } from "../op/gate-quorum";
|
|
27
28
|
import type { ReasonCode } from "./reason-codes";
|
|
28
29
|
import type { ReleaseRevocation } from "../lifecycle/release-revocation";
|
|
29
30
|
import { CHANGE_SETS_DIR, changeSetFile } from "../lifecycle/change-set-store";
|
|
@@ -188,7 +189,7 @@ export function approveCommand(component: string, gate: string, env: string | nu
|
|
|
188
189
|
const approvalOf = (r: GateResolutionRecord): StatusGateApproval => ({ principal: r.resolvedBy, channel: r.origin ?? null, relayedBy: r.relayedBy ?? null, at: r.timestamp });
|
|
189
190
|
|
|
190
191
|
/** One gate in one environment, decided against its newest pending fact. */
|
|
191
|
-
function decide(component: string, standing: PendingGateRecord, facts: PendingGateRecord[], all: GateResolutionRecord[], now: string, rule: GateApprovalRule | null): StatusGate {
|
|
192
|
+
function decide(component: string, standing: PendingGateRecord, facts: PendingGateRecord[], all: GateResolutionRecord[], now: string, rule: GateApprovalRule | null, judge?: GateQuorumJudge): StatusGate {
|
|
192
193
|
const gate = standing.gate;
|
|
193
194
|
// #3163: under identity.gates, an unsigned approval, or one from outside the class, doesn't count, as a run reads it.
|
|
194
195
|
// #4045: nor does an approval for another release, and an `empty` pass is never an approval.
|
|
@@ -208,8 +209,9 @@ function decide(component: string, standing: PendingGateRecord, facts: PendingGa
|
|
|
208
209
|
let mismatched: GateResolutionRecord | undefined;
|
|
209
210
|
if (standing.approval) {
|
|
210
211
|
// #3206: a human approval from before a fact re-recorded for a mode or context change still counts.
|
|
211
|
-
|
|
212
|
-
|
|
212
|
+
// #4290: approval rules count roles from .chant/trust.json at base, for a proven approver.
|
|
213
|
+
const tally = tallyGateApprovals(own, gate, since, planDigest, standing.approval, humanQuorumSince(facts, standing), judge);
|
|
214
|
+
approved = tally.permit !== undefined || tally.met;
|
|
213
215
|
approvals = tally.permit && !tally.counted.includes(tally.permit) ? [...tally.counted, tally.permit] : tally.counted;
|
|
214
216
|
needed = tally.need;
|
|
215
217
|
mismatched = tally.mismatched;
|
|
@@ -249,7 +251,7 @@ function decide(component: string, standing: PendingGateRecord, facts: PendingGa
|
|
|
249
251
|
* gate whose other facts have one is left out, as a run leaves it out: such a
|
|
250
252
|
* line binds no environment, and `chant approve --expire` writes one.
|
|
251
253
|
*/
|
|
252
|
-
export function gatesInLedger(component: string, content: string, envs: readonly string[], now: string, rules: GateRuleLookup = () => null): { gates: StatusGate[]; malformed: number } {
|
|
254
|
+
export function gatesInLedger(component: string, content: string, envs: readonly string[], now: string, rules: GateRuleLookup = () => null, judge?: GateQuorumJudge): { gates: StatusGate[]; malformed: number } {
|
|
253
255
|
const { pending, resolutions, malformed } = parseGateLedger(content);
|
|
254
256
|
const gates: StatusGate[] = [];
|
|
255
257
|
for (const gate of [...new Set(pending.map((p) => p.gate))].sort()) {
|
|
@@ -264,7 +266,7 @@ export function gatesInLedger(component: string, content: string, envs: readonly
|
|
|
264
266
|
}
|
|
265
267
|
for (const env of [...byEnv.keys()].sort((a, b) => envs.indexOf(a ?? "") - envs.indexOf(b ?? ""))) {
|
|
266
268
|
const envFacts = byEnv.get(env)!;
|
|
267
|
-
gates.push(decide(component, latestPendingGate(envFacts, gate)!, envFacts, resolutions, now, rules(gate)));
|
|
269
|
+
gates.push(decide(component, latestPendingGate(envFacts, gate)!, envFacts, resolutions, now, rules(gate), judge));
|
|
268
270
|
}
|
|
269
271
|
}
|
|
270
272
|
return { gates, malformed };
|
|
@@ -281,6 +283,7 @@ export async function readMemberGates(
|
|
|
281
283
|
read: GateLedgerReader = defaultGateLedgerReader,
|
|
282
284
|
rules: GateRuleLookup = () => null,
|
|
283
285
|
listChangeSets: ChangeSetLister = defaultChangeSetLister,
|
|
286
|
+
judge?: GateQuorumJudge,
|
|
284
287
|
): Promise<{ gates: StatusGate[]; ledger: StatusGateLedger }> {
|
|
285
288
|
const ledger: StatusGateLedger = { layout, path: dir, shared: false, malformed: 0, reason: null };
|
|
286
289
|
if (commit === null) {
|
|
@@ -298,7 +301,7 @@ export async function readMemberGates(
|
|
|
298
301
|
}
|
|
299
302
|
const gates: StatusGate[] = [];
|
|
300
303
|
for (const component of [...files.keys()].sort()) {
|
|
301
|
-
const read = gatesInLedger(component, files.get(component)!, envs, now, rules);
|
|
304
|
+
const read = gatesInLedger(component, files.get(component)!, envs, now, rules, judge);
|
|
302
305
|
gates.push(...read.gates);
|
|
303
306
|
ledger.malformed += read.malformed;
|
|
304
307
|
}
|
package/src/workspace/status.ts
CHANGED
|
@@ -61,6 +61,7 @@ import { gateAdmissionFrom, releaseRuleFrom } from "./identity";
|
|
|
61
61
|
import { checkReleaseSignature, type GateAdmissionRule, type ReleaseSignatureCheck } from "./identity-gate-rule";
|
|
62
62
|
import { scopeSource } from "./write-scope";
|
|
63
63
|
import { GATE_REASON_CODES, readMemberGates, type GateLedgerReader, type StatusGate, type StatusGateLedger } from "./status-gates";
|
|
64
|
+
import { trustQuorumJudge } from "../op/gate-quorum";
|
|
64
65
|
import { STEWARD_REASON_CODES, readMemberStewards, type StatusSteward, type StewardReasonCode } from "./status-stewards";
|
|
65
66
|
import { gitTop, workingTree } from "./tree";
|
|
66
67
|
import { handToRootChant } from "./which-chant";
|
|
@@ -718,6 +719,8 @@ export async function workspaceStatus(query: StatusQuery): Promise<StatusDocumen
|
|
|
718
719
|
const ruleSource = scopeSource(found.dir);
|
|
719
720
|
const rules = (gate: string) => gateAdmissionFrom(ruleSource, gate);
|
|
720
721
|
const releaseRule = releaseRuleFrom(ruleSource).rule;
|
|
722
|
+
// #4290: approval rules read roles from the trust policy at base, for a proven approver.
|
|
723
|
+
const judge = trustQuorumJudge({ signers: ruleSource.policy, roles: ruleSource.policy.roles });
|
|
721
724
|
// #4033 (ws-105): revoked commits, read from local tags once and only when a release is met.
|
|
722
725
|
// Tags that can't be judged read as none revoked; workspace check reports the declaration.
|
|
723
726
|
let revokedCommits: Promise<ReadonlyMap<string, ReleaseRevocation>> | undefined;
|
|
@@ -730,7 +733,7 @@ export async function workspaceStatus(query: StatusQuery): Promise<StatusDocumen
|
|
|
730
733
|
const environments: StatusEnvironment[] = [];
|
|
731
734
|
for (const env of envs) environments.push(await readEnvironment(m, env, found.dir, read, revokedOf, query.readOutcomes));
|
|
732
735
|
const own = await hasMemberLedger(m, found.dir);
|
|
733
|
-
const gates = await readMemberGates(own ? `${MEMBERS_DIR}/${m.name}/${GATES_DIR}` : GATES_DIR, own ? "members" : "flat", commit, envs, found.dir, now, query.readGates, rules);
|
|
736
|
+
const gates = await readMemberGates(own ? `${MEMBERS_DIR}/${m.name}/${GATES_DIR}` : GATES_DIR, own ? "members" : "flat", commit, envs, found.dir, now, query.readGates, rules, undefined, judge);
|
|
734
737
|
const stewards = await (query.readStewards ?? readMemberStewards)(resolve(found.dir, m.dir), query.env, now, m.kind, m.box);
|
|
735
738
|
// The declaration's x- keys go back on the objects that hold them (#3595).
|
|
736
739
|
const x = (pointer: string) => extensionsAt(declaration, pointer);
|
|
@@ -11,7 +11,7 @@ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
|
11
11
|
import { tmpdir } from "node:os";
|
|
12
12
|
import { join } from "node:path";
|
|
13
13
|
import { afterAll, describe, expect, test } from "vitest";
|
|
14
|
-
import { GATE_SEAL_NAMESPACE, gateSealPayload, gateSealRefusal, gateSealVersion, sshKeyFingerprint, verifySshSignatureWith, type GateSigners, type SealedGateApproval } from "
|
|
14
|
+
import { GATE_SEAL_NAMESPACE, gateSealPayload, gateSealRefusal, gateSealVersion, sshKeyFingerprint, verifySshSignatureWith, type GateSigners, type SealedGateApproval } from "../../lifecycle/gate-seal";
|
|
15
15
|
import { checkGateSeal } from "./seal";
|
|
16
16
|
import { emptyPolicy, parseAllowedSigners, type TrustPolicy } from "./policy";
|
|
17
17
|
import { hasSshKeygen } from "./test-repo";
|
|
@@ -30,10 +30,10 @@ import type { SealCode } from "../records";
|
|
|
30
30
|
import { type TrustPolicy } from "./policy";
|
|
31
31
|
import type { Signer } from "./trust-files";
|
|
32
32
|
import { sshKeygen, verifySshSignature } from "./ssh-commit";
|
|
33
|
-
import { GATE_SEAL_NAMESPACE, gateSealPayload, namespaceAllowed, sshSignatureKey, verifySshSignatureWith, type SealedGateApproval } from "
|
|
33
|
+
import { GATE_SEAL_NAMESPACE, gateSealPayload, namespaceAllowed, sshSignatureKey, verifySshSignatureWith, type SealedGateApproval } from "../../lifecycle/gate-seal";
|
|
34
34
|
import { RELEASE_SEAL_NAMESPACE, releaseSealPayload } from "../identity-gate-rule";
|
|
35
35
|
|
|
36
|
-
export { GATE_SEAL_NAMESPACE, gateSealPayload, gateSealVersion, sealedFieldsOf, type SealedGateApproval } from "
|
|
36
|
+
export { GATE_SEAL_NAMESPACE, gateSealPayload, gateSealVersion, sealedFieldsOf, type SealedGateApproval } from "../../lifecycle/gate-seal";
|
|
37
37
|
|
|
38
38
|
/** The ssh signature namespace of a verdict seal. */
|
|
39
39
|
export const REVIEW_SEAL_NAMESPACE = "chant-review";
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"gate-seal.d.ts","sourceRoot":"","sources":["../../../src/workspace/trust/gate-seal.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAGH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,eAAe,CAAC;AAE5C;;;GAGG;AACH,eAAO,MAAM,mBAAmB,eAAe,CAAC;AAEhD,kIAAkI;AAClI,MAAM,WAAW,kBAAkB;IACjC,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,UAAU,EAAE,MAAM,CAAC;IACnB,2EAA2E;IAC3E,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,SAAS,EAAE,MAAM,CAAC;IAClB,uFAAuF;IACvF,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,iGAAiG;IACjG,WAAW,CAAC,EAAE;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,SAAS,EAAE,MAAM,CAAC;QAAC,gBAAgB,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;IACzF,IAAI,CAAC,EAAE,OAAO,CAAC;CAChB;AAED;;;;;GAKG;AACH,wBAAgB,cAAc,CAAC,CAAC,EAAE,kBAAkB,GAAG,kBAAkB,CAWxE;AAED;;;;GAIG;AACH,wBAAgB,eAAe,CAAC,CAAC,EAAE,IAAI,CAAC,kBAAkB,EAAE,kBAAkB,GAAG,aAAa,CAAC,GAAG,CAAC,GAAG,CAAC,CAGtG;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,eAAe,CAAC,QAAQ,EAAE,kBAAkB,GAAG,MAAM,CAKpE;AA8BD,kFAAkF;AAClF,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAEtD;AAmCD,iDAAiD;AACjD,MAAM,MAAM,iBAAiB,GAAG;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,GAAG;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAAC;AAE1F;;;;GAIG;AACH,wBAAgB,sBAAsB,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,IAAI,EAAE,SAAS,MAAM,EAAE,GAAG,iBAAiB,CA0CtI;AAED;;;;GAIG;AACH,wBAAgB,eAAe,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAU9D;AAID;;;;GAIG;AACH,MAAM,WAAW,WAAW;IAC1B,0EAA0E;IAC1E,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IACpB,6BAA6B;IAC7B,WAAW,EAAE,MAAM,CAAC;IACpB,uGAAuG;IACvG,MAAM,EAAE,OAAO,CAAC;IAChB,OAAO,EAAE,MAAM,EAAE,CAAC;IAClB,yFAAyF;IACzF,QAAQ,EAAE,MAAM,EAAE,CAAC;CACpB;AAID,kJAAkJ;AAClJ,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,GAAG,SAAS,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAYrF;AAED;;;;;;GAMG;AACH,wBAAgB,eAAe,CAAC,OAAO,EAAE,WAAW,EAAE,CAAC,EAAE,kBAAkB,GAAG,MAAM,GAAG,IAAI,CAmB1F"}
|
|
File without changes
|