Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 81 additions & 0 deletions docs/bootstrap/policy-exceptions.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,84 @@ exceptions:
issue: https://github.com/OMT-Global/bootstrap/issues/55
expiresAt: 2026-09-01
```

## Material-action notifications and hard stops

Configure the second required notification destination by naming the
environment variable that will contain its webhook URL. Store only the variable
name in the manifest; the URL and any secret-bearing routing token remain in
the execution environment. Webhook transmission remains disabled and fails
closed until the secure transport follow-up is merged.

```yaml
notifications:
webhookUrlEnv: BOOTSTRAP_NOTIFICATION_WEBHOOK_URL
```

Describe one material action in a JSON file. The governing target accepts a
local issue or pull request such as `#55`, an `owner/repo#55` shorthand, or a
full GitHub issue or pull-request URL.

```json
{
"id": "release-policy-2026-07-16",
"action": "repository-settings-change",
"summary": "Enable private vulnerability reporting.",
"governingTarget": "#55"
}
```

Plan before delivery:

```sh
bootstrap notifications plan --manifest project.bootstrap.yaml --input material-action.json
bootstrap notifications deliver --manifest project.bootstrap.yaml --input material-action.json
```

Bootstrap also converts the manifest's expiring-exception intents directly.
This is plan-only unless `--deliver` is explicit:

```sh
bootstrap notifications exceptions --manifest project.bootstrap.yaml
bootstrap notifications exceptions --manifest project.bootstrap.yaml --deliver
```

All notification commands support `--json` and return stable `PRS-NOTIFY-001` and
`PRS-HARDSTOP-001` results. Delivery writes the redacted notification to the
governing GitHub issue or pull request. The configured webhook destination
remains blocking until the secure transport follow-up is merged, so ordinary
material actions cannot continue on partial delivery.

For a defined hard stop, add its category plus explicit human approval
evidence. Planning and delivery remain blocking until both fields are present:

```json
{
"id": "license-change-2026-07-16",
"action": "license-change",
"summary": "Apply the approved repository license change.",
"governingTarget": "https://github.com/acme/example/issues/55",
"approval": {
"evidence": "https://github.com/acme/example/issues/55#issuecomment-1"
}
}
```

The evidence comment must be on the governing issue or pull request, authored
by a configured `github.reviewers` maintainer, and contain this exact marker:

```text
APPROVE PRS-HARDSTOP-001 action=license-change-2026-07-16 category=license-change digest=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
```

The action category is the canonical Flow hard-stop category; there is no
separate caller-controlled hard-stop flag. Planning records that verification
is required and returns the canonical `approvalDigest`; copy that exact digest
into the approval marker. The digest binds the action ID, category, summary,
and governing target so approval cannot be replayed after any of those fields
change. Delivery fetches and verifies the GitHub comment before returning an
approved result.

Notification delivery still occurs for a blocked hard stop so the governing
record captures the request. Work continues only when both required
destinations succeed and the hard stop has explicit approval evidence.
23 changes: 23 additions & 0 deletions docs/bootstrap/repository-class-migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,26 @@ to `infrastructure` instead.
`project.maturity` describes the product lifecycle (`experimental` through
`archived`). It is intentionally independent from `release.maturity`, which
selects the release-automation profile.

## Publisher defaults and class deviations

The resolved publisher key defaults to `project.owner`. A publisher can set a
different stable key and an explicit spending approval threshold with a
non-negative amount and three-letter ISO currency code:

```yaml
publisher:
key: acme-public
spendingApprovalThreshold:
amount: 500
currency: USD
```

Bootstrap does not invent a monetary threshold when the publisher omits one.
Callers must treat that state as an unresolved human decision rather than as
permission to spend.

A repository that cannot yet declare one of the seven canonical classes must
carry a currently valid policy exception with `policy:
repository-classification` and `scope: repo.class`. Missing, expired, or
otherwise invalid exceptions remain blocking conformance violations.
60 changes: 60 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,14 @@ import {
import { planRepo, applyRepo } from "./render.js";
import { createPublicProvenance, validatePublicProvenance } from "./provenance.js";
import { planFleetPolicyUpgrades, type FleetUpgradeCandidate } from "./upgrades.js";
import {
deliverExceptionNotifications,
deliverMaterialAction,
formatExceptionNotificationReport,
formatMaterialActionReport,
planExceptionNotifications,
planMaterialAction
} from "./notifications.js";

function defaultTargetDir(manifest: Awaited<ReturnType<typeof loadManifest>>, cwd = process.cwd()): string {
const currentBasename = path.basename(cwd);
Expand Down Expand Up @@ -75,6 +83,11 @@ function formatFleetReport(report: Awaited<ReturnType<typeof reconcileFleet>>):
].join("\n");
}

async function readJsonInput(inputPath: string): Promise<unknown> {
const raw = await import("node:fs/promises").then(({ readFile }) => readFile(path.resolve(inputPath), "utf8"));
return JSON.parse(raw) as unknown;
}

async function main(): Promise<void> {
const program = new Command();
program
Expand Down Expand Up @@ -273,6 +286,53 @@ async function main(): Promise<void> {
process.exitCode = report.exitCode;
});

const notifications = program
.command("notifications")
.description("Plan or deliver material-action notifications and enforce defined human hard stops.");

notifications
.command("plan")
.description("Validate a material action and print its non-mutating notification plan.")
.requiredOption("--input <path>", "Path to a material-action JSON document")
.option("--manifest <path>", "Path to manifest")
.option("--json", "Emit versioned JSON")
.action(async (options) => {
const manifest = await loadManifest(resolveManifestPath(options.manifest));
const report = planMaterialAction(manifest, await readJsonInput(options.input));
process.stdout.write(`${options.json ? JSON.stringify(report, null, 2) : formatMaterialActionReport(report)}\n`);
process.exitCode = report.exitCode;
});

notifications
.command("deliver")
.description("Write a material-action notification to its governing GitHub target; configured webhook delivery is currently disabled.")
.requiredOption("--input <path>", "Path to a material-action JSON document")
.option("--manifest <path>", "Path to manifest")
.option("--json", "Emit versioned JSON")
.action(async (options) => {
const manifest = await loadManifest(resolveManifestPath(options.manifest));
const report = await deliverMaterialAction(manifest, await readJsonInput(options.input));
process.stdout.write(`${options.json ? JSON.stringify(report, null, 2) : formatMaterialActionReport(report)}\n`);
process.exitCode = report.exitCode;
});

notifications
.command("exceptions")
.description("Plan expiring-exception notifications, or deliver them with --deliver.")
.option("--manifest <path>", "Path to manifest")
.option("--deliver", "Write notifications to governing GitHub targets; configured webhook delivery is currently disabled")
.option("--json", "Emit versioned JSON")
.action(async (options) => {
const manifest = await loadManifest(resolveManifestPath(options.manifest));
const report = options.deliver
? await deliverExceptionNotifications(manifest)
: planExceptionNotifications(manifest);
process.stdout.write(
`${options.json ? JSON.stringify(report, null, 2) : formatExceptionNotificationReport(report)}\n`
);
process.exitCode = report.exitCode;
});

const provenance = program.command("provenance").description("Validate public provenance manifests before publication.");
provenance
.command("validate")
Expand Down
19 changes: 18 additions & 1 deletion src/conformance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,10 +47,27 @@ function summary(results: ConformanceResult[]): ConformanceReport["summary"] {

export async function runConformance(manifest: BootstrapManifest, targetDir: string): Promise<ConformanceReport> {
const results: ConformanceResult[] = [];
const exceptionReport = validatePolicyExceptions(manifest.exceptions);
const validExceptionIds = new Set(
exceptionReport.results.filter((entry) => entry.status !== "block").map((entry) => entry.exceptionId)
);
const classException = manifest.exceptions.find(
(entry) =>
entry.policy === "repository-classification" &&
entry.scope === "repo.class" &&
validExceptionIds.has(entry.id)
);

results.push(
manifest.repo.class
? result("PRS-CLASS-001", "pass", [manifest.repo.class], "Keep the canonical repository class current.")
: classException
? result(
"PRS-CLASS-001",
"pass",
[`approved exception ${classException.id}`],
"Keep the approved repository-classification exception current until a canonical class is declared."
)
: result("PRS-CLASS-001", "blocking", ["repo.class is absent"], "Declare a canonical repo.class or complete an explicit legacy migration.")
);
results.push(
Expand All @@ -75,7 +92,7 @@ export async function runConformance(manifest: BootstrapManifest, targetDir: str
}
}

for (const exception of validatePolicyExceptions(manifest.exceptions).results) {
for (const exception of exceptionReport.results) {
results.push(
result(
exception.ruleId,
Expand Down
37 changes: 35 additions & 2 deletions src/manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,22 @@ const policySchema = z.object({
})
});

const publisherSchema = z.object({
key: z.string().min(1).optional(),
spendingApprovalThreshold: z
.object({
amount: z.number().finite().nonnegative(),
currency: z
.string()
.refine((value) => Intl.supportedValuesOf("currency").includes(value), "Use a supported ISO 4217 currency code.")
})
.optional()
});

const notificationSchema = z.object({
webhookUrlEnv: z.string().regex(/^[A-Z_][A-Z0-9_]*$/)
});

const exceptionSchema = z.object({
id: z.string().min(1),
policy: z.string().min(1),
Expand Down Expand Up @@ -289,6 +305,8 @@ const manifestSchema = z.object({
owner: z.string().min(1),
defaultBranch: z.string().min(1).optional()
}),
publisher: publisherSchema.optional(),
notifications: notificationSchema.optional(),
repo: z
.object({
class: z
Expand Down Expand Up @@ -418,7 +436,7 @@ const manifestSchema = z.object({
}).passthrough();

const KNOWN_MANIFEST_SETTINGS = new Set([
"version", "project", "repo", "archetype", "github", "ci", "release", "agents", "capabilities", "policy", "exceptions", "environments"
"version", "project", "publisher", "notifications", "repo", "archetype", "github", "ci", "release", "agents", "capabilities", "policy", "exceptions", "environments"
]);

function slugify(value: string): string {
Expand Down Expand Up @@ -757,6 +775,13 @@ export function normalizeManifest(raw: z.input<typeof manifestSchema>): Bootstra
owner: parsed.project.owner,
defaultBranch
},
publisher: {
key: parsed.publisher?.key ?? parsed.project.owner,
...(parsed.publisher?.spendingApprovalThreshold
? { spendingApprovalThreshold: { ...parsed.publisher.spendingApprovalThreshold } }
: {})
},
...(parsed.notifications ? { notifications: { ...parsed.notifications } } : {}),
repo: normalizeRepo(parsed.repo),
archetype: {
kind: parsed.archetype.kind,
Expand Down Expand Up @@ -924,6 +949,8 @@ export async function loadManifest(manifestPath: string): Promise<BootstrapManif

interface ManifestOverrides {
project?: Partial<BootstrapManifest["project"]>;
publisher?: Partial<BootstrapManifest["publisher"]>;
notifications?: BootstrapManifest["notifications"];
repo?: Partial<BootstrapManifest["repo"]>;
archetype?: Partial<BootstrapManifest["archetype"]>;
github?: Partial<BootstrapManifest["github"]>;
Expand All @@ -946,6 +973,8 @@ export function createSampleManifest(overrides?: ManifestOverrides): string {
owner: overrides?.project?.owner ?? "your-org",
defaultBranch: overrides?.project?.defaultBranch ?? "main"
},
publisher: overrides?.publisher,
notifications: overrides?.notifications,
repo: overrides?.repo,
archetype: {
kind: overrides?.archetype?.kind ?? "node-ts-service",
Expand All @@ -964,7 +993,11 @@ export function createSampleManifest(overrides?: ManifestOverrides): string {
}

function serializableManifest(manifest: BootstrapManifest): BootstrapManifest | Record<string, unknown> {
const { unknownSettings: _unknownSettings, ...serializable } = manifest;
const { unknownSettings: _unknownSettings, publisher, ...withoutInternalSettings } = manifest;
const serializable =
publisher.key === manifest.project.owner && publisher.spendingApprovalThreshold === undefined
? withoutInternalSettings
: { ...withoutInternalSettings, publisher };
if (manifest.version !== 2 || !manifest.capabilities?.release) {
return serializable;
}
Expand Down
Loading
Loading