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
79 changes: 79 additions & 0 deletions docs/bootstrap/policy-exceptions.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,82 @@ 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 contains its webhook URL. Store only the variable
name in the manifest; the URL and any secret-bearing routing token remain in
the execution environment.

```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 and to the configured HTTPS webhook. A
failed destination blocks continuation.

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.
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 and configured webhook.")
.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 the governing GitHub targets and configured webhook")
.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
10 changes: 9 additions & 1 deletion src/manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,10 @@ const publisherSchema = z.object({
.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 @@ -302,6 +306,7 @@ const manifestSchema = z.object({
defaultBranch: z.string().min(1).optional()
}),
publisher: publisherSchema.optional(),
notifications: notificationSchema.optional(),
repo: z
.object({
class: z
Expand Down Expand Up @@ -431,7 +436,7 @@ const manifestSchema = z.object({
}).passthrough();

const KNOWN_MANIFEST_SETTINGS = new Set([
"version", "project", "publisher", "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 @@ -776,6 +781,7 @@ export function normalizeManifest(raw: z.input<typeof manifestSchema>): Bootstra
? { spendingApprovalThreshold: { ...parsed.publisher.spendingApprovalThreshold } }
: {})
},
...(parsed.notifications ? { notifications: { ...parsed.notifications } } : {}),
repo: normalizeRepo(parsed.repo),
archetype: {
kind: parsed.archetype.kind,
Expand Down Expand Up @@ -944,6 +950,7 @@ 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 @@ -967,6 +974,7 @@ export function createSampleManifest(overrides?: ManifestOverrides): string {
defaultBranch: overrides?.project?.defaultBranch ?? "main"
},
publisher: overrides?.publisher,
notifications: overrides?.notifications,
repo: overrides?.repo,
archetype: {
kind: overrides?.archetype?.kind ?? "node-ts-service",
Expand Down
Loading
Loading