From 18bc63336dd90cf88cf97e9b237a17b83328e0b7 Mon Sep 17 00:00:00 2001 From: hudsonaikins-crown Date: Wed, 15 Jul 2026 15:57:51 -0700 Subject: [PATCH 01/12] Add approved release automation Entire-Checkpoint: a23449f406cf --- CHANGELOG.md | 24 ++ CONTRIBUTING.md | 3 + README.md | 11 + docs/getting-started.md | 33 ++ docs/operations-hardening.md | 2 + docs/releases/v0.3.0.md | 11 + examples/tabellio-release/minimal-intent.json | 71 ++++ package.json | 11 +- schemas/preflight.schema.json | 36 ++ schemas/release-approval.schema.json | 18 + schemas/release-operation-receipt.schema.json | 37 ++ schemas/release-operation.schema.json | 92 +++++ scripts/check-tabellio-release.mjs | 28 ++ scripts/lib/contract-checks.mjs | 58 +++ scripts/lib/preflight.mjs | 264 ++++++++++++++ scripts/lib/release-operation.mjs | 108 ++++++ scripts/lib/release-planner.mjs | 178 +++++++++ scripts/lib/release-workflow.mjs | 343 ++++++++++++++++++ scripts/tabellio-preflight.mjs | 27 ++ scripts/tabellio-release.mjs | 97 +++++ tests/preflight.test.mjs | 101 ++++++ tests/release-workflow.test.mjs | 235 ++++++++++++ 22 files changed, 1785 insertions(+), 3 deletions(-) create mode 100644 docs/releases/v0.3.0.md create mode 100644 examples/tabellio-release/minimal-intent.json create mode 100644 schemas/preflight.schema.json create mode 100644 schemas/release-approval.schema.json create mode 100644 schemas/release-operation-receipt.schema.json create mode 100644 schemas/release-operation.schema.json create mode 100644 scripts/check-tabellio-release.mjs create mode 100644 scripts/lib/contract-checks.mjs create mode 100644 scripts/lib/preflight.mjs create mode 100644 scripts/lib/release-operation.mjs create mode 100644 scripts/lib/release-planner.mjs create mode 100644 scripts/lib/release-workflow.mjs create mode 100644 scripts/tabellio-preflight.mjs create mode 100644 scripts/tabellio-release.mjs create mode 100644 tests/preflight.test.mjs create mode 100644 tests/release-workflow.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index c2b22a3..dbe8308 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,30 @@ All notable changes to Tabellio are recorded here. ## Unreleased +## 0.3.0 - 2026-07-15 + +### Added + +- Deterministic `tabellio-preflight` checks for Node, GitHub remotes, GitHub CLI authentication, platform configuration, Entire version and enablement, required Codex hooks, hook trust, and release-main cleanliness. +- Exact `/hooks` recovery guidance when Entire integration exists but Codex has not trusted the repository hook commands. +- Integrity-bound `tabellio-release-operation/v0.1` plans and short-lived release approvals. +- Resumable, idempotent post-merge release execution for private control-ref publication, annotated tag publication, and GitHub release creation. +- Isolated consumer-repository dogfood covering release planning, exact validation, terminal review sync, control transport, tag publication, failure recovery, and GitHub release invocation. + +### Changed + +- Release planning now runs exact merged-head validation and terminal review synchronization before requesting remote-write approval. +- Pull-request merge remains an explicit operator gate; release execution begins only after the resulting commit and all publishable control OIDs are known. + +### Release Gates + +- `tabellio-preflight --profile release` +- `npm run check` +- Fallow whole-repository dead-code and stale-suppression scan +- Fallow changed-code audit against `origin/main` +- `npm pack --dry-run --json` +- Exact merged-head Tabellio validation + ## 0.2.0 - 2026-07-15 ### Added diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index eb9e08d..7c722f3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -92,9 +92,12 @@ Avoid: Before tagging a release: +- run `node scripts/tabellio-preflight.mjs --profile release` - update `CHANGELOG.md` - confirm README examples use the intended release tag - run local checks - run an exact-head `tabellio-validate` pass - confirm the durable review cycle is ready - tag from a clean `origin/main` commit + +After the release PR is explicitly merged, use `tabellio-release plan` to run merged-head validation, synchronize the terminal review cycle, and bind exact publishable control refs. Review the generated intent, create a short-lived `tabellio-release-approval/v0.1`, then run `tabellio-release execute` once. The executor publishes control refs, the annotated tag, and the GitHub release. It never merges the pull request. diff --git a/README.md b/README.md index 646b8e1..2ff8f8c 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,8 @@ AI-assisted pull requests should not depend on reviewer trust alone. Tabellio gi | `scripts/lib/review-cycle.mjs` | Durable GitHub and agent review/fix state machine | | `scripts/lib/validation-runner.mjs` | Exact-commit, shell-free validation with bounded evidence logs | | `scripts/lib/control-ref-transport.mjs` | Approval-gated, fast-forward-only sharing of review, validation, and Entire refs | +| `scripts/tabellio-preflight.mjs` | Fail-closed GitHub, Entire, hook-trust, and release-main readiness checks | +| `scripts/tabellio-release.mjs` | Integrity-bound post-merge control-ref, tag, and GitHub release orchestration | | `scripts/lib/` | Git process, repository contract, worktree, and context primitives | | `scripts/` | Dependency-free capture, writer, and validators | | `examples/` | Minimal valid context, evidence, review, validation, stack, and ledger fixtures | @@ -103,6 +105,15 @@ node scripts/tabellio-validate.mjs run --repo . --commit HEAD --manifest tabelli Keep `origin` limited to ordinary code branches and tags. Configure a separate private GitHub repository under another remote name before publishing control refs. The transport refuses to target `origin`. +Before agent or release work, run: + +```bash +node scripts/tabellio-preflight.mjs --profile agent +node scripts/tabellio-preflight.mjs --profile release +``` + +Hook trust failures identify the exact Codex `/hooks` action required. Release profile additionally requires clean `main` equal to `origin/main`. + Validate the bundled fixture: ```bash diff --git a/docs/getting-started.md b/docs/getting-started.md index 6d8a1f5..7ad2de9 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -69,6 +69,39 @@ node scripts/tabellio-control-ref.mjs plan \ `TABELLIO_CONTROL_REMOTE` must name a separately configured private GitHub repository remote. It cannot be `origin`. Create a matching `tabellio-control-ref-approval/v0.1` document after reviewing the exact local and remote OIDs, then execute it once with `tabellio-control-ref.mjs execute`. Multi-ref publication is atomic. Non-fast-forward publication, divergence, changed refs, expired approvals, and reused approvals fail closed. +## Preflight And Release + +Run preflight before agent work and again from clean merged `main`: + +```bash +node scripts/tabellio-preflight.mjs --profile agent +node scripts/tabellio-preflight.mjs --profile release +``` + +`entire doctor` must report healthy metadata and trusted Codex hooks. When trust is missing, open `/hooks` in Codex and approve the four repository hooks. + +After explicit PR merge, create the exact release plan: + +```bash +node scripts/tabellio-release.mjs plan \ + --owner example \ + --remote-repo repository \ + --number 42 \ + --version 0.3.0 \ + --notes docs/releases/v0.3.0.md \ + --out /tmp/tabellio-release-intent.json +``` + +Review the intent and create a short-lived `tabellio-release-approval/v0.1` bound to `integrity.digest`. Then execute: + +```bash +node scripts/tabellio-release.mjs execute \ + --intent /tmp/tabellio-release-intent.json \ + --approval /secure/tabellio-release-approval.json +``` + +Execution publishes exact private control refs, the annotated tag, and the GitHub release. Merge stays outside this command because the final squash commit must exist before release approval can bind it. + ## Local Validation From this repository: diff --git a/docs/operations-hardening.md b/docs/operations-hardening.md index 8eb596a..e220ac2 100644 --- a/docs/operations-hardening.md +++ b/docs/operations-hardening.md @@ -34,6 +34,8 @@ Review cycles, validation results, and Entire checkpoints are published together Automatic Entire session pushes to `origin` remain disabled. The approved control-ref transport publishes `refs/heads/entire/checkpoints/v1` with the review and validation refs only to a separately configured private GitHub repository. Planning and execution reject `origin`. +Release planning is local and credentialed-read-only: it requires clean merged `main`, runs exact-head validation, synchronizes terminal review state, and snapshots the resulting control OIDs. Remote publication requires a separate short-lived release approval. Execution writes an atomic local receipt before each phase and reconciles already-published control refs, tags, and releases during retry. Pull-request merge remains a separate explicit action because an approval cannot safely bind a squash commit that does not exist yet. + ## Production Checklist - Back up the private GitHub control repository and test restore drills. diff --git a/docs/releases/v0.3.0.md b/docs/releases/v0.3.0.md new file mode 100644 index 0000000..9281690 --- /dev/null +++ b/docs/releases/v0.3.0.md @@ -0,0 +1,11 @@ +Tabellio v0.3.0 makes release readiness and publication deterministic. + +## Highlights + +- `tabellio-preflight` catches missing or untrusted Entire hooks before commit or release work begins. +- Release planning binds merged Git state, exact validation, terminal review state, private control refs, release notes, and package version into one integrity-protected intent. +- One approved post-merge execution publishes exact control refs, an annotated Git tag, and the GitHub release. +- Release receipts survive partial failures and resume completed phases without repeating them. +- Isolated repository tests prove the complete plan and publication workflow without touching live GitHub resources. + +Merge remains an explicit operator action. This prevents a release approval from authorizing an unknown squash commit. diff --git a/examples/tabellio-release/minimal-intent.json b/examples/tabellio-release/minimal-intent.json new file mode 100644 index 0000000..5bb3fb0 --- /dev/null +++ b/examples/tabellio-release/minimal-intent.json @@ -0,0 +1,71 @@ +{ + "schemaVersion": "tabellio-release-operation/v0.1", + "repository": { + "id": "github.com/example/repository", + "owner": "example", + "name": "repository" + }, + "version": "0.3.0", + "tag": "v0.3.0", + "revision": { + "commit": "dddddddddddddddddddddddddddddddddddddddd", + "parent": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" + }, + "pullRequest": { + "number": 42, + "headCommit": "ffffffffffffffffffffffffffffffffffffffff", + "mergeCommit": "dddddddddddddddddddddddddddddddddddddddd" + }, + "code": { + "remote": "origin", + "branch": "main" + }, + "control": { + "intent": { + "schemaVersion": "tabellio-control-ref-operation/v0.1", + "operation": "publish", + "repository": { + "id": "github.com/example/repository" + }, + "remote": "control", + "refs": [ + { + "name": "refs/tabellio/reviews", + "localOid": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "remoteOid": null + }, + { + "name": "refs/tabellio/validations", + "localOid": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "remoteOid": null + }, + { + "name": "refs/heads/entire/checkpoints/v1", + "localOid": "cccccccccccccccccccccccccccccccccccccccc", + "remoteOid": null + } + ], + "createdAt": "2026-07-15T12:00:00.000Z", + "integrity": { + "algorithm": "sha256", + "digest": "4d0cf553f4bf4b9bcc860e1e3ae2f7142359cc9d2d7b5666d3a9b6596f43de6b" + } + } + }, + "validation": { + "runId": "validation-example", + "resultVersion": "1111111111111111111111111111111111111111", + "status": "passed", + "headCommit": "dddddddddddddddddddddddddddddddddddddddd" + }, + "release": { + "title": "Tabellio v0.3.0", + "notesPath": "docs/releases/v0.3.0.md", + "notesDigest": "2222222222222222222222222222222222222222222222222222222222222222" + }, + "createdAt": "2026-07-15T12:00:00.000Z", + "integrity": { + "algorithm": "sha256", + "digest": "de270b5038fa12d0301426fe260652bd4c6cdbe69bc668006a395f10df430726" + } +} diff --git a/package.json b/package.json index ef1331a..e4698e2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@intelip/tabellio", - "version": "0.2.0", + "version": "0.3.0", "description": "Tabellio: GitHub-native context and evidence for agentic development.", "type": "module", "license": "Apache-2.0", @@ -17,7 +17,9 @@ "tabellio-review": "scripts/tabellio-review.mjs", "tabellio-validate": "scripts/tabellio-validate.mjs", "tabellio-control-ref": "scripts/tabellio-control-ref.mjs", - "tabellio-ledger": "scripts/tabellio-ledger.mjs" + "tabellio-ledger": "scripts/tabellio-ledger.mjs", + "tabellio-preflight": "scripts/tabellio-preflight.mjs", + "tabellio-release": "scripts/tabellio-release.mjs" }, "bugs": { "url": "https://github.com/IntelIP/Tabellio/issues" @@ -54,6 +56,9 @@ "tabellio:review:example:check": "node scripts/check-tabellio-review-cycle.mjs --cycle examples/tabellio-review/minimal-cycle.json && node scripts/check-tabellio-agent-review.mjs --review examples/tabellio-review/minimal-agent-review.json", "tabellio:validate": "node scripts/tabellio-validate.mjs", "tabellio:control-ref": "node scripts/tabellio-control-ref.mjs", + "tabellio:preflight": "node scripts/tabellio-preflight.mjs", + "tabellio:release": "node scripts/tabellio-release.mjs", + "tabellio:release:example:check": "node scripts/check-tabellio-release.mjs --intent examples/tabellio-release/minimal-intent.json", "tabellio:platform:check": "node scripts/check-tabellio-platform.mjs", "tabellio:validate:example:check": "node scripts/check-tabellio-validation.mjs --manifest tabellio.validation.json && node scripts/check-tabellio-validation.mjs --result examples/tabellio-validation/minimal-result.json", "tabellio:ledger": "node scripts/tabellio-ledger.mjs", @@ -66,7 +71,7 @@ "tabellio:evidence:write": "node scripts/write-tabellio-evidence-envelope.mjs --out tabellio-pr-evidence.json", "tabellio:evidence:check": "node scripts/check-tabellio-evidence-envelope.mjs --evidence tabellio-pr-evidence.json", "tabellio:external-actions:check": "node scripts/check-tabellio-external-actions.mjs --evidence tabellio-pr-evidence.json", - "check": "npm test && npm run tabellio:platform:check && npm run tabellio:run:example:check && npm run tabellio:stack:example:check && npm run tabellio:stack:operation:example:check && npm run tabellio:review:example:check && npm run tabellio:validate:example:check && npm run tabellio:ledger:example:check && npm run tabellio:context:example:check && npm run tabellio:evidence:example:check && node scripts/check-tabellio-external-actions.mjs --evidence examples/tabellio-evidence/minimal-evidence.json" + "check": "npm test && npm run tabellio:platform:check && npm run tabellio:run:example:check && npm run tabellio:stack:example:check && npm run tabellio:stack:operation:example:check && npm run tabellio:review:example:check && npm run tabellio:validate:example:check && npm run tabellio:ledger:example:check && npm run tabellio:release:example:check && npm run tabellio:context:example:check && npm run tabellio:evidence:example:check && node scripts/check-tabellio-external-actions.mjs --evidence examples/tabellio-evidence/minimal-evidence.json" }, "engines": { "node": ">=20" diff --git a/schemas/preflight.schema.json b/schemas/preflight.schema.json new file mode 100644 index 0000000..b9851b4 --- /dev/null +++ b/schemas/preflight.schema.json @@ -0,0 +1,36 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "urn:tabellio:schema:preflight:v0.1", + "title": "Tabellio Preflight Result", + "type": "object", + "additionalProperties": false, + "required": ["schemaVersion", "profile", "repository", "status", "checks", "checkedAt"], + "properties": { + "schemaVersion": { "const": "tabellio-preflight/v0.1" }, + "profile": { "enum": ["agent", "release"] }, + "repository": { + "type": "object", + "additionalProperties": false, + "required": ["id"], + "properties": { "id": { "type": "string", "minLength": 1 } } + }, + "status": { "enum": ["ready", "blocked"] }, + "checks": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["id", "required", "status", "detail", "resolution"], + "properties": { + "id": { "type": "string", "minLength": 1 }, + "required": { "const": true }, + "status": { "enum": ["passed", "blocked"] }, + "detail": { "type": "string", "minLength": 1, "maxLength": 500 }, + "resolution": { "type": ["string", "null"], "maxLength": 500 } + } + } + }, + "checkedAt": { "type": "string", "format": "date-time" } + } +} diff --git a/schemas/release-approval.schema.json b/schemas/release-approval.schema.json new file mode 100644 index 0000000..bc23380 --- /dev/null +++ b/schemas/release-approval.schema.json @@ -0,0 +1,18 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "urn:tabellio:schema:release-approval:v0.1", + "title": "Tabellio Release Approval", + "type": "object", + "additionalProperties": false, + "required": ["schemaVersion", "id", "intentDigest", "approved", "approvedBy", "approvedAt", "expiresAt", "reason"], + "properties": { + "schemaVersion": { "const": "tabellio-release-approval/v0.1" }, + "id": { "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$" }, + "intentDigest": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, + "approved": { "const": true }, + "approvedBy": { "type": "string", "minLength": 1 }, + "approvedAt": { "type": "string", "format": "date-time" }, + "expiresAt": { "type": "string", "format": "date-time" }, + "reason": { "type": "string", "minLength": 1 } + } +} diff --git a/schemas/release-operation-receipt.schema.json b/schemas/release-operation-receipt.schema.json new file mode 100644 index 0000000..7e8e4c1 --- /dev/null +++ b/schemas/release-operation-receipt.schema.json @@ -0,0 +1,37 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "urn:tabellio:schema:release-operation-receipt:v0.1", + "title": "Tabellio Release Operation Receipt", + "type": "object", + "additionalProperties": false, + "required": ["schemaVersion", "approvalId", "intentDigest", "repository", "version", "status", "phases", "createdAt", "updatedAt", "completedAt"], + "properties": { + "schemaVersion": { "const": "tabellio-release-operation-receipt/v0.1" }, + "approvalId": { "type": "string", "minLength": 1 }, + "intentDigest": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, + "repository": { "type": "string", "minLength": 1 }, + "version": { "type": "string", "pattern": "^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)$" }, + "status": { "enum": ["pending", "running", "failed", "succeeded"] }, + "phases": { + "type": "array", + "minItems": 4, + "maxItems": 4, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["id", "status", "startedAt", "completedAt", "evidence", "error"], + "properties": { + "id": { "enum": ["verify", "control-refs", "tag", "github-release"] }, + "status": { "enum": ["pending", "running", "failed", "completed"] }, + "startedAt": { "type": ["string", "null"], "format": "date-time" }, + "completedAt": { "type": ["string", "null"], "format": "date-time" }, + "evidence": { "type": ["object", "null"] }, + "error": { "type": ["string", "null"], "maxLength": 500 } + } + } + }, + "createdAt": { "type": "string", "format": "date-time" }, + "updatedAt": { "type": "string", "format": "date-time" }, + "completedAt": { "type": ["string", "null"], "format": "date-time" } + } +} diff --git a/schemas/release-operation.schema.json b/schemas/release-operation.schema.json new file mode 100644 index 0000000..01452d5 --- /dev/null +++ b/schemas/release-operation.schema.json @@ -0,0 +1,92 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "urn:tabellio:schema:release-operation:v0.1", + "title": "Tabellio Release Operation", + "type": "object", + "additionalProperties": false, + "required": ["schemaVersion", "repository", "version", "tag", "revision", "pullRequest", "code", "control", "validation", "release", "createdAt", "integrity"], + "properties": { + "schemaVersion": { "const": "tabellio-release-operation/v0.1" }, + "repository": { + "type": "object", + "additionalProperties": false, + "required": ["id", "owner", "name"], + "properties": { + "id": { "type": "string", "minLength": 1 }, + "owner": { "type": "string", "pattern": "^[A-Za-z0-9_.-]+$" }, + "name": { "type": "string", "pattern": "^[A-Za-z0-9_.-]+$" } + } + }, + "version": { "type": "string", "pattern": "^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)$" }, + "tag": { "type": "string", "pattern": "^v(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)$" }, + "revision": { + "type": "object", + "additionalProperties": false, + "required": ["commit", "parent"], + "properties": { + "commit": { "$ref": "#/$defs/oid" }, + "parent": { "$ref": "#/$defs/oid" } + } + }, + "pullRequest": { + "type": "object", + "additionalProperties": false, + "required": ["number", "headCommit", "mergeCommit"], + "properties": { + "number": { "type": "integer", "minimum": 1 }, + "headCommit": { "$ref": "#/$defs/oid" }, + "mergeCommit": { "$ref": "#/$defs/oid" } + } + }, + "code": { + "type": "object", + "additionalProperties": false, + "required": ["remote", "branch"], + "properties": { + "remote": { "const": "origin" }, + "branch": { "const": "main" } + } + }, + "control": { + "type": "object", + "additionalProperties": false, + "required": ["intent"], + "properties": { "intent": { "$ref": "control-ref-operation.schema.json" } } + }, + "validation": { + "type": "object", + "additionalProperties": false, + "required": ["runId", "resultVersion", "status", "headCommit"], + "properties": { + "runId": { "type": "string", "minLength": 1 }, + "resultVersion": { "$ref": "#/$defs/oid" }, + "status": { "const": "passed" }, + "headCommit": { "$ref": "#/$defs/oid" } + } + }, + "release": { + "type": "object", + "additionalProperties": false, + "required": ["title", "notesPath", "notesDigest"], + "properties": { + "title": { "type": "string", "minLength": 1 }, + "notesPath": { "type": "string", "minLength": 1 }, + "notesDigest": { "$ref": "#/$defs/sha256" } + } + }, + "createdAt": { "type": "string", "format": "date-time" }, + "integrity": { + "type": "object", + "additionalProperties": false, + "required": ["algorithm", "digest"], + "properties": { + "algorithm": { "const": "sha256" }, + "digest": { "$ref": "#/$defs/sha256" } + } + } + }, + "$defs": { + "oid": { "type": "string", "pattern": "^([0-9a-f]{40}|[0-9a-f]{64})$" }, + "sha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" } + } +} diff --git a/scripts/check-tabellio-release.mjs b/scripts/check-tabellio-release.mjs new file mode 100644 index 0000000..416fcd1 --- /dev/null +++ b/scripts/check-tabellio-release.mjs @@ -0,0 +1,28 @@ +#!/usr/bin/env node + +import { readFile } from "node:fs/promises"; +import { resolve } from "node:path"; + +import { reportCliError } from "./lib/cli-options.mjs"; +import { validateReleaseIntent } from "./lib/release-operation.mjs"; + +main().catch(reportCliError); + +async function main() { + const index = process.argv.indexOf("--intent"); + const path = index >= 0 ? process.argv[index + 1] : null; + if (!path || process.argv.length !== 4) throw new Error("Usage: check-tabellio-release --intent ."); + const intent = validateReleaseIntent(JSON.parse(await readFile(resolve(path), "utf8"))); + console.log(JSON.stringify({ + ok: true, + status: "release_intent_ready", + path, + summary: { + version: intent.version, + tag: intent.tag, + commit: intent.revision.commit, + pullRequest: intent.pullRequest.number, + controlRefCount: intent.control.intent.refs.length, + }, + }, null, 2)); +} diff --git a/scripts/lib/contract-checks.mjs b/scripts/lib/contract-checks.mjs new file mode 100644 index 0000000..aaee1c9 --- /dev/null +++ b/scripts/lib/contract-checks.mjs @@ -0,0 +1,58 @@ +function ensure(condition, message) { + if (!condition) throw new Error(message); +} + +export const contract = Object.freeze({ + object(value, path) { + ensure(Object.prototype.toString.call(value) === "[object Object]", `${path} must be an object.`); + }, + + exactKeys(value, expected, path) { + const actual = Object.keys(value); + const wanted = new Set(expected); + ensure(actual.length === wanted.size && actual.every((key) => wanted.has(key)), `${path} must contain exactly: ${expected.join(", ")}.`); + }, + + string(value, path) { + ensure(typeof value === "string" && value.trim() !== "", `${path} must be a non-empty string.`); + }, + + slug(value, path) { + ensure(typeof value === "string" && /^[A-Za-z0-9_.-]+$/.test(value), `${path} contains unsupported characters.`); + }, + + semver(value, path) { + ensure(typeof value === "string" && /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)$/.test(value), `${path} must be a stable semantic version.`); + }, + + oid(value, path) { + ensure(typeof value === "string" && /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(value), `${path} must be a Git object ID.`); + }, + + sha256(value, path) { + ensure(typeof value === "string" && /^[0-9a-f]{64}$/.test(value), `${path} must be a SHA-256 digest.`); + }, + + positiveInteger(value, path) { + ensure(Number.isInteger(value) && value > 0, `${path} must be a positive integer.`); + }, + + member(value, values, path) { + ensure(values.includes(value), `${path} must be one of: ${values.join(", ")}.`); + }, + + equals(value, expected, path) { + ensure(value === expected, `${path} must be ${JSON.stringify(expected)}.`); + }, + + date(value, path) { + this.string(value, path); + ensure(!Number.isNaN(Date.parse(value)), `${path} must be an ISO date-time string.`); + }, + + safeRelativePath(value, path) { + this.string(value, path); + const unsafe = value === "." || value.startsWith("/") || /^[A-Za-z]:[\\/]/.test(value) || value.split(/[\\/]/).includes(".."); + ensure(!unsafe, `${path} must be a safe relative path.`); + }, +}); diff --git a/scripts/lib/preflight.mjs b/scripts/lib/preflight.mjs new file mode 100644 index 0000000..35ec799 --- /dev/null +++ b/scripts/lib/preflight.mjs @@ -0,0 +1,264 @@ +import { readFile } from "node:fs/promises"; +import { resolve } from "node:path"; + +import { runExternalCommand } from "./external-command.mjs"; +import { runGit } from "./git-process.mjs"; +import { contract } from "./contract-checks.mjs"; +import { validatePlatformConfig } from "./platform-config.mjs"; +import { repositoryIdentity } from "./repository-identity.mjs"; +import { NativeGitStore } from "../providers/native-git-store.mjs"; + +const PREFLIGHT_VERSION = "tabellio-preflight/v0.1"; +const PROFILES = new Set(["agent", "release"]); +const MINIMUM_ENTIRE_VERSION = [0, 7, 7]; + +export async function runPreflight({ + repoPath = process.cwd(), + profile = "agent", + entireBinary = "entire", + ghBinary = "gh", + commandRunner = runExternalCommand, + nodeVersion = process.versions.node, + now = new Date(), +} = {}) { + if (!PROFILES.has(profile)) throw new Error("profile must be agent or release."); + const checks = []; + const resolvedRepo = resolve(repoPath); + let store = null; + let repositoryId = null; + + await record(checks, "node-version", async () => { + if (majorVersion(nodeVersion) < 20) return blocked(`Node ${nodeVersion} is below required major 20.`, "Install Node.js 20 or later."); + return passed(`Node ${nodeVersion}.`); + }); + + await record(checks, "git-repository", async () => { + store = await NativeGitStore.open(resolvedRepo); + repositoryId = await repositoryIdentity(store); + return passed(`Repository ${repositoryId}.`); + }); + + await recordRepositoryChecks({ checks, store, profile }); + + await record(checks, "entire-version", async () => { + const result = await commandRunner({ binary: entireBinary, args: ["--version"], cwd: resolvedRepo, timeoutMs: 30_000 }); + const version = parseEntireVersion(`${result.stdout}\n${result.stderr}`); + if (!version) return blocked("Entire CLI version could not be parsed.", "Install Entire CLI 0.7.7 or later."); + if (compareVersion(version, MINIMUM_ENTIRE_VERSION) < 0) { + return blocked(`Entire CLI ${version.join(".")} is below required 0.7.7.`, "Upgrade Entire CLI."); + } + return passed(`Entire CLI ${version.join(".")}.`); + }); + + await record(checks, "entire-enabled", async () => { + const result = await commandRunner({ binary: entireBinary, args: ["status", "--json"], cwd: resolvedRepo, timeoutMs: 30_000 }); + const status = JSON.parse(result.stdout); + if (status.enabled !== true) return blocked("Entire is disabled.", "Run entire enable --agent codex --strategy manual-commit."); + if (!Array.isArray(status.agents) || !status.agents.includes("Codex")) { + return blocked("Entire is enabled without Codex integration.", "Run entire agent add codex."); + } + return passed("Entire enabled for Codex."); + }); + + await record(checks, "entire-doctor", async () => { + const result = await commandRunner({ binary: entireBinary, args: ["doctor"], cwd: resolvedRepo, timeoutMs: 30_000 }); + const output = `${result.stdout}\n${result.stderr}`; + if (/Codex hook trust:\s*REVIEW NEEDED/i.test(output)) { + return blocked("Codex Entire hooks are not trusted on this machine.", "Open /hooks in Codex, approve all four repository hooks, then rerun preflight."); + } + if (!/Metadata branches:\s*OK/i.test(output)) { + return blocked("Entire metadata branches are unhealthy or unverifiable.", "Run entire doctor and resolve metadata branch errors."); + } + if (!/Codex hook trust:/i.test(output)) { + return blocked("Entire doctor did not verify Codex hook trust.", "Reinstall Entire Codex hooks and approve them through /hooks."); + } + return passed("Entire metadata and Codex hook trust healthy."); + }); + + await record(checks, "github-auth", async () => { + await commandRunner({ binary: ghBinary, args: ["auth", "status", "--hostname", "github.com"], cwd: resolvedRepo, timeoutMs: 30_000 }); + return passed("GitHub CLI authenticated for github.com."); + }); + + const status = preflightStatus(checks); + return validatePreflightResult({ + schemaVersion: PREFLIGHT_VERSION, + profile, + repository: { id: knownRepositoryId(repositoryId) }, + status, + checks, + checkedAt: now.toISOString(), + }); +} + +export function validatePreflightResult(value) { + contract.object(value, "preflight"); + contract.exactKeys(value, ["schemaVersion", "profile", "repository", "status", "checks", "checkedAt"], "preflight"); + contract.equals(value.schemaVersion, PREFLIGHT_VERSION, "preflight.schemaVersion"); + contract.member(value.profile, ["agent", "release"], "preflight.profile"); + contract.object(value.repository, "preflight.repository"); + contract.exactKeys(value.repository, ["id"], "preflight.repository"); + contract.string(value.repository.id, "preflight.repository.id"); + contract.member(value.status, ["ready", "blocked"], "preflight.status"); + validatePreflightChecks(value.checks); + contract.equals(value.status, preflightStatus(value.checks), "preflight.status"); + contract.date(value.checkedAt, "preflight.checkedAt"); + return value; +} + +async function recordRepositoryChecks({ checks, store, profile }) { + if (!store) return; + await record(checks, "platform-contract", () => checkPlatformContract(store)); + await record(checks, "github-remotes", () => checkGitHubRemotes(store)); + await record(checks, "codex-hooks", () => checkCodexHooks(store)); + if (profile === "release") await record(checks, "clean-main", () => checkCleanMain(store)); +} + +async function checkPlatformContract(store) { + const source = await readFile(resolve(store.repoPath, "tabellio.platform.json"), "utf8"); + validatePlatformConfig(JSON.parse(source)); + return passed("GitHub code storage and external control-state contract valid."); +} + +async function checkGitHubRemotes(store) { + const [origin, control] = await Promise.all([ + store.gitConfig("remote.origin.url"), + store.gitConfig("remote.control.url"), + ]); + const rules = [ + () => origin ? null : blocked("origin remote missing.", "Configure GitHub code remote as origin."), + () => control ? null : blocked("control remote missing.", "Configure separate private GitHub control remote."), + () => isGitHubRemote(origin) ? null : blocked("origin is not a GitHub remote.", "Set origin to the canonical GitHub repository."), + () => isGitHubRemote(control) ? null : blocked("control is not a GitHub remote.", "Set control to the private GitHub control repository."), + () => origin !== control ? null : blocked("origin and control resolve to the same URL.", "Use separate GitHub repositories for code and private control state."), + ]; + return firstBlocker(rules, passed("origin and control are distinct GitHub remotes.")); +} + +async function checkCodexHooks(store) { + const hooks = JSON.parse(await readFile(resolve(store.repoPath, ".codex/hooks.json"), "utf8")); + const names = new Set(Object.keys(hooks.hooks || {}).map((name) => name.toLowerCase())); + const required = ["sessionstart", "userpromptsubmit", "stop", "posttooluse"]; + const missing = required.filter((name) => !names.has(name)); + if (missing.length > 0) return blocked(`Codex Entire hooks missing: ${missing.join(", ")}.`, "Reinstall Entire Codex hooks for this repository."); + return passed("Four required Entire Codex hooks declared."); +} + +async function checkCleanMain(store) { + const [status, branch, head, remoteMain] = await Promise.all([ + runGit({ args: ["status", "--porcelain=v1"], cwd: store.repoPath }), + runGit({ args: ["branch", "--show-current"], cwd: store.repoPath }), + store.resolveRef("HEAD"), + store.resolveRef("origin/main"), + ]); + const branchName = branch.stdout.trim(); + const rules = [ + () => status.stdout === "" ? null : blocked("Worktree is not clean.", "Commit or remove local changes before release planning."), + () => branchName === "main" ? null : blocked(`Current branch is ${branchName || "detached"}.`, "Switch to main before release planning."), + () => head === remoteMain ? null : blocked("main does not equal origin/main.", "Fetch and fast-forward main before release planning."), + ]; + return firstBlocker(rules, passed(`Clean main at ${head}.`)); +} + +function firstBlocker(rules, fallback) { + for (const rule of rules) { + const result = rule(); + if (result) return result; + } + return fallback; +} + +function validatePreflightChecks(checks) { + if (!Array.isArray(checks) || checks.length === 0) throw new Error("preflight.checks must be non-empty."); + const ids = new Set(); + for (const [index, check] of checks.entries()) { + validatePreflightCheck(check, index, ids); + } +} + +function validatePreflightCheck(check, index, ids) { + const path = `preflight.checks[${index}]`; + contract.object(check, path); + contract.exactKeys(check, ["id", "required", "status", "detail", "resolution"], path); + contract.string(check.id, `${path}.id`); + if (ids.has(check.id)) throw new Error(`preflight.checks contains duplicate id ${check.id}.`); + ids.add(check.id); + contract.equals(check.required, true, `${path}.required`); + contract.member(check.status, ["passed", "blocked"], `${path}.status`); + contract.string(check.detail, `${path}.detail`); + validateResolution(check, path); +} + +function validateResolution(check, path) { + if (check.status === "blocked") contract.string(check.resolution, `${path}.resolution`); + if (check.status === "passed") contract.equals(check.resolution, null, `${path}.resolution`); +} + +function preflightStatus(checks) { + const blockedCheck = checks.find((check) => check.required && check.status === "blocked"); + return blockedCheck ? "blocked" : "ready"; +} + +function knownRepositoryId(value) { + return value || "unknown"; +} + +async function record(checks, id, action) { + try { + const result = await action(); + checks.push({ id, required: true, ...result }); + } catch (error) { + checks.push({ + id, + required: true, + status: "blocked", + detail: safeError(error), + resolution: defaultResolution(id), + }); + } +} + +function passed(detail) { + return { status: "passed", detail, resolution: null }; +} + +function blocked(detail, resolution) { + return { status: "blocked", detail, resolution }; +} + +function safeError(error) { + const message = error instanceof Error ? error.message : String(error); + return message.replace(/gho_[A-Za-z0-9_]+/g, "[REDACTED]").slice(0, 500); +} + +function defaultResolution(id) { + if (id.startsWith("entire")) return "Install or repair Entire, then rerun preflight."; + if (id === "github-auth") return "Run gh auth login for github.com."; + return "Resolve this required check, then rerun preflight."; +} + +function isGitHubRemote(value) { + return /^(?:https:\/\/github\.com\/|git@github\.com:)[^/]+\/[^/]+(?:\.git)?$/i.test(value); +} + +function parseEntireVersion(value) { + const match = value.match(/Entire CLI\s+(\d+)\.(\d+)\.(\d+)/i); + return match ? match.slice(1).map(Number) : null; +} + +function majorVersion(value) { + const match = String(value).match(/^(\d+)/); + return match ? Number(match[1]) : 0; +} + +function compareVersion(left, right) { + for (let index = 0; index < Math.max(left.length, right.length); index += 1) { + const delta = versionPart(left, index) - versionPart(right, index); + if (delta !== 0) return delta; + } + return 0; +} + +function versionPart(version, index) { + return Number.isInteger(version[index]) ? version[index] : 0; +} diff --git a/scripts/lib/release-operation.mjs b/scripts/lib/release-operation.mjs new file mode 100644 index 0000000..ad48115 --- /dev/null +++ b/scripts/lib/release-operation.mjs @@ -0,0 +1,108 @@ +import { validateOperationApproval } from "./approval-validation.mjs"; +import { contract } from "./contract-checks.mjs"; +import { validateControlRefIntent } from "./control-ref-transport.mjs"; +import { digestObject } from "./stack-operation.mjs"; + +const RELEASE_OPERATION_VERSION = "tabellio-release-operation/v0.1"; +const RELEASE_APPROVAL_VERSION = "tabellio-release-approval/v0.1"; + +export function createReleaseIntent({ + repository, + version, + revision, + pullRequest, + controlIntent, + validation, + release, + createdAt = new Date().toISOString(), +}) { + const unsigned = { + schemaVersion: RELEASE_OPERATION_VERSION, + repository, + version, + tag: `v${version}`, + revision, + pullRequest, + code: { remote: "origin", branch: "main" }, + control: { intent: controlIntent }, + validation, + release, + createdAt, + }; + return validateReleaseIntent({ + ...unsigned, + integrity: { algorithm: "sha256", digest: digestObject(unsigned) }, + }); +} + +export function validateReleaseIntent(value) { + contract.object(value, "intent"); + contract.exactKeys(value, [ + "schemaVersion", "repository", "version", "tag", "revision", "pullRequest", "code", + "control", "validation", "release", "createdAt", "integrity", + ], "intent"); + contract.equals(value.schemaVersion, RELEASE_OPERATION_VERSION, "intent.schemaVersion"); + + contract.object(value.repository, "intent.repository"); + contract.exactKeys(value.repository, ["id", "owner", "name"], "intent.repository"); + contract.string(value.repository.id, "intent.repository.id"); + contract.slug(value.repository.owner, "intent.repository.owner"); + contract.slug(value.repository.name, "intent.repository.name"); + + contract.semver(value.version, "intent.version"); + contract.equals(value.tag, `v${value.version}`, "intent.tag"); + + contract.object(value.revision, "intent.revision"); + contract.exactKeys(value.revision, ["commit", "parent"], "intent.revision"); + contract.oid(value.revision.commit, "intent.revision.commit"); + contract.oid(value.revision.parent, "intent.revision.parent"); + if (value.revision.commit.length !== value.revision.parent.length) throw new Error("intent.revision object IDs must use the same format."); + + contract.object(value.pullRequest, "intent.pullRequest"); + contract.exactKeys(value.pullRequest, ["number", "headCommit", "mergeCommit"], "intent.pullRequest"); + contract.positiveInteger(value.pullRequest.number, "intent.pullRequest.number"); + contract.oid(value.pullRequest.headCommit, "intent.pullRequest.headCommit"); + contract.oid(value.pullRequest.mergeCommit, "intent.pullRequest.mergeCommit"); + contract.equals(value.pullRequest.mergeCommit, value.revision.commit, "intent.pullRequest.mergeCommit"); + + contract.object(value.code, "intent.code"); + contract.exactKeys(value.code, ["remote", "branch"], "intent.code"); + contract.equals(value.code.remote, "origin", "intent.code.remote"); + contract.equals(value.code.branch, "main", "intent.code.branch"); + + contract.object(value.control, "intent.control"); + contract.exactKeys(value.control, ["intent"], "intent.control"); + validateControlRefIntent(value.control.intent); + contract.equals(value.control.intent.operation, "publish", "intent.control.intent.operation"); + contract.equals(value.control.intent.repository.id, value.repository.id, "intent.control.intent.repository.id"); + + contract.object(value.validation, "intent.validation"); + contract.exactKeys(value.validation, ["runId", "resultVersion", "status", "headCommit"], "intent.validation"); + contract.string(value.validation.runId, "intent.validation.runId"); + contract.oid(value.validation.resultVersion, "intent.validation.resultVersion"); + contract.equals(value.validation.status, "passed", "intent.validation.status"); + contract.equals(value.validation.headCommit, value.revision.commit, "intent.validation.headCommit"); + + contract.object(value.release, "intent.release"); + contract.exactKeys(value.release, ["title", "notesPath", "notesDigest"], "intent.release"); + contract.string(value.release.title, "intent.release.title"); + contract.safeRelativePath(value.release.notesPath, "intent.release.notesPath"); + contract.sha256(value.release.notesDigest, "intent.release.notesDigest"); + + contract.date(value.createdAt, "intent.createdAt"); + contract.object(value.integrity, "intent.integrity"); + contract.exactKeys(value.integrity, ["algorithm", "digest"], "intent.integrity"); + contract.equals(value.integrity.algorithm, "sha256", "intent.integrity.algorithm"); + contract.sha256(value.integrity.digest, "intent.integrity.digest"); + const { integrity: _integrity, ...unsigned } = value; + contract.equals(value.integrity.digest, digestObject(unsigned), "intent.integrity.digest"); + return value; +} + +export function validateReleaseApproval(value, intent, { now = new Date() } = {}) { + return validateOperationApproval(value, intent, { + schemaVersion: RELEASE_APPROVAL_VERSION, + validateIntent: validateReleaseIntent, + now, + }); +} diff --git a/scripts/lib/release-planner.mjs b/scripts/lib/release-planner.mjs new file mode 100644 index 0000000..c477a9e --- /dev/null +++ b/scripts/lib/release-planner.mjs @@ -0,0 +1,178 @@ +import { createHash } from "node:crypto"; +import { resolve } from "node:path"; + +import { createControlRefIntent, snapshotControlRefs } from "./control-ref-transport.mjs"; +import { contract } from "./contract-checks.mjs"; +import { runExternalCommand } from "./external-command.mjs"; +import { GitJsonLedger } from "./git-json-ledger.mjs"; +import { runGit } from "./git-process.mjs"; +import { runPreflight } from "./preflight.mjs"; +import { createReleaseIntent } from "./release-operation.mjs"; +import { repositoryIdentity } from "./repository-identity.mjs"; +import { ReviewCycleManager } from "./review-cycle.mjs"; +import { ValidationRunner } from "./validation-runner.mjs"; +import { GitHubProvider } from "../providers/github-provider.mjs"; +import { NativeGitStore } from "../providers/native-git-store.mjs"; + +export async function planRelease({ + repoPath = process.cwd(), + repositoryId: explicitRepositoryId = null, + owner, + repo, + number, + version, + notesPath, + title = `Tabellio v${version}`, + controlRemote = "control", + manifestPath = "tabellio.validation.json", + runnerId = "tabellio-release", + token, + apiUrl, + ghBinary = "gh", + commandRunner = runExternalCommand, + preflightRunner = runPreflight, + githubProvider = null, + now = new Date(), +} = {}) { + validatePlanInput({ owner, repo, number, version, notesPath }); + const store = await NativeGitStore.open(resolve(repoPath)); + const preflight = await preflightRunner({ repoPath: store.repoPath, profile: "release", ghBinary }); + assertReadyPreflight(preflight); + const repositoryId = await repositoryIdentity(store, explicitRepositoryId); + const evidence = await loadReleaseEvidence({ store, notesPath, ghBinary, owner, repo, number, commandRunner }); + const { headCommit, parentCommit, notesSource, pr } = validateReleaseEvidence(evidence, { version, number }); + const validationLedger = await GitJsonLedger.open({ repoPath: store.repoPath, ref: "refs/tabellio/validations" }); + const validation = await validateMergedHead({ store, validationLedger, repositoryId, headCommit, parentCommit, manifestPath, runnerId, now }); + const provider = await resolveGitHubProvider({ githubProvider, apiUrl, token, ghBinary, commandRunner, cwd: store.repoPath }); + await assertMergedReview({ store, validationLedger, provider, repositoryId, owner, repo, number, runnerId, now }); + const controlIntent = await planControlPublication({ store, repositoryId, controlRemote, now }); + return createReleaseIntent({ + repository: { id: repositoryId, owner, name: repo }, + version, + revision: { commit: headCommit, parent: parentCommit }, + pullRequest: { number, headCommit: pr.headRefOid, mergeCommit: pr.mergeCommit.oid }, + controlIntent, + validation: { + runId: validation.result.runId, + resultVersion: validation.version, + status: validation.result.status, + headCommit: validation.result.revision.headCommit, + }, + release: { title, notesPath, notesDigest: sha256(notesSource) }, + createdAt: now.toISOString(), + }); +} + +function validatePlanInput({ owner, repo, number, version, notesPath }) { + contract.string(owner, "owner"); + contract.string(repo, "repo"); + contract.positiveInteger(number, "number"); + contract.semver(version, "version"); + contract.safeRelativePath(notesPath, "notesPath"); +} + +function assertReadyPreflight(preflight) { + if (preflight.status === "ready") return; + const blockers = preflight.checks.filter((check) => check.status === "blocked").map((check) => check.id); + throw new Error(`Release preflight blocked: ${blockers.join(", ")}.`); +} + +async function loadReleaseEvidence({ store, notesPath, ghBinary, owner, repo, number, commandRunner }) { + const [headCommit, parentCommit, packageSource, changelogSource, notesSource, prView] = await Promise.all([ + store.resolveRef("HEAD"), + store.resolveRef("HEAD^"), + runGit({ args: ["show", "HEAD:package.json"], cwd: store.repoPath }), + runGit({ args: ["show", "HEAD:CHANGELOG.md"], cwd: store.repoPath }), + runGit({ args: ["show", `HEAD:${notesPath}`], cwd: store.repoPath }), + commandRunner({ + binary: ghBinary, + args: ["pr", "view", String(number), "--repo", `${owner}/${repo}`, "--json", "state,headRefOid,mergeCommit"], + cwd: store.repoPath, + timeoutMs: 30_000, + }), + ]); + return { headCommit, parentCommit, packageSource: packageSource.stdout, changelogSource: changelogSource.stdout, notesSource: notesSource.stdout, prView: prView.stdout }; +} + +function validateReleaseEvidence({ headCommit, parentCommit, packageSource, changelogSource, notesSource, prView }, { version, number }) { + const packageJson = JSON.parse(packageSource); + contract.equals(packageJson.version, version, "package.json version"); + assertDatedChangelog(changelogSource, version); + const pr = JSON.parse(prView); + assertMergedPullRequest(pr, { number, headCommit }); + return { headCommit, parentCommit, notesSource, pr }; +} + +function assertDatedChangelog(source, version) { + const found = new RegExp(`^## ${escapeRegex(version)} - \\d{4}-\\d{2}-\\d{2}$`, "m").test(source); + if (!found) throw new Error(`CHANGELOG.md has no dated ${version} release section.`); +} + +function assertMergedPullRequest(pr, { number, headCommit }) { + if (pr.state !== "MERGED") throw new Error(`Pull request ${number} is not merged.`); + if (!pr.mergeCommit) throw new Error(`Pull request ${number} has no merge commit.`); + if (pr.mergeCommit.oid !== headCommit) throw new Error(`Pull request ${number} merge commit does not equal local main.`); +} + +async function validateMergedHead({ store, validationLedger, repositoryId, headCommit, parentCommit, manifestPath, runnerId, now }) { + const validation = await new ValidationRunner({ store, ledger: validationLedger }).run({ + repositoryId, + commit: headCommit, + base: parentCommit, + manifestPath, + runnerId, + now, + }); + if (validation.result.status !== "passed") throw new Error("Exact merged-head validation failed."); + return validation; +} + +async function resolveGitHubProvider({ githubProvider, apiUrl, token, ghBinary, commandRunner, cwd }) { + if (githubProvider) return githubProvider; + const resolvedToken = token || await tokenFromGh({ ghBinary, commandRunner, cwd }); + return new GitHubProvider({ baseUrl: apiUrl, token: resolvedToken }); +} + +async function assertMergedReview({ store, validationLedger, provider, repositoryId, owner, repo, number, runnerId, now }) { + const reviewLedger = await GitJsonLedger.open({ repoPath: store.repoPath, ref: "refs/tabellio/reviews" }); + const manager = new ReviewCycleManager({ + store, + ledger: reviewLedger, + validationLedger, + provider, + repositoryId, + owner, + repo, + }); + const review = await manager.sync({ number, actor: runnerId, now }); + if (review.cycle.status !== "merged") throw new Error(`Review cycle ${number} is ${review.cycle.status}, not merged.`); +} + +async function planControlPublication({ store, repositoryId, controlRemote, now }) { + return createControlRefIntent({ + operation: "publish", + repositoryId, + remote: controlRemote, + refs: await snapshotControlRefs({ + repoPath: store.repoPath, + remote: controlRemote, + refs: ["refs/tabellio/reviews", "refs/tabellio/validations", "refs/heads/entire/checkpoints/v1"], + }), + createdAt: now.toISOString(), + }); +} + +async function tokenFromGh({ ghBinary, commandRunner, cwd }) { + const result = await commandRunner({ binary: ghBinary, args: ["auth", "token", "--hostname", "github.com"], cwd, timeoutMs: 30_000 }); + const token = result.stdout.trim(); + if (!token) throw new Error("GitHub CLI returned an empty token."); + return token; +} + +function sha256(value) { + return createHash("sha256").update(value).digest("hex"); +} + +function escapeRegex(value) { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} diff --git a/scripts/lib/release-workflow.mjs b/scripts/lib/release-workflow.mjs new file mode 100644 index 0000000..e691344 --- /dev/null +++ b/scripts/lib/release-workflow.mjs @@ -0,0 +1,343 @@ +import { createHash } from "node:crypto"; +import { mkdir, readFile, rename, writeFile } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; + +import { + ApprovedControlRefTransport, + snapshotControlRefs, +} from "./control-ref-transport.mjs"; +import { runExternalCommand } from "./external-command.mjs"; +import { runGit } from "./git-process.mjs"; +import { repositoryIdentity } from "./repository-identity.mjs"; +import { validateReleaseApproval, validateReleaseIntent } from "./release-operation.mjs"; +import { NativeGitStore } from "../providers/native-git-store.mjs"; + +const PHASES = ["verify", "control-refs", "tag", "github-release"]; + +export class ReleaseExecutor { + constructor({ repoPath, stateRoot, actions }) { + this.repoPath = repoPath; + this.stateRoot = stateRoot; + this.actions = actions; + } + + static async open({ repoPath = process.cwd(), stateRoot = null, ghBinary = "gh", commandRunner = runExternalCommand } = {}) { + const store = await NativeGitStore.open(resolve(repoPath)); + const common = (await runGit({ args: ["rev-parse", "--git-common-dir"], cwd: store.repoPath })).stdout.trim(); + const root = stateRoot ? resolve(stateRoot) : resolve(store.repoPath, common, "tabellio", "release-operations"); + const actions = new ReleaseActions({ store, ghBinary, commandRunner }); + return new ReleaseExecutor({ repoPath: store.repoPath, stateRoot: root, actions }); + } + + async execute({ intent, approval, now = new Date() }) { + validateReleaseIntent(intent); + validateReleaseApproval(approval, intent, { now }); + await mkdir(this.stateRoot, { recursive: true }); + const path = resolve(this.stateRoot, `${approval.id}.json`); + const existing = await readState(path); + let state = resumeOrCreate(existing, intent, approval, now); + if (state.status === "succeeded") return { ...state, receiptPath: path }; + + for (const phase of PHASES) { + const current = state.phases.find((entry) => entry.id === phase); + if (current.status === "completed") continue; + state = await executePhase({ state, current, phase, path, actions: this.actions, intent, approval, now }); + } + await completeRelease(path, state); + return { ...state, receiptPath: path }; + } +} + +class ReleaseActions { + constructor({ store, ghBinary = "gh", commandRunner = runExternalCommand }) { + this.store = store; + this.ghBinary = ghBinary; + this.commandRunner = commandRunner; + } + + async run(phase, context) { + const action = { + verify: this.#verify, + "control-refs": this.#publishControl, + tag: this.#publishTag, + "github-release": this.#publishRelease, + }[phase]; + if (!action) throw new Error(`Unsupported release phase: ${phase}.`); + return action.call(this, context); + } + + async #verify({ intent }) { + const [repositoryId, head, remoteMain, packageSource, notesSource, refs] = await Promise.all([ + repositoryIdentity(this.store), + this.store.resolveRef("HEAD"), + this.store.resolveRef("origin/main"), + readFile(resolve(this.store.repoPath, "package.json"), "utf8"), + readFile(resolve(this.store.repoPath, intent.release.notesPath)), + snapshotControlRefs({ + repoPath: this.store.repoPath, + remote: intent.control.intent.remote, + refs: intent.control.intent.refs.map((entry) => entry.name), + }), + ]); + const status = await runGit({ args: ["status", "--porcelain=v1"], cwd: this.store.repoPath }); + const branch = await runGit({ args: ["branch", "--show-current"], cwd: this.store.repoPath }); + assertReleaseRepository({ repositoryId, head, remoteMain, branch: branch.stdout, status: status.stdout }, intent); + assertReleaseArtifacts({ packageSource, notesSource, refs }, intent); + return { commit: head, version: intent.version, controlIntentDigest: intent.control.intent.integrity.digest }; + } + + async #publishControl({ intent, approval, now }) { + const snapshot = await snapshotControlRefs({ + repoPath: this.store.repoPath, + remote: intent.control.intent.remote, + refs: intent.control.intent.refs.map((entry) => entry.name), + }); + if (snapshot.every((entry) => entry.localOid === entry.remoteOid)) { + return { status: "already-published", refs: snapshot.map((entry) => entry.name) }; + } + const controlApproval = { + schemaVersion: "tabellio-control-ref-approval/v0.1", + id: `${approval.id.slice(0, 110)}-control`, + intentDigest: intent.control.intent.integrity.digest, + approved: true, + approvedBy: approval.approvedBy, + approvedAt: approval.approvedAt, + expiresAt: approval.expiresAt, + reason: `Release ${intent.tag}: ${approval.reason}`, + }; + const transport = await ApprovedControlRefTransport.open({ repoPath: this.store.repoPath }); + const result = await transport.execute({ + intent: intent.control.intent, + approval: controlApproval, + repositoryId: intent.repository.id, + now, + }); + return { status: result.status, approvalId: result.approvalId, refs: result.refs }; + } + + async #publishTag({ intent }) { + const local = await localTagTarget(this.store.repoPath, intent.tag); + const remote = await remoteTagTarget(this.store.repoPath, intent.code.remote, intent.tag); + assertTagTarget(local, intent, "locally"); + assertTagTarget(remote, intent, "remotely"); + await ensureLocalTag(this.store.repoPath, intent, local); + await ensureRemoteTag(this.store.repoPath, intent, remote); + return { tag: intent.tag, commit: intent.revision.commit, status: tagPublishStatus(remote) }; + } + + async #publishRelease({ intent }) { + const repository = `${intent.repository.owner}/${intent.repository.name}`; + const existing = await optionalCommand(this.commandRunner, { + binary: this.ghBinary, + args: ["release", "view", intent.tag, "--repo", repository, "--json", "tagName,url,isDraft,isPrerelease"], + cwd: this.store.repoPath, + timeoutMs: 30_000, + }); + if (existing) return reconcileExistingRelease(existing.stdout, intent); + const created = await this.commandRunner({ + binary: this.ghBinary, + args: [ + "release", "create", intent.tag, + "--repo", repository, + "--title", intent.release.title, + "--notes-file", resolve(this.store.repoPath, intent.release.notesPath), + "--verify-tag", + ], + cwd: this.store.repoPath, + timeoutMs: 15 * 60 * 1000, + }); + return { status: "published", url: created.stdout.trim(), tag: intent.tag }; + } +} + +function resumeOrCreate(existing, intent, approval, now) { + if (!existing) return initialState(intent, approval, now); + validateResumeState(existing, intent, approval); + return existing; +} + +async function executePhase({ state, current, phase, path, actions, intent, approval, now }) { + current.status = "running"; + current.startedAt ||= new Date().toISOString(); + current.error = null; + state.status = "running"; + state.updatedAt = new Date().toISOString(); + await writeState(path, state); + try { + current.evidence = await actions.run(phase, { intent, approval, now }); + completePhase(current, state); + await writeState(path, state); + return state; + } catch (error) { + failPhase(current, state, error); + await writeState(path, state); + throw error; + } +} + +function completePhase(current, state) { + current.status = "completed"; + current.completedAt = new Date().toISOString(); + state.updatedAt = current.completedAt; +} + +function failPhase(current, state, error) { + current.status = "failed"; + current.error = safeError(error); + current.completedAt = new Date().toISOString(); + state.status = "failed"; + state.updatedAt = current.completedAt; +} + +async function completeRelease(path, state) { + state.status = "succeeded"; + state.completedAt = new Date().toISOString(); + state.updatedAt = state.completedAt; + await writeState(path, state); +} + +function assertReleaseRepository(actual, intent) { + assertReleaseRevision(actual, intent); + assertReleaseWorkspace(actual); +} + +function assertReleaseRevision(actual, intent) { + if (actual.repositoryId !== intent.repository.id) throw new Error("Release repository identity changed."); + if (actual.head !== intent.revision.commit) throw new Error("Release HEAD changed after planning."); + if (actual.remoteMain !== actual.head) throw new Error("Release commit no longer equals origin/main."); +} + +function assertReleaseWorkspace(actual) { + if (actual.branch.trim() !== "main") throw new Error("Release execution requires main."); + if (actual.status !== "") throw new Error("Release execution requires a clean worktree."); +} + +function assertReleaseArtifacts(actual, intent) { + if (JSON.parse(actual.packageSource).version !== intent.version) throw new Error("package.json version changed after release planning."); + if (sha256(actual.notesSource) !== intent.release.notesDigest) throw new Error("Release notes changed after release planning."); + if (JSON.stringify(actual.refs) !== JSON.stringify(intent.control.intent.refs)) throw new Error("Control refs changed after release planning."); +} + +function assertTagTarget(target, intent, location) { + if (target && target !== intent.revision.commit) throw new Error(`${intent.tag} exists ${location} at a different commit.`); +} + +async function ensureLocalTag(repoPath, intent, local) { + if (local) return; + await runGit({ + args: ["tag", "-a", intent.tag, "-m", intent.release.title, intent.revision.commit], + cwd: repoPath, + }); +} + +async function ensureRemoteTag(repoPath, intent, remote) { + if (remote) return; + await runGit({ args: ["push", intent.code.remote, intent.tag], cwd: repoPath, timeoutMs: 15 * 60 * 1000 }); +} + +function tagPublishStatus(remote) { + return remote ? "already-published" : "published"; +} + +function reconcileExistingRelease(source, intent) { + const release = JSON.parse(source); + const mismatched = release.tagName !== intent.tag || release.isDraft || release.isPrerelease; + if (mismatched) throw new Error("Existing GitHub release does not match final release intent."); + return { status: "already-published", url: release.url, tag: release.tagName }; +} + +function initialState(intent, approval, now) { + const timestamp = now.toISOString(); + return { + schemaVersion: "tabellio-release-operation-receipt/v0.1", + approvalId: approval.id, + intentDigest: intent.integrity.digest, + repository: intent.repository.id, + version: intent.version, + status: "pending", + phases: PHASES.map((id) => ({ id, status: "pending", startedAt: null, completedAt: null, evidence: null, error: null })), + createdAt: timestamp, + updatedAt: timestamp, + completedAt: null, + }; +} + +function validateResumeState(state, intent, approval) { + if (state.approvalId !== approval.id || state.intentDigest !== intent.integrity.digest) { + throw new Error("Existing release receipt does not match approval and intent."); + } +} + +async function readState(path) { + try { + return JSON.parse(await readFile(path, "utf8")); + } catch (error) { + if (error?.code === "ENOENT") return null; + throw error; + } +} + +async function writeState(path, value) { + await mkdir(dirname(path), { recursive: true }); + const temporary = `${path}.tmp`; + await writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 }); + await rename(temporary, path); +} + +async function localTagTarget(repoPath, tag) { + try { + return (await runGit({ args: ["rev-parse", `${tag}^{}`], cwd: repoPath })).stdout.trim(); + } catch { + return null; + } +} + +async function remoteTagTarget(repoPath, remote, tag) { + const result = await runGit({ + args: ["ls-remote", "--tags", remote, `refs/tags/${tag}`, `refs/tags/${tag}^{}`], + cwd: repoPath, + timeoutMs: 15 * 60 * 1000, + }); + const rows = result.stdout.trim().split(/\r?\n/).filter(Boolean).map((line) => line.split(/\s+/)); + const peeled = rows.find(([, ref]) => ref === `refs/tags/${tag}^{}`); + if (peeled) return peeled[0]; + const direct = rows.find(([, ref]) => ref === `refs/tags/${tag}`); + return direct ? direct[0] : null; +} + +async function optionalCommand(runner, options) { + try { + return await runner(options); + } catch (error) { + if (isMissingRelease(error)) return null; + throw error; + } +} + +function isMissingRelease(error) { + if (errorField(error, "exitCode") !== 1) return false; + return /release not found|not found|HTTP 404/i.test(commandErrorOutput(error)); +} + +function commandErrorOutput(error) { + return `${errorMessage(error)}\n${errorField(error, "stdout")}\n${errorField(error, "stderr")}`; +} + +function errorMessage(error) { + return error instanceof Error ? error.message : String(error); +} + +function errorField(error, field) { + if (typeof error !== "object") return ""; + if (error === null) return ""; + const value = error[field]; + return value === undefined ? "" : value; +} + +function sha256(value) { + return createHash("sha256").update(value).digest("hex"); +} + +function safeError(error) { + return (error instanceof Error ? error.message : String(error)).replace(/gho_[A-Za-z0-9_]+/g, "[REDACTED]").slice(0, 500); +} diff --git a/scripts/tabellio-preflight.mjs b/scripts/tabellio-preflight.mjs new file mode 100644 index 0000000..f65719f --- /dev/null +++ b/scripts/tabellio-preflight.mjs @@ -0,0 +1,27 @@ +#!/usr/bin/env node + +import { resolve } from "node:path"; + +import { assertAllowedOptions, parseOptionPairs, reportCliError } from "./lib/cli-options.mjs"; +import { runPreflight } from "./lib/preflight.mjs"; + +main().catch(reportCliError); + +async function main() { + const options = parseOptionPairs(process.argv.slice(2), "tabellio-preflight"); + assertAllowedOptions(options, ["repo", "profile", "entireBinary", "ghBinary"]); + const { + repo = process.cwd(), + profile = "agent", + entireBinary = "entire", + ghBinary = "gh", + } = options; + const result = await runPreflight({ + repoPath: resolve(repo), + profile, + entireBinary, + ghBinary, + }); + console.log(JSON.stringify({ ok: result.status === "ready", result }, null, 2)); + if (result.status !== "ready") process.exitCode = 1; +} diff --git a/scripts/tabellio-release.mjs b/scripts/tabellio-release.mjs new file mode 100644 index 0000000..2b84fc1 --- /dev/null +++ b/scripts/tabellio-release.mjs @@ -0,0 +1,97 @@ +#!/usr/bin/env node + +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; + +import { + assertAllowedOptions, + parseOptionPairs, + positiveNumberOption, + reportCliError, + requireOptions, +} from "./lib/cli-options.mjs"; +import { planRelease } from "./lib/release-planner.mjs"; +import { ReleaseExecutor } from "./lib/release-workflow.mjs"; + +const OPTIONS = { + plan: ["repo", "repoId", "owner", "remoteRepo", "number", "version", "notes", "title", "controlRemote", "manifest", "runnerId", "tokenFile", "apiUrl", "ghBinary", "out"], + execute: ["repo", "intent", "approval", "stateRoot", "ghBinary"], +}; + +main().catch(reportCliError); + +async function main() { + const command = process.argv[2]; + if (!Object.hasOwn(OPTIONS, command)) throw new Error("Expected command: plan or execute."); + const options = parseOptionPairs(process.argv.slice(3), command); + assertAllowedOptions(options, OPTIONS[command]); + if (command === "plan") await plan(options); + else await execute(options); +} + +async function plan(options) { + requireOptions(options, ["owner", "remoteRepo", "number", "version", "notes", "out"], "plan"); + const { + repo = process.cwd(), + repoId, + owner, + remoteRepo, + number, + version, + notes, + title = `Tabellio v${version}`, + controlRemote = "control", + manifest = "tabellio.validation.json", + runnerId = "tabellio-release", + tokenFile, + apiUrl = process.env.GITHUB_API_URL, + ghBinary = "gh", + out: outputPath, + } = options; + const token = await releaseToken(tokenFile); + const intent = await planRelease({ + repoPath: resolve(repo), + repositoryId: repoId, + owner, + repo: remoteRepo, + number: positiveNumberOption(number, "--number"), + version, + notesPath: notes, + title, + controlRemote, + manifestPath: manifest, + runnerId, + token, + apiUrl, + ghBinary, + }); + const out = resolve(outputPath); + await mkdir(dirname(out), { recursive: true }); + await writeFile(out, `${JSON.stringify(intent, null, 2)}\n`, { flag: "wx" }); + console.log(JSON.stringify({ ok: true, intent, out }, null, 2)); +} + +async function releaseToken(tokenFile) { + if (tokenFile) return (await readFile(resolve(tokenFile), "utf8")).trim(); + const environmentToken = process.env.GITHUB_TOKEN; + return typeof environmentToken === "string" ? environmentToken.trim() : undefined; +} + +async function execute(options) { + requireOptions(options, ["intent", "approval"], "execute"); + const [intent, approval] = await Promise.all([ + readJson(options.intent), + readJson(options.approval), + ]); + const executor = await ReleaseExecutor.open({ + repoPath: resolve(options.repo ?? process.cwd()), + stateRoot: options.stateRoot, + ghBinary: options.ghBinary ?? "gh", + }); + const receipt = await executor.execute({ intent, approval }); + console.log(JSON.stringify({ ok: true, receipt }, null, 2)); +} + +async function readJson(path) { + return JSON.parse(await readFile(resolve(path), "utf8")); +} diff --git a/tests/preflight.test.mjs b/tests/preflight.test.mjs new file mode 100644 index 0000000..6f4d22b --- /dev/null +++ b/tests/preflight.test.mjs @@ -0,0 +1,101 @@ +import assert from "node:assert/strict"; +import { mkdir, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import test from "node:test"; + +import { runGit } from "../scripts/lib/git-process.mjs"; +import { runPreflight, validatePreflightResult } from "../scripts/lib/preflight.mjs"; +import { createFixture } from "./helpers/git-fixture.mjs"; + +test("preflight proves GitHub and Entire readiness without exposing credentials", async (t) => { + const fixture = await preparedFixture(t); + const result = await runPreflight({ + repoPath: fixture.seed, + profile: "agent", + commandRunner: fakeCommands({ trusted: true }), + now: new Date("2026-07-15T12:00:00.000Z"), + }); + assert.equal(result.status, "ready"); + assert.equal(result.checks.every((check) => check.status === "passed"), true); + assert.equal(JSON.stringify(result).includes("gho_secret"), false); + assert.equal(validatePreflightResult(result), result); +}); + +test("preflight fails early with exact Codex hook approval remedy", async (t) => { + const fixture = await preparedFixture(t); + const result = await runPreflight({ + repoPath: fixture.seed, + commandRunner: fakeCommands({ trusted: false }), + }); + assert.equal(result.status, "blocked"); + const trust = result.checks.find((check) => check.id === "entire-doctor"); + assert.equal(trust.status, "blocked"); + assert.match(trust.resolution, /Open \/hooks in Codex/); +}); + +test("release preflight requires clean main equal to origin main", async (t) => { + const fixture = await preparedFixture(t); + await writeFile(join(fixture.seed, "DIRTY.md"), "dirty\n"); + const result = await runPreflight({ + repoPath: fixture.seed, + profile: "release", + commandRunner: fakeCommands({ trusted: true }), + }); + assert.equal(result.status, "blocked"); + assert.match(result.checks.find((check) => check.id === "clean-main").detail, /not clean/); +}); + +async function preparedFixture(t) { + const fixture = await createFixture(); + t.after(() => rm(fixture.root, { recursive: true, force: true })); + await runGit({ args: ["remote", "set-url", "origin", "https://github.com/example/repository.git"], cwd: fixture.seed }); + await runGit({ args: ["remote", "add", "control", "git@github.com:example/repository-control.git"], cwd: fixture.seed }); + await mkdir(join(fixture.seed, ".codex"), { recursive: true }); + await writeFile(join(fixture.seed, ".codex", "hooks.json"), JSON.stringify({ + hooks: { SessionStart: [], UserPromptSubmit: [], Stop: [], PostToolUse: [] }, + })); + await writeFile(join(fixture.seed, "tabellio.platform.json"), JSON.stringify(platform())); + return fixture; +} + +function fakeCommands({ trusted }) { + const commands = new Map([ + ["entire:--version", () => result("Entire CLI 0.7.7\n")], + ["entire:status", () => result('{"enabled":true,"agents":["Codex"],"active_sessions":[]}\n')], + ["entire:doctor", () => result(`Metadata branches: OK\nCodex hook trust: ${trusted ? "OK" : "REVIEW NEEDED"}\n`)], + ["gh:auth", () => result("", "Logged in with gho_secret\n")], + ]); + return async ({ binary, args }) => { + const handler = commands.get(`${binary}:${args[0]}`); + if (handler) return handler(); + throw new Error(`Unexpected command: ${binary} ${args.join(" ")}`); + }; +} + +function result(stdout, stderr = "") { + return { stdout, stderr, exitCode: 0, signal: null }; +} + +function platform() { + return { + schemaVersion: "tabellio-platform/v0.3", + codeStorage: { + provider: "github", + remoteName: "origin", + publicSurface: "code-and-thin-pr", + codeRef: "refs/heads/main", + allowedRefPrefixes: ["refs/heads/", "refs/tags/"], + }, + workflow: { + stackManager: "git-spice", + controlState: "external", + controlProvider: "github", + controlRemoteName: "control", + controlRefs: ["refs/tabellio/reviews", "refs/tabellio/validations", "refs/heads/entire/checkpoints/v1"], + publishControlRefsToCodeStorage: false, + }, + ledger: { provider: "entire", storage: "external", checkpointRef: "refs/heads/entire/checkpoints/v1" }, + validation: { runner: "tabellio-validate", manifest: "tabellio.validation.json", storage: "external", resultRef: "refs/tabellio/validations" }, + reviews: { provider: "tabellio", storage: "external", stateRef: "refs/tabellio/reviews" }, + }; +} diff --git a/tests/release-workflow.test.mjs b/tests/release-workflow.test.mjs new file mode 100644 index 0000000..fab9d4a --- /dev/null +++ b/tests/release-workflow.test.mjs @@ -0,0 +1,235 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; + +import { createControlRefIntent, snapshotControlRefs } from "../scripts/lib/control-ref-transport.mjs"; +import { GitJsonLedger } from "../scripts/lib/git-json-ledger.mjs"; +import { runGit } from "../scripts/lib/git-process.mjs"; +import { createReleaseIntent, validateReleaseApproval, validateReleaseIntent } from "../scripts/lib/release-operation.mjs"; +import { planRelease } from "../scripts/lib/release-planner.mjs"; +import { repositoryIdentity } from "../scripts/lib/repository-identity.mjs"; +import { ReleaseExecutor } from "../scripts/lib/release-workflow.mjs"; +import { NativeGitStore } from "../scripts/providers/native-git-store.mjs"; +import { createFixture, identityEnv } from "./helpers/git-fixture.mjs"; + +const createdAt = "2026-07-15T12:00:00.000Z"; +const approvedAt = "2026-07-15T12:01:00.000Z"; +const expiresAt = "2026-07-15T13:01:00.000Z"; +const now = new Date("2026-07-15T12:02:00.000Z"); + +test("release intent binds merged commit, validation, control refs, notes, and approval", () => { + const intent = exampleIntent(); + assert.equal(validateReleaseIntent(intent), intent); + const approval = approvalFor(intent, "release-contract"); + assert.equal(validateReleaseApproval(approval, intent, { now }), approval); + const tampered = structuredClone(intent); + tampered.release.title = "Changed"; + assert.throws(() => validateReleaseIntent(tampered), /integrity.digest/); +}); + +test("release executor resumes failed phases without repeating completed work", async (t) => { + const root = await mkdtemp(join(tmpdir(), "tabellio-release-resume-")); + t.after(() => rm(root, { recursive: true, force: true })); + const calls = []; + let failed = false; + const actions = { + async run(phase) { + calls.push(phase); + if (phase === "tag" && !failed) { + failed = true; + throw new Error("simulated tag failure"); + } + return { phase }; + }, + }; + const executor = new ReleaseExecutor({ repoPath: root, stateRoot: root, actions }); + const intent = exampleIntent(); + const approval = approvalFor(intent, "resume-release"); + await assert.rejects(executor.execute({ intent, approval, now }), /simulated tag failure/); + const receipt = await executor.execute({ intent, approval, now }); + assert.equal(receipt.status, "succeeded"); + assert.deepEqual(calls, ["verify", "control-refs", "tag", "tag", "github-release"]); + assert.equal(receipt.phases.every((phase) => phase.status === "completed"), true); +}); + +test("approved release publishes exact control ref, annotated tag, and GitHub release in isolated repos", async (t) => { + const fixture = await createFixture(); + t.after(() => rm(fixture.root, { recursive: true, force: true })); + const control = join(fixture.root, "control.git"); + await NativeGitStore.createBare(control); + await runGit({ args: ["remote", "add", "control", control], cwd: fixture.seed }); + const notesPath = "docs/releases/v1.2.3.md"; + await mkdir(join(fixture.seed, "docs", "releases"), { recursive: true }); + await writeFile(join(fixture.seed, "package.json"), '{"version":"1.2.3"}\n'); + await writeFile(join(fixture.seed, notesPath), "Release 1.2.3\n"); + await runGit({ args: ["add", "package.json", notesPath], cwd: fixture.seed }); + await runGit({ + args: ["commit", "-m", "Prepare release", "-m", "Entire-Checkpoint: abcdef123456"], + cwd: fixture.seed, + env: identityEnv(), + }); + await runGit({ args: ["push", "origin", "main"], cwd: fixture.seed }); + const store = await NativeGitStore.open(fixture.seed); + const repositoryId = await repositoryIdentity(store); + const head = await store.resolveRef("HEAD"); + const parent = await store.resolveRef("HEAD^"); + const ledger = await GitJsonLedger.open({ repoPath: fixture.seed, ref: "refs/tabellio/validations" }); + const validationWrite = await ledger.write("commits/result.json", { status: "passed" }, { expectedVersion: null }); + const controlIntent = createControlRefIntent({ + operation: "publish", + repositoryId, + remote: "control", + refs: await snapshotControlRefs({ repoPath: fixture.seed, remote: "control", refs: ["refs/tabellio/validations"] }), + createdAt, + }); + const intent = createReleaseIntent({ + repository: { id: repositoryId, owner: "example", name: "repository" }, + version: "1.2.3", + revision: { commit: head, parent }, + pullRequest: { number: 7, headCommit: parent, mergeCommit: head }, + controlIntent, + validation: { runId: "validation-test", resultVersion: validationWrite.version, status: "passed", headCommit: head }, + release: { title: "Tabellio v1.2.3", notesPath, notesDigest: digest("Release 1.2.3\n") }, + createdAt, + }); + const ghCalls = []; + const commandRunner = async ({ args }) => { + ghCalls.push(args); + if (args[1] === "view") throw Object.assign(new Error("not found"), { exitCode: 1 }); + return { stdout: "https://github.com/example/repository/releases/tag/v1.2.3\n", stderr: "", exitCode: 0, signal: null }; + }; + const stateRoot = join(fixture.root, "release-state"); + const executor = await ReleaseExecutor.open({ repoPath: fixture.seed, stateRoot, commandRunner }); + const approval = approvalFor(intent, "publish-release"); + const receipt = await executor.execute({ intent, approval, now }); + assert.equal(receipt.status, "succeeded"); + assert.equal(ghCalls.length, 2); + const remoteTag = await runGit({ args: ["ls-remote", "--tags", "origin", "refs/tags/v1.2.3^{}"], cwd: fixture.seed }); + assert.equal(remoteTag.stdout.split(/\s+/)[0], head); + const remoteControl = await runGit({ args: ["ls-remote", "control", "refs/tabellio/validations"], cwd: fixture.seed }); + assert.equal(remoteControl.stdout.split(/\s+/)[0], controlIntent.refs[0].localOid); + const stored = JSON.parse(await readFile(join(stateRoot, "publish-release.json"), "utf8")); + assert.equal(stored.status, "succeeded"); +}); + +test("release planning binds merged PR proof after exact validation and terminal review sync", async (t) => { + const fixture = await createFixture(); + t.after(() => rm(fixture.root, { recursive: true, force: true })); + const control = join(fixture.root, "control-plan.git"); + await NativeGitStore.createBare(control); + await runGit({ args: ["remote", "add", "control", control], cwd: fixture.seed }); + const notesPath = "docs/releases/v2.0.0.md"; + await mkdir(join(fixture.seed, "docs", "releases"), { recursive: true }); + await writeFile(join(fixture.seed, "package.json"), '{"version":"2.0.0"}\n'); + await writeFile(join(fixture.seed, "CHANGELOG.md"), "## 2.0.0 - 2026-07-15\n"); + await writeFile(join(fixture.seed, notesPath), "Release 2.0.0\n"); + await writeFile(join(fixture.seed, "tabellio.validation.json"), JSON.stringify({ + schemaVersion: "tabellio-validation/v0.1", + id: "release-plan-test", + failFast: true, + requireEntireCheckpoint: true, + commands: [{ id: "pass", argv: [process.execPath, "-e", "process.exit(0)"], cwd: ".", timeoutMs: 10_000, required: true }], + })); + await runGit({ args: ["add", "package.json", "CHANGELOG.md", "tabellio.validation.json", notesPath], cwd: fixture.seed }); + await runGit({ + args: ["commit", "-m", "Merge release", "-m", "Entire-Checkpoint: abcdef123456"], + cwd: fixture.seed, + env: identityEnv(), + }); + await runGit({ args: ["push", "origin", "main"], cwd: fixture.seed }); + const store = await NativeGitStore.open(fixture.seed); + const head = await store.resolveRef("HEAD"); + const parent = await store.resolveRef("HEAD^"); + await runGit({ args: ["update-ref", "refs/heads/entire/checkpoints/v1", head], cwd: fixture.seed }); + const provider = mergedProvider({ head: parent, base: parent }); + const commandRunner = async ({ args }) => { + if (args[0] === "pr" && args[1] === "view") { + return { stdout: JSON.stringify({ state: "MERGED", headRefOid: parent, mergeCommit: { oid: head } }), stderr: "", exitCode: 0, signal: null }; + } + throw new Error(`Unexpected command: ${args.join(" ")}`); + }; + const intent = await planRelease({ + repoPath: fixture.seed, + repositoryId: "example/repository", + owner: "example", + repo: "repository", + number: 9, + version: "2.0.0", + notesPath, + token: "test-token", + commandRunner, + preflightRunner: async () => ({ status: "ready", checks: [] }), + githubProvider: provider, + now, + }); + assert.equal(intent.revision.commit, head); + assert.equal(intent.pullRequest.headCommit, parent); + assert.equal(intent.validation.status, "passed"); + assert.equal(intent.control.intent.refs.length, 3); + assert.equal(validateReleaseIntent(intent), intent); +}); + +function exampleIntent() { + const controlIntent = createControlRefIntent({ + operation: "publish", + repositoryId: "github.com/example/repository", + remote: "control", + refs: [{ name: "refs/tabellio/validations", localOid: "c".repeat(40), remoteOid: "b".repeat(40) }], + createdAt, + }); + return createReleaseIntent({ + repository: { id: "github.com/example/repository", owner: "example", name: "repository" }, + version: "1.2.3", + revision: { commit: "d".repeat(40), parent: "a".repeat(40) }, + pullRequest: { number: 7, headCommit: "c".repeat(40), mergeCommit: "d".repeat(40) }, + controlIntent, + validation: { runId: "validation-example", resultVersion: "e".repeat(40), status: "passed", headCommit: "d".repeat(40) }, + release: { title: "Tabellio v1.2.3", notesPath: "docs/releases/v1.2.3.md", notesDigest: digest("notes") }, + createdAt, + }); +} + +function approvalFor(intent, id) { + return { + schemaVersion: "tabellio-release-approval/v0.1", + id, + intentDigest: intent.integrity.digest, + approved: true, + approvedBy: "human-reviewer", + approvedAt, + expiresAt, + reason: "Approved exact release operation.", + }; +} + +function digest(value) { + return createHash("sha256").update(value).digest("hex"); +} + +function mergedProvider({ head, base }) { + return { + async changeRequest() { + return { + id: "9", + number: 9, + title: "Release", + state: "merged", + draft: false, + mergeable: null, + source: { branch: "release", commit: head }, + target: { branch: "main", commit: base }, + author: "agent", + webUrl: "https://github.com/example/repository/pull/9", + createdAt, + updatedAt: createdAt, + }; + }, + async listReviews() { return []; }, + async listReviewComments() { return []; }, + async listIssueComments() { return []; }, + async commitStatus() { return { commit: head, state: "success", total: 0, statuses: [] }; }, + }; +} From 08c28bc88bc9bc19183483b63745bec32bc33e4f Mon Sep 17 00:00:00 2001 From: hudsonaikins-crown Date: Wed, 15 Jul 2026 16:01:42 -0700 Subject: [PATCH 02/12] Configure release fixture identity Entire-Checkpoint: a23449f406cf --- tests/release-workflow.test.mjs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/release-workflow.test.mjs b/tests/release-workflow.test.mjs index fab9d4a..ae4ee0c 100644 --- a/tests/release-workflow.test.mjs +++ b/tests/release-workflow.test.mjs @@ -58,6 +58,7 @@ test("release executor resumes failed phases without repeating completed work", test("approved release publishes exact control ref, annotated tag, and GitHub release in isolated repos", async (t) => { const fixture = await createFixture(); t.after(() => rm(fixture.root, { recursive: true, force: true })); + await configureRepositoryIdentity(fixture.seed); const control = join(fixture.root, "control.git"); await NativeGitStore.createBare(control); await runGit({ args: ["remote", "add", "control", control], cwd: fixture.seed }); @@ -209,6 +210,11 @@ function digest(value) { return createHash("sha256").update(value).digest("hex"); } +async function configureRepositoryIdentity(repoPath) { + await runGit({ args: ["config", "user.name", "Tabellio Test"], cwd: repoPath }); + await runGit({ args: ["config", "user.email", "tabellio@example.invalid"], cwd: repoPath }); +} + function mergedProvider({ head, base }) { return { async changeRequest() { From c891023bfb76103ae27cca3585392110ab1524f2 Mon Sep 17 00:00:00 2001 From: hudsonaikins-crown Date: Wed, 15 Jul 2026 16:43:54 -0700 Subject: [PATCH 03/12] fix: harden release automation invariants Entire-Checkpoint: 1e7d65448717 --- schemas/release-operation-receipt.schema.json | 31 +++- scripts/lib/github-repository.mjs | 87 ++++++++++ scripts/lib/preflight.mjs | 113 +++++++++---- scripts/lib/release-operation.mjs | 8 +- scripts/lib/release-planner.mjs | 28 +++- scripts/lib/release-workflow.mjs | 150 ++++++++++++++---- scripts/lib/repository-identity.mjs | 4 + tests/preflight.test.mjs | 59 ++++++- tests/release-workflow.test.mjs | 98 +++++++++++- 9 files changed, 498 insertions(+), 80 deletions(-) create mode 100644 scripts/lib/github-repository.mjs diff --git a/schemas/release-operation-receipt.schema.json b/schemas/release-operation-receipt.schema.json index 7e8e4c1..7268b9e 100644 --- a/schemas/release-operation-receipt.schema.json +++ b/schemas/release-operation-receipt.schema.json @@ -16,7 +16,20 @@ "type": "array", "minItems": 4, "maxItems": 4, - "items": { + "prefixItems": [ + { "$ref": "#/$defs/verifyPhase" }, + { "$ref": "#/$defs/controlRefsPhase" }, + { "$ref": "#/$defs/tagPhase" }, + { "$ref": "#/$defs/githubReleasePhase" } + ], + "items": false + }, + "createdAt": { "type": "string", "format": "date-time" }, + "updatedAt": { "type": "string", "format": "date-time" }, + "completedAt": { "type": ["string", "null"], "format": "date-time" } + }, + "$defs": { + "phase": { "type": "object", "additionalProperties": false, "required": ["id", "status", "startedAt", "completedAt", "evidence", "error"], @@ -28,10 +41,18 @@ "evidence": { "type": ["object", "null"] }, "error": { "type": ["string", "null"], "maxLength": 500 } } - } }, - "createdAt": { "type": "string", "format": "date-time" }, - "updatedAt": { "type": "string", "format": "date-time" }, - "completedAt": { "type": ["string", "null"], "format": "date-time" } + "verifyPhase": { + "allOf": [{ "$ref": "#/$defs/phase" }, { "properties": { "id": { "const": "verify" } } }] + }, + "controlRefsPhase": { + "allOf": [{ "$ref": "#/$defs/phase" }, { "properties": { "id": { "const": "control-refs" } } }] + }, + "tagPhase": { + "allOf": [{ "$ref": "#/$defs/phase" }, { "properties": { "id": { "const": "tag" } } }] + }, + "githubReleasePhase": { + "allOf": [{ "$ref": "#/$defs/phase" }, { "properties": { "id": { "const": "github-release" } } }] + } } } diff --git a/scripts/lib/github-repository.mjs b/scripts/lib/github-repository.mjs new file mode 100644 index 0000000..2f58724 --- /dev/null +++ b/scripts/lib/github-repository.mjs @@ -0,0 +1,87 @@ +import { runGit } from "./git-process.mjs"; + +const GITHUB_HOST = "github.com"; +const SLUG = /^[A-Za-z0-9_.-]+$/; +const REMOTE_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/; +const GIT_REF = /^refs\/[A-Za-z0-9][A-Za-z0-9._\/-]*$/; + +export function parseGitHubRepositoryRemote(value) { + if (typeof value !== "string") return null; + const parts = repositoryRemoteParts(value); + if (!parts) return null; + const [owner, rawName] = parts; + const name = rawName.replace(/\.git$/i, ""); + if (![owner, name].every((part) => SLUG.test(part))) return null; + return { + owner, + name, + fullName: `${owner}/${name}`, + identity: `${GITHUB_HOST}/${owner}/${name}`, + key: `${owner}/${name}`.toLowerCase(), + }; +} + +export function sameGitHubRepository(left, right) { + return left !== null && right !== null && left.key === right.key; +} + +export async function readRemoteRefOid({ repoPath, remote, ref }) { + assertSafeRemoteName(remote); + assertSafeGitRef(ref); + const result = await runGit({ + args: ["ls-remote", "--refs", remote, ref], + cwd: repoPath, + timeoutMs: 15 * 60 * 1000, + }); + return parseRemoteRefOutput(result.stdout, remote, ref); +} + +function repositoryRemoteParts(value) { + const https = httpsParts(value); + return https || sshParts(value); +} + +function httpsParts(value) { + try { + const parsed = new URL(value); + if (parsed.origin.toLowerCase() !== `https://${GITHUB_HOST}`) return null; + if (`${parsed.username}${parsed.password}${parsed.search}${parsed.hash}` !== "") return null; + return repositoryPathParts(parsed.pathname); + } catch { + return null; + } +} + +function sshParts(value) { + const match = value.match(/^git@github\.com:([^\s]+)$/i); + return match ? repositoryPathParts(match[1]) : null; +} + +function repositoryPathParts(value) { + const parts = value.replace(/^\/+|\/+$/g, "").split("/"); + return parts.length === 2 ? parts : null; +} + +function assertSafeRemoteName(remote) { + if (!REMOTE_NAME.test(remote)) throw new Error("remote must be a safe Git remote name."); +} + +function assertSafeGitRef(ref) { + if (!GIT_REF.test(ref)) throw new Error("ref must be a safe fully qualified Git ref."); + if (ref.includes("..")) throw new Error("ref must be a safe fully qualified Git ref."); +} + +function parseRemoteRefOutput(source, remote, ref) { + const rows = remoteOutputRows(source); + if (rows.length !== 1) throw new Error(`Remote ${remote} returned ${rows.length} values for ${ref}.`); + const [oid, actualRef] = rows[0].split(/\s+/); + if (actualRef !== ref) throw new Error(`Remote ${remote} returned an invalid value for ${ref}.`); + if (!/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(oid)) throw new Error(`Remote ${remote} returned an invalid value for ${ref}.`); + return oid; +} + +function remoteOutputRows(source) { + const trimmed = source.trim(); + if (trimmed === "") return []; + return trimmed.split(/\r?\n/); +} diff --git a/scripts/lib/preflight.mjs b/scripts/lib/preflight.mjs index 35ec799..905f4ff 100644 --- a/scripts/lib/preflight.mjs +++ b/scripts/lib/preflight.mjs @@ -4,6 +4,11 @@ import { resolve } from "node:path"; import { runExternalCommand } from "./external-command.mjs"; import { runGit } from "./git-process.mjs"; import { contract } from "./contract-checks.mjs"; +import { + parseGitHubRepositoryRemote, + readRemoteRefOid, + sameGitHubRepository, +} from "./github-repository.mjs"; import { validatePlatformConfig } from "./platform-config.mjs"; import { repositoryIdentity } from "./repository-identity.mjs"; import { NativeGitStore } from "../providers/native-git-store.mjs"; @@ -18,6 +23,8 @@ export async function runPreflight({ entireBinary = "entire", ghBinary = "gh", commandRunner = runExternalCommand, + controlRemote = null, + remoteRefReader = readRemoteRefOid, nodeVersion = process.versions.node, now = new Date(), } = {}) { @@ -38,7 +45,7 @@ export async function runPreflight({ return passed(`Repository ${repositoryId}.`); }); - await recordRepositoryChecks({ checks, store, profile }); + await recordRepositoryChecks({ checks, store, profile, commandRunner, ghBinary, controlRemote, remoteRefReader }); await record(checks, "entire-version", async () => { const result = await commandRunner({ binary: entireBinary, args: ["--version"], cwd: resolvedRepo, timeoutMs: 30_000 }); @@ -106,12 +113,12 @@ export function validatePreflightResult(value) { return value; } -async function recordRepositoryChecks({ checks, store, profile }) { +async function recordRepositoryChecks({ checks, store, profile, commandRunner, ghBinary, controlRemote, remoteRefReader }) { if (!store) return; await record(checks, "platform-contract", () => checkPlatformContract(store)); - await record(checks, "github-remotes", () => checkGitHubRemotes(store)); + await record(checks, "github-remotes", () => checkGitHubRemotes({ store, commandRunner, ghBinary, controlRemote })); await record(checks, "codex-hooks", () => checkCodexHooks(store)); - if (profile === "release") await record(checks, "clean-main", () => checkCleanMain(store)); + if (profile === "release") await record(checks, "clean-main", () => checkCleanMain(store, remoteRefReader)); } async function checkPlatformContract(store) { @@ -120,32 +127,70 @@ async function checkPlatformContract(store) { return passed("GitHub code storage and external control-state contract valid."); } -async function checkGitHubRemotes(store) { - const [origin, control] = await Promise.all([ +async function checkGitHubRemotes({ store, commandRunner, ghBinary, controlRemote }) { + const platform = await readPlatformConfig(store); + const configuredControl = platform.workflow.controlRemoteName; + const selectedControl = controlRemote || configuredControl; + const selectionBlocker = controlSelectionBlocker(selectedControl, configuredControl); + if (selectionBlocker) return selectionBlocker; + const [originUrl, controlUrl] = await Promise.all([ store.gitConfig("remote.origin.url"), - store.gitConfig("remote.control.url"), + store.gitConfig(`remote.${selectedControl}.url`), ]); - const rules = [ - () => origin ? null : blocked("origin remote missing.", "Configure GitHub code remote as origin."), - () => control ? null : blocked("control remote missing.", "Configure separate private GitHub control remote."), - () => isGitHubRemote(origin) ? null : blocked("origin is not a GitHub remote.", "Set origin to the canonical GitHub repository."), - () => isGitHubRemote(control) ? null : blocked("control is not a GitHub remote.", "Set control to the private GitHub control repository."), - () => origin !== control ? null : blocked("origin and control resolve to the same URL.", "Use separate GitHub repositories for code and private control state."), - ]; - return firstBlocker(rules, passed("origin and control are distinct GitHub remotes.")); + const origin = parseGitHubRepositoryRemote(originUrl); + const control = parseGitHubRepositoryRemote(controlUrl); + const remoteBlocker = githubRemoteBlocker({ originUrl, controlUrl, origin, control, selectedControl }); + if (remoteBlocker) return remoteBlocker; + const result = await commandRunner({ + binary: ghBinary, + args: ["repo", "view", control.fullName, "--json", "nameWithOwner,isPrivate"], + cwd: store.repoPath, + timeoutMs: 30_000, + }); + return privateControlResult(JSON.parse(result.stdout), control, selectedControl); +} + +function controlSelectionBlocker(selected, configured) { + if (selected === configured) return null; + return blocked(`Selected control remote ${selected} does not match platform remote ${configured}.`, `Use --control-remote ${configured}.`); +} + +function githubRemoteBlocker({ originUrl, controlUrl, origin, control, selectedControl }) { + return firstBlocker([ + () => originUrl ? null : blocked("origin remote missing.", "Configure GitHub code remote as origin."), + () => controlUrl ? null : blocked(`${selectedControl} remote missing.`, "Configure the private GitHub control remote."), + () => origin ? null : blocked("origin is not a supported GitHub remote.", "Set origin to the canonical GitHub repository."), + () => control ? null : blocked(`${selectedControl} is not a supported GitHub remote.`, "Set the control remote to the private GitHub control repository."), + () => sameGitHubRepository(origin, control) ? blocked("origin and control target the same GitHub repository.", "Use separate GitHub repositories for code and private control state.") : null, + ], null); +} + +function privateControlResult(repository, control, selectedControl) { + if (String(repository.nameWithOwner).toLowerCase() !== control.key) { + return blocked("GitHub returned a different control repository identity.", "Correct the control remote and GitHub authentication scope."); + } + if (repository.isPrivate !== true) { + return blocked(`Control repository ${control.fullName} is public.`, "Make the GitHub control repository private before continuing."); + } + return passed(`origin and ${selectedControl} are distinct; control repository is private.`); } async function checkCodexHooks(store) { const hooks = JSON.parse(await readFile(resolve(store.repoPath, ".codex/hooks.json"), "utf8")); - const names = new Set(Object.keys(hooks.hooks || {}).map((name) => name.toLowerCase())); - const required = ["sessionstart", "userpromptsubmit", "stop", "posttooluse"]; - const missing = required.filter((name) => !names.has(name)); + const declared = new Map(Object.entries(hooks.hooks || {}).map(([name, entries]) => [name.toLowerCase(), entries])); + const required = new Map([ + ["sessionstart", "session-start"], + ["userpromptsubmit", "user-prompt-submit"], + ["stop", "stop"], + ["posttooluse", "post-tool-use"], + ]); + const missing = [...required].filter(([event, command]) => !hasEntireHook(declared.get(event), command)).map(([event]) => event); if (missing.length > 0) return blocked(`Codex Entire hooks missing: ${missing.join(", ")}.`, "Reinstall Entire Codex hooks for this repository."); - return passed("Four required Entire Codex hooks declared."); + return passed("Four required Entire Codex hook commands declared."); } -async function checkCleanMain(store) { - const [status, branch, head, remoteMain] = await Promise.all([ +async function checkCleanMain(store, remoteRefReader) { + const [status, branch, head, trackedMain] = await Promise.all([ runGit({ args: ["status", "--porcelain=v1"], cwd: store.repoPath }), runGit({ args: ["branch", "--show-current"], cwd: store.repoPath }), store.resolveRef("HEAD"), @@ -155,9 +200,27 @@ async function checkCleanMain(store) { const rules = [ () => status.stdout === "" ? null : blocked("Worktree is not clean.", "Commit or remove local changes before release planning."), () => branchName === "main" ? null : blocked(`Current branch is ${branchName || "detached"}.`, "Switch to main before release planning."), - () => head === remoteMain ? null : blocked("main does not equal origin/main.", "Fetch and fast-forward main before release planning."), + () => head === trackedMain ? null : blocked("main does not equal tracked origin/main.", "Fetch and fast-forward main before release planning."), ]; - return firstBlocker(rules, passed(`Clean main at ${head}.`)); + const localBlocker = firstBlocker(rules, null); + if (localBlocker) return localBlocker; + const liveMain = await remoteRefReader({ repoPath: store.repoPath, remote: "origin", ref: "refs/heads/main" }); + if (head !== liveMain) return blocked("main does not equal live origin/main.", "Fetch and fast-forward main before release planning."); + return passed(`Clean main equals live origin/main at ${head}.`); +} + +async function readPlatformConfig(store) { + const source = await readFile(resolve(store.repoPath, "tabellio.platform.json"), "utf8"); + return validatePlatformConfig(JSON.parse(source)); +} + +function hasEntireHook(entries, expectedCommand) { + if (!Array.isArray(entries)) return false; + const commandPattern = new RegExp(`(?:^|[\\s;&|'\"])(?:exec\\s+)?entire\\s+hooks\\s+codex\\s+${expectedCommand}(?=$|[\\s;&|'\"])`); + return entries.some((entry) => Array.isArray(entry?.hooks) && entry.hooks.some((hook) => { + if (hook?.type !== "command" || typeof hook.command !== "string") return false; + return commandPattern.test(hook.command); + })); } function firstBlocker(rules, fallback) { @@ -237,10 +300,6 @@ function defaultResolution(id) { return "Resolve this required check, then rerun preflight."; } -function isGitHubRemote(value) { - return /^(?:https:\/\/github\.com\/|git@github\.com:)[^/]+\/[^/]+(?:\.git)?$/i.test(value); -} - function parseEntireVersion(value) { const match = value.match(/Entire CLI\s+(\d+)\.(\d+)\.(\d+)/i); return match ? match.slice(1).map(Number) : null; diff --git a/scripts/lib/release-operation.mjs b/scripts/lib/release-operation.mjs index ad48115..106d699 100644 --- a/scripts/lib/release-operation.mjs +++ b/scripts/lib/release-operation.mjs @@ -5,6 +5,7 @@ import { digestObject } from "./stack-operation.mjs"; const RELEASE_OPERATION_VERSION = "tabellio-release-operation/v0.1"; const RELEASE_APPROVAL_VERSION = "tabellio-release-approval/v0.1"; +const MAX_RELEASE_APPROVAL_MS = 60 * 60 * 1000; export function createReleaseIntent({ repository, @@ -74,6 +75,7 @@ export function validateReleaseIntent(value) { contract.exactKeys(value.control, ["intent"], "intent.control"); validateControlRefIntent(value.control.intent); contract.equals(value.control.intent.operation, "publish", "intent.control.intent.operation"); + contract.equals(value.control.intent.remote, "control", "intent.control.intent.remote"); contract.equals(value.control.intent.repository.id, value.repository.id, "intent.control.intent.repository.id"); contract.object(value.validation, "intent.validation"); @@ -100,9 +102,13 @@ export function validateReleaseIntent(value) { } export function validateReleaseApproval(value, intent, { now = new Date() } = {}) { - return validateOperationApproval(value, intent, { + const approval = validateOperationApproval(value, intent, { schemaVersion: RELEASE_APPROVAL_VERSION, validateIntent: validateReleaseIntent, now, }); + if (Date.parse(approval.expiresAt) - Date.parse(approval.approvedAt) > MAX_RELEASE_APPROVAL_MS) { + throw new Error("Release approval lifetime must not exceed one hour."); + } + return approval; } diff --git a/scripts/lib/release-planner.mjs b/scripts/lib/release-planner.mjs index c477a9e..2a351d1 100644 --- a/scripts/lib/release-planner.mjs +++ b/scripts/lib/release-planner.mjs @@ -5,10 +5,10 @@ import { createControlRefIntent, snapshotControlRefs } from "./control-ref-trans import { contract } from "./contract-checks.mjs"; import { runExternalCommand } from "./external-command.mjs"; import { GitJsonLedger } from "./git-json-ledger.mjs"; +import { parseGitHubRepositoryRemote } from "./github-repository.mjs"; import { runGit } from "./git-process.mjs"; import { runPreflight } from "./preflight.mjs"; import { createReleaseIntent } from "./release-operation.mjs"; -import { repositoryIdentity } from "./repository-identity.mjs"; import { ReviewCycleManager } from "./review-cycle.mjs"; import { ValidationRunner } from "./validation-runner.mjs"; import { GitHubProvider } from "../providers/github-provider.mjs"; @@ -34,11 +34,11 @@ export async function planRelease({ githubProvider = null, now = new Date(), } = {}) { - validatePlanInput({ owner, repo, number, version, notesPath }); + validatePlanInput({ owner, repo, number, version, notesPath, controlRemote }); const store = await NativeGitStore.open(resolve(repoPath)); - const preflight = await preflightRunner({ repoPath: store.repoPath, profile: "release", ghBinary }); + const preflight = await preflightRunner({ repoPath: store.repoPath, profile: "release", ghBinary, commandRunner, controlRemote }); assertReadyPreflight(preflight); - const repositoryId = await repositoryIdentity(store, explicitRepositoryId); + const repositoryId = await bindReleaseRepository(store, { owner, repo, explicitRepositoryId }); const evidence = await loadReleaseEvidence({ store, notesPath, ghBinary, owner, repo, number, commandRunner }); const { headCommit, parentCommit, notesSource, pr } = validateReleaseEvidence(evidence, { version, number }); const validationLedger = await GitJsonLedger.open({ repoPath: store.repoPath, ref: "refs/tabellio/validations" }); @@ -63,12 +63,30 @@ export async function planRelease({ }); } -function validatePlanInput({ owner, repo, number, version, notesPath }) { +function validatePlanInput({ owner, repo, number, version, notesPath, controlRemote }) { contract.string(owner, "owner"); contract.string(repo, "repo"); contract.positiveInteger(number, "number"); contract.semver(version, "version"); contract.safeRelativePath(notesPath, "notesPath"); + contract.equals(controlRemote, "control", "controlRemote"); +} + +async function bindReleaseRepository(store, { owner, repo, explicitRepositoryId }) { + const origin = parseGitHubRepositoryRemote(await store.gitConfig("remote.origin.url")); + if (!origin) throw new Error("origin must be a supported GitHub repository remote."); + assertRequestedRepository(origin, owner, repo); + assertExplicitRepositoryId(origin, explicitRepositoryId); + return origin.identity; +} + +function assertRequestedRepository(origin, owner, repo) { + if (origin.key !== `${owner}/${repo}`.toLowerCase()) throw new Error(`Requested repository ${owner}/${repo} does not match origin ${origin.fullName}.`); +} + +function assertExplicitRepositoryId(origin, explicitRepositoryId) { + if (!explicitRepositoryId) return; + if (explicitRepositoryId.toLowerCase() !== origin.identity.toLowerCase()) throw new Error(`repositoryId does not match origin ${origin.identity}.`); } function assertReadyPreflight(preflight) { diff --git a/scripts/lib/release-workflow.mjs b/scripts/lib/release-workflow.mjs index e691344..ce0dce6 100644 --- a/scripts/lib/release-workflow.mjs +++ b/scripts/lib/release-workflow.mjs @@ -1,13 +1,16 @@ import { createHash } from "node:crypto"; -import { mkdir, readFile, rename, writeFile } from "node:fs/promises"; -import { dirname, resolve } from "node:path"; +import { mkdir, mkdtemp, readFile, rename, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; import { ApprovedControlRefTransport, snapshotControlRefs, } from "./control-ref-transport.mjs"; import { runExternalCommand } from "./external-command.mjs"; +import { readRemoteRefOid } from "./github-repository.mjs"; import { runGit } from "./git-process.mjs"; +import { withOperationLock } from "./operation-lock.mjs"; import { repositoryIdentity } from "./repository-identity.mjs"; import { validateReleaseApproval, validateReleaseIntent } from "./release-operation.mjs"; import { NativeGitStore } from "../providers/native-git-store.mjs"; @@ -15,23 +18,39 @@ import { NativeGitStore } from "../providers/native-git-store.mjs"; const PHASES = ["verify", "control-refs", "tag", "github-release"]; export class ReleaseExecutor { - constructor({ repoPath, stateRoot, actions }) { + constructor({ repoPath, stateRoot, actions, lockRunner = withOperationLock }) { this.repoPath = repoPath; this.stateRoot = stateRoot; this.actions = actions; + this.lockRunner = lockRunner; } - static async open({ repoPath = process.cwd(), stateRoot = null, ghBinary = "gh", commandRunner = runExternalCommand } = {}) { + static async open({ + repoPath = process.cwd(), + stateRoot = null, + ghBinary = "gh", + commandRunner = runExternalCommand, + remoteRefReader = readRemoteRefOid, + } = {}) { const store = await NativeGitStore.open(resolve(repoPath)); const common = (await runGit({ args: ["rev-parse", "--git-common-dir"], cwd: store.repoPath })).stdout.trim(); const root = stateRoot ? resolve(stateRoot) : resolve(store.repoPath, common, "tabellio", "release-operations"); - const actions = new ReleaseActions({ store, ghBinary, commandRunner }); + const actions = new ReleaseActions({ store, ghBinary, commandRunner, remoteRefReader }); return new ReleaseExecutor({ repoPath: store.repoPath, stateRoot: root, actions }); } async execute({ intent, approval, now = new Date() }) { validateReleaseIntent(intent); validateReleaseApproval(approval, intent, { now }); + return this.lockRunner({ + repoPath: this.repoPath, + stateRoot: this.stateRoot, + lockName: "release-operation", + label: "release operation", + }, () => this.#executeLocked({ intent, approval, now })); + } + + async #executeLocked({ intent, approval, now }) { await mkdir(this.stateRoot, { recursive: true }); const path = resolve(this.stateRoot, `${approval.id}.json`); const existing = await readState(path); @@ -49,10 +68,11 @@ export class ReleaseExecutor { } class ReleaseActions { - constructor({ store, ghBinary = "gh", commandRunner = runExternalCommand }) { + constructor({ store, ghBinary = "gh", commandRunner = runExternalCommand, remoteRefReader = readRemoteRefOid }) { this.store = store; this.ghBinary = ghBinary; this.commandRunner = commandRunner; + this.remoteRefReader = remoteRefReader; } async run(phase, context) { @@ -67,22 +87,23 @@ class ReleaseActions { } async #verify({ intent }) { - const [repositoryId, head, remoteMain, packageSource, notesSource, refs] = await Promise.all([ + const [repositoryId, head, trackedMain, packageResult, notesSource, refs] = await Promise.all([ repositoryIdentity(this.store), this.store.resolveRef("HEAD"), this.store.resolveRef("origin/main"), - readFile(resolve(this.store.repoPath, "package.json"), "utf8"), - readFile(resolve(this.store.repoPath, intent.release.notesPath)), + runGit({ args: ["show", `${intent.revision.commit}:package.json`], cwd: this.store.repoPath }), + sourceAtCommit(this.store.repoPath, intent.revision.commit, intent.release.notesPath), snapshotControlRefs({ repoPath: this.store.repoPath, remote: intent.control.intent.remote, refs: intent.control.intent.refs.map((entry) => entry.name), }), ]); + const liveMain = await this.remoteRefReader({ repoPath: this.store.repoPath, remote: "origin", ref: "refs/heads/main" }); const status = await runGit({ args: ["status", "--porcelain=v1"], cwd: this.store.repoPath }); const branch = await runGit({ args: ["branch", "--show-current"], cwd: this.store.repoPath }); - assertReleaseRepository({ repositoryId, head, remoteMain, branch: branch.stdout, status: status.stdout }, intent); - assertReleaseArtifacts({ packageSource, notesSource, refs }, intent); + assertReleaseRepository({ repositoryId, head, trackedMain, liveMain, branch: branch.stdout, status: status.stdout }, intent); + assertReleaseArtifacts({ packageSource: packageResult.stdout, notesSource, refs }, intent); return { commit: head, version: intent.version, controlIntentDigest: intent.control.intent.integrity.digest }; } @@ -92,7 +113,8 @@ class ReleaseActions { remote: intent.control.intent.remote, refs: intent.control.intent.refs.map((entry) => entry.name), }); - if (snapshot.every((entry) => entry.localOid === entry.remoteOid)) { + const publication = controlPublicationState(snapshot, intent.control.intent.refs); + if (publication === "published") { return { status: "already-published", refs: snapshot.map((entry) => entry.name) }; } const controlApproval = { @@ -116,36 +138,40 @@ class ReleaseActions { } async #publishTag({ intent }) { - const local = await localTagTarget(this.store.repoPath, intent.tag); - const remote = await remoteTagTarget(this.store.repoPath, intent.code.remote, intent.tag); + let local = await localTagState(this.store.repoPath, intent.tag); + const remote = await remoteTagState(this.store.repoPath, intent.code.remote, intent.tag); assertTagTarget(local, intent, "locally"); assertTagTarget(remote, intent, "remotely"); await ensureLocalTag(this.store.repoPath, intent, local); + local = await localTagState(this.store.repoPath, intent.tag); + assertTagTarget(local, intent, "locally"); await ensureRemoteTag(this.store.repoPath, intent, remote); return { tag: intent.tag, commit: intent.revision.commit, status: tagPublishStatus(remote) }; } async #publishRelease({ intent }) { const repository = `${intent.repository.owner}/${intent.repository.name}`; + const notesSource = await sourceAtCommit(this.store.repoPath, intent.revision.commit, intent.release.notesPath); + if (sha256(notesSource) !== intent.release.notesDigest) throw new Error("Release notes at the approved commit do not match release intent."); const existing = await optionalCommand(this.commandRunner, { binary: this.ghBinary, - args: ["release", "view", intent.tag, "--repo", repository, "--json", "tagName,url,isDraft,isPrerelease"], + args: ["release", "view", intent.tag, "--repo", repository, "--json", "tagName,name,body,url,isDraft,isPrerelease"], cwd: this.store.repoPath, timeoutMs: 30_000, }); if (existing) return reconcileExistingRelease(existing.stdout, intent); - const created = await this.commandRunner({ + const created = await withTemporaryReleaseNotes(notesSource, async (notesFile) => this.commandRunner({ binary: this.ghBinary, args: [ "release", "create", intent.tag, "--repo", repository, "--title", intent.release.title, - "--notes-file", resolve(this.store.repoPath, intent.release.notesPath), + "--notes-file", notesFile, "--verify-tag", ], cwd: this.store.repoPath, timeoutMs: 15 * 60 * 1000, - }); + })); return { status: "published", url: created.stdout.trim(), tag: intent.tag }; } } @@ -202,9 +228,10 @@ function assertReleaseRepository(actual, intent) { } function assertReleaseRevision(actual, intent) { - if (actual.repositoryId !== intent.repository.id) throw new Error("Release repository identity changed."); - if (actual.head !== intent.revision.commit) throw new Error("Release HEAD changed after planning."); - if (actual.remoteMain !== actual.head) throw new Error("Release commit no longer equals origin/main."); + assertEqual(actual.repositoryId, intent.repository.id, "Release repository identity changed."); + assertEqual(actual.head, intent.revision.commit, "Release HEAD changed after planning."); + assertEqual(actual.trackedMain, actual.head, "Release commit no longer equals tracked origin/main."); + assertEqual(actual.liveMain, actual.head, "Release commit no longer equals live origin/main."); } function assertReleaseWorkspace(actual) { @@ -215,11 +242,13 @@ function assertReleaseWorkspace(actual) { function assertReleaseArtifacts(actual, intent) { if (JSON.parse(actual.packageSource).version !== intent.version) throw new Error("package.json version changed after release planning."); if (sha256(actual.notesSource) !== intent.release.notesDigest) throw new Error("Release notes changed after release planning."); - if (JSON.stringify(actual.refs) !== JSON.stringify(intent.control.intent.refs)) throw new Error("Control refs changed after release planning."); + controlPublicationState(actual.refs, intent.control.intent.refs); } function assertTagTarget(target, intent, location) { - if (target && target !== intent.revision.commit) throw new Error(`${intent.tag} exists ${location} at a different commit.`); + if (!target) return; + if (!target.annotated) throw new Error(`${intent.tag} exists ${location} as a lightweight tag.`); + if (target.target !== intent.revision.commit) throw new Error(`${intent.tag} exists ${location} at a different commit.`); } async function ensureLocalTag(repoPath, intent, local) { @@ -241,8 +270,16 @@ function tagPublishStatus(remote) { function reconcileExistingRelease(source, intent) { const release = JSON.parse(source); - const mismatched = release.tagName !== intent.tag || release.isDraft || release.isPrerelease; - if (mismatched) throw new Error("Existing GitHub release does not match final release intent."); + const body = typeof release.body === "string" ? release.body : ""; + const matches = [ + release.tagName === intent.tag, + release.name === intent.release.title, + typeof release.body === "string", + sha256(body) === intent.release.notesDigest, + release.isDraft === false, + release.isPrerelease === false, + ]; + if (matches.includes(false)) throw new Error("Existing GitHub release does not match final release intent."); return { status: "already-published", url: release.url, tag: release.tagName }; } @@ -284,25 +321,68 @@ async function writeState(path, value) { await rename(temporary, path); } -async function localTagTarget(repoPath, tag) { - try { - return (await runGit({ args: ["rev-parse", `${tag}^{}`], cwd: repoPath })).stdout.trim(); - } catch { - return null; - } +async function localTagState(repoPath, tag) { + const direct = await runGit({ + args: ["rev-parse", "--verify", "--end-of-options", `refs/tags/${tag}`], + cwd: repoPath, + acceptableExitCodes: [0, 1, 128], + }); + if (direct.exitCode !== 0) return null; + const type = await runGit({ args: ["cat-file", "-t", direct.stdout.trim()], cwd: repoPath }); + const target = await runGit({ args: ["rev-parse", `${tag}^{}`], cwd: repoPath }); + return { annotated: type.stdout.trim() === "tag", target: target.stdout.trim() }; } -async function remoteTagTarget(repoPath, remote, tag) { +async function remoteTagState(repoPath, remote, tag) { const result = await runGit({ args: ["ls-remote", "--tags", remote, `refs/tags/${tag}`, `refs/tags/${tag}^{}`], cwd: repoPath, timeoutMs: 15 * 60 * 1000, }); const rows = result.stdout.trim().split(/\r?\n/).filter(Boolean).map((line) => line.split(/\s+/)); - const peeled = rows.find(([, ref]) => ref === `refs/tags/${tag}^{}`); - if (peeled) return peeled[0]; const direct = rows.find(([, ref]) => ref === `refs/tags/${tag}`); - return direct ? direct[0] : null; + if (!direct) return null; + const peeled = rows.find(([, ref]) => ref === `refs/tags/${tag}^{}`); + return { annotated: Boolean(peeled), target: peeled?.[0] ?? direct[0] }; +} + +function controlPublicationState(snapshot, expected) { + const actualByName = new Map(snapshot.map((entry) => [entry.name, entry])); + const states = expected.map((approved) => approvedControlRefState(actualByName.get(approved.name), approved)); + if (states.every((state) => state === "published")) return "published"; + if (states.every((state) => state === "pending")) return "pending"; + throw new Error("Control refs are only partially published."); +} + +function approvedControlRefState(actual, approved) { + assertApprovedLocalRef(actual, approved); + if (actual.remoteOid === approved.localOid) return "published"; + if (actual.remoteOid === approved.remoteOid) return "pending"; + throw new Error(`Approved remote control ref ${approved.name} changed.`); +} + +function assertApprovedLocalRef(actual, approved) { + if (!actual) throw new Error(`Approved local control ref ${approved.name} is missing.`); + if (actual.localOid !== approved.localOid) throw new Error(`Approved local control ref ${approved.name} changed.`); +} + +function assertEqual(actual, expected, message) { + if (actual !== expected) throw new Error(message); +} + +async function sourceAtCommit(repoPath, commit, path) { + return (await runGit({ args: ["show", `${commit}:${path}`], cwd: repoPath })).stdout; +} + +async function withTemporaryReleaseNotes(source, action) { + const directory = await mkdtemp(join(tmpdir(), "tabellio-release-notes-")); + const path = join(directory, "notes.md"); + try { + await writeFile(path, source, { mode: 0o600 }); + return await action(path); + } finally { + await rm(directory, { recursive: true, force: true }); + } } async function optionalCommand(runner, options) { diff --git a/scripts/lib/repository-identity.mjs b/scripts/lib/repository-identity.mjs index f8661a9..7375dbd 100644 --- a/scripts/lib/repository-identity.mjs +++ b/scripts/lib/repository-identity.mjs @@ -1,5 +1,7 @@ import { createHash } from "node:crypto"; +import { parseGitHubRepositoryRemote } from "./github-repository.mjs"; + export async function repositoryIdentity(store, explicitId = null) { if (explicitId) return explicitId; const remote = await store.gitConfig("remote.origin.url"); @@ -7,6 +9,8 @@ export async function repositoryIdentity(store, explicitId = null) { } function normalizeRepositoryRemote(remote) { + const github = parseGitHubRepositoryRemote(remote); + if (github) return github.identity; if (/^[A-Za-z]:[\\/]/.test(remote) || remote.startsWith("/") || remote.startsWith("\\\\")) { return hashedRemote(remote); } diff --git a/tests/preflight.test.mjs b/tests/preflight.test.mjs index 6f4d22b..14d510a 100644 --- a/tests/preflight.test.mjs +++ b/tests/preflight.test.mjs @@ -5,7 +5,7 @@ import test from "node:test"; import { runGit } from "../scripts/lib/git-process.mjs"; import { runPreflight, validatePreflightResult } from "../scripts/lib/preflight.mjs"; -import { createFixture } from "./helpers/git-fixture.mjs"; +import { createFixture, identityEnv } from "./helpers/git-fixture.mjs"; test("preflight proves GitHub and Entire readiness without exposing credentials", async (t) => { const fixture = await preparedFixture(t); @@ -45,6 +45,53 @@ test("release preflight requires clean main equal to origin main", async (t) => assert.match(result.checks.find((check) => check.id === "clean-main").detail, /not clean/); }); +test("preflight requires executable Entire hook commands, not empty event keys", async (t) => { + const fixture = await preparedFixture(t); + await writeFile(join(fixture.seed, ".codex", "hooks.json"), JSON.stringify({ + hooks: { SessionStart: [], UserPromptSubmit: [], Stop: [], PostToolUse: [] }, + })); + const result = await runPreflight({ repoPath: fixture.seed, commandRunner: fakeCommands({ trusted: true }) }); + const hooks = result.checks.find((check) => check.id === "codex-hooks"); + assert.equal(hooks.status, "blocked"); + assert.match(hooks.detail, /sessionstart/); +}); + +test("preflight normalizes GitHub remote identities and requires private control storage", async (t) => { + const fixture = await preparedFixture(t); + await runGit({ args: ["remote", "set-url", "control", "git@github.com:EXAMPLE/REPOSITORY.git"], cwd: fixture.seed }); + const same = await runPreflight({ repoPath: fixture.seed, commandRunner: fakeCommands({ trusted: true }) }); + assert.match(same.checks.find((check) => check.id === "github-remotes").detail, /same GitHub repository/); + + await runGit({ args: ["remote", "set-url", "control", "git@github.com:example/repository-control.git"], cwd: fixture.seed }); + const publicControl = await runPreflight({ + repoPath: fixture.seed, + commandRunner: fakeCommands({ trusted: true, privateControl: false }), + }); + assert.match(publicControl.checks.find((check) => check.id === "github-remotes").detail, /public/); +}); + +test("release preflight binds configured control remote and live origin main", async (t) => { + const fixture = await preparedFixture(t); + const wrongControl = await runPreflight({ + repoPath: fixture.seed, + profile: "release", + controlRemote: "backup", + commandRunner: fakeCommands({ trusted: true }), + }); + assert.match(wrongControl.checks.find((check) => check.id === "github-remotes").detail, /does not match platform remote/); + + await runGit({ args: ["add", ".codex/hooks.json", "tabellio.platform.json"], cwd: fixture.seed }); + await runGit({ args: ["commit", "-m", "Add preflight contract"], cwd: fixture.seed, env: identityEnv() }); + await runGit({ args: ["update-ref", "refs/remotes/origin/main", "HEAD"], cwd: fixture.seed }); + const stale = await runPreflight({ + repoPath: fixture.seed, + profile: "release", + commandRunner: fakeCommands({ trusted: true }), + remoteRefReader: async () => "f".repeat(40), + }); + assert.match(stale.checks.find((check) => check.id === "clean-main").detail, /live origin\/main/); +}); + async function preparedFixture(t) { const fixture = await createFixture(); t.after(() => rm(fixture.root, { recursive: true, force: true })); @@ -52,18 +99,24 @@ async function preparedFixture(t) { await runGit({ args: ["remote", "add", "control", "git@github.com:example/repository-control.git"], cwd: fixture.seed }); await mkdir(join(fixture.seed, ".codex"), { recursive: true }); await writeFile(join(fixture.seed, ".codex", "hooks.json"), JSON.stringify({ - hooks: { SessionStart: [], UserPromptSubmit: [], Stop: [], PostToolUse: [] }, + hooks: Object.fromEntries([ + ["SessionStart", "session-start"], + ["UserPromptSubmit", "user-prompt-submit"], + ["Stop", "stop"], + ["PostToolUse", "post-tool-use"], + ].map(([event, command]) => [event, [{ hooks: [{ type: "command", command: `entire hooks codex ${command}` }] }]])), })); await writeFile(join(fixture.seed, "tabellio.platform.json"), JSON.stringify(platform())); return fixture; } -function fakeCommands({ trusted }) { +function fakeCommands({ trusted, privateControl = true }) { const commands = new Map([ ["entire:--version", () => result("Entire CLI 0.7.7\n")], ["entire:status", () => result('{"enabled":true,"agents":["Codex"],"active_sessions":[]}\n')], ["entire:doctor", () => result(`Metadata branches: OK\nCodex hook trust: ${trusted ? "OK" : "REVIEW NEEDED"}\n`)], ["gh:auth", () => result("", "Logged in with gho_secret\n")], + ["gh:repo", () => result(`${JSON.stringify({ nameWithOwner: "example/repository-control", isPrivate: privateControl })}\n`)], ]); return async ({ binary, args }) => { const handler = commands.get(`${binary}:${args[0]}`); diff --git a/tests/release-workflow.test.mjs b/tests/release-workflow.test.mjs index ae4ee0c..4281a90 100644 --- a/tests/release-workflow.test.mjs +++ b/tests/release-workflow.test.mjs @@ -28,6 +28,8 @@ test("release intent binds merged commit, validation, control refs, notes, and a const tampered = structuredClone(intent); tampered.release.title = "Changed"; assert.throws(() => validateReleaseIntent(tampered), /integrity.digest/); + const longApproval = { ...approval, expiresAt: "2026-07-15T13:01:00.001Z" }; + assert.throws(() => validateReleaseApproval(longApproval, intent, { now }), /must not exceed one hour/); }); test("release executor resumes failed phases without repeating completed work", async (t) => { @@ -45,7 +47,12 @@ test("release executor resumes failed phases without repeating completed work", return { phase }; }, }; - const executor = new ReleaseExecutor({ repoPath: root, stateRoot: root, actions }); + const executor = new ReleaseExecutor({ + repoPath: root, + stateRoot: root, + actions, + lockRunner: async (_options, action) => action(), + }); const intent = exampleIntent(); const approval = approvalFor(intent, "resume-release"); await assert.rejects(executor.execute({ intent, approval, now }), /simulated tag failure/); @@ -55,6 +62,30 @@ test("release executor resumes failed phases without repeating completed work", assert.equal(receipt.phases.every((phase) => phase.status === "completed"), true); }); +test("release executor serializes concurrent execution of the same approval", async (t) => { + const fixture = await createFixture(); + t.after(() => rm(fixture.root, { recursive: true, force: true })); + let unblock; + const actions = { + async run(phase) { + if (phase === "verify") await new Promise((resolve) => { unblock = resolve; }); + return { phase }; + }, + }; + const executor = new ReleaseExecutor({ + repoPath: fixture.seed, + stateRoot: join(fixture.root, "release-lock-state"), + actions, + }); + const intent = exampleIntent(); + const approval = approvalFor(intent, "concurrent-release"); + const first = executor.execute({ intent, approval, now }); + while (!unblock) await new Promise((resolve) => setImmediate(resolve)); + await assert.rejects(executor.execute({ intent, approval, now }), /Another release operation is active/); + unblock(); + assert.equal((await first).status, "succeeded"); +}); + test("approved release publishes exact control ref, annotated tag, and GitHub release in isolated repos", async (t) => { const fixture = await createFixture(); t.after(() => rm(fixture.root, { recursive: true, force: true })); @@ -97,23 +128,56 @@ test("approved release publishes exact control ref, annotated tag, and GitHub re createdAt, }); const ghCalls = []; + const publishedNotes = []; + let existingRelease = null; const commandRunner = async ({ args }) => { ghCalls.push(args); - if (args[1] === "view") throw Object.assign(new Error("not found"), { exitCode: 1 }); + if (args[1] === "view") { + if (existingRelease) return { stdout: JSON.stringify(existingRelease), stderr: "", exitCode: 0, signal: null }; + throw Object.assign(new Error("not found"), { exitCode: 1 }); + } + const notesFile = args[args.indexOf("--notes-file") + 1]; + publishedNotes.push(await readFile(notesFile, "utf8")); return { stdout: "https://github.com/example/repository/releases/tag/v1.2.3\n", stderr: "", exitCode: 0, signal: null }; }; const stateRoot = join(fixture.root, "release-state"); - const executor = await ReleaseExecutor.open({ repoPath: fixture.seed, stateRoot, commandRunner }); + const executor = await ReleaseExecutor.open({ + repoPath: fixture.seed, + stateRoot, + commandRunner, + remoteRefReader: async () => head, + }); const approval = approvalFor(intent, "publish-release"); + await runGit({ args: ["tag", intent.tag, head], cwd: fixture.seed }); + await runGit({ args: ["push", "origin", intent.tag], cwd: fixture.seed }); + await runGit({ args: ["tag", "-d", intent.tag], cwd: fixture.seed }); + await assert.rejects(executor.execute({ intent, approval, now }), /remotely as a lightweight tag/); + await runGit({ args: ["push", "origin", `:refs/tags/${intent.tag}`], cwd: fixture.seed }); + await writeFile(join(fixture.seed, notesPath), "MUTATED WORKTREE NOTES\n"); const receipt = await executor.execute({ intent, approval, now }); assert.equal(receipt.status, "succeeded"); assert.equal(ghCalls.length, 2); + assert.deepEqual(publishedNotes, ["Release 1.2.3\n"]); const remoteTag = await runGit({ args: ["ls-remote", "--tags", "origin", "refs/tags/v1.2.3^{}"], cwd: fixture.seed }); assert.equal(remoteTag.stdout.split(/\s+/)[0], head); const remoteControl = await runGit({ args: ["ls-remote", "control", "refs/tabellio/validations"], cwd: fixture.seed }); assert.equal(remoteControl.stdout.split(/\s+/)[0], controlIntent.refs[0].localOid); const stored = JSON.parse(await readFile(join(stateRoot, "publish-release.json"), "utf8")); assert.equal(stored.status, "succeeded"); + + await runGit({ args: ["restore", notesPath], cwd: fixture.seed }); + existingRelease = { + tagName: intent.tag, + name: intent.release.title, + body: "wrong release body\n", + url: "https://github.com/example/repository/releases/tag/v1.2.3", + isDraft: false, + isPrerelease: false, + }; + await assert.rejects( + executor.execute({ intent, approval: approvalFor(intent, "mismatched-existing-release"), now }), + /Existing GitHub release does not match final release intent/, + ); }); test("release planning binds merged PR proof after exact validation and terminal review sync", async (t) => { @@ -141,6 +205,7 @@ test("release planning binds merged PR proof after exact validation and terminal env: identityEnv(), }); await runGit({ args: ["push", "origin", "main"], cwd: fixture.seed }); + await runGit({ args: ["remote", "set-url", "origin", "https://github.com/example/repository.git"], cwd: fixture.seed }); const store = await NativeGitStore.open(fixture.seed); const head = await store.resolveRef("HEAD"); const parent = await store.resolveRef("HEAD^"); @@ -152,9 +217,19 @@ test("release planning binds merged PR proof after exact validation and terminal } throw new Error(`Unexpected command: ${args.join(" ")}`); }; + await assert.rejects(planRelease({ + repoPath: fixture.seed, + owner: "example", + repo: "other-repository", + number: 9, + version: "2.0.0", + notesPath, + preflightRunner: async () => ({ status: "ready", checks: [] }), + now, + }), /does not match origin example\/repository/); const intent = await planRelease({ repoPath: fixture.seed, - repositoryId: "example/repository", + repositoryId: "github.com/example/repository", owner: "example", repo: "repository", number: 9, @@ -173,6 +248,21 @@ test("release planning binds merged PR proof after exact validation and terminal assert.equal(validateReleaseIntent(intent), intent); }); +test("release receipt schema fixes every phase to its canonical position", async () => { + const schema = JSON.parse(await readFile(new URL("../schemas/release-operation-receipt.schema.json", import.meta.url), "utf8")); + assert.deepEqual(schema.properties.phases.prefixItems.map((entry) => entry.$ref), [ + "#/$defs/verifyPhase", + "#/$defs/controlRefsPhase", + "#/$defs/tagPhase", + "#/$defs/githubReleasePhase", + ]); + assert.equal(schema.properties.phases.items, false); + assert.equal(schema.$defs.verifyPhase.allOf[1].properties.id.const, "verify"); + assert.equal(schema.$defs.controlRefsPhase.allOf[1].properties.id.const, "control-refs"); + assert.equal(schema.$defs.tagPhase.allOf[1].properties.id.const, "tag"); + assert.equal(schema.$defs.githubReleasePhase.allOf[1].properties.id.const, "github-release"); +}); + function exampleIntent() { const controlIntent = createControlRefIntent({ operation: "publish", From 5e631e02a7f645264c10698a9ab4d5c7f2d711ed Mon Sep 17 00:00:00 2001 From: hudsonaikins-crown Date: Wed, 15 Jul 2026 17:06:13 -0700 Subject: [PATCH 04/12] fix: bind release retries to approved state Entire-Checkpoint: 1e7d65448717 --- docs/operations-hardening.md | 4 +- docs/releases/v0.3.0.md | 4 +- docs/review-loop.md | 4 +- examples/tabellio-release/minimal-intent.json | 5 +- examples/tabellio-review/minimal-cycle.json | 25 ++++++--- schemas/release-operation.schema.json | 12 ++++- schemas/review-cycle.schema.json | 8 ++- scripts/lib/contract-checks.mjs | 3 ++ scripts/lib/preflight.mjs | 18 +++++-- scripts/lib/release-operation.mjs | 8 ++- scripts/lib/release-planner.mjs | 24 ++++++--- scripts/lib/release-workflow.mjs | 38 +++++++++++--- scripts/lib/review-cycle.mjs | 23 +++++++- tests/context-and-evidence.test.mjs | 5 +- tests/preflight.test.mjs | 27 +++++++--- tests/release-workflow.test.mjs | 52 ++++++++++++++++--- tests/review-cycle.test.mjs | 14 ++++- 17 files changed, 215 insertions(+), 59 deletions(-) diff --git a/docs/operations-hardening.md b/docs/operations-hardening.md index e220ac2..56d60c9 100644 --- a/docs/operations-hardening.md +++ b/docs/operations-hardening.md @@ -30,11 +30,11 @@ Do not maintain a second merge authority. Independent squash or rebase merges cr ## Control-State Publication -Review cycles, validation results, and Entire checkpoints are published together with `git push --atomic`, explicit force-with-lease expectations, and a one-use approval. Publication rejects non-fast-forward updates, divergence, changed local or remote object IDs, expired approvals, and reused approval IDs. +Review cycles, validation results, and Entire checkpoints are published together with `git push --atomic`, explicit force-with-lease expectations, and a one-use approval. Publication rejects non-fast-forward updates, divergence, changed local or remote object IDs, expired approvals, and reused approval IDs. Release retries derive a fresh one-use control approval from the still-active release approval after re-verifying the exact repositories and OIDs. Automatic Entire session pushes to `origin` remain disabled. The approved control-ref transport publishes `refs/heads/entire/checkpoints/v1` with the review and validation refs only to a separately configured private GitHub repository. Planning and execution reject `origin`. -Release planning is local and credentialed-read-only: it requires clean merged `main`, runs exact-head validation, synchronizes terminal review state, and snapshots the resulting control OIDs. Remote publication requires a separate short-lived release approval. Execution writes an atomic local receipt before each phase and reconciles already-published control refs, tags, and releases during retry. Pull-request merge remains a separate explicit action because an approval cannot safely bind a squash commit that does not exist yet. +Release planning is local and credentialed-read-only: it requires clean merged `main`, runs exact-head validation, requires durable proof that the exact pull-request head reached `ready` before merge, binds both GitHub repository identities, and snapshots the resulting control OIDs. Remote publication requires a separate release approval capped at one hour. Execution re-verifies code and control repositories before every resumed write, writes an atomic local receipt before each phase, and reconciles already-published control refs, tags, and releases during retry. Pull-request merge remains a separate explicit action because an approval cannot safely bind a squash commit that does not exist yet. ## Production Checklist diff --git a/docs/releases/v0.3.0.md b/docs/releases/v0.3.0.md index 9281690..74998a0 100644 --- a/docs/releases/v0.3.0.md +++ b/docs/releases/v0.3.0.md @@ -3,9 +3,9 @@ Tabellio v0.3.0 makes release readiness and publication deterministic. ## Highlights - `tabellio-preflight` catches missing or untrusted Entire hooks before commit or release work begins. -- Release planning binds merged Git state, exact validation, terminal review state, private control refs, release notes, and package version into one integrity-protected intent. +- Release planning binds merged Git state, pre-merge head readiness, exact validation, both GitHub repository identities, private control refs, release notes, and package version into one integrity-protected intent. - One approved post-merge execution publishes exact control refs, an annotated Git tag, and the GitHub release. -- Release receipts survive partial failures and resume completed phases without repeating them. +- Release receipts survive partial failures, re-verify repository targets on every retry, and resume completed publication phases without repeating them. - Isolated repository tests prove the complete plan and publication workflow without touching live GitHub resources. Merge remains an explicit operator action. This prevents a release approval from authorizing an unknown squash commit. diff --git a/docs/review-loop.md b/docs/review-loop.md index 2f5fb5e..efbab22 100644 --- a/docs/review-loop.md +++ b/docs/review-loop.md @@ -25,7 +25,7 @@ Review status is deterministic: | `update_required` | Fix exists locally but is not in the remote PR head | | `blocked` | A remote check failed or GitHub reports the change as non-mergeable | | `validating` | No validation result exists yet, or checks are pending/running | -| `ready` | Feedback is handled, fixes are published, and checks are clear | +| `ready` | Feedback is handled, fixes are published, checks are clear, and a head-bound readiness event is recorded | | `merged` / `closed` | Pull-request terminal state | ## Sync GitHub @@ -83,7 +83,7 @@ git-spice restacks rewrite commit IDs. Tabellio retains `originalCommit` and rem ## Storage And Transport -Ledger writes create normal Git blobs, trees, and commits without changing the working tree. Concurrent writers use compare-and-swap on `refs/tabellio/reviews`; stale writers fail instead of overwriting newer state. The same implementation works in normal and bare repositories. The latest cycle retains the newest 100 audit events; older versions remain recoverable from the ledger's Git commit history. +Ledger writes create normal Git blobs, trees, and commits without changing the working tree. Concurrent writers use compare-and-swap on `refs/tabellio/reviews`; stale writers fail instead of overwriting newer state. The same implementation works in normal and bare repositories. The latest cycle retains the newest 100 audit events; older versions remain recoverable from the ledger's Git commit history. A `ready` event stores the exact pull-request head commit so release planning can prove readiness existed before the terminal merged sync. To share the ledger, configure a separate private GitHub repository as the control-state remote, for example: diff --git a/examples/tabellio-release/minimal-intent.json b/examples/tabellio-release/minimal-intent.json index 5bb3fb0..6dad0d2 100644 --- a/examples/tabellio-release/minimal-intent.json +++ b/examples/tabellio-release/minimal-intent.json @@ -21,6 +21,9 @@ "branch": "main" }, "control": { + "repository": { + "id": "github.com/example/repository-control" + }, "intent": { "schemaVersion": "tabellio-control-ref-operation/v0.1", "operation": "publish", @@ -66,6 +69,6 @@ "createdAt": "2026-07-15T12:00:00.000Z", "integrity": { "algorithm": "sha256", - "digest": "de270b5038fa12d0301426fe260652bd4c6cdbe69bc668006a395f10df430726" + "digest": "7a5bbc64a02e37356acb82576698584e8e21f3f0539a6a572d1cf8083a6b7c7c" } } diff --git a/examples/tabellio-review/minimal-cycle.json b/examples/tabellio-review/minimal-cycle.json index 33d759c..a15f02e 100644 --- a/examples/tabellio-review/minimal-cycle.json +++ b/examples/tabellio-review/minimal-cycle.json @@ -35,17 +35,26 @@ "updatedAt": "2026-07-10T12:00:00.000Z" }] }, - "events": [{ - "id": "event-example-001", - "type": "synced", - "actor": "example-agent", - "at": "2026-07-10T12:00:00.000Z", - "detail": "Synced 0 feedback items at example head." - }], + "events": [ + { + "id": "event-example-001", + "type": "synced", + "actor": "example-agent", + "at": "2026-07-10T12:00:00.000Z", + "detail": "Synced 0 feedback items at example head." + }, + { + "id": "event-example-002", + "type": "ready", + "actor": "example-agent", + "at": "2026-07-10T12:00:00.000Z", + "detail": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + } + ], "createdAt": "2026-07-10T12:00:00.000Z", "updatedAt": "2026-07-10T12:00:00.000Z", "integrity": { "algorithm": "sha256", - "digest": "88a01451f8b6e7f9d7df66c89dcc0e44234dcc89f78c18e5fef3ca750341e040" + "digest": "0cd789f42ee49845ae468f299ef16b311728d16d091d44b8b68ed380c04f9651" } } diff --git a/schemas/release-operation.schema.json b/schemas/release-operation.schema.json index 01452d5..84e3c5a 100644 --- a/schemas/release-operation.schema.json +++ b/schemas/release-operation.schema.json @@ -50,8 +50,16 @@ "control": { "type": "object", "additionalProperties": false, - "required": ["intent"], - "properties": { "intent": { "$ref": "control-ref-operation.schema.json" } } + "required": ["repository", "intent"], + "properties": { + "repository": { + "type": "object", + "additionalProperties": false, + "required": ["id"], + "properties": { "id": { "type": "string", "minLength": 1 } } + }, + "intent": { "$ref": "urn:tabellio:schema:control-ref-operation:v0.1" } + } }, "validation": { "type": "object", diff --git a/schemas/review-cycle.schema.json b/schemas/review-cycle.schema.json index 34e5c2b..c6fca87 100644 --- a/schemas/review-cycle.schema.json +++ b/schemas/review-cycle.schema.json @@ -151,11 +151,15 @@ "additionalProperties": false, "properties": { "id": { "type": "string", "minLength": 1 }, - "type": { "enum": ["synced", "triaged", "fix-recorded", "agent-review-imported"] }, + "type": { "enum": ["synced", "triaged", "fix-recorded", "agent-review-imported", "ready"] }, "actor": { "type": "string", "minLength": 1 }, "at": { "type": "string", "format": "date-time" }, "detail": { "type": "string", "minLength": 1, "maxLength": 2000 } - } + }, + "allOf": [{ + "if": { "properties": { "type": { "const": "ready" } }, "required": ["type"] }, + "then": { "properties": { "detail": { "$ref": "#/$defs/oid" } } } + }] } } } diff --git a/scripts/lib/contract-checks.mjs b/scripts/lib/contract-checks.mjs index aaee1c9..36c4217 100644 --- a/scripts/lib/contract-checks.mjs +++ b/scripts/lib/contract-checks.mjs @@ -2,6 +2,8 @@ function ensure(condition, message) { if (!condition) throw new Error(message); } +const ISO_DATE_TIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/; + export const contract = Object.freeze({ object(value, path) { ensure(Object.prototype.toString.call(value) === "[object Object]", `${path} must be an object.`); @@ -47,6 +49,7 @@ export const contract = Object.freeze({ date(value, path) { this.string(value, path); + ensure(ISO_DATE_TIME.test(value), `${path} must be an ISO date-time string.`); ensure(!Number.isNaN(Date.parse(value)), `${path} must be an ISO date-time string.`); }, diff --git a/scripts/lib/preflight.mjs b/scripts/lib/preflight.mjs index 905f4ff..293d188 100644 --- a/scripts/lib/preflight.mjs +++ b/scripts/lib/preflight.mjs @@ -216,11 +216,19 @@ async function readPlatformConfig(store) { function hasEntireHook(entries, expectedCommand) { if (!Array.isArray(entries)) return false; - const commandPattern = new RegExp(`(?:^|[\\s;&|'\"])(?:exec\\s+)?entire\\s+hooks\\s+codex\\s+${expectedCommand}(?=$|[\\s;&|'\"])`); - return entries.some((entry) => Array.isArray(entry?.hooks) && entry.hooks.some((hook) => { - if (hook?.type !== "command" || typeof hook.command !== "string") return false; - return commandPattern.test(hook.command); - })); + const direct = new RegExp(`^(?:exec )?entire hooks codex ${expectedCommand}$`); + const guarded = new RegExp(`^sh -c 'if ! command -v entire >/dev/null 2>&1; then .+; fi; exec entire hooks codex ${expectedCommand}'$`); + return entries.some((entry) => hookCommands(entry).some((hook) => matchesEntireHook(hook, direct, guarded))); +} + +function hookCommands(entry) { + return Array.isArray(entry?.hooks) ? entry.hooks : []; +} + +function matchesEntireHook(hook, direct, guarded) { + if (hook?.type !== "command") return false; + if (typeof hook.command !== "string") return false; + return [direct, guarded].some((pattern) => pattern.test(hook.command)); } function firstBlocker(rules, fallback) { diff --git a/scripts/lib/release-operation.mjs b/scripts/lib/release-operation.mjs index 106d699..33001cf 100644 --- a/scripts/lib/release-operation.mjs +++ b/scripts/lib/release-operation.mjs @@ -13,6 +13,7 @@ export function createReleaseIntent({ revision, pullRequest, controlIntent, + controlRepository, validation, release, createdAt = new Date().toISOString(), @@ -25,7 +26,7 @@ export function createReleaseIntent({ revision, pullRequest, code: { remote: "origin", branch: "main" }, - control: { intent: controlIntent }, + control: { repository: controlRepository, intent: controlIntent }, validation, release, createdAt, @@ -72,7 +73,10 @@ export function validateReleaseIntent(value) { contract.equals(value.code.branch, "main", "intent.code.branch"); contract.object(value.control, "intent.control"); - contract.exactKeys(value.control, ["intent"], "intent.control"); + contract.exactKeys(value.control, ["repository", "intent"], "intent.control"); + contract.object(value.control.repository, "intent.control.repository"); + contract.exactKeys(value.control.repository, ["id"], "intent.control.repository"); + contract.string(value.control.repository.id, "intent.control.repository.id"); validateControlRefIntent(value.control.intent); contract.equals(value.control.intent.operation, "publish", "intent.control.intent.operation"); contract.equals(value.control.intent.remote, "control", "intent.control.intent.remote"); diff --git a/scripts/lib/release-planner.mjs b/scripts/lib/release-planner.mjs index 2a351d1..0f4a610 100644 --- a/scripts/lib/release-planner.mjs +++ b/scripts/lib/release-planner.mjs @@ -5,11 +5,11 @@ import { createControlRefIntent, snapshotControlRefs } from "./control-ref-trans import { contract } from "./contract-checks.mjs"; import { runExternalCommand } from "./external-command.mjs"; import { GitJsonLedger } from "./git-json-ledger.mjs"; -import { parseGitHubRepositoryRemote } from "./github-repository.mjs"; +import { parseGitHubRepositoryRemote, sameGitHubRepository } from "./github-repository.mjs"; import { runGit } from "./git-process.mjs"; import { runPreflight } from "./preflight.mjs"; import { createReleaseIntent } from "./release-operation.mjs"; -import { ReviewCycleManager } from "./review-cycle.mjs"; +import { ReviewCycleManager, reviewCycleHasReadyEvidence } from "./review-cycle.mjs"; import { ValidationRunner } from "./validation-runner.mjs"; import { GitHubProvider } from "../providers/github-provider.mjs"; import { NativeGitStore } from "../providers/native-git-store.mjs"; @@ -38,7 +38,8 @@ export async function planRelease({ const store = await NativeGitStore.open(resolve(repoPath)); const preflight = await preflightRunner({ repoPath: store.repoPath, profile: "release", ghBinary, commandRunner, controlRemote }); assertReadyPreflight(preflight); - const repositoryId = await bindReleaseRepository(store, { owner, repo, explicitRepositoryId }); + const repositories = await bindReleaseRepositories(store, { owner, repo, explicitRepositoryId, controlRemote }); + const repositoryId = repositories.code.identity; const evidence = await loadReleaseEvidence({ store, notesPath, ghBinary, owner, repo, number, commandRunner }); const { headCommit, parentCommit, notesSource, pr } = validateReleaseEvidence(evidence, { version, number }); const validationLedger = await GitJsonLedger.open({ repoPath: store.repoPath, ref: "refs/tabellio/validations" }); @@ -52,6 +53,7 @@ export async function planRelease({ revision: { commit: headCommit, parent: parentCommit }, pullRequest: { number, headCommit: pr.headRefOid, mergeCommit: pr.mergeCommit.oid }, controlIntent, + controlRepository: { id: repositories.control.identity }, validation: { runId: validation.result.runId, resultVersion: validation.version, @@ -72,12 +74,19 @@ function validatePlanInput({ owner, repo, number, version, notesPath, controlRem contract.equals(controlRemote, "control", "controlRemote"); } -async function bindReleaseRepository(store, { owner, repo, explicitRepositoryId }) { - const origin = parseGitHubRepositoryRemote(await store.gitConfig("remote.origin.url")); +async function bindReleaseRepositories(store, { owner, repo, explicitRepositoryId, controlRemote }) { + const [originUrl, controlUrl] = await Promise.all([ + store.gitConfig("remote.origin.url"), + store.gitConfig(`remote.${controlRemote}.url`), + ]); + const origin = parseGitHubRepositoryRemote(originUrl); if (!origin) throw new Error("origin must be a supported GitHub repository remote."); + const control = parseGitHubRepositoryRemote(controlUrl); + if (!control) throw new Error(`${controlRemote} must be a supported GitHub repository remote.`); + if (sameGitHubRepository(origin, control)) throw new Error("Control repository must differ from the code repository."); assertRequestedRepository(origin, owner, repo); assertExplicitRepositoryId(origin, explicitRepositoryId); - return origin.identity; + return { code: origin, control }; } function assertRequestedRepository(origin, owner, repo) { @@ -164,6 +173,9 @@ async function assertMergedReview({ store, validationLedger, provider, repositor }); const review = await manager.sync({ number, actor: runnerId, now }); if (review.cycle.status !== "merged") throw new Error(`Review cycle ${number} is ${review.cycle.status}, not merged.`); + if (!reviewCycleHasReadyEvidence(review.cycle, review.cycle.changeRequest.headCommit)) { + throw new Error(`Review cycle ${number} has no ready evidence for the merged pull-request head.`); + } } async function planControlPublication({ store, repositoryId, controlRemote, now }) { diff --git a/scripts/lib/release-workflow.mjs b/scripts/lib/release-workflow.mjs index ce0dce6..001a33a 100644 --- a/scripts/lib/release-workflow.mjs +++ b/scripts/lib/release-workflow.mjs @@ -1,4 +1,4 @@ -import { createHash } from "node:crypto"; +import { createHash, randomUUID } from "node:crypto"; import { mkdir, mkdtemp, readFile, rename, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { dirname, join, resolve } from "node:path"; @@ -8,7 +8,7 @@ import { snapshotControlRefs, } from "./control-ref-transport.mjs"; import { runExternalCommand } from "./external-command.mjs"; -import { readRemoteRefOid } from "./github-repository.mjs"; +import { parseGitHubRepositoryRemote, readRemoteRefOid } from "./github-repository.mjs"; import { runGit } from "./git-process.mjs"; import { withOperationLock } from "./operation-lock.mjs"; import { repositoryIdentity } from "./repository-identity.mjs"; @@ -31,11 +31,12 @@ export class ReleaseExecutor { ghBinary = "gh", commandRunner = runExternalCommand, remoteRefReader = readRemoteRefOid, + remoteRepositoryReader = githubRemoteRepositoryIdentity, } = {}) { const store = await NativeGitStore.open(resolve(repoPath)); const common = (await runGit({ args: ["rev-parse", "--git-common-dir"], cwd: store.repoPath })).stdout.trim(); const root = stateRoot ? resolve(stateRoot) : resolve(store.repoPath, common, "tabellio", "release-operations"); - const actions = new ReleaseActions({ store, ghBinary, commandRunner, remoteRefReader }); + const actions = new ReleaseActions({ store, ghBinary, commandRunner, remoteRefReader, remoteRepositoryReader }); return new ReleaseExecutor({ repoPath: store.repoPath, stateRoot: root, actions }); } @@ -57,7 +58,9 @@ export class ReleaseExecutor { let state = resumeOrCreate(existing, intent, approval, now); if (state.status === "succeeded") return { ...state, receiptPath: path }; - for (const phase of PHASES) { + const verification = state.phases.find((entry) => entry.id === "verify"); + state = await executePhase({ state, current: verification, phase: "verify", path, actions: this.actions, intent, approval, now }); + for (const phase of PHASES.slice(1)) { const current = state.phases.find((entry) => entry.id === phase); if (current.status === "completed") continue; state = await executePhase({ state, current, phase, path, actions: this.actions, intent, approval, now }); @@ -68,11 +71,18 @@ export class ReleaseExecutor { } class ReleaseActions { - constructor({ store, ghBinary = "gh", commandRunner = runExternalCommand, remoteRefReader = readRemoteRefOid }) { + constructor({ + store, + ghBinary = "gh", + commandRunner = runExternalCommand, + remoteRefReader = readRemoteRefOid, + remoteRepositoryReader = githubRemoteRepositoryIdentity, + }) { this.store = store; this.ghBinary = ghBinary; this.commandRunner = commandRunner; this.remoteRefReader = remoteRefReader; + this.remoteRepositoryReader = remoteRepositoryReader; } async run(phase, context) { @@ -87,8 +97,9 @@ class ReleaseActions { } async #verify({ intent }) { - const [repositoryId, head, trackedMain, packageResult, notesSource, refs] = await Promise.all([ + const [repositoryId, controlRepositoryId, head, trackedMain, packageResult, notesSource, refs] = await Promise.all([ repositoryIdentity(this.store), + this.remoteRepositoryReader(this.store, intent.control.intent.remote), this.store.resolveRef("HEAD"), this.store.resolveRef("origin/main"), runGit({ args: ["show", `${intent.revision.commit}:package.json`], cwd: this.store.repoPath }), @@ -102,12 +113,14 @@ class ReleaseActions { const liveMain = await this.remoteRefReader({ repoPath: this.store.repoPath, remote: "origin", ref: "refs/heads/main" }); const status = await runGit({ args: ["status", "--porcelain=v1"], cwd: this.store.repoPath }); const branch = await runGit({ args: ["branch", "--show-current"], cwd: this.store.repoPath }); - assertReleaseRepository({ repositoryId, head, trackedMain, liveMain, branch: branch.stdout, status: status.stdout }, intent); + assertReleaseRepository({ repositoryId, controlRepositoryId, head, trackedMain, liveMain, branch: branch.stdout, status: status.stdout }, intent); assertReleaseArtifacts({ packageSource: packageResult.stdout, notesSource, refs }, intent); return { commit: head, version: intent.version, controlIntentDigest: intent.control.intent.integrity.digest }; } async #publishControl({ intent, approval, now }) { + const controlRepositoryId = await this.remoteRepositoryReader(this.store, intent.control.intent.remote); + assertEqual(controlRepositoryId.toLowerCase(), intent.control.repository.id.toLowerCase(), "Release control repository identity changed."); const snapshot = await snapshotControlRefs({ repoPath: this.store.repoPath, remote: intent.control.intent.remote, @@ -119,7 +132,7 @@ class ReleaseActions { } const controlApproval = { schemaVersion: "tabellio-control-ref-approval/v0.1", - id: `${approval.id.slice(0, 110)}-control`, + id: `${approval.id.slice(0, 80)}-control-${randomUUID()}`, intentDigest: intent.control.intent.integrity.digest, approved: true, approvedBy: approval.approvedBy, @@ -138,6 +151,8 @@ class ReleaseActions { } async #publishTag({ intent }) { + const repositoryId = await repositoryIdentity(this.store); + assertEqual(repositoryId, intent.repository.id, "Release repository identity changed before tag publication."); let local = await localTagState(this.store.repoPath, intent.tag); const remote = await remoteTagState(this.store.repoPath, intent.code.remote, intent.tag); assertTagTarget(local, intent, "locally"); @@ -229,6 +244,7 @@ function assertReleaseRepository(actual, intent) { function assertReleaseRevision(actual, intent) { assertEqual(actual.repositoryId, intent.repository.id, "Release repository identity changed."); + assertEqual(actual.controlRepositoryId.toLowerCase(), intent.control.repository.id.toLowerCase(), "Release control repository identity changed."); assertEqual(actual.head, intent.revision.commit, "Release HEAD changed after planning."); assertEqual(actual.trackedMain, actual.head, "Release commit no longer equals tracked origin/main."); assertEqual(actual.liveMain, actual.head, "Release commit no longer equals live origin/main."); @@ -374,6 +390,12 @@ async function sourceAtCommit(repoPath, commit, path) { return (await runGit({ args: ["show", `${commit}:${path}`], cwd: repoPath })).stdout; } +async function githubRemoteRepositoryIdentity(store, remote) { + const repository = parseGitHubRepositoryRemote(await store.gitConfig(`remote.${remote}.url`)); + if (!repository) throw new Error(`Release ${remote} remote is not a supported GitHub repository.`); + return repository.identity; +} + async function withTemporaryReleaseNotes(source, action) { const directory = await mkdtemp(join(tmpdir(), "tabellio-release-notes-")); const path = join(directory, "notes.md"); diff --git a/scripts/lib/review-cycle.mjs b/scripts/lib/review-cycle.mjs index 1a97d08..90258cc 100644 --- a/scripts/lib/review-cycle.mjs +++ b/scripts/lib/review-cycle.mjs @@ -205,7 +205,9 @@ export class ReviewCycleManager { } async #save(cycle, expectedVersion) { - cycle.status = deriveStatus(cycle); + const status = deriveStatus(cycle); + recordReadyEvidence(cycle, status); + cycle.status = status; cycle.integrity = { algorithm: "sha256", digest: cycleDigest(cycle) }; validateReviewCycle(cycle); const path = cyclePath(this.repositoryId, this.owner, this.repo, cycle.changeRequest.number); @@ -256,6 +258,11 @@ export class ReviewCycleManager { } } +export function reviewCycleHasReadyEvidence(cycle, headCommit) { + return Array.isArray(cycle?.events) + && cycle.events.some((item) => item.type === "ready" && item.detail === headCommit); +} + export function validateReviewCycle(value) { object(value, "review cycle"); exactKeys(value, ["schemaVersion", "id", "repository", "provider", "changeRequest", "status", "round", "feedback", "fixes", "checks", "events", "createdAt", "updatedAt", "integrity"], "review cycle"); @@ -532,6 +539,17 @@ function appendEvent(events, next) { return [...events, next].slice(-100); } +function recordReadyEvidence(cycle, status) { + if (status !== "ready") return; + if (reviewCycleHasReadyEvidence(cycle, cycle.changeRequest.headCommit)) return; + cycle.events = appendEvent(cycle.events, event("ready", readinessActor(cycle.events), cycle.updatedAt, cycle.changeRequest.headCommit)); +} + +function readinessActor(events) { + const latest = events.at(-1); + return latest ? latest.actor : "tabellio"; +} + function validateChangeRequest(value) { object(value, "changeRequest"); exactKeys(value, ["id", "number", "url", "title", "state", "draft", "mergeable", "headBranch", "headCommit", "baseBranch", "baseCommit", "updatedAt"], "changeRequest"); @@ -616,11 +634,12 @@ function validateEvent(value, path) { object(value, path); exactKeys(value, ["id", "type", "actor", "at", "detail"], path); requiredString(value.id, `${path}.id`); - member(value.type, ["synced", "triaged", "fix-recorded", "agent-review-imported"], `${path}.type`); + member(value.type, ["synced", "triaged", "fix-recorded", "agent-review-imported", "ready"], `${path}.type`); requiredString(value.actor, `${path}.actor`); date(value.at, `${path}.at`); requiredString(value.detail, `${path}.detail`); maxLength(value.detail, 2_000, `${path}.detail`); + if (value.type === "ready") oid(value.detail, `${path}.detail`); } function exactKeys(value, expected, path) { diff --git a/tests/context-and-evidence.test.mjs b/tests/context-and-evidence.test.mjs index 801e0eb..390ba05 100644 --- a/tests/context-and-evidence.test.mjs +++ b/tests/context-and-evidence.test.mjs @@ -262,11 +262,14 @@ test("core runtime and adoption docs do not require GitHub Actions", async () => }); test("stable schema identifiers keep external references resolvable", async () => { - const [evidenceSchema, policySchema] = await Promise.all([ + const [evidenceSchema, policySchema, releaseSchema, controlSchema] = await Promise.all([ readFile(`${projectRoot}/schemas/evidence-envelope.schema.json`, "utf8").then(JSON.parse), readFile(`${projectRoot}/schemas/external-action-policy.schema.json`, "utf8").then(JSON.parse), + readFile(`${projectRoot}/schemas/release-operation.schema.json`, "utf8").then(JSON.parse), + readFile(`${projectRoot}/schemas/control-ref-operation.schema.json`, "utf8").then(JSON.parse), ]); assert.equal(evidenceSchema.properties.externalActionPolicy.$ref, policySchema.$id); + assert.equal(releaseSchema.properties.control.properties.intent.$ref, controlSchema.$id); }); test("required repository validation is recorded in evidence", async (t) => { diff --git a/tests/preflight.test.mjs b/tests/preflight.test.mjs index 14d510a..ee601fd 100644 --- a/tests/preflight.test.mjs +++ b/tests/preflight.test.mjs @@ -54,6 +54,10 @@ test("preflight requires executable Entire hook commands, not empty event keys", const hooks = result.checks.find((check) => check.id === "codex-hooks"); assert.equal(hooks.status, "blocked"); assert.match(hooks.detail, /sessionstart/); + + await writeEntireHooks(fixture.seed, (command) => `false && entire hooks codex ${command}`); + const disabled = await runPreflight({ repoPath: fixture.seed, commandRunner: fakeCommands({ trusted: true }) }); + assert.equal(disabled.checks.find((check) => check.id === "codex-hooks").status, "blocked"); }); test("preflight normalizes GitHub remote identities and requires private control storage", async (t) => { @@ -98,18 +102,25 @@ async function preparedFixture(t) { await runGit({ args: ["remote", "set-url", "origin", "https://github.com/example/repository.git"], cwd: fixture.seed }); await runGit({ args: ["remote", "add", "control", "git@github.com:example/repository-control.git"], cwd: fixture.seed }); await mkdir(join(fixture.seed, ".codex"), { recursive: true }); - await writeFile(join(fixture.seed, ".codex", "hooks.json"), JSON.stringify({ - hooks: Object.fromEntries([ - ["SessionStart", "session-start"], - ["UserPromptSubmit", "user-prompt-submit"], - ["Stop", "stop"], - ["PostToolUse", "post-tool-use"], - ].map(([event, command]) => [event, [{ hooks: [{ type: "command", command: `entire hooks codex ${command}` }] }]])), - })); + await writeEntireHooks(fixture.seed, (command) => `entire hooks codex ${command}`); await writeFile(join(fixture.seed, "tabellio.platform.json"), JSON.stringify(platform())); return fixture; } +async function writeEntireHooks(repoPath, commandFor) { + const commands = [ + ["SessionStart", "session-start"], + ["UserPromptSubmit", "user-prompt-submit"], + ["Stop", "stop"], + ["PostToolUse", "post-tool-use"], + ]; + const hooks = Object.fromEntries(commands.map(([event, command]) => [ + event, + [{ hooks: [{ type: "command", command: commandFor(command) }] }], + ])); + await writeFile(join(repoPath, ".codex", "hooks.json"), JSON.stringify({ hooks })); +} + function fakeCommands({ trusted, privateControl = true }) { const commands = new Map([ ["entire:--version", () => result("Entire CLI 0.7.7\n")], diff --git a/tests/release-workflow.test.mjs b/tests/release-workflow.test.mjs index 4281a90..f12626b 100644 --- a/tests/release-workflow.test.mjs +++ b/tests/release-workflow.test.mjs @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import { createHash } from "node:crypto"; -import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import test from "node:test"; @@ -12,6 +12,7 @@ import { createReleaseIntent, validateReleaseApproval, validateReleaseIntent } f import { planRelease } from "../scripts/lib/release-planner.mjs"; import { repositoryIdentity } from "../scripts/lib/repository-identity.mjs"; import { ReleaseExecutor } from "../scripts/lib/release-workflow.mjs"; +import { ReviewCycleManager } from "../scripts/lib/review-cycle.mjs"; import { NativeGitStore } from "../scripts/providers/native-git-store.mjs"; import { createFixture, identityEnv } from "./helpers/git-fixture.mjs"; @@ -30,6 +31,7 @@ test("release intent binds merged commit, validation, control refs, notes, and a assert.throws(() => validateReleaseIntent(tampered), /integrity.digest/); const longApproval = { ...approval, expiresAt: "2026-07-15T13:01:00.001Z" }; assert.throws(() => validateReleaseApproval(longApproval, intent, { now }), /must not exceed one hour/); + assert.throws(() => exampleIntent({ releaseCreatedAt: "1" }), /ISO date-time/); }); test("release executor resumes failed phases without repeating completed work", async (t) => { @@ -58,7 +60,7 @@ test("release executor resumes failed phases without repeating completed work", await assert.rejects(executor.execute({ intent, approval, now }), /simulated tag failure/); const receipt = await executor.execute({ intent, approval, now }); assert.equal(receipt.status, "succeeded"); - assert.deepEqual(calls, ["verify", "control-refs", "tag", "tag", "github-release"]); + assert.deepEqual(calls, ["verify", "control-refs", "tag", "verify", "tag", "github-release"]); assert.equal(receipt.phases.every((phase) => phase.status === "completed"), true); }); @@ -123,6 +125,7 @@ test("approved release publishes exact control ref, annotated tag, and GitHub re revision: { commit: head, parent }, pullRequest: { number: 7, headCommit: parent, mergeCommit: head }, controlIntent, + controlRepository: { id: "github.com/example/repository-control" }, validation: { runId: "validation-test", resultVersion: validationWrite.version, status: "passed", headCommit: head }, release: { title: "Tabellio v1.2.3", notesPath, notesDigest: digest("Release 1.2.3\n") }, createdAt, @@ -141,21 +144,35 @@ test("approved release publishes exact control ref, annotated tag, and GitHub re return { stdout: "https://github.com/example/repository/releases/tag/v1.2.3\n", stderr: "", exitCode: 0, signal: null }; }; const stateRoot = join(fixture.root, "release-state"); + let liveReads = 0; + let currentControlIdentity = intent.control.repository.id; const executor = await ReleaseExecutor.open({ repoPath: fixture.seed, stateRoot, commandRunner, - remoteRefReader: async () => head, + remoteRefReader: async () => { liveReads += 1; return head; }, + remoteRepositoryReader: async () => currentControlIdentity, }); const approval = approvalFor(intent, "publish-release"); await runGit({ args: ["tag", intent.tag, head], cwd: fixture.seed }); await runGit({ args: ["push", "origin", intent.tag], cwd: fixture.seed }); await runGit({ args: ["tag", "-d", intent.tag], cwd: fixture.seed }); + const rejectControl = join(control, "hooks", "pre-receive"); + await writeFile(rejectControl, "#!/bin/sh\nexit 1\n"); + await chmod(rejectControl, 0o755); + await assert.rejects(executor.execute({ intent, approval, now }), /git push/); + await rm(rejectControl); + currentControlIdentity = "github.com/example/retargeted-control"; + await assert.rejects(executor.execute({ intent, approval, now }), /control repository identity changed/); + currentControlIdentity = intent.control.repository.id; await assert.rejects(executor.execute({ intent, approval, now }), /remotely as a lightweight tag/); await runGit({ args: ["push", "origin", `:refs/tags/${intent.tag}`], cwd: fixture.seed }); await writeFile(join(fixture.seed, notesPath), "MUTATED WORKTREE NOTES\n"); + await assert.rejects(executor.execute({ intent, approval, now }), /clean worktree/); + await runGit({ args: ["restore", notesPath], cwd: fixture.seed }); const receipt = await executor.execute({ intent, approval, now }); assert.equal(receipt.status, "succeeded"); + assert.equal(liveReads, 5); assert.equal(ghCalls.length, 2); assert.deepEqual(publishedNotes, ["Release 1.2.3\n"]); const remoteTag = await runGit({ args: ["ls-remote", "--tags", "origin", "refs/tags/v1.2.3^{}"], cwd: fixture.seed }); @@ -165,7 +182,6 @@ test("approved release publishes exact control ref, annotated tag, and GitHub re const stored = JSON.parse(await readFile(join(stateRoot, "publish-release.json"), "utf8")); assert.equal(stored.status, "succeeded"); - await runGit({ args: ["restore", notesPath], cwd: fixture.seed }); existingRelease = { tagName: intent.tag, name: intent.release.title, @@ -206,11 +222,23 @@ test("release planning binds merged PR proof after exact validation and terminal }); await runGit({ args: ["push", "origin", "main"], cwd: fixture.seed }); await runGit({ args: ["remote", "set-url", "origin", "https://github.com/example/repository.git"], cwd: fixture.seed }); + await runGit({ args: ["remote", "set-url", "control", "https://github.com/example/repository-control.git"], cwd: fixture.seed }); + await runGit({ args: ["config", `url.file://${control}.insteadOf`, "https://github.com/example/repository-control.git"], cwd: fixture.seed }); const store = await NativeGitStore.open(fixture.seed); const head = await store.resolveRef("HEAD"); const parent = await store.resolveRef("HEAD^"); await runGit({ args: ["update-ref", "refs/heads/entire/checkpoints/v1", head], cwd: fixture.seed }); const provider = mergedProvider({ head: parent, base: parent }); + const reviewLedger = await GitJsonLedger.open({ repoPath: fixture.seed, ref: "refs/tabellio/reviews" }); + const readyManager = new ReviewCycleManager({ + store, + ledger: reviewLedger, + provider: readyProvider({ head: parent, base: parent }), + repositoryId: "github.com/example/repository", + owner: "example", + repo: "repository", + }); + assert.equal((await readyManager.sync({ number: 9, actor: "test", now })).cycle.status, "ready"); const commandRunner = async ({ args }) => { if (args[0] === "pr" && args[1] === "view") { return { stdout: JSON.stringify({ state: "MERGED", headRefOid: parent, mergeCommit: { oid: head } }), stderr: "", exitCode: 0, signal: null }; @@ -244,6 +272,7 @@ test("release planning binds merged PR proof after exact validation and terminal assert.equal(intent.revision.commit, head); assert.equal(intent.pullRequest.headCommit, parent); assert.equal(intent.validation.status, "passed"); + assert.equal(intent.control.repository.id, "github.com/example/repository-control"); assert.equal(intent.control.intent.refs.length, 3); assert.equal(validateReleaseIntent(intent), intent); }); @@ -263,7 +292,7 @@ test("release receipt schema fixes every phase to its canonical position", async assert.equal(schema.$defs.githubReleasePhase.allOf[1].properties.id.const, "github-release"); }); -function exampleIntent() { +function exampleIntent({ releaseCreatedAt = createdAt } = {}) { const controlIntent = createControlRefIntent({ operation: "publish", repositoryId: "github.com/example/repository", @@ -277,9 +306,10 @@ function exampleIntent() { revision: { commit: "d".repeat(40), parent: "a".repeat(40) }, pullRequest: { number: 7, headCommit: "c".repeat(40), mergeCommit: "d".repeat(40) }, controlIntent, + controlRepository: { id: "github.com/example/repository-control" }, validation: { runId: "validation-example", resultVersion: "e".repeat(40), status: "passed", headCommit: "d".repeat(40) }, release: { title: "Tabellio v1.2.3", notesPath: "docs/releases/v1.2.3.md", notesDigest: digest("notes") }, - createdAt, + createdAt: releaseCreatedAt, }); } @@ -329,3 +359,13 @@ function mergedProvider({ head, base }) { async commitStatus() { return { commit: head, state: "success", total: 0, statuses: [] }; }, }; } + +function readyProvider({ head, base }) { + const provider = mergedProvider({ head, base }); + return { + ...provider, + async changeRequest() { + return { ...await provider.changeRequest(), state: "open", mergeable: true }; + }, + }; +} diff --git a/tests/review-cycle.test.mjs b/tests/review-cycle.test.mjs index 41a002b..e5bb295 100644 --- a/tests/review-cycle.test.mjs +++ b/tests/review-cycle.test.mjs @@ -5,6 +5,7 @@ import test from "node:test"; import { GitJsonLedger } from "../scripts/lib/git-json-ledger.mjs"; import { ReviewCycleManager, + reviewCycleHasReadyEvidence, validateAgentReview, validateReviewCycle, } from "../scripts/lib/review-cycle.mjs"; @@ -124,9 +125,16 @@ test("review cycle persists GitHub and agent feedback through triage and checkpo provider.setHead(fixCommit2); result = await manager.sync({ number: 7, actor: "sync-agent", now: new Date("2026-07-10T20:06:00.000Z") }); assert.equal(result.cycle.status, "ready"); + assert.equal(reviewCycleHasReadyEvidence(result.cycle, fixCommit2), true); assert.equal(result.cycle.fixes.length, 2); assert.equal(validateReviewCycle(result.cycle), result.cycle); + provider.setState("merged"); + result = await manager.sync({ number: 7, actor: "sync-agent", now: new Date("2026-07-10T20:06:30.000Z") }); + assert.equal(result.cycle.status, "merged"); + assert.equal(reviewCycleHasReadyEvidence(result.cycle, fixCommit2), true); + provider.setState("open"); + provider.setDraft(true); result = await manager.sync({ number: 7, actor: "sync-agent", now: new Date("2026-07-10T20:07:00.000Z") }); assert.equal(result.cycle.status, "draft"); @@ -139,7 +147,7 @@ test("review cycle persists GitHub and agent feedback through triage and checkpo tampered.status = "ready"; assert.throws(() => validateReviewCycle(tampered), /digest does not match|status does not match/); const history = await runGit({ args: ["rev-list", "--count", "refs/tabellio/reviews"], cwd: fixture.seed }); - assert.equal(Number(history.stdout.trim()), 9); + assert.equal(Number(history.stdout.trim()), 10); const worktree = await runGit({ args: ["status", "--porcelain=v1"], cwd: fixture.seed }); assert.equal(worktree.stdout, ""); }); @@ -206,17 +214,19 @@ function fakeProvider(fixture) { let headCommit = fixture.featureCommit; let draft = false; let mergeable = true; + let state = "open"; return { setChecks(value) { checkState = value; }, setHead(value) { headCommit = value; }, setDraft(value) { draft = value; }, setMergeable(value) { mergeable = value; }, + setState(value) { state = value; }, async changeRequest() { return { id: "21", number: 7, title: "Agent change", - state: "open", + state, draft, mergeable, source: { branch: "feature", commit: headCommit }, From 72d579611d323b0a1f4c58399271c490e6aa417a Mon Sep 17 00:00:00 2001 From: hudsonaikins-crown Date: Wed, 15 Jul 2026 17:23:54 -0700 Subject: [PATCH 05/12] fix: close release authorization gaps Entire-Checkpoint: 1e7d65448717 --- docs/getting-started.md | 2 +- docs/operations-hardening.md | 4 +-- docs/releases/v0.3.0.md | 2 +- docs/review-loop.md | 2 +- scripts/lib/release-operation.mjs | 11 ++++++++ scripts/lib/release-planner.mjs | 19 +++++++++---- scripts/lib/release-workflow.mjs | 44 ++++++++++++++++++++---------- scripts/lib/review-cycle.mjs | 39 ++++++++++++++++++++------ tests/helpers/platform-fixture.mjs | 23 ++++++++++++++++ tests/preflight.test.mjs | 27 ++---------------- tests/release-workflow.test.mjs | 43 +++++++++++++++++++++++------ tests/review-cycle.test.mjs | 15 +++++++++- 12 files changed, 163 insertions(+), 68 deletions(-) create mode 100644 tests/helpers/platform-fixture.mjs diff --git a/docs/getting-started.md b/docs/getting-started.md index 7ad2de9..e638811 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -100,7 +100,7 @@ node scripts/tabellio-release.mjs execute \ --approval /secure/tabellio-release-approval.json ``` -Execution publishes exact private control refs, the annotated tag, and the GitHub release. Merge stays outside this command because the final squash commit must exist before release approval can bind it. +Planning accepts only the validation manifest named by `tabellio.platform.json`. Execution rechecks the distinct repository identities and current private control visibility, then publishes exact private control refs, the annotated tag, and the GitHub release. Merge stays outside this command because the final squash commit must exist before release approval can bind it. ## Local Validation diff --git a/docs/operations-hardening.md b/docs/operations-hardening.md index 56d60c9..749504b 100644 --- a/docs/operations-hardening.md +++ b/docs/operations-hardening.md @@ -32,9 +32,9 @@ Do not maintain a second merge authority. Independent squash or rebase merges cr Review cycles, validation results, and Entire checkpoints are published together with `git push --atomic`, explicit force-with-lease expectations, and a one-use approval. Publication rejects non-fast-forward updates, divergence, changed local or remote object IDs, expired approvals, and reused approval IDs. Release retries derive a fresh one-use control approval from the still-active release approval after re-verifying the exact repositories and OIDs. -Automatic Entire session pushes to `origin` remain disabled. The approved control-ref transport publishes `refs/heads/entire/checkpoints/v1` with the review and validation refs only to a separately configured private GitHub repository. Planning and execution reject `origin`. +Automatic Entire session pushes to `origin` remain disabled. The approved control-ref transport publishes `refs/heads/entire/checkpoints/v1` with the review and validation refs only to a separately configured private GitHub repository. Planning and execution reject `origin`, reject repository aliases, and recheck private visibility immediately before publication. -Release planning is local and credentialed-read-only: it requires clean merged `main`, runs exact-head validation, requires durable proof that the exact pull-request head reached `ready` before merge, binds both GitHub repository identities, and snapshots the resulting control OIDs. Remote publication requires a separate release approval capped at one hour. Execution re-verifies code and control repositories before every resumed write, writes an atomic local receipt before each phase, and reconciles already-published control refs, tags, and releases during retry. Pull-request merge remains a separate explicit action because an approval cannot safely bind a squash commit that does not exist yet. +Release planning is local and credentialed-read-only: it requires clean merged `main`, runs exact-head validation from the platform-configured manifest, requires durable proof that the exact pull-request head reached `ready` before merge, rejects new terminal feedback or failed checks, binds both GitHub repository identities, and snapshots the resulting control OIDs. Remote publication requires a separate release approval capped at one hour. Execution re-verifies code identity plus control identity and privacy before every resumed write, writes an atomic local receipt before each phase, and reconciles already-published control refs, tags, and releases during retry. Pull-request merge remains a separate explicit action because an approval cannot safely bind a squash commit that does not exist yet. ## Production Checklist diff --git a/docs/releases/v0.3.0.md b/docs/releases/v0.3.0.md index 74998a0..22fa119 100644 --- a/docs/releases/v0.3.0.md +++ b/docs/releases/v0.3.0.md @@ -3,7 +3,7 @@ Tabellio v0.3.0 makes release readiness and publication deterministic. ## Highlights - `tabellio-preflight` catches missing or untrusted Entire hooks before commit or release work begins. -- Release planning binds merged Git state, pre-merge head readiness, exact validation, both GitHub repository identities, private control refs, release notes, and package version into one integrity-protected intent. +- Release planning binds merged Git state, pre-merge head readiness, the platform validation manifest, both distinct GitHub repository identities, private control refs, release notes, and package version into one integrity-protected intent. - One approved post-merge execution publishes exact control refs, an annotated Git tag, and the GitHub release. - Release receipts survive partial failures, re-verify repository targets on every retry, and resume completed publication phases without repeating them. - Isolated repository tests prove the complete plan and publication workflow without touching live GitHub resources. diff --git a/docs/review-loop.md b/docs/review-loop.md index efbab22..899e2bc 100644 --- a/docs/review-loop.md +++ b/docs/review-loop.md @@ -83,7 +83,7 @@ git-spice restacks rewrite commit IDs. Tabellio retains `originalCommit` and rem ## Storage And Transport -Ledger writes create normal Git blobs, trees, and commits without changing the working tree. Concurrent writers use compare-and-swap on `refs/tabellio/reviews`; stale writers fail instead of overwriting newer state. The same implementation works in normal and bare repositories. The latest cycle retains the newest 100 audit events; older versions remain recoverable from the ledger's Git commit history. A `ready` event stores the exact pull-request head commit so release planning can prove readiness existed before the terminal merged sync. +Ledger writes create normal Git blobs, trees, and commits without changing the working tree. Concurrent writers use compare-and-swap on `refs/tabellio/reviews`; stale writers fail instead of overwriting newer state. The same implementation works in normal and bare repositories. The latest cycle retains the newest 100 audit events; older versions remain recoverable from the ledger's Git commit history. A `ready` event stores the exact pull-request head commit so release planning can prove readiness existed before the terminal merged sync. Terminal sync migrates a legacy `ready` cycle into this evidence form, while newly observed feedback or failed checks still block release. To share the ledger, configure a separate private GitHub repository as the control-state remote, for example: diff --git a/scripts/lib/release-operation.mjs b/scripts/lib/release-operation.mjs index 33001cf..2f4f5f9 100644 --- a/scripts/lib/release-operation.mjs +++ b/scripts/lib/release-operation.mjs @@ -50,6 +50,7 @@ export function validateReleaseIntent(value) { contract.string(value.repository.id, "intent.repository.id"); contract.slug(value.repository.owner, "intent.repository.owner"); contract.slug(value.repository.name, "intent.repository.name"); + assertCodeRepositoryIdentity(value.repository); contract.semver(value.version, "intent.version"); contract.equals(value.tag, `v${value.version}`, "intent.tag"); @@ -77,6 +78,7 @@ export function validateReleaseIntent(value) { contract.object(value.control.repository, "intent.control.repository"); contract.exactKeys(value.control.repository, ["id"], "intent.control.repository"); contract.string(value.control.repository.id, "intent.control.repository.id"); + assertSeparateControlRepository(value.repository, value.control.repository); validateControlRefIntent(value.control.intent); contract.equals(value.control.intent.operation, "publish", "intent.control.intent.operation"); contract.equals(value.control.intent.remote, "control", "intent.control.intent.remote"); @@ -116,3 +118,12 @@ export function validateReleaseApproval(value, intent, { now = new Date() } = {} } return approval; } + +function assertCodeRepositoryIdentity(repository) { + const expected = `github.com/${repository.owner}/${repository.name}`; + if (repository.id.toLowerCase() !== expected.toLowerCase()) throw new Error("intent.repository.id must match intent.repository.owner and intent.repository.name."); +} + +function assertSeparateControlRepository(repository, controlRepository) { + if (controlRepository.id.toLowerCase() === repository.id.toLowerCase()) throw new Error("intent.control.repository.id must differ from intent.repository.id."); +} diff --git a/scripts/lib/release-planner.mjs b/scripts/lib/release-planner.mjs index 0f4a610..2e95a52 100644 --- a/scripts/lib/release-planner.mjs +++ b/scripts/lib/release-planner.mjs @@ -8,8 +8,9 @@ import { GitJsonLedger } from "./git-json-ledger.mjs"; import { parseGitHubRepositoryRemote, sameGitHubRepository } from "./github-repository.mjs"; import { runGit } from "./git-process.mjs"; import { runPreflight } from "./preflight.mjs"; +import { validatePlatformConfig } from "./platform-config.mjs"; import { createReleaseIntent } from "./release-operation.mjs"; -import { ReviewCycleManager, reviewCycleHasReadyEvidence } from "./review-cycle.mjs"; +import { ReviewCycleManager, reviewCycleHasReleaseReadiness } from "./review-cycle.mjs"; import { ValidationRunner } from "./validation-runner.mjs"; import { GitHubProvider } from "../providers/github-provider.mjs"; import { NativeGitStore } from "../providers/native-git-store.mjs"; @@ -34,10 +35,11 @@ export async function planRelease({ githubProvider = null, now = new Date(), } = {}) { - validatePlanInput({ owner, repo, number, version, notesPath, controlRemote }); + validatePlanInput({ owner, repo, number, version, notesPath, controlRemote, manifestPath }); const store = await NativeGitStore.open(resolve(repoPath)); const preflight = await preflightRunner({ repoPath: store.repoPath, profile: "release", ghBinary, commandRunner, controlRemote }); assertReadyPreflight(preflight); + await assertPlatformManifest(store, manifestPath); const repositories = await bindReleaseRepositories(store, { owner, repo, explicitRepositoryId, controlRemote }); const repositoryId = repositories.code.identity; const evidence = await loadReleaseEvidence({ store, notesPath, ghBinary, owner, repo, number, commandRunner }); @@ -65,15 +67,22 @@ export async function planRelease({ }); } -function validatePlanInput({ owner, repo, number, version, notesPath, controlRemote }) { +function validatePlanInput({ owner, repo, number, version, notesPath, controlRemote, manifestPath }) { contract.string(owner, "owner"); contract.string(repo, "repo"); contract.positiveInteger(number, "number"); contract.semver(version, "version"); contract.safeRelativePath(notesPath, "notesPath"); + contract.safeRelativePath(manifestPath, "manifestPath"); contract.equals(controlRemote, "control", "controlRemote"); } +async function assertPlatformManifest(store, manifestPath) { + const source = await runGit({ args: ["show", "HEAD:tabellio.platform.json"], cwd: store.repoPath }); + const platform = validatePlatformConfig(JSON.parse(source.stdout)); + contract.equals(manifestPath, platform.validation.manifest, "manifestPath"); +} + async function bindReleaseRepositories(store, { owner, repo, explicitRepositoryId, controlRemote }) { const [originUrl, controlUrl] = await Promise.all([ store.gitConfig("remote.origin.url"), @@ -173,8 +182,8 @@ async function assertMergedReview({ store, validationLedger, provider, repositor }); const review = await manager.sync({ number, actor: runnerId, now }); if (review.cycle.status !== "merged") throw new Error(`Review cycle ${number} is ${review.cycle.status}, not merged.`); - if (!reviewCycleHasReadyEvidence(review.cycle, review.cycle.changeRequest.headCommit)) { - throw new Error(`Review cycle ${number} has no ready evidence for the merged pull-request head.`); + if (!reviewCycleHasReleaseReadiness(review.cycle, review.cycle.changeRequest.headCommit)) { + throw new Error(`Review cycle ${number} is not release-ready for the merged pull-request head.`); } } diff --git a/scripts/lib/release-workflow.mjs b/scripts/lib/release-workflow.mjs index 001a33a..418c02f 100644 --- a/scripts/lib/release-workflow.mjs +++ b/scripts/lib/release-workflow.mjs @@ -31,12 +31,13 @@ export class ReleaseExecutor { ghBinary = "gh", commandRunner = runExternalCommand, remoteRefReader = readRemoteRefOid, - remoteRepositoryReader = githubRemoteRepositoryIdentity, + codeRepositoryReader = repositoryIdentity, + controlRepositoryReader = privateGitHubRemoteRepository, } = {}) { const store = await NativeGitStore.open(resolve(repoPath)); const common = (await runGit({ args: ["rev-parse", "--git-common-dir"], cwd: store.repoPath })).stdout.trim(); const root = stateRoot ? resolve(stateRoot) : resolve(store.repoPath, common, "tabellio", "release-operations"); - const actions = new ReleaseActions({ store, ghBinary, commandRunner, remoteRefReader, remoteRepositoryReader }); + const actions = new ReleaseActions({ store, ghBinary, commandRunner, remoteRefReader, codeRepositoryReader, controlRepositoryReader }); return new ReleaseExecutor({ repoPath: store.repoPath, stateRoot: root, actions }); } @@ -76,13 +77,15 @@ class ReleaseActions { ghBinary = "gh", commandRunner = runExternalCommand, remoteRefReader = readRemoteRefOid, - remoteRepositoryReader = githubRemoteRepositoryIdentity, + codeRepositoryReader = repositoryIdentity, + controlRepositoryReader = privateGitHubRemoteRepository, }) { this.store = store; this.ghBinary = ghBinary; this.commandRunner = commandRunner; this.remoteRefReader = remoteRefReader; - this.remoteRepositoryReader = remoteRepositoryReader; + this.codeRepositoryReader = codeRepositoryReader; + this.controlRepositoryReader = controlRepositoryReader; } async run(phase, context) { @@ -97,9 +100,9 @@ class ReleaseActions { } async #verify({ intent }) { - const [repositoryId, controlRepositoryId, head, trackedMain, packageResult, notesSource, refs] = await Promise.all([ - repositoryIdentity(this.store), - this.remoteRepositoryReader(this.store, intent.control.intent.remote), + const [repositoryId, controlRepository, head, trackedMain, packageResult, notesSource, refs] = await Promise.all([ + this.codeRepositoryReader(this.store), + this.controlRepositoryReader(this.store, intent.control.intent.remote, this.ghBinary, this.commandRunner), this.store.resolveRef("HEAD"), this.store.resolveRef("origin/main"), runGit({ args: ["show", `${intent.revision.commit}:package.json`], cwd: this.store.repoPath }), @@ -113,14 +116,14 @@ class ReleaseActions { const liveMain = await this.remoteRefReader({ repoPath: this.store.repoPath, remote: "origin", ref: "refs/heads/main" }); const status = await runGit({ args: ["status", "--porcelain=v1"], cwd: this.store.repoPath }); const branch = await runGit({ args: ["branch", "--show-current"], cwd: this.store.repoPath }); - assertReleaseRepository({ repositoryId, controlRepositoryId, head, trackedMain, liveMain, branch: branch.stdout, status: status.stdout }, intent); + assertReleaseRepository({ repositoryId, controlRepository, head, trackedMain, liveMain, branch: branch.stdout, status: status.stdout }, intent); assertReleaseArtifacts({ packageSource: packageResult.stdout, notesSource, refs }, intent); return { commit: head, version: intent.version, controlIntentDigest: intent.control.intent.integrity.digest }; } async #publishControl({ intent, approval, now }) { - const controlRepositoryId = await this.remoteRepositoryReader(this.store, intent.control.intent.remote); - assertEqual(controlRepositoryId.toLowerCase(), intent.control.repository.id.toLowerCase(), "Release control repository identity changed."); + const controlRepository = await this.controlRepositoryReader(this.store, intent.control.intent.remote, this.ghBinary, this.commandRunner); + assertControlRepository(controlRepository, intent); const snapshot = await snapshotControlRefs({ repoPath: this.store.repoPath, remote: intent.control.intent.remote, @@ -151,7 +154,7 @@ class ReleaseActions { } async #publishTag({ intent }) { - const repositoryId = await repositoryIdentity(this.store); + const repositoryId = await this.codeRepositoryReader(this.store); assertEqual(repositoryId, intent.repository.id, "Release repository identity changed before tag publication."); let local = await localTagState(this.store.repoPath, intent.tag); const remote = await remoteTagState(this.store.repoPath, intent.code.remote, intent.tag); @@ -244,7 +247,7 @@ function assertReleaseRepository(actual, intent) { function assertReleaseRevision(actual, intent) { assertEqual(actual.repositoryId, intent.repository.id, "Release repository identity changed."); - assertEqual(actual.controlRepositoryId.toLowerCase(), intent.control.repository.id.toLowerCase(), "Release control repository identity changed."); + assertControlRepository(actual.controlRepository, intent); assertEqual(actual.head, intent.revision.commit, "Release HEAD changed after planning."); assertEqual(actual.trackedMain, actual.head, "Release commit no longer equals tracked origin/main."); assertEqual(actual.liveMain, actual.head, "Release commit no longer equals live origin/main."); @@ -390,10 +393,23 @@ async function sourceAtCommit(repoPath, commit, path) { return (await runGit({ args: ["show", `${commit}:${path}`], cwd: repoPath })).stdout; } -async function githubRemoteRepositoryIdentity(store, remote) { +async function privateGitHubRemoteRepository(store, remote, ghBinary, commandRunner) { const repository = parseGitHubRepositoryRemote(await store.gitConfig(`remote.${remote}.url`)); if (!repository) throw new Error(`Release ${remote} remote is not a supported GitHub repository.`); - return repository.identity; + const result = await commandRunner({ + binary: ghBinary, + args: ["repo", "view", repository.fullName, "--json", "nameWithOwner,isPrivate"], + cwd: store.repoPath, + timeoutMs: 30_000, + }); + const view = JSON.parse(result.stdout); + if (String(view.nameWithOwner).toLowerCase() !== repository.key) throw new Error("GitHub returned a different control repository identity."); + return { id: repository.identity, isPrivate: view.isPrivate === true }; +} + +function assertControlRepository(actual, intent) { + assertEqual(actual.id.toLowerCase(), intent.control.repository.id.toLowerCase(), "Release control repository identity changed."); + if (!actual.isPrivate) throw new Error("Release control repository is not private."); } async function withTemporaryReleaseNotes(source, action) { diff --git a/scripts/lib/review-cycle.mjs b/scripts/lib/review-cycle.mjs index 90258cc..abce2f5 100644 --- a/scripts/lib/review-cycle.mjs +++ b/scripts/lib/review-cycle.mjs @@ -44,6 +44,7 @@ export class ReviewCycleManager { const record = await this.#read(number); const existing = record.value; if (existing) validateReviewCycle(existing); + const priorEvents = eventsWithLegacyReadiness(existing); const timestamp = now.toISOString(); const feedback = mergeProviderFeedback(existing?.feedback ?? [], [ ...reviews.map((value) => reviewFeedback(value, timestamp)), @@ -81,7 +82,7 @@ export class ReviewCycleManager { feedback, fixes, checks, - events: appendEvent(existing?.events ?? [], event("synced", actor, timestamp, `Synced ${feedback.length} feedback items at ${changeRequest.source.commit}.`)), + events: appendEvent(priorEvents, event("synced", actor, timestamp, `Synced ${feedback.length} feedback items at ${changeRequest.source.commit}.`)), createdAt: existing?.createdAt ?? timestamp, updatedAt: timestamp, integrity: { algorithm: "sha256", digest: "0".repeat(64) }, @@ -263,6 +264,10 @@ export function reviewCycleHasReadyEvidence(cycle, headCommit) { && cycle.events.some((item) => item.type === "ready" && item.detail === headCommit); } +export function reviewCycleHasReleaseReadiness(cycle, headCommit) { + return reviewCycleHasReadyEvidence(cycle, headCommit) && readinessStatus(cycle) === "ready"; +} + export function validateReviewCycle(value) { object(value, "review cycle"); exactKeys(value, ["schemaVersion", "id", "repository", "provider", "changeRequest", "status", "round", "feedback", "fixes", "checks", "events", "createdAt", "updatedAt", "integrity"], "review cycle"); @@ -483,15 +488,24 @@ function feedback({ function deriveStatus(cycle) { if (cycle.changeRequest.state === "merged") return "merged"; if (cycle.changeRequest.state === "closed") return "closed"; - if (cycle.changeRequest.draft) return "draft"; - if (cycle.changeRequest.mergeable === false) return "blocked"; - if (["error", "failure", "failed"].includes(cycle.checks.state)) return "blocked"; + return readinessStatus(cycle); +} + +function readinessStatus(cycle) { const live = cycle.feedback.filter((item) => item.providerState !== "stale"); - if (live.some((item) => item.disposition === "pending")) return "needs_triage"; - if (live.some((item) => item.disposition === "actionable" && item.resolution === "open")) return "changes_requested"; - if (cycle.fixes.some((fix) => fix.published !== true)) return "update_required"; - if (["none", "pending", "running"].includes(cycle.checks.state)) return "validating"; - return "ready"; + const matchingStatus = [ + { active: cycle.changeRequest.draft, status: "draft" }, + { active: cycle.changeRequest.mergeable === false, status: "blocked" }, + { active: ["error", "failure", "failed"].includes(cycle.checks.state), status: "blocked" }, + { active: live.some((item) => item.disposition === "pending"), status: "needs_triage" }, + { + active: live.some((item) => item.disposition === "actionable" && item.resolution === "open"), + status: "changes_requested", + }, + { active: cycle.fixes.some((fix) => fix.published !== true), status: "update_required" }, + { active: ["none", "pending", "running"].includes(cycle.checks.state), status: "validating" }, + ].find(({ active }) => active); + return matchingStatus ? matchingStatus.status : "ready"; } function cycleDigest(value) { @@ -539,6 +553,13 @@ function appendEvent(events, next) { return [...events, next].slice(-100); } +function eventsWithLegacyReadiness(existing) { + if (!existing) return []; + if (existing.status !== "ready") return existing.events; + if (reviewCycleHasReadyEvidence(existing, existing.changeRequest.headCommit)) return existing.events; + return appendEvent(existing.events, event("ready", readinessActor(existing.events), existing.updatedAt, existing.changeRequest.headCommit)); +} + function recordReadyEvidence(cycle, status) { if (status !== "ready") return; if (reviewCycleHasReadyEvidence(cycle, cycle.changeRequest.headCommit)) return; diff --git a/tests/helpers/platform-fixture.mjs b/tests/helpers/platform-fixture.mjs new file mode 100644 index 0000000..206d729 --- /dev/null +++ b/tests/helpers/platform-fixture.mjs @@ -0,0 +1,23 @@ +export function platformFixture() { + return { + schemaVersion: "tabellio-platform/v0.3", + codeStorage: { + provider: "github", + remoteName: "origin", + publicSurface: "code-and-thin-pr", + codeRef: "refs/heads/main", + allowedRefPrefixes: ["refs/heads/", "refs/tags/"], + }, + workflow: { + stackManager: "git-spice", + controlState: "external", + controlProvider: "github", + controlRemoteName: "control", + controlRefs: ["refs/tabellio/reviews", "refs/tabellio/validations", "refs/heads/entire/checkpoints/v1"], + publishControlRefsToCodeStorage: false, + }, + ledger: { provider: "entire", storage: "external", checkpointRef: "refs/heads/entire/checkpoints/v1" }, + validation: { runner: "tabellio-validate", manifest: "tabellio.validation.json", storage: "external", resultRef: "refs/tabellio/validations" }, + reviews: { provider: "tabellio", storage: "external", stateRef: "refs/tabellio/reviews" }, + }; +} diff --git a/tests/preflight.test.mjs b/tests/preflight.test.mjs index ee601fd..3bd5313 100644 --- a/tests/preflight.test.mjs +++ b/tests/preflight.test.mjs @@ -6,6 +6,7 @@ import test from "node:test"; import { runGit } from "../scripts/lib/git-process.mjs"; import { runPreflight, validatePreflightResult } from "../scripts/lib/preflight.mjs"; import { createFixture, identityEnv } from "./helpers/git-fixture.mjs"; +import { platformFixture } from "./helpers/platform-fixture.mjs"; test("preflight proves GitHub and Entire readiness without exposing credentials", async (t) => { const fixture = await preparedFixture(t); @@ -103,7 +104,7 @@ async function preparedFixture(t) { await runGit({ args: ["remote", "add", "control", "git@github.com:example/repository-control.git"], cwd: fixture.seed }); await mkdir(join(fixture.seed, ".codex"), { recursive: true }); await writeEntireHooks(fixture.seed, (command) => `entire hooks codex ${command}`); - await writeFile(join(fixture.seed, "tabellio.platform.json"), JSON.stringify(platform())); + await writeFile(join(fixture.seed, "tabellio.platform.json"), JSON.stringify(platformFixture())); return fixture; } @@ -139,27 +140,3 @@ function fakeCommands({ trusted, privateControl = true }) { function result(stdout, stderr = "") { return { stdout, stderr, exitCode: 0, signal: null }; } - -function platform() { - return { - schemaVersion: "tabellio-platform/v0.3", - codeStorage: { - provider: "github", - remoteName: "origin", - publicSurface: "code-and-thin-pr", - codeRef: "refs/heads/main", - allowedRefPrefixes: ["refs/heads/", "refs/tags/"], - }, - workflow: { - stackManager: "git-spice", - controlState: "external", - controlProvider: "github", - controlRemoteName: "control", - controlRefs: ["refs/tabellio/reviews", "refs/tabellio/validations", "refs/heads/entire/checkpoints/v1"], - publishControlRefsToCodeStorage: false, - }, - ledger: { provider: "entire", storage: "external", checkpointRef: "refs/heads/entire/checkpoints/v1" }, - validation: { runner: "tabellio-validate", manifest: "tabellio.validation.json", storage: "external", resultRef: "refs/tabellio/validations" }, - reviews: { provider: "tabellio", storage: "external", stateRef: "refs/tabellio/reviews" }, - }; -} diff --git a/tests/release-workflow.test.mjs b/tests/release-workflow.test.mjs index f12626b..cf5d246 100644 --- a/tests/release-workflow.test.mjs +++ b/tests/release-workflow.test.mjs @@ -10,11 +10,11 @@ import { GitJsonLedger } from "../scripts/lib/git-json-ledger.mjs"; import { runGit } from "../scripts/lib/git-process.mjs"; import { createReleaseIntent, validateReleaseApproval, validateReleaseIntent } from "../scripts/lib/release-operation.mjs"; import { planRelease } from "../scripts/lib/release-planner.mjs"; -import { repositoryIdentity } from "../scripts/lib/repository-identity.mjs"; import { ReleaseExecutor } from "../scripts/lib/release-workflow.mjs"; import { ReviewCycleManager } from "../scripts/lib/review-cycle.mjs"; import { NativeGitStore } from "../scripts/providers/native-git-store.mjs"; import { createFixture, identityEnv } from "./helpers/git-fixture.mjs"; +import { platformFixture } from "./helpers/platform-fixture.mjs"; const createdAt = "2026-07-15T12:00:00.000Z"; const approvedAt = "2026-07-15T12:01:00.000Z"; @@ -32,6 +32,8 @@ test("release intent binds merged commit, validation, control refs, notes, and a const longApproval = { ...approval, expiresAt: "2026-07-15T13:01:00.001Z" }; assert.throws(() => validateReleaseApproval(longApproval, intent, { now }), /must not exceed one hour/); assert.throws(() => exampleIntent({ releaseCreatedAt: "1" }), /ISO date-time/); + assert.throws(() => exampleIntent({ owner: "different" }), /must match/); + assert.throws(() => exampleIntent({ controlRepositoryId: "github.com/example/repository" }), /must differ/); }); test("release executor resumes failed phases without repeating completed work", async (t) => { @@ -107,7 +109,7 @@ test("approved release publishes exact control ref, annotated tag, and GitHub re }); await runGit({ args: ["push", "origin", "main"], cwd: fixture.seed }); const store = await NativeGitStore.open(fixture.seed); - const repositoryId = await repositoryIdentity(store); + const repositoryId = "github.com/example/repository"; const head = await store.resolveRef("HEAD"); const parent = await store.resolveRef("HEAD^"); const ledger = await GitJsonLedger.open({ repoPath: fixture.seed, ref: "refs/tabellio/validations" }); @@ -146,12 +148,14 @@ test("approved release publishes exact control ref, annotated tag, and GitHub re const stateRoot = join(fixture.root, "release-state"); let liveReads = 0; let currentControlIdentity = intent.control.repository.id; + let currentControlPrivate = true; const executor = await ReleaseExecutor.open({ repoPath: fixture.seed, stateRoot, commandRunner, remoteRefReader: async () => { liveReads += 1; return head; }, - remoteRepositoryReader: async () => currentControlIdentity, + codeRepositoryReader: async () => repositoryId, + controlRepositoryReader: async () => ({ id: currentControlIdentity, isPrivate: currentControlPrivate }), }); const approval = approvalFor(intent, "publish-release"); await runGit({ args: ["tag", intent.tag, head], cwd: fixture.seed }); @@ -165,6 +169,9 @@ test("approved release publishes exact control ref, annotated tag, and GitHub re currentControlIdentity = "github.com/example/retargeted-control"; await assert.rejects(executor.execute({ intent, approval, now }), /control repository identity changed/); currentControlIdentity = intent.control.repository.id; + currentControlPrivate = false; + await assert.rejects(executor.execute({ intent, approval, now }), /control repository is not private/); + currentControlPrivate = true; await assert.rejects(executor.execute({ intent, approval, now }), /remotely as a lightweight tag/); await runGit({ args: ["push", "origin", `:refs/tags/${intent.tag}`], cwd: fixture.seed }); await writeFile(join(fixture.seed, notesPath), "MUTATED WORKTREE NOTES\n"); @@ -172,7 +179,7 @@ test("approved release publishes exact control ref, annotated tag, and GitHub re await runGit({ args: ["restore", notesPath], cwd: fixture.seed }); const receipt = await executor.execute({ intent, approval, now }); assert.equal(receipt.status, "succeeded"); - assert.equal(liveReads, 5); + assert.equal(liveReads, 6); assert.equal(ghCalls.length, 2); assert.deepEqual(publishedNotes, ["Release 1.2.3\n"]); const remoteTag = await runGit({ args: ["ls-remote", "--tags", "origin", "refs/tags/v1.2.3^{}"], cwd: fixture.seed }); @@ -214,7 +221,8 @@ test("release planning binds merged PR proof after exact validation and terminal requireEntireCheckpoint: true, commands: [{ id: "pass", argv: [process.execPath, "-e", "process.exit(0)"], cwd: ".", timeoutMs: 10_000, required: true }], })); - await runGit({ args: ["add", "package.json", "CHANGELOG.md", "tabellio.validation.json", notesPath], cwd: fixture.seed }); + await writeFile(join(fixture.seed, "tabellio.platform.json"), JSON.stringify(platformFixture())); + await runGit({ args: ["add", "package.json", "CHANGELOG.md", "tabellio.validation.json", "tabellio.platform.json", notesPath], cwd: fixture.seed }); await runGit({ args: ["commit", "-m", "Merge release", "-m", "Entire-Checkpoint: abcdef123456"], cwd: fixture.seed, @@ -245,6 +253,17 @@ test("release planning binds merged PR proof after exact validation and terminal } throw new Error(`Unexpected command: ${args.join(" ")}`); }; + await assert.rejects(planRelease({ + repoPath: fixture.seed, + owner: "example", + repo: "repository", + number: 9, + version: "2.0.0", + notesPath, + manifestPath: "alternate.validation.json", + preflightRunner: async () => ({ status: "ready", checks: [] }), + now, + }), /manifestPath must be "tabellio.validation.json"/); await assert.rejects(planRelease({ repoPath: fixture.seed, owner: "example", @@ -292,21 +311,27 @@ test("release receipt schema fixes every phase to its canonical position", async assert.equal(schema.$defs.githubReleasePhase.allOf[1].properties.id.const, "github-release"); }); -function exampleIntent({ releaseCreatedAt = createdAt } = {}) { +function exampleIntent({ + releaseCreatedAt = createdAt, + repositoryId = "github.com/example/repository", + owner = "example", + repoName = "repository", + controlRepositoryId = "github.com/example/repository-control", +} = {}) { const controlIntent = createControlRefIntent({ operation: "publish", - repositoryId: "github.com/example/repository", + repositoryId, remote: "control", refs: [{ name: "refs/tabellio/validations", localOid: "c".repeat(40), remoteOid: "b".repeat(40) }], createdAt, }); return createReleaseIntent({ - repository: { id: "github.com/example/repository", owner: "example", name: "repository" }, + repository: { id: repositoryId, owner, name: repoName }, version: "1.2.3", revision: { commit: "d".repeat(40), parent: "a".repeat(40) }, pullRequest: { number: 7, headCommit: "c".repeat(40), mergeCommit: "d".repeat(40) }, controlIntent, - controlRepository: { id: "github.com/example/repository-control" }, + controlRepository: { id: controlRepositoryId }, validation: { runId: "validation-example", resultVersion: "e".repeat(40), status: "passed", headCommit: "d".repeat(40) }, release: { title: "Tabellio v1.2.3", notesPath: "docs/releases/v1.2.3.md", notesDigest: digest("notes") }, createdAt: releaseCreatedAt, diff --git a/tests/review-cycle.test.mjs b/tests/review-cycle.test.mjs index e5bb295..4feb4c0 100644 --- a/tests/review-cycle.test.mjs +++ b/tests/review-cycle.test.mjs @@ -6,6 +6,7 @@ import { GitJsonLedger } from "../scripts/lib/git-json-ledger.mjs"; import { ReviewCycleManager, reviewCycleHasReadyEvidence, + reviewCycleHasReleaseReadiness, validateAgentReview, validateReviewCycle, } from "../scripts/lib/review-cycle.mjs"; @@ -129,10 +130,22 @@ test("review cycle persists GitHub and agent feedback through triage and checkpo assert.equal(result.cycle.fixes.length, 2); assert.equal(validateReviewCycle(result.cycle), result.cycle); + const legacyReady = structuredClone(result.cycle); + legacyReady.events = legacyReady.events.filter((item) => item.type !== "ready"); + const { integrity: _integrity, ...legacyUnsigned } = legacyReady; + legacyReady.integrity = { algorithm: "sha256", digest: digestObject(legacyUnsigned) }; + await ledger.write(result.path, legacyReady, { expectedVersion: result.version }); provider.setState("merged"); result = await manager.sync({ number: 7, actor: "sync-agent", now: new Date("2026-07-10T20:06:30.000Z") }); assert.equal(result.cycle.status, "merged"); assert.equal(reviewCycleHasReadyEvidence(result.cycle, fixCommit2), true); + assert.equal(reviewCycleHasReleaseReadiness(result.cycle, fixCommit2), true); + + provider.setChecks("failure"); + result = await manager.sync({ number: 7, actor: "sync-agent", now: new Date("2026-07-10T20:06:45.000Z") }); + assert.equal(result.cycle.status, "merged"); + assert.equal(reviewCycleHasReleaseReadiness(result.cycle, fixCommit2), false); + provider.setChecks("success"); provider.setState("open"); provider.setDraft(true); @@ -147,7 +160,7 @@ test("review cycle persists GitHub and agent feedback through triage and checkpo tampered.status = "ready"; assert.throws(() => validateReviewCycle(tampered), /digest does not match|status does not match/); const history = await runGit({ args: ["rev-list", "--count", "refs/tabellio/reviews"], cwd: fixture.seed }); - assert.equal(Number(history.stdout.trim()), 10); + assert.equal(Number(history.stdout.trim()), 12); const worktree = await runGit({ args: ["status", "--porcelain=v1"], cwd: fixture.seed }); assert.equal(worktree.stdout, ""); }); From 29248aabd3704b87dcd2c3883a52ac1e032893c0 Mon Sep 17 00:00:00 2001 From: hudsonaikins-crown Date: Wed, 15 Jul 2026 17:45:01 -0700 Subject: [PATCH 06/12] fix: reconcile squash release evidence Entire-Checkpoint: 1e7d65448717 --- docs/getting-started.md | 2 +- docs/operations-hardening.md | 2 +- docs/releases/v0.3.0.md | 4 +- .../tabellio-validation/minimal-result.json | 7 ++- schemas/validation-result.schema.json | 10 ++++ scripts/lib/github-repository.mjs | 35 ++++++++--- scripts/lib/release-planner.mjs | 34 ++++++++++- scripts/lib/release-workflow.mjs | 17 ++++-- scripts/lib/validation-runner.mjs | 59 ++++++++++++------- scripts/tabellio-release.mjs | 4 +- tests/preflight.test.mjs | 4 ++ tests/release-workflow.test.mjs | 42 ++++++++----- 12 files changed, 163 insertions(+), 57 deletions(-) diff --git a/docs/getting-started.md b/docs/getting-started.md index e638811..22fbc44 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -100,7 +100,7 @@ node scripts/tabellio-release.mjs execute \ --approval /secure/tabellio-release-approval.json ``` -Planning accepts only the validation manifest named by `tabellio.platform.json`. Execution rechecks the distinct repository identities and current private control visibility, then publishes exact private control refs, the annotated tag, and the GitHub release. Merge stays outside this command because the final squash commit must exist before release approval can bind it. +Planning accepts only the validation manifest named by `tabellio.platform.json`. It runs commands on the exact merged commit and binds checkpoint evidence to the pre-merge pull-request head, so a squash merge does not erase proof. Execution accepts canonical HTTPS, SCP-style SSH, and `ssh://git@github.com/...` remotes, rechecks the distinct repository identities and current private control visibility, then publishes exact private control refs, the annotated tag, and the GitHub release. Merge stays outside this command because the final squash commit must exist before release approval can bind it. ## Local Validation diff --git a/docs/operations-hardening.md b/docs/operations-hardening.md index 749504b..93593bd 100644 --- a/docs/operations-hardening.md +++ b/docs/operations-hardening.md @@ -34,7 +34,7 @@ Review cycles, validation results, and Entire checkpoints are published together Automatic Entire session pushes to `origin` remain disabled. The approved control-ref transport publishes `refs/heads/entire/checkpoints/v1` with the review and validation refs only to a separately configured private GitHub repository. Planning and execution reject `origin`, reject repository aliases, and recheck private visibility immediately before publication. -Release planning is local and credentialed-read-only: it requires clean merged `main`, runs exact-head validation from the platform-configured manifest, requires durable proof that the exact pull-request head reached `ready` before merge, rejects new terminal feedback or failed checks, binds both GitHub repository identities, and snapshots the resulting control OIDs. Remote publication requires a separate release approval capped at one hour. Execution re-verifies code identity plus control identity and privacy before every resumed write, writes an atomic local receipt before each phase, and reconciles already-published control refs, tags, and releases during retry. Pull-request merge remains a separate explicit action because an approval cannot safely bind a squash commit that does not exist yet. +Release planning is local and credentialed-read-only: it requires clean merged `main`, runs exact-head commands from the platform-configured manifest, binds checkpoint evidence to the exact pre-merge pull-request head, requires durable proof that that head reached `ready`, rejects new terminal feedback or failed checks, binds both GitHub repository identities, and snapshots the resulting control OIDs. Remote publication requires a separate release approval capped at one hour. Execution re-verifies case-normalized code identity plus control identity and privacy before every resumed write, writes an atomic local receipt before each phase, and reruns idempotent reconciliation for control refs, tags, and releases during retry. Pull-request merge remains a separate explicit action because an approval cannot safely bind a squash commit that does not exist yet. ## Production Checklist diff --git a/docs/releases/v0.3.0.md b/docs/releases/v0.3.0.md index 22fa119..a5da154 100644 --- a/docs/releases/v0.3.0.md +++ b/docs/releases/v0.3.0.md @@ -5,7 +5,9 @@ Tabellio v0.3.0 makes release readiness and publication deterministic. - `tabellio-preflight` catches missing or untrusted Entire hooks before commit or release work begins. - Release planning binds merged Git state, pre-merge head readiness, the platform validation manifest, both distinct GitHub repository identities, private control refs, release notes, and package version into one integrity-protected intent. - One approved post-merge execution publishes exact control refs, an annotated Git tag, and the GitHub release. -- Release receipts survive partial failures, re-verify repository targets on every retry, and resume completed publication phases without repeating them. +- Release receipts survive partial failures, re-verify repository targets on every retry, and rerun idempotent publication reconciliation so rolled-back remote state cannot be skipped. - Isolated repository tests prove the complete plan and publication workflow without touching live GitHub resources. Merge remains an explicit operator action. This prevents a release approval from authorizing an unknown squash commit. + +For squash merges, validation commands run against the exact merged commit while checkpoint evidence remains bound to the separately recorded pull-request head. The CLI returns the schema-valid receipt and its local `receiptPath` as separate fields. diff --git a/examples/tabellio-validation/minimal-result.json b/examples/tabellio-validation/minimal-result.json index c9019ca..9f489ce 100644 --- a/examples/tabellio-validation/minimal-result.json +++ b/examples/tabellio-validation/minimal-result.json @@ -7,6 +7,11 @@ "mergeBase": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "headCommit": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" }, + "checkpointRevision": { + "baseCommit": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "mergeBase": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "headCommit": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, "suite": { "id": "tabellio-default", "manifestPath": "tabellio.validation.json", @@ -44,6 +49,6 @@ "completedAt": "2026-07-10T12:00:01.000Z", "integrity": { "algorithm": "sha256", - "digest": "e0bf535d574ca275855e3fee7cccc4512785eaa9f4041975459d898145a4338f" + "digest": "caf2abe98c754e85c4be89c702dc13511d08006db8827ac67c8d6b79c3e03d2a" } } diff --git a/schemas/validation-result.schema.json b/schemas/validation-result.schema.json index 2ff80d7..5d379dd 100644 --- a/schemas/validation-result.schema.json +++ b/schemas/validation-result.schema.json @@ -24,6 +24,16 @@ "headCommit": { "$ref": "#/$defs/oid" } } }, + "checkpointRevision": { + "type": "object", + "required": ["baseCommit", "mergeBase", "headCommit"], + "additionalProperties": false, + "properties": { + "baseCommit": { "$ref": "#/$defs/oid" }, + "mergeBase": { "$ref": "#/$defs/oid" }, + "headCommit": { "$ref": "#/$defs/oid" } + } + }, "suite": { "type": "object", "required": ["id", "manifestPath", "manifestDigest"], diff --git a/scripts/lib/github-repository.mjs b/scripts/lib/github-repository.mjs index 2f58724..a0c89a9 100644 --- a/scripts/lib/github-repository.mjs +++ b/scripts/lib/github-repository.mjs @@ -38,18 +38,15 @@ export async function readRemoteRefOid({ repoPath, remote, ref }) { function repositoryRemoteParts(value) { const https = httpsParts(value); - return https || sshParts(value); + return https || sshUrlParts(value) || sshParts(value); } function httpsParts(value) { - try { - const parsed = new URL(value); - if (parsed.origin.toLowerCase() !== `https://${GITHUB_HOST}`) return null; - if (`${parsed.username}${parsed.password}${parsed.search}${parsed.hash}` !== "") return null; - return repositoryPathParts(parsed.pathname); - } catch { - return null; - } + const parsed = parseUrl(value); + if (!parsed) return null; + if (parsed.origin.toLowerCase() !== `https://${GITHUB_HOST}`) return null; + if (`${parsed.username}${parsed.password}${parsed.search}${parsed.hash}` !== "") return null; + return repositoryPathParts(parsed.pathname); } function sshParts(value) { @@ -57,6 +54,26 @@ function sshParts(value) { return match ? repositoryPathParts(match[1]) : null; } +function sshUrlParts(value) { + const parsed = parseUrl(value); + if (!parsed) return null; + const valid = [ + parsed.protocol === "ssh:", + parsed.hostname.toLowerCase() === GITHUB_HOST, + parsed.username === "git", + `${parsed.password}${parsed.port}${parsed.search}${parsed.hash}` === "", + ].every(Boolean); + return valid ? repositoryPathParts(parsed.pathname) : null; +} + +function parseUrl(value) { + try { + return new URL(value); + } catch { + return null; + } +} + function repositoryPathParts(value) { const parts = value.replace(/^\/+|\/+$/g, "").split("/"); return parts.length === 2 ? parts : null; diff --git a/scripts/lib/release-planner.mjs b/scripts/lib/release-planner.mjs index 2e95a52..520712d 100644 --- a/scripts/lib/release-planner.mjs +++ b/scripts/lib/release-planner.mjs @@ -44,8 +44,19 @@ export async function planRelease({ const repositoryId = repositories.code.identity; const evidence = await loadReleaseEvidence({ store, notesPath, ghBinary, owner, repo, number, commandRunner }); const { headCommit, parentCommit, notesSource, pr } = validateReleaseEvidence(evidence, { version, number }); + const pullRequestHead = await resolvePullRequestHead(store, number, pr.headRefOid); const validationLedger = await GitJsonLedger.open({ repoPath: store.repoPath, ref: "refs/tabellio/validations" }); - const validation = await validateMergedHead({ store, validationLedger, repositoryId, headCommit, parentCommit, manifestPath, runnerId, now }); + const validation = await validateMergedHead({ + store, + validationLedger, + repositoryId, + headCommit, + parentCommit, + pullRequestHead, + manifestPath, + runnerId, + now, + }); const provider = await resolveGitHubProvider({ githubProvider, apiUrl, token, ghBinary, commandRunner, cwd: store.repoPath }); await assertMergedReview({ store, validationLedger, provider, repositoryId, owner, repo, number, runnerId, now }); const controlIntent = await planControlPublication({ store, repositoryId, controlRemote, now }); @@ -147,14 +158,33 @@ function assertDatedChangelog(source, version) { function assertMergedPullRequest(pr, { number, headCommit }) { if (pr.state !== "MERGED") throw new Error(`Pull request ${number} is not merged.`); if (!pr.mergeCommit) throw new Error(`Pull request ${number} has no merge commit.`); + contract.oid(pr.headRefOid, `pull request ${number} head`); + contract.oid(pr.mergeCommit.oid, `pull request ${number} merge commit`); if (pr.mergeCommit.oid !== headCommit) throw new Error(`Pull request ${number} merge commit does not equal local main.`); } -async function validateMergedHead({ store, validationLedger, repositoryId, headCommit, parentCommit, manifestPath, runnerId, now }) { +async function resolvePullRequestHead(store, number, expectedHead) { + try { + return await store.resolveRef(expectedHead); + } catch { + await runGit({ + args: ["fetch", "--no-tags", "origin", `refs/pull/${number}/head`], + cwd: store.repoPath, + timeoutMs: 15 * 60 * 1000, + }); + const fetchedHead = await store.resolveRef("FETCH_HEAD"); + contract.equals(fetchedHead, expectedHead, `pull request ${number} fetched head`); + return fetchedHead; + } +} + +async function validateMergedHead({ store, validationLedger, repositoryId, headCommit, parentCommit, pullRequestHead, manifestPath, runnerId, now }) { const validation = await new ValidationRunner({ store, ledger: validationLedger }).run({ repositoryId, commit: headCommit, base: parentCommit, + checkpointHead: pullRequestHead, + checkpointBase: parentCommit, manifestPath, runnerId, now, diff --git a/scripts/lib/release-workflow.mjs b/scripts/lib/release-workflow.mjs index 418c02f..b110ffd 100644 --- a/scripts/lib/release-workflow.mjs +++ b/scripts/lib/release-workflow.mjs @@ -57,17 +57,16 @@ export class ReleaseExecutor { const path = resolve(this.stateRoot, `${approval.id}.json`); const existing = await readState(path); let state = resumeOrCreate(existing, intent, approval, now); - if (state.status === "succeeded") return { ...state, receiptPath: path }; + if (state.status === "succeeded") return releaseResult(state, path); const verification = state.phases.find((entry) => entry.id === "verify"); state = await executePhase({ state, current: verification, phase: "verify", path, actions: this.actions, intent, approval, now }); for (const phase of PHASES.slice(1)) { const current = state.phases.find((entry) => entry.id === phase); - if (current.status === "completed") continue; state = await executePhase({ state, current, phase, path, actions: this.actions, intent, approval, now }); } await completeRelease(path, state); - return { ...state, receiptPath: path }; + return releaseResult(state, path); } } @@ -155,7 +154,7 @@ class ReleaseActions { async #publishTag({ intent }) { const repositoryId = await this.codeRepositoryReader(this.store); - assertEqual(repositoryId, intent.repository.id, "Release repository identity changed before tag publication."); + assertRepositoryIdentity(repositoryId, intent.repository.id, "Release repository identity changed before tag publication."); let local = await localTagState(this.store.repoPath, intent.tag); const remote = await remoteTagState(this.store.repoPath, intent.code.remote, intent.tag); assertTagTarget(local, intent, "locally"); @@ -240,13 +239,17 @@ async function completeRelease(path, state) { await writeState(path, state); } +function releaseResult(receipt, receiptPath) { + return { receipt, receiptPath }; +} + function assertReleaseRepository(actual, intent) { assertReleaseRevision(actual, intent); assertReleaseWorkspace(actual); } function assertReleaseRevision(actual, intent) { - assertEqual(actual.repositoryId, intent.repository.id, "Release repository identity changed."); + assertRepositoryIdentity(actual.repositoryId, intent.repository.id, "Release repository identity changed."); assertControlRepository(actual.controlRepository, intent); assertEqual(actual.head, intent.revision.commit, "Release HEAD changed after planning."); assertEqual(actual.trackedMain, actual.head, "Release commit no longer equals tracked origin/main."); @@ -412,6 +415,10 @@ function assertControlRepository(actual, intent) { if (!actual.isPrivate) throw new Error("Release control repository is not private."); } +function assertRepositoryIdentity(actual, expected, message) { + assertEqual(actual.toLowerCase(), expected.toLowerCase(), message); +} + async function withTemporaryReleaseNotes(source, action) { const directory = await mkdtemp(join(tmpdir(), "tabellio-release-notes-")); const path = join(directory, "notes.md"); diff --git a/scripts/lib/validation-runner.mjs b/scripts/lib/validation-runner.mjs index 5f1841f..54fc718 100644 --- a/scripts/lib/validation-runner.mjs +++ b/scripts/lib/validation-runner.mjs @@ -21,6 +21,8 @@ export class ValidationRunner { repositoryId, commit, base, + checkpointHead = null, + checkpointBase = null, manifestPath = "tabellio.validation.json", runnerId = "local", now = new Date(), @@ -28,23 +30,20 @@ export class ValidationRunner { requiredString(repositoryId, "repositoryId"); requiredString(runnerId, "runnerId"); validateRelativePath(manifestPath, "manifestPath"); - const [headCommit, baseCommit] = await Promise.all([ - this.store.resolveRef(commit), - this.store.resolveRef(base), - ]); - const mergeBase = (await runGit({ - args: ["merge-base", baseCommit, headCommit], - cwd: this.store.repoPath, - })).stdout.trim(); + if ((checkpointHead === null) !== (checkpointBase === null)) throw new Error("checkpointHead and checkpointBase must be supplied together."); + const revision = await resolveRevision(this.store, base, commit); + const checkpointRevision = checkpointHead === null + ? revision + : await resolveRevision(this.store, checkpointBase, checkpointHead); const manifestSource = await runGit({ - args: ["show", `${headCommit}:${manifestPath}`], + args: ["show", `${revision.headCommit}:${manifestPath}`], cwd: this.store.repoPath, }); const manifest = JSON.parse(manifestSource.stdout); validateValidationManifest(manifest); - const checkpoints = await checkpointIds(this.store.repoPath, mergeBase, headCommit); + const checkpoints = await checkpointIds(this.store.repoPath, checkpointRevision.mergeBase, checkpointRevision.headCommit); if (manifest.requireEntireCheckpoint && checkpoints.length === 0) { - throw new Error(`Validation range ${mergeBase}..${headCommit} has no Entire checkpoint.`); + throw new Error(`Checkpoint range ${checkpointRevision.mergeBase}..${checkpointRevision.headCommit} has no Entire checkpoint.`); } const runId = `validation-${randomUUID()}`; @@ -57,7 +56,7 @@ export class ValidationRunner { const startedAt = now.toISOString(); const commands = []; try { - await runGit({ args: ["worktree", "add", "--detach", workspace, headCommit], cwd: this.store.repoPath }); + await runGit({ args: ["worktree", "add", "--detach", workspace, revision.headCommit], cwd: this.store.repoPath }); await mkdir(resolve(home, "tmp"), { recursive: true }); let stopped = false; for (const command of manifest.commands) { @@ -84,7 +83,8 @@ export class ValidationRunner { schemaVersion: VALIDATION_RESULT_SCHEMA_VERSION, runId, repository: { id: repositoryId }, - revision: { baseCommit, mergeBase, headCommit }, + revision, + checkpointRevision, suite: { id: manifest.id, manifestPath, @@ -100,7 +100,7 @@ export class ValidationRunner { }; result.integrity.digest = validationResultDigest(result); validateValidationResult(result); - const path = validationPath(headCommit, runId); + const path = validationPath(revision.headCommit, runId); const written = await writeResultWithRetry(this.ledger, path, result); return { result, path, version: written.version }; } @@ -154,17 +154,16 @@ export function validateValidationManifest(value) { export function validateValidationResult(value) { object(value, "validation result"); - exactKeys(value, ["schemaVersion", "runId", "repository", "revision", "suite", "runner", "status", "checkpoints", "commands", "startedAt", "completedAt", "integrity"], "validation result"); + const keys = ["schemaVersion", "runId", "repository", "revision", "suite", "runner", "status", "checkpoints", "commands", "startedAt", "completedAt", "integrity"]; + if (Object.hasOwn(value, "checkpointRevision")) keys.push("checkpointRevision"); + exactKeys(value, keys, "validation result"); equals(value.schemaVersion, VALIDATION_RESULT_SCHEMA_VERSION, "validation result.schemaVersion"); requiredString(value.runId, "validation result.runId"); object(value.repository, "validation result.repository"); exactKeys(value.repository, ["id"], "validation result.repository"); requiredString(value.repository.id, "validation result.repository.id"); - object(value.revision, "validation result.revision"); - exactKeys(value.revision, ["baseCommit", "mergeBase", "headCommit"], "validation result.revision"); - oid(value.revision.baseCommit, "validation result.revision.baseCommit"); - oid(value.revision.mergeBase, "validation result.revision.mergeBase"); - oid(value.revision.headCommit, "validation result.revision.headCommit"); + validateRevision(value.revision, "validation result.revision"); + if (value.checkpointRevision !== undefined) validateRevision(value.checkpointRevision, "validation result.checkpointRevision"); object(value.suite, "validation result.suite"); exactKeys(value.suite, ["id", "manifestPath", "manifestDigest"], "validation result.suite"); requiredString(value.suite.id, "validation result.suite.id"); @@ -190,6 +189,14 @@ export function validateValidationResult(value) { return value; } +function validateRevision(value, path) { + object(value, path); + exactKeys(value, ["baseCommit", "mergeBase", "headCommit"], path); + oid(value.baseCommit, `${path}.baseCommit`); + oid(value.mergeBase, `${path}.mergeBase`); + oid(value.headCommit, `${path}.headCommit`); +} + async function runValidationCommand(command, workspace, home) { const cwd = resolve(workspace, command.cwd); if (relative(workspace, cwd).startsWith("..") || isAbsolute(relative(workspace, cwd))) throw new Error(`Command ${command.id} cwd escapes validation workspace.`); @@ -296,6 +303,18 @@ function emptyOutput() { return { bytes: 0, digest: createHash("sha256").update("").digest("hex"), tail: "", truncated: false }; } +async function resolveRevision(store, base, head) { + const [baseCommit, headCommit] = await Promise.all([ + store.resolveRef(base), + store.resolveRef(head), + ]); + const mergeBase = (await runGit({ + args: ["merge-base", baseCommit, headCommit], + cwd: store.repoPath, + })).stdout.trim(); + return { baseCommit, mergeBase, headCommit }; +} + async function checkpointIds(cwd, baseCommit, headCommit) { const result = await runGit({ args: ["log", "--format=%(trailers:key=Entire-Checkpoint,valueonly)", "--no-merges", `${baseCommit}..${headCommit}`], diff --git a/scripts/tabellio-release.mjs b/scripts/tabellio-release.mjs index 2b84fc1..41dd359 100644 --- a/scripts/tabellio-release.mjs +++ b/scripts/tabellio-release.mjs @@ -88,8 +88,8 @@ async function execute(options) { stateRoot: options.stateRoot, ghBinary: options.ghBinary ?? "gh", }); - const receipt = await executor.execute({ intent, approval }); - console.log(JSON.stringify({ ok: true, receipt }, null, 2)); + const result = await executor.execute({ intent, approval }); + console.log(JSON.stringify({ ok: true, ...result }, null, 2)); } async function readJson(path) { diff --git a/tests/preflight.test.mjs b/tests/preflight.test.mjs index 3bd5313..e8b552c 100644 --- a/tests/preflight.test.mjs +++ b/tests/preflight.test.mjs @@ -63,6 +63,10 @@ test("preflight requires executable Entire hook commands, not empty event keys", test("preflight normalizes GitHub remote identities and requires private control storage", async (t) => { const fixture = await preparedFixture(t); + await runGit({ args: ["remote", "set-url", "control", "ssh://git@github.com/example/repository-control.git"], cwd: fixture.seed }); + const sshUrl = await runPreflight({ repoPath: fixture.seed, commandRunner: fakeCommands({ trusted: true }) }); + assert.equal(sshUrl.checks.find((check) => check.id === "github-remotes").status, "passed"); + await runGit({ args: ["remote", "set-url", "control", "git@github.com:EXAMPLE/REPOSITORY.git"], cwd: fixture.seed }); const same = await runPreflight({ repoPath: fixture.seed, commandRunner: fakeCommands({ trusted: true }) }); assert.match(same.checks.find((check) => check.id === "github-remotes").detail, /same GitHub repository/); diff --git a/tests/release-workflow.test.mjs b/tests/release-workflow.test.mjs index cf5d246..c85e2a7 100644 --- a/tests/release-workflow.test.mjs +++ b/tests/release-workflow.test.mjs @@ -36,7 +36,7 @@ test("release intent binds merged commit, validation, control refs, notes, and a assert.throws(() => exampleIntent({ controlRepositoryId: "github.com/example/repository" }), /must differ/); }); -test("release executor resumes failed phases without repeating completed work", async (t) => { +test("release executor reconciles completed publication phases after a failure", async (t) => { const root = await mkdtemp(join(tmpdir(), "tabellio-release-resume-")); t.after(() => rm(root, { recursive: true, force: true })); const calls = []; @@ -60,10 +60,12 @@ test("release executor resumes failed phases without repeating completed work", const intent = exampleIntent(); const approval = approvalFor(intent, "resume-release"); await assert.rejects(executor.execute({ intent, approval, now }), /simulated tag failure/); - const receipt = await executor.execute({ intent, approval, now }); - assert.equal(receipt.status, "succeeded"); - assert.deepEqual(calls, ["verify", "control-refs", "tag", "verify", "tag", "github-release"]); - assert.equal(receipt.phases.every((phase) => phase.status === "completed"), true); + const result = await executor.execute({ intent, approval, now }); + assert.equal(result.receipt.status, "succeeded"); + assert.deepEqual(calls, ["verify", "control-refs", "tag", "verify", "control-refs", "tag", "github-release"]); + assert.equal(result.receipt.phases.every((phase) => phase.status === "completed"), true); + assert.equal(result.receiptPath, join(root, "resume-release.json")); + assert.equal(Object.hasOwn(result.receipt, "receiptPath"), false); }); test("release executor serializes concurrent execution of the same approval", async (t) => { @@ -87,7 +89,7 @@ test("release executor serializes concurrent execution of the same approval", as while (!unblock) await new Promise((resolve) => setImmediate(resolve)); await assert.rejects(executor.execute({ intent, approval, now }), /Another release operation is active/); unblock(); - assert.equal((await first).status, "succeeded"); + assert.equal((await first).receipt.status, "succeeded"); }); test("approved release publishes exact control ref, annotated tag, and GitHub release in isolated repos", async (t) => { @@ -154,7 +156,7 @@ test("approved release publishes exact control ref, annotated tag, and GitHub re stateRoot, commandRunner, remoteRefReader: async () => { liveReads += 1; return head; }, - codeRepositoryReader: async () => repositoryId, + codeRepositoryReader: async () => "github.com/EXAMPLE/REPOSITORY", controlRepositoryReader: async () => ({ id: currentControlIdentity, isPrivate: currentControlPrivate }), }); const approval = approvalFor(intent, "publish-release"); @@ -173,12 +175,15 @@ test("approved release publishes exact control ref, annotated tag, and GitHub re await assert.rejects(executor.execute({ intent, approval, now }), /control repository is not private/); currentControlPrivate = true; await assert.rejects(executor.execute({ intent, approval, now }), /remotely as a lightweight tag/); + await runGit({ args: ["push", "control", ":refs/tabellio/validations"], cwd: fixture.seed }); await runGit({ args: ["push", "origin", `:refs/tags/${intent.tag}`], cwd: fixture.seed }); await writeFile(join(fixture.seed, notesPath), "MUTATED WORKTREE NOTES\n"); await assert.rejects(executor.execute({ intent, approval, now }), /clean worktree/); await runGit({ args: ["restore", notesPath], cwd: fixture.seed }); - const receipt = await executor.execute({ intent, approval, now }); - assert.equal(receipt.status, "succeeded"); + const result = await executor.execute({ intent, approval, now }); + assert.equal(result.receipt.status, "succeeded"); + assert.equal(Object.hasOwn(result.receipt, "receiptPath"), false); + assert.equal(result.receiptPath, join(stateRoot, "publish-release.json")); assert.equal(liveReads, 6); assert.equal(ghCalls.length, 2); assert.deepEqual(publishedNotes, ["Release 1.2.3\n"]); @@ -209,6 +214,7 @@ test("release planning binds merged PR proof after exact validation and terminal const control = join(fixture.root, "control-plan.git"); await NativeGitStore.createBare(control); await runGit({ args: ["remote", "add", "control", control], cwd: fixture.seed }); + await runGit({ args: ["switch", "-c", "release-pr"], cwd: fixture.seed }); const notesPath = "docs/releases/v2.0.0.md"; await mkdir(join(fixture.seed, "docs", "releases"), { recursive: true }); await writeFile(join(fixture.seed, "package.json"), '{"version":"2.0.0"}\n'); @@ -224,10 +230,14 @@ test("release planning binds merged PR proof after exact validation and terminal await writeFile(join(fixture.seed, "tabellio.platform.json"), JSON.stringify(platformFixture())); await runGit({ args: ["add", "package.json", "CHANGELOG.md", "tabellio.validation.json", "tabellio.platform.json", notesPath], cwd: fixture.seed }); await runGit({ - args: ["commit", "-m", "Merge release", "-m", "Entire-Checkpoint: abcdef123456"], + args: ["commit", "-m", "Prepare release", "-m", "Entire-Checkpoint: abcdef123456"], cwd: fixture.seed, env: identityEnv(), }); + const pullRequestHead = (await runGit({ args: ["rev-parse", "HEAD"], cwd: fixture.seed })).stdout.trim(); + await runGit({ args: ["switch", "main"], cwd: fixture.seed }); + await runGit({ args: ["merge", "--squash", "release-pr"], cwd: fixture.seed }); + await runGit({ args: ["commit", "-m", "Squash release"], cwd: fixture.seed, env: identityEnv() }); await runGit({ args: ["push", "origin", "main"], cwd: fixture.seed }); await runGit({ args: ["remote", "set-url", "origin", "https://github.com/example/repository.git"], cwd: fixture.seed }); await runGit({ args: ["remote", "set-url", "control", "https://github.com/example/repository-control.git"], cwd: fixture.seed }); @@ -235,13 +245,15 @@ test("release planning binds merged PR proof after exact validation and terminal const store = await NativeGitStore.open(fixture.seed); const head = await store.resolveRef("HEAD"); const parent = await store.resolveRef("HEAD^"); - await runGit({ args: ["update-ref", "refs/heads/entire/checkpoints/v1", head], cwd: fixture.seed }); - const provider = mergedProvider({ head: parent, base: parent }); + const squashCheckpoints = await runGit({ args: ["log", "-1", "--format=%(trailers:key=Entire-Checkpoint,valueonly)", head], cwd: fixture.seed }); + assert.equal(squashCheckpoints.stdout.trim(), ""); + await runGit({ args: ["update-ref", "refs/heads/entire/checkpoints/v1", pullRequestHead], cwd: fixture.seed }); + const provider = mergedProvider({ head: pullRequestHead, base: parent }); const reviewLedger = await GitJsonLedger.open({ repoPath: fixture.seed, ref: "refs/tabellio/reviews" }); const readyManager = new ReviewCycleManager({ store, ledger: reviewLedger, - provider: readyProvider({ head: parent, base: parent }), + provider: readyProvider({ head: pullRequestHead, base: parent }), repositoryId: "github.com/example/repository", owner: "example", repo: "repository", @@ -249,7 +261,7 @@ test("release planning binds merged PR proof after exact validation and terminal assert.equal((await readyManager.sync({ number: 9, actor: "test", now })).cycle.status, "ready"); const commandRunner = async ({ args }) => { if (args[0] === "pr" && args[1] === "view") { - return { stdout: JSON.stringify({ state: "MERGED", headRefOid: parent, mergeCommit: { oid: head } }), stderr: "", exitCode: 0, signal: null }; + return { stdout: JSON.stringify({ state: "MERGED", headRefOid: pullRequestHead, mergeCommit: { oid: head } }), stderr: "", exitCode: 0, signal: null }; } throw new Error(`Unexpected command: ${args.join(" ")}`); }; @@ -289,7 +301,7 @@ test("release planning binds merged PR proof after exact validation and terminal now, }); assert.equal(intent.revision.commit, head); - assert.equal(intent.pullRequest.headCommit, parent); + assert.equal(intent.pullRequest.headCommit, pullRequestHead); assert.equal(intent.validation.status, "passed"); assert.equal(intent.control.repository.id, "github.com/example/repository-control"); assert.equal(intent.control.intent.refs.length, 3); From 2fd2c98b05db38f2cf1757e489b13da2faf18d1a Mon Sep 17 00:00:00 2001 From: hudsonaikins-crown Date: Wed, 15 Jul 2026 18:04:36 -0700 Subject: [PATCH 07/12] fix: bind release publication targets Entire-Checkpoint: 1e7d65448717 --- docs/getting-started.md | 2 +- docs/operations-hardening.md | 4 +- docs/releases/v0.3.0.md | 4 +- scripts/lib/github-repository.mjs | 27 ++++++++ scripts/lib/preflight.mjs | 41 +++++------- scripts/lib/release-planner.mjs | 17 +++-- scripts/lib/release-workflow.mjs | 101 +++++++++++++++++++++++++----- tests/preflight.test.mjs | 20 +++++- tests/release-workflow.test.mjs | 43 +++++++++++-- 9 files changed, 194 insertions(+), 65 deletions(-) diff --git a/docs/getting-started.md b/docs/getting-started.md index 22fbc44..ca17c9c 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -100,7 +100,7 @@ node scripts/tabellio-release.mjs execute \ --approval /secure/tabellio-release-approval.json ``` -Planning accepts only the validation manifest named by `tabellio.platform.json`. It runs commands on the exact merged commit and binds checkpoint evidence to the pre-merge pull-request head, so a squash merge does not erase proof. Execution accepts canonical HTTPS, SCP-style SSH, and `ssh://git@github.com/...` remotes, rechecks the distinct repository identities and current private control visibility, then publishes exact private control refs, the annotated tag, and the GitHub release. Merge stays outside this command because the final squash commit must exist before release approval can bind it. +Planning accepts only the validation manifest named by `tabellio.platform.json`. It runs commands on the exact merged commit and binds checkpoint evidence to the pre-merge pull-request head, so a squash merge does not erase proof. Execution accepts canonical HTTPS, SCP-style SSH, and `ssh://git@github.com/...` remotes, resolves Git fetch/push URL rewrites, rechecks the distinct repository identities and current private control visibility, then publishes exact private control refs, a deterministic annotated tag, and the GitHub release. Merge stays outside this command because the final squash commit must exist before release approval can bind it. ## Local Validation diff --git a/docs/operations-hardening.md b/docs/operations-hardening.md index 93593bd..1a63af2 100644 --- a/docs/operations-hardening.md +++ b/docs/operations-hardening.md @@ -30,11 +30,11 @@ Do not maintain a second merge authority. Independent squash or rebase merges cr ## Control-State Publication -Review cycles, validation results, and Entire checkpoints are published together with `git push --atomic`, explicit force-with-lease expectations, and a one-use approval. Publication rejects non-fast-forward updates, divergence, changed local or remote object IDs, expired approvals, and reused approval IDs. Release retries derive a fresh one-use control approval from the still-active release approval after re-verifying the exact repositories and OIDs. +Review cycles, validation results, and Entire checkpoints are published together with `git push --atomic`, explicit force-with-lease expectations, and a one-use approval. Publication permits refs that were already unchanged at planning while rejecting partial publication, non-fast-forward updates, divergence, changed local or remote object IDs, expired approvals, and reused approval IDs. Release retries derive a fresh one-use control approval from the still-active release approval after re-verifying the exact repositories and OIDs. Automatic Entire session pushes to `origin` remain disabled. The approved control-ref transport publishes `refs/heads/entire/checkpoints/v1` with the review and validation refs only to a separately configured private GitHub repository. Planning and execution reject `origin`, reject repository aliases, and recheck private visibility immediately before publication. -Release planning is local and credentialed-read-only: it requires clean merged `main`, runs exact-head commands from the platform-configured manifest, binds checkpoint evidence to the exact pre-merge pull-request head, requires durable proof that that head reached `ready`, rejects new terminal feedback or failed checks, binds both GitHub repository identities, and snapshots the resulting control OIDs. Remote publication requires a separate release approval capped at one hour. Execution re-verifies case-normalized code identity plus control identity and privacy before every resumed write, writes an atomic local receipt before each phase, and reruns idempotent reconciliation for control refs, tags, and releases during retry. Pull-request merge remains a separate explicit action because an approval cannot safely bind a squash commit that does not exist yet. +Release planning is local and credentialed-read-only: it requires clean merged `main`, runs exact-head commands from the platform-configured manifest, binds checkpoint evidence to the exact pre-merge pull-request head, requires durable proof that that head reached `ready`, rejects new terminal feedback or failed checks, binds the effective fetch and push targets for both GitHub remotes, and snapshots the resulting control OIDs. Remote publication requires a separate release approval capped at one hour. Execution re-verifies case-normalized code identity plus control identity and privacy before every write, writes an atomic local receipt before each phase, and reruns idempotent reconciliation for control refs, exact annotated-tag objects, and releases after failures or prior success. Pull-request merge remains a separate explicit action because an approval cannot safely bind a squash commit that does not exist yet. ## Production Checklist diff --git a/docs/releases/v0.3.0.md b/docs/releases/v0.3.0.md index a5da154..2f899b9 100644 --- a/docs/releases/v0.3.0.md +++ b/docs/releases/v0.3.0.md @@ -5,9 +5,9 @@ Tabellio v0.3.0 makes release readiness and publication deterministic. - `tabellio-preflight` catches missing or untrusted Entire hooks before commit or release work begins. - Release planning binds merged Git state, pre-merge head readiness, the platform validation manifest, both distinct GitHub repository identities, private control refs, release notes, and package version into one integrity-protected intent. - One approved post-merge execution publishes exact control refs, an annotated Git tag, and the GitHub release. -- Release receipts survive partial failures, re-verify repository targets on every retry, and rerun idempotent publication reconciliation so rolled-back remote state cannot be skipped. +- Release receipts survive partial failures, re-verify effective fetch and push targets on every retry, and rerun idempotent publication reconciliation even after an earlier success so rolled-back remote state cannot be skipped. - Isolated repository tests prove the complete plan and publication workflow without touching live GitHub resources. Merge remains an explicit operator action. This prevents a release approval from authorizing an unknown squash commit. -For squash merges, validation commands run against the exact merged commit while checkpoint evidence remains bound to the separately recorded pull-request head. The CLI returns the schema-valid receipt and its local `receiptPath` as separate fields. +For squash merges, validation commands run against the exact merged commit while checkpoint evidence remains bound to the separately recorded pull-request head. Annotated tags use a deterministic automation tagger and exact object/message reconciliation. The CLI returns the schema-valid receipt and its local `receiptPath` as separate fields. diff --git a/scripts/lib/github-repository.mjs b/scripts/lib/github-repository.mjs index a0c89a9..3502955 100644 --- a/scripts/lib/github-repository.mjs +++ b/scripts/lib/github-repository.mjs @@ -25,6 +25,23 @@ export function sameGitHubRepository(left, right) { return left !== null && right !== null && left.key === right.key; } +export async function effectiveGitHubRepository(store, remote) { + assertSafeRemoteName(remote); + const [fetchUrls, pushUrls] = await Promise.all([ + effectiveRemoteUrls(store.repoPath, remote, false), + effectiveRemoteUrls(store.repoPath, remote, true), + ]); + const repositories = [...fetchUrls, ...pushUrls].map(parseGitHubRepositoryRemote); + if (repositories.some((repository) => repository === null)) { + throw new Error(`Remote ${remote} has a non-GitHub effective fetch or push URL.`); + } + const repository = repositories[0]; + if (!repositories.every((candidate) => sameGitHubRepository(repository, candidate))) { + throw new Error(`Remote ${remote} effective fetch and push URLs target different GitHub repositories.`); + } + return repository; +} + export async function readRemoteRefOid({ repoPath, remote, ref }) { assertSafeRemoteName(remote); assertSafeGitRef(ref); @@ -36,6 +53,16 @@ export async function readRemoteRefOid({ repoPath, remote, ref }) { return parseRemoteRefOutput(result.stdout, remote, ref); } +async function effectiveRemoteUrls(repoPath, remote, push) { + const result = await runGit({ + args: ["remote", "get-url", ...(push ? ["--push"] : []), "--all", remote], + cwd: repoPath, + }); + const urls = result.stdout.split(/\r?\n/).map((value) => value.trim()).filter(Boolean); + if (urls.length === 0) throw new Error(`Remote ${remote} has no effective ${push ? "push" : "fetch"} URL.`); + return urls; +} + function repositoryRemoteParts(value) { const https = httpsParts(value); return https || sshUrlParts(value) || sshParts(value); diff --git a/scripts/lib/preflight.mjs b/scripts/lib/preflight.mjs index 293d188..acd7a9d 100644 --- a/scripts/lib/preflight.mjs +++ b/scripts/lib/preflight.mjs @@ -5,7 +5,7 @@ import { runExternalCommand } from "./external-command.mjs"; import { runGit } from "./git-process.mjs"; import { contract } from "./contract-checks.mjs"; import { - parseGitHubRepositoryRemote, + effectiveGitHubRepository, readRemoteRefOid, sameGitHubRepository, } from "./github-repository.mjs"; @@ -25,6 +25,7 @@ export async function runPreflight({ commandRunner = runExternalCommand, controlRemote = null, remoteRefReader = readRemoteRefOid, + remoteRepositoryReader = effectiveGitHubRepository, nodeVersion = process.versions.node, now = new Date(), } = {}) { @@ -45,7 +46,7 @@ export async function runPreflight({ return passed(`Repository ${repositoryId}.`); }); - await recordRepositoryChecks({ checks, store, profile, commandRunner, ghBinary, controlRemote, remoteRefReader }); + await recordRepositoryChecks({ checks, store, profile, commandRunner, ghBinary, controlRemote, remoteRefReader, remoteRepositoryReader }); await record(checks, "entire-version", async () => { const result = await commandRunner({ binary: entireBinary, args: ["--version"], cwd: resolvedRepo, timeoutMs: 30_000 }); @@ -70,14 +71,11 @@ export async function runPreflight({ await record(checks, "entire-doctor", async () => { const result = await commandRunner({ binary: entireBinary, args: ["doctor"], cwd: resolvedRepo, timeoutMs: 30_000 }); const output = `${result.stdout}\n${result.stderr}`; - if (/Codex hook trust:\s*REVIEW NEEDED/i.test(output)) { - return blocked("Codex Entire hooks are not trusted on this machine.", "Open /hooks in Codex, approve all four repository hooks, then rerun preflight."); - } if (!/Metadata branches:\s*OK/i.test(output)) { return blocked("Entire metadata branches are unhealthy or unverifiable.", "Run entire doctor and resolve metadata branch errors."); } - if (!/Codex hook trust:/i.test(output)) { - return blocked("Entire doctor did not verify Codex hook trust.", "Reinstall Entire Codex hooks and approve them through /hooks."); + if (!/^Codex hook trust:\s*OK\s*$/im.test(output)) { + return blocked("Codex Entire hook trust is unhealthy or unverifiable.", "Open /hooks in Codex, approve all four repository hooks, then rerun preflight."); } return passed("Entire metadata and Codex hook trust healthy."); }); @@ -113,10 +111,10 @@ export function validatePreflightResult(value) { return value; } -async function recordRepositoryChecks({ checks, store, profile, commandRunner, ghBinary, controlRemote, remoteRefReader }) { +async function recordRepositoryChecks({ checks, store, profile, commandRunner, ghBinary, controlRemote, remoteRefReader, remoteRepositoryReader }) { if (!store) return; await record(checks, "platform-contract", () => checkPlatformContract(store)); - await record(checks, "github-remotes", () => checkGitHubRemotes({ store, commandRunner, ghBinary, controlRemote })); + await record(checks, "github-remotes", () => checkGitHubRemotes({ store, commandRunner, ghBinary, controlRemote, remoteRepositoryReader })); await record(checks, "codex-hooks", () => checkCodexHooks(store)); if (profile === "release") await record(checks, "clean-main", () => checkCleanMain(store, remoteRefReader)); } @@ -127,20 +125,19 @@ async function checkPlatformContract(store) { return passed("GitHub code storage and external control-state contract valid."); } -async function checkGitHubRemotes({ store, commandRunner, ghBinary, controlRemote }) { +async function checkGitHubRemotes({ store, commandRunner, ghBinary, controlRemote, remoteRepositoryReader }) { const platform = await readPlatformConfig(store); const configuredControl = platform.workflow.controlRemoteName; const selectedControl = controlRemote || configuredControl; const selectionBlocker = controlSelectionBlocker(selectedControl, configuredControl); if (selectionBlocker) return selectionBlocker; - const [originUrl, controlUrl] = await Promise.all([ - store.gitConfig("remote.origin.url"), - store.gitConfig(`remote.${selectedControl}.url`), + const [origin, control] = await Promise.all([ + remoteRepositoryReader(store, "origin"), + remoteRepositoryReader(store, selectedControl), ]); - const origin = parseGitHubRepositoryRemote(originUrl); - const control = parseGitHubRepositoryRemote(controlUrl); - const remoteBlocker = githubRemoteBlocker({ originUrl, controlUrl, origin, control, selectedControl }); - if (remoteBlocker) return remoteBlocker; + if (sameGitHubRepository(origin, control)) { + return blocked("origin and control target the same GitHub repository.", "Use separate GitHub repositories for code and private control state."); + } const result = await commandRunner({ binary: ghBinary, args: ["repo", "view", control.fullName, "--json", "nameWithOwner,isPrivate"], @@ -155,16 +152,6 @@ function controlSelectionBlocker(selected, configured) { return blocked(`Selected control remote ${selected} does not match platform remote ${configured}.`, `Use --control-remote ${configured}.`); } -function githubRemoteBlocker({ originUrl, controlUrl, origin, control, selectedControl }) { - return firstBlocker([ - () => originUrl ? null : blocked("origin remote missing.", "Configure GitHub code remote as origin."), - () => controlUrl ? null : blocked(`${selectedControl} remote missing.`, "Configure the private GitHub control remote."), - () => origin ? null : blocked("origin is not a supported GitHub remote.", "Set origin to the canonical GitHub repository."), - () => control ? null : blocked(`${selectedControl} is not a supported GitHub remote.`, "Set the control remote to the private GitHub control repository."), - () => sameGitHubRepository(origin, control) ? blocked("origin and control target the same GitHub repository.", "Use separate GitHub repositories for code and private control state.") : null, - ], null); -} - function privateControlResult(repository, control, selectedControl) { if (String(repository.nameWithOwner).toLowerCase() !== control.key) { return blocked("GitHub returned a different control repository identity.", "Correct the control remote and GitHub authentication scope."); diff --git a/scripts/lib/release-planner.mjs b/scripts/lib/release-planner.mjs index 520712d..e54a696 100644 --- a/scripts/lib/release-planner.mjs +++ b/scripts/lib/release-planner.mjs @@ -5,7 +5,7 @@ import { createControlRefIntent, snapshotControlRefs } from "./control-ref-trans import { contract } from "./contract-checks.mjs"; import { runExternalCommand } from "./external-command.mjs"; import { GitJsonLedger } from "./git-json-ledger.mjs"; -import { parseGitHubRepositoryRemote, sameGitHubRepository } from "./github-repository.mjs"; +import { effectiveGitHubRepository, sameGitHubRepository } from "./github-repository.mjs"; import { runGit } from "./git-process.mjs"; import { runPreflight } from "./preflight.mjs"; import { validatePlatformConfig } from "./platform-config.mjs"; @@ -33,6 +33,7 @@ export async function planRelease({ commandRunner = runExternalCommand, preflightRunner = runPreflight, githubProvider = null, + remoteRepositoryReader = effectiveGitHubRepository, now = new Date(), } = {}) { validatePlanInput({ owner, repo, number, version, notesPath, controlRemote, manifestPath }); @@ -40,7 +41,7 @@ export async function planRelease({ const preflight = await preflightRunner({ repoPath: store.repoPath, profile: "release", ghBinary, commandRunner, controlRemote }); assertReadyPreflight(preflight); await assertPlatformManifest(store, manifestPath); - const repositories = await bindReleaseRepositories(store, { owner, repo, explicitRepositoryId, controlRemote }); + const repositories = await bindReleaseRepositories(store, { owner, repo, explicitRepositoryId, controlRemote, remoteRepositoryReader }); const repositoryId = repositories.code.identity; const evidence = await loadReleaseEvidence({ store, notesPath, ghBinary, owner, repo, number, commandRunner }); const { headCommit, parentCommit, notesSource, pr } = validateReleaseEvidence(evidence, { version, number }); @@ -94,15 +95,11 @@ async function assertPlatformManifest(store, manifestPath) { contract.equals(manifestPath, platform.validation.manifest, "manifestPath"); } -async function bindReleaseRepositories(store, { owner, repo, explicitRepositoryId, controlRemote }) { - const [originUrl, controlUrl] = await Promise.all([ - store.gitConfig("remote.origin.url"), - store.gitConfig(`remote.${controlRemote}.url`), +async function bindReleaseRepositories(store, { owner, repo, explicitRepositoryId, controlRemote, remoteRepositoryReader }) { + const [origin, control] = await Promise.all([ + remoteRepositoryReader(store, "origin"), + remoteRepositoryReader(store, controlRemote), ]); - const origin = parseGitHubRepositoryRemote(originUrl); - if (!origin) throw new Error("origin must be a supported GitHub repository remote."); - const control = parseGitHubRepositoryRemote(controlUrl); - if (!control) throw new Error(`${controlRemote} must be a supported GitHub repository remote.`); if (sameGitHubRepository(origin, control)) throw new Error("Control repository must differ from the code repository."); assertRequestedRepository(origin, owner, repo); assertExplicitRepositoryId(origin, explicitRepositoryId); diff --git a/scripts/lib/release-workflow.mjs b/scripts/lib/release-workflow.mjs index b110ffd..6be08cd 100644 --- a/scripts/lib/release-workflow.mjs +++ b/scripts/lib/release-workflow.mjs @@ -8,10 +8,9 @@ import { snapshotControlRefs, } from "./control-ref-transport.mjs"; import { runExternalCommand } from "./external-command.mjs"; -import { parseGitHubRepositoryRemote, readRemoteRefOid } from "./github-repository.mjs"; +import { effectiveGitHubRepository, readRemoteRefOid } from "./github-repository.mjs"; import { runGit } from "./git-process.mjs"; import { withOperationLock } from "./operation-lock.mjs"; -import { repositoryIdentity } from "./repository-identity.mjs"; import { validateReleaseApproval, validateReleaseIntent } from "./release-operation.mjs"; import { NativeGitStore } from "../providers/native-git-store.mjs"; @@ -31,7 +30,7 @@ export class ReleaseExecutor { ghBinary = "gh", commandRunner = runExternalCommand, remoteRefReader = readRemoteRefOid, - codeRepositoryReader = repositoryIdentity, + codeRepositoryReader = codeGitHubRemoteRepository, controlRepositoryReader = privateGitHubRemoteRepository, } = {}) { const store = await NativeGitStore.open(resolve(repoPath)); @@ -57,8 +56,6 @@ export class ReleaseExecutor { const path = resolve(this.stateRoot, `${approval.id}.json`); const existing = await readState(path); let state = resumeOrCreate(existing, intent, approval, now); - if (state.status === "succeeded") return releaseResult(state, path); - const verification = state.phases.find((entry) => entry.id === "verify"); state = await executePhase({ state, current: verification, phase: "verify", path, actions: this.actions, intent, approval, now }); for (const phase of PHASES.slice(1)) { @@ -76,7 +73,7 @@ class ReleaseActions { ghBinary = "gh", commandRunner = runExternalCommand, remoteRefReader = readRemoteRefOid, - codeRepositoryReader = repositoryIdentity, + codeRepositoryReader = codeGitHubRemoteRepository, controlRepositoryReader = privateGitHubRemoteRepository, }) { this.store = store; @@ -159,9 +156,10 @@ class ReleaseActions { const remote = await remoteTagState(this.store.repoPath, intent.code.remote, intent.tag); assertTagTarget(local, intent, "locally"); assertTagTarget(remote, intent, "remotely"); - await ensureLocalTag(this.store.repoPath, intent, local); + await ensureLocalTag(this.store.repoPath, intent, local, remote); local = await localTagState(this.store.repoPath, intent.tag); assertTagTarget(local, intent, "locally"); + assertMatchingTagObjects(local, remote, intent.tag); await ensureRemoteTag(this.store.repoPath, intent, remote); return { tag: intent.tag, commit: intent.revision.commit, status: tagPublishStatus(remote) }; } @@ -269,15 +267,41 @@ function assertReleaseArtifacts(actual, intent) { function assertTagTarget(target, intent, location) { if (!target) return; - if (!target.annotated) throw new Error(`${intent.tag} exists ${location} as a lightweight tag.`); + assertAnnotatedTag(target, intent.tag, location); + assertTagCommit(target, intent, location); + assertTagMessage(target, intent, location); +} + +function assertAnnotatedTag(target, tag, location) { + if (!target.annotated) throw new Error(`${tag} exists ${location} as a lightweight tag.`); +} + +function assertTagCommit(target, intent, location) { if (target.target !== intent.revision.commit) throw new Error(`${intent.tag} exists ${location} at a different commit.`); } -async function ensureLocalTag(repoPath, intent, local) { +function assertTagMessage(target, intent, location) { + if (target.message !== intent.release.title) throw new Error(`${intent.tag} exists ${location} with a different annotation.`); +} + +function assertMatchingTagObjects(local, remote, tag) { + if (local && remote && local.object !== remote.object) throw new Error(`${tag} local and remote annotated tag objects differ.`); +} + +async function ensureLocalTag(repoPath, intent, local, remote) { if (local) return; + if (remote) { + await runGit({ args: ["update-ref", `refs/tags/${intent.tag}`, remote.object], cwd: repoPath }); + return; + } await runGit({ args: ["tag", "-a", intent.tag, "-m", intent.release.title, intent.revision.commit], cwd: repoPath, + env: { + GIT_COMMITTER_NAME: "Tabellio Release Automation", + GIT_COMMITTER_EMAIL: "release@tabellio.invalid", + GIT_COMMITTER_DATE: intent.createdAt, + }, }); } @@ -350,9 +374,7 @@ async function localTagState(repoPath, tag) { acceptableExitCodes: [0, 1, 128], }); if (direct.exitCode !== 0) return null; - const type = await runGit({ args: ["cat-file", "-t", direct.stdout.trim()], cwd: repoPath }); - const target = await runGit({ args: ["rev-parse", `${tag}^{}`], cwd: repoPath }); - return { annotated: type.stdout.trim() === "tag", target: target.stdout.trim() }; + return tagStateFromObject(repoPath, direct.stdout.trim()); } async function remoteTagState(repoPath, remote, tag) { @@ -365,19 +387,61 @@ async function remoteTagState(repoPath, remote, tag) { const direct = rows.find(([, ref]) => ref === `refs/tags/${tag}`); if (!direct) return null; const peeled = rows.find(([, ref]) => ref === `refs/tags/${tag}^{}`); - return { annotated: Boolean(peeled), target: peeled?.[0] ?? direct[0] }; + if (!peeled) return { annotated: false, object: direct[0], target: direct[0], message: null }; + await ensureRemoteTagObject(repoPath, remote, tag, direct[0]); + return tagStateFromObject(repoPath, direct[0]); +} + +async function ensureRemoteTagObject(repoPath, remote, tag, object) { + const exists = await runGit({ + args: ["cat-file", "-e", `${object}^{tag}`], + cwd: repoPath, + acceptableExitCodes: [0, 1, 128], + }); + if (exists.exitCode === 0) return; + await runGit({ + args: ["fetch", "--no-tags", "--no-write-fetch-head", remote, `refs/tags/${tag}`], + cwd: repoPath, + timeoutMs: 15 * 60 * 1000, + }); +} + +async function tagStateFromObject(repoPath, object) { + const type = await runGit({ args: ["cat-file", "-t", object], cwd: repoPath }); + if (type.stdout.trim() !== "tag") return { annotated: false, object, target: object, message: null }; + const [target, payload] = await Promise.all([ + runGit({ args: ["rev-parse", `${object}^{}`], cwd: repoPath }), + runGit({ args: ["cat-file", "tag", object], cwd: repoPath }), + ]); + return { annotated: true, object, target: target.stdout.trim(), message: tagObjectMessage(payload.stdout) }; +} + +function tagObjectMessage(payload) { + const separator = payload.indexOf("\n\n"); + if (separator === -1) return null; + const message = payload.slice(separator + 2); + return message.endsWith("\n") ? message.slice(0, -1) : message; } function controlPublicationState(snapshot, expected) { const actualByName = new Map(snapshot.map((entry) => [entry.name, entry])); const states = expected.map((approved) => approvedControlRefState(actualByName.get(approved.name), approved)); - if (states.every((state) => state === "published")) return "published"; - if (states.every((state) => state === "pending")) return "pending"; + if (states.every((state) => ["published", "unchanged"].includes(state))) return "published"; + if (states.every((state) => ["pending", "unchanged"].includes(state))) return "pending"; throw new Error("Control refs are only partially published."); } function approvedControlRefState(actual, approved) { assertApprovedLocalRef(actual, approved); + if (controlRefWasUnchanged(actual, approved)) return "unchanged"; + return changedControlRefState(actual, approved); +} + +function controlRefWasUnchanged(actual, approved) { + return approved.remoteOid === approved.localOid && actual.remoteOid === approved.localOid; +} + +function changedControlRefState(actual, approved) { if (actual.remoteOid === approved.localOid) return "published"; if (actual.remoteOid === approved.remoteOid) return "pending"; throw new Error(`Approved remote control ref ${approved.name} changed.`); @@ -397,8 +461,7 @@ async function sourceAtCommit(repoPath, commit, path) { } async function privateGitHubRemoteRepository(store, remote, ghBinary, commandRunner) { - const repository = parseGitHubRepositoryRemote(await store.gitConfig(`remote.${remote}.url`)); - if (!repository) throw new Error(`Release ${remote} remote is not a supported GitHub repository.`); + const repository = await effectiveGitHubRepository(store, remote); const result = await commandRunner({ binary: ghBinary, args: ["repo", "view", repository.fullName, "--json", "nameWithOwner,isPrivate"], @@ -410,6 +473,10 @@ async function privateGitHubRemoteRepository(store, remote, ghBinary, commandRun return { id: repository.identity, isPrivate: view.isPrivate === true }; } +async function codeGitHubRemoteRepository(store) { + return (await effectiveGitHubRepository(store, "origin")).identity; +} + function assertControlRepository(actual, intent) { assertEqual(actual.id.toLowerCase(), intent.control.repository.id.toLowerCase(), "Release control repository identity changed."); if (!actual.isPrivate) throw new Error("Release control repository is not private."); diff --git a/tests/preflight.test.mjs b/tests/preflight.test.mjs index e8b552c..2b18e80 100644 --- a/tests/preflight.test.mjs +++ b/tests/preflight.test.mjs @@ -32,6 +32,12 @@ test("preflight fails early with exact Codex hook approval remedy", async (t) => const trust = result.checks.find((check) => check.id === "entire-doctor"); assert.equal(trust.status, "blocked"); assert.match(trust.resolution, /Open \/hooks in Codex/); + + const unknown = await runPreflight({ + repoPath: fixture.seed, + commandRunner: fakeCommands({ trusted: true, trustStatus: "UNKNOWN" }), + }); + assert.equal(unknown.checks.find((check) => check.id === "entire-doctor").status, "blocked"); }); test("release preflight requires clean main equal to origin main", async (t) => { @@ -67,6 +73,16 @@ test("preflight normalizes GitHub remote identities and requires private control const sshUrl = await runPreflight({ repoPath: fixture.seed, commandRunner: fakeCommands({ trusted: true }) }); assert.equal(sshUrl.checks.find((check) => check.id === "github-remotes").status, "passed"); + await runGit({ args: ["remote", "set-url", "--add", "--push", "origin", "https://github.com/example/redirected.git"], cwd: fixture.seed }); + const pushUrl = await runPreflight({ repoPath: fixture.seed, commandRunner: fakeCommands({ trusted: true }) }); + assert.match(pushUrl.checks.find((check) => check.id === "github-remotes").detail, /effective fetch and push URLs target different/); + await runGit({ args: ["config", "--unset-all", "remote.origin.pushurl"], cwd: fixture.seed }); + + await runGit({ args: ["config", "url.https://github.com/example/rewritten.git.pushInsteadOf", "https://github.com/example/repository.git"], cwd: fixture.seed }); + const rewritten = await runPreflight({ repoPath: fixture.seed, commandRunner: fakeCommands({ trusted: true }) }); + assert.match(rewritten.checks.find((check) => check.id === "github-remotes").detail, /effective fetch and push URLs target different/); + await runGit({ args: ["config", "--unset-all", "url.https://github.com/example/rewritten.git.pushInsteadOf"], cwd: fixture.seed }); + await runGit({ args: ["remote", "set-url", "control", "git@github.com:EXAMPLE/REPOSITORY.git"], cwd: fixture.seed }); const same = await runPreflight({ repoPath: fixture.seed, commandRunner: fakeCommands({ trusted: true }) }); assert.match(same.checks.find((check) => check.id === "github-remotes").detail, /same GitHub repository/); @@ -126,11 +142,11 @@ async function writeEntireHooks(repoPath, commandFor) { await writeFile(join(repoPath, ".codex", "hooks.json"), JSON.stringify({ hooks })); } -function fakeCommands({ trusted, privateControl = true }) { +function fakeCommands({ trusted, trustStatus = trusted ? "OK" : "REVIEW NEEDED", privateControl = true }) { const commands = new Map([ ["entire:--version", () => result("Entire CLI 0.7.7\n")], ["entire:status", () => result('{"enabled":true,"agents":["Codex"],"active_sessions":[]}\n')], - ["entire:doctor", () => result(`Metadata branches: OK\nCodex hook trust: ${trusted ? "OK" : "REVIEW NEEDED"}\n`)], + ["entire:doctor", () => result(`Metadata branches: OK\nCodex hook trust: ${trustStatus}\n`)], ["gh:auth", () => result("", "Logged in with gho_secret\n")], ["gh:repo", () => result(`${JSON.stringify({ nameWithOwner: "example/repository-control", isPrivate: privateControl })}\n`)], ]); diff --git a/tests/release-workflow.test.mjs b/tests/release-workflow.test.mjs index c85e2a7..bb857af 100644 --- a/tests/release-workflow.test.mjs +++ b/tests/release-workflow.test.mjs @@ -7,6 +7,7 @@ import test from "node:test"; import { createControlRefIntent, snapshotControlRefs } from "../scripts/lib/control-ref-transport.mjs"; import { GitJsonLedger } from "../scripts/lib/git-json-ledger.mjs"; +import { parseGitHubRepositoryRemote } from "../scripts/lib/github-repository.mjs"; import { runGit } from "../scripts/lib/git-process.mjs"; import { createReleaseIntent, validateReleaseApproval, validateReleaseIntent } from "../scripts/lib/release-operation.mjs"; import { planRelease } from "../scripts/lib/release-planner.mjs"; @@ -116,11 +117,13 @@ test("approved release publishes exact control ref, annotated tag, and GitHub re const parent = await store.resolveRef("HEAD^"); const ledger = await GitJsonLedger.open({ repoPath: fixture.seed, ref: "refs/tabellio/validations" }); const validationWrite = await ledger.write("commits/result.json", { status: "passed" }, { expectedVersion: null }); + await runGit({ args: ["update-ref", "refs/tabellio/reviews", head], cwd: fixture.seed }); + await runGit({ args: ["push", "control", "refs/tabellio/reviews"], cwd: fixture.seed }); const controlIntent = createControlRefIntent({ operation: "publish", repositoryId, remote: "control", - refs: await snapshotControlRefs({ repoPath: fixture.seed, remote: "control", refs: ["refs/tabellio/validations"] }), + refs: await snapshotControlRefs({ repoPath: fixture.seed, remote: "control", refs: ["refs/tabellio/validations", "refs/tabellio/reviews"] }), createdAt, }); const intent = createReleaseIntent({ @@ -180,13 +183,21 @@ test("approved release publishes exact control ref, annotated tag, and GitHub re await writeFile(join(fixture.seed, notesPath), "MUTATED WORKTREE NOTES\n"); await assert.rejects(executor.execute({ intent, approval, now }), /clean worktree/); await runGit({ args: ["restore", notesPath], cwd: fixture.seed }); + await runGit({ args: ["config", "--unset", "user.name"], cwd: fixture.seed }); + await runGit({ args: ["config", "--unset", "user.email"], cwd: fixture.seed }); const result = await executor.execute({ intent, approval, now }); assert.equal(result.receipt.status, "succeeded"); assert.equal(Object.hasOwn(result.receipt, "receiptPath"), false); assert.equal(result.receiptPath, join(stateRoot, "publish-release.json")); - assert.equal(liveReads, 6); - assert.equal(ghCalls.length, 2); - assert.deepEqual(publishedNotes, ["Release 1.2.3\n"]); + const approvedTagObject = (await runGit({ args: ["rev-parse", `refs/tags/${intent.tag}`], cwd: fixture.seed })).stdout.trim(); + + await runGit({ args: ["push", "control", ":refs/tabellio/validations"], cwd: fixture.seed }); + await runGit({ args: ["push", "origin", `:refs/tags/${intent.tag}`], cwd: fixture.seed }); + const reconciled = await executor.execute({ intent, approval, now }); + assert.equal(reconciled.receipt.status, "succeeded"); + assert.equal(liveReads, 7); + assert.equal(ghCalls.length, 4); + assert.deepEqual(publishedNotes, ["Release 1.2.3\n", "Release 1.2.3\n"]); const remoteTag = await runGit({ args: ["ls-remote", "--tags", "origin", "refs/tags/v1.2.3^{}"], cwd: fixture.seed }); assert.equal(remoteTag.stdout.split(/\s+/)[0], head); const remoteControl = await runGit({ args: ["ls-remote", "control", "refs/tabellio/validations"], cwd: fixture.seed }); @@ -194,6 +205,23 @@ test("approved release publishes exact control ref, annotated tag, and GitHub re const stored = JSON.parse(await readFile(join(stateRoot, "publish-release.json"), "utf8")); assert.equal(stored.status, "succeeded"); + await runGit({ args: ["tag", "-d", intent.tag], cwd: fixture.seed }); + await runGit({ + args: ["tag", "-a", intent.tag, "-m", intent.release.title, intent.revision.commit], + cwd: fixture.seed, + env: { + GIT_COMMITTER_NAME: "Different Tagger", + GIT_COMMITTER_EMAIL: "different@example.invalid", + GIT_COMMITTER_DATE: "2026-07-15T12:30:00.000Z", + }, + }); + const alternateTagObject = (await runGit({ args: ["rev-parse", `refs/tags/${intent.tag}`], cwd: fixture.seed })).stdout.trim(); + assert.notEqual(alternateTagObject, approvedTagObject); + await runGit({ args: ["push", "--force", "origin", intent.tag], cwd: fixture.seed }); + await runGit({ args: ["update-ref", `refs/tags/${intent.tag}`, approvedTagObject], cwd: fixture.seed }); + await assert.rejects(executor.execute({ intent, approval, now }), /annotated tag objects differ/); + await runGit({ args: ["push", "--force", "origin", intent.tag], cwd: fixture.seed }); + existingRelease = { tagName: intent.tag, name: intent.release.title, @@ -265,6 +293,11 @@ test("release planning binds merged PR proof after exact validation and terminal } throw new Error(`Unexpected command: ${args.join(" ")}`); }; + const remoteRepositoryReader = async (_store, remote) => parseGitHubRepositoryRemote( + remote === "origin" + ? "https://github.com/example/repository.git" + : "https://github.com/example/repository-control.git", + ); await assert.rejects(planRelease({ repoPath: fixture.seed, owner: "example", @@ -284,6 +317,7 @@ test("release planning binds merged PR proof after exact validation and terminal version: "2.0.0", notesPath, preflightRunner: async () => ({ status: "ready", checks: [] }), + remoteRepositoryReader, now, }), /does not match origin example\/repository/); const intent = await planRelease({ @@ -298,6 +332,7 @@ test("release planning binds merged PR proof after exact validation and terminal commandRunner, preflightRunner: async () => ({ status: "ready", checks: [] }), githubProvider: provider, + remoteRepositoryReader, now, }); assert.equal(intent.revision.commit, head); From 4d29e282f9f40ddf5a5841f2f8609f8556d939ac Mon Sep 17 00:00:00 2001 From: hudsonaikins-crown Date: Wed, 15 Jul 2026 18:13:46 -0700 Subject: [PATCH 08/12] fix: preserve approved tag messages Entire-Checkpoint: 1e7d65448717 --- docs/releases/v0.3.0.md | 2 +- scripts/lib/release-workflow.mjs | 2 +- tests/release-workflow.test.mjs | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/releases/v0.3.0.md b/docs/releases/v0.3.0.md index 2f899b9..7aa6194 100644 --- a/docs/releases/v0.3.0.md +++ b/docs/releases/v0.3.0.md @@ -10,4 +10,4 @@ Tabellio v0.3.0 makes release readiness and publication deterministic. Merge remains an explicit operator action. This prevents a release approval from authorizing an unknown squash commit. -For squash merges, validation commands run against the exact merged commit while checkpoint evidence remains bound to the separately recorded pull-request head. Annotated tags use a deterministic automation tagger and exact object/message reconciliation. The CLI returns the schema-valid receipt and its local `receiptPath` as separate fields. +For squash merges, validation commands run against the exact merged commit while checkpoint evidence remains bound to the separately recorded pull-request head. Annotated tags use a deterministic automation tagger, verbatim approved titles, and exact object/message reconciliation. The CLI returns the schema-valid receipt and its local `receiptPath` as separate fields. diff --git a/scripts/lib/release-workflow.mjs b/scripts/lib/release-workflow.mjs index 6be08cd..02df15c 100644 --- a/scripts/lib/release-workflow.mjs +++ b/scripts/lib/release-workflow.mjs @@ -295,7 +295,7 @@ async function ensureLocalTag(repoPath, intent, local, remote) { return; } await runGit({ - args: ["tag", "-a", intent.tag, "-m", intent.release.title, intent.revision.commit], + args: ["tag", "-a", "--cleanup=verbatim", intent.tag, "-m", intent.release.title, intent.revision.commit], cwd: repoPath, env: { GIT_COMMITTER_NAME: "Tabellio Release Automation", diff --git a/tests/release-workflow.test.mjs b/tests/release-workflow.test.mjs index bb857af..8450881 100644 --- a/tests/release-workflow.test.mjs +++ b/tests/release-workflow.test.mjs @@ -134,7 +134,7 @@ test("approved release publishes exact control ref, annotated tag, and GitHub re controlIntent, controlRepository: { id: "github.com/example/repository-control" }, validation: { runId: "validation-test", resultVersion: validationWrite.version, status: "passed", headCommit: head }, - release: { title: "Tabellio v1.2.3", notesPath, notesDigest: digest("Release 1.2.3\n") }, + release: { title: "# Tabellio v1.2.3 ", notesPath, notesDigest: digest("Release 1.2.3\n") }, createdAt, }); const ghCalls = []; @@ -207,7 +207,7 @@ test("approved release publishes exact control ref, annotated tag, and GitHub re await runGit({ args: ["tag", "-d", intent.tag], cwd: fixture.seed }); await runGit({ - args: ["tag", "-a", intent.tag, "-m", intent.release.title, intent.revision.commit], + args: ["tag", "-a", "--cleanup=verbatim", intent.tag, "-m", intent.release.title, intent.revision.commit], cwd: fixture.seed, env: { GIT_COMMITTER_NAME: "Different Tagger", From 3dd78619ed59eaef0ba455f044824ede61e0fc24 Mon Sep 17 00:00:00 2001 From: hudsonaikins-crown Date: Wed, 15 Jul 2026 18:33:18 -0700 Subject: [PATCH 09/12] fix: version release evidence contracts Entire-Checkpoint: 1e7d65448717 --- docs/releases/v0.3.0.md | 6 +- docs/review-loop.md | 2 +- docs/validation-runner.md | 2 + examples/tabellio-review/minimal-cycle.json | 4 +- .../tabellio-validation/minimal-result.json | 4 +- schemas/review-cycle.schema.json | 4 +- schemas/validation-result.schema.json | 6 +- scripts/lib/release-workflow.mjs | 55 ++++++++++++++----- scripts/lib/review-cycle.mjs | 40 +++++++++++--- scripts/lib/validation-runner.mjs | 24 +++++--- tests/release-workflow.test.mjs | 2 +- tests/review-cycle.test.mjs | 23 +++++++- 12 files changed, 128 insertions(+), 44 deletions(-) diff --git a/docs/releases/v0.3.0.md b/docs/releases/v0.3.0.md index 7aa6194..29f3cca 100644 --- a/docs/releases/v0.3.0.md +++ b/docs/releases/v0.3.0.md @@ -4,10 +4,12 @@ Tabellio v0.3.0 makes release readiness and publication deterministic. - `tabellio-preflight` catches missing or untrusted Entire hooks before commit or release work begins. - Release planning binds merged Git state, pre-merge head readiness, the platform validation manifest, both distinct GitHub repository identities, private control refs, release notes, and package version into one integrity-protected intent. -- One approved post-merge execution publishes exact control refs, an annotated Git tag, and the GitHub release. +- One approved post-merge execution publishes exact control refs, a canonical annotated Git tag, and the GitHub release. - Release receipts survive partial failures, re-verify effective fetch and push targets on every retry, and rerun idempotent publication reconciliation even after an earlier success so rolled-back remote state cannot be skipped. - Isolated repository tests prove the complete plan and publication workflow without touching live GitHub resources. Merge remains an explicit operator action. This prevents a release approval from authorizing an unknown squash commit. -For squash merges, validation commands run against the exact merged commit while checkpoint evidence remains bound to the separately recorded pull-request head. Annotated tags use a deterministic automation tagger, verbatim approved titles, and exact object/message reconciliation. The CLI returns the schema-valid receipt and its local `receiptPath` as separate fields. +For squash merges, validation commands run against the exact merged commit while checkpoint evidence remains bound to the separately recorded pull-request head. Annotated tags use one canonical Git object derived from the approved commit, tag name, title, timestamp, and automation identity; retries reject any metadata drift. The CLI returns the schema-valid receipt and its local `receiptPath` as separate fields. + +Review cycles now emit `tabellio-review-cycle/v0.3`, which records head-bound `ready` events. Validation results now emit `tabellio-validation-result/v0.2`, which requires the checkpoint revision separately from the validated revision. Runtime readers still accept v0.2 review cycles and v0.1 validation results so existing private ledgers can migrate on their next write. diff --git a/docs/review-loop.md b/docs/review-loop.md index 899e2bc..89bd7fa 100644 --- a/docs/review-loop.md +++ b/docs/review-loop.md @@ -83,7 +83,7 @@ git-spice restacks rewrite commit IDs. Tabellio retains `originalCommit` and rem ## Storage And Transport -Ledger writes create normal Git blobs, trees, and commits without changing the working tree. Concurrent writers use compare-and-swap on `refs/tabellio/reviews`; stale writers fail instead of overwriting newer state. The same implementation works in normal and bare repositories. The latest cycle retains the newest 100 audit events; older versions remain recoverable from the ledger's Git commit history. A `ready` event stores the exact pull-request head commit so release planning can prove readiness existed before the terminal merged sync. Terminal sync migrates a legacy `ready` cycle into this evidence form, while newly observed feedback or failed checks still block release. +Ledger writes create normal Git blobs, trees, and commits without changing the working tree. Concurrent writers use compare-and-swap on `refs/tabellio/reviews`; stale writers fail instead of overwriting newer state. The same implementation works in normal and bare repositories. The latest `tabellio-review-cycle/v0.3` cycle retains the newest 100 audit events; older versions remain recoverable from the ledger's Git commit history. A `ready` event stores the exact pull-request head commit so release planning can prove readiness existed before the terminal merged sync, and sync preserves that evidence when the event window is full. Terminal sync migrates a legacy v0.2 `ready` cycle into this evidence form, while newly observed feedback or failed checks still block release. To share the ledger, configure a separate private GitHub repository as the control-state remote, for example: diff --git a/docs/validation-runner.md b/docs/validation-runner.md index c7b68d9..a814a40 100644 --- a/docs/validation-runner.md +++ b/docs/validation-runner.md @@ -47,6 +47,8 @@ The runner: 7. Removes the worktree even after failure. 8. Writes an integrity-protected result to `refs/tabellio/validations` with compare-and-swap retries. +New results use `tabellio-validation-result/v0.2`. They require `checkpointRevision` so checkpoint proof remains bound to the pull-request head when the validated revision is a later squash-merge commit. Runtime readers continue to accept legacy v0.1 results. + Read the newest result for a commit: ```bash diff --git a/examples/tabellio-review/minimal-cycle.json b/examples/tabellio-review/minimal-cycle.json index a15f02e..840eeef 100644 --- a/examples/tabellio-review/minimal-cycle.json +++ b/examples/tabellio-review/minimal-cycle.json @@ -1,5 +1,5 @@ { - "schemaVersion": "tabellio-review-cycle/v0.2", + "schemaVersion": "tabellio-review-cycle/v0.3", "id": "review-example-001", "repository": { "id": "example/tabellio" }, "provider": { "id": "github", "owner": "example", "repo": "tabellio" }, @@ -55,6 +55,6 @@ "updatedAt": "2026-07-10T12:00:00.000Z", "integrity": { "algorithm": "sha256", - "digest": "0cd789f42ee49845ae468f299ef16b311728d16d091d44b8b68ed380c04f9651" + "digest": "4bc32c6f6161c4c3a2bb22924e7587734c579bf6edb4b056615cbd9737385985" } } diff --git a/examples/tabellio-validation/minimal-result.json b/examples/tabellio-validation/minimal-result.json index 9f489ce..48bbec8 100644 --- a/examples/tabellio-validation/minimal-result.json +++ b/examples/tabellio-validation/minimal-result.json @@ -1,5 +1,5 @@ { - "schemaVersion": "tabellio-validation-result/v0.1", + "schemaVersion": "tabellio-validation-result/v0.2", "runId": "validation-example-001", "repository": { "id": "example/tabellio" }, "revision": { @@ -49,6 +49,6 @@ "completedAt": "2026-07-10T12:00:01.000Z", "integrity": { "algorithm": "sha256", - "digest": "caf2abe98c754e85c4be89c702dc13511d08006db8827ac67c8d6b79c3e03d2a" + "digest": "eefca7fe3e063446cfdc3151ad9238c5015e577741e3023ffb2e0d06fcbf194d" } } diff --git a/schemas/review-cycle.schema.json b/schemas/review-cycle.schema.json index c6fca87..203ac28 100644 --- a/schemas/review-cycle.schema.json +++ b/schemas/review-cycle.schema.json @@ -1,12 +1,12 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "urn:tabellio:schema:review-cycle:v0.2", + "$id": "urn:tabellio:schema:review-cycle:v0.3", "title": "Tabellio Review Cycle", "type": "object", "required": ["schemaVersion", "id", "repository", "provider", "changeRequest", "status", "round", "feedback", "fixes", "checks", "events", "createdAt", "updatedAt", "integrity"], "additionalProperties": false, "properties": { - "schemaVersion": { "const": "tabellio-review-cycle/v0.2" }, + "schemaVersion": { "const": "tabellio-review-cycle/v0.3" }, "id": { "type": "string", "minLength": 1 }, "repository": { "type": "object", diff --git a/schemas/validation-result.schema.json b/schemas/validation-result.schema.json index 5d379dd..7781cf9 100644 --- a/schemas/validation-result.schema.json +++ b/schemas/validation-result.schema.json @@ -1,12 +1,12 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "urn:tabellio:schema:validation-result:v0.1", + "$id": "urn:tabellio:schema:validation-result:v0.2", "title": "Tabellio Validation Result", "type": "object", - "required": ["schemaVersion", "runId", "repository", "revision", "suite", "runner", "status", "checkpoints", "commands", "startedAt", "completedAt", "integrity"], + "required": ["schemaVersion", "runId", "repository", "revision", "checkpointRevision", "suite", "runner", "status", "checkpoints", "commands", "startedAt", "completedAt", "integrity"], "additionalProperties": false, "properties": { - "schemaVersion": { "const": "tabellio-validation-result/v0.1" }, + "schemaVersion": { "const": "tabellio-validation-result/v0.2" }, "runId": { "type": "string", "minLength": 1 }, "repository": { "type": "object", diff --git a/scripts/lib/release-workflow.mjs b/scripts/lib/release-workflow.mjs index 02df15c..c079a3a 100644 --- a/scripts/lib/release-workflow.mjs +++ b/scripts/lib/release-workflow.mjs @@ -152,13 +152,14 @@ class ReleaseActions { async #publishTag({ intent }) { const repositoryId = await this.codeRepositoryReader(this.store); assertRepositoryIdentity(repositoryId, intent.repository.id, "Release repository identity changed before tag publication."); + const canonicalObject = await canonicalTagObject(this.store.repoPath, intent); let local = await localTagState(this.store.repoPath, intent.tag); const remote = await remoteTagState(this.store.repoPath, intent.code.remote, intent.tag); - assertTagTarget(local, intent, "locally"); - assertTagTarget(remote, intent, "remotely"); - await ensureLocalTag(this.store.repoPath, intent, local, remote); + assertTagTarget(local, intent, "locally", canonicalObject); + assertTagTarget(remote, intent, "remotely", canonicalObject); + await ensureLocalTag(this.store.repoPath, intent, local, remote, canonicalObject); local = await localTagState(this.store.repoPath, intent.tag); - assertTagTarget(local, intent, "locally"); + assertTagTarget(local, intent, "locally", canonicalObject); assertMatchingTagObjects(local, remote, intent.tag); await ensureRemoteTag(this.store.repoPath, intent, remote); return { tag: intent.tag, commit: intent.revision.commit, status: tagPublishStatus(remote) }; @@ -265,11 +266,12 @@ function assertReleaseArtifacts(actual, intent) { controlPublicationState(actual.refs, intent.control.intent.refs); } -function assertTagTarget(target, intent, location) { +function assertTagTarget(target, intent, location, canonicalObject) { if (!target) return; assertAnnotatedTag(target, intent.tag, location); assertTagCommit(target, intent, location); assertTagMessage(target, intent, location); + assertCanonicalTag(target, intent.tag, location, canonicalObject); } function assertAnnotatedTag(target, tag, location) { @@ -284,25 +286,48 @@ function assertTagMessage(target, intent, location) { if (target.message !== intent.release.title) throw new Error(`${intent.tag} exists ${location} with a different annotation.`); } +function assertCanonicalTag(target, tag, location, canonicalObject) { + if (target.object !== canonicalObject) throw new Error(`${tag} exists ${location} with noncanonical metadata.`); +} + function assertMatchingTagObjects(local, remote, tag) { if (local && remote && local.object !== remote.object) throw new Error(`${tag} local and remote annotated tag objects differ.`); } -async function ensureLocalTag(repoPath, intent, local, remote) { +async function ensureLocalTag(repoPath, intent, local, remote, canonicalObject) { if (local) return; if (remote) { await runGit({ args: ["update-ref", `refs/tags/${intent.tag}`, remote.object], cwd: repoPath }); return; } - await runGit({ - args: ["tag", "-a", "--cleanup=verbatim", intent.tag, "-m", intent.release.title, intent.revision.commit], - cwd: repoPath, - env: { - GIT_COMMITTER_NAME: "Tabellio Release Automation", - GIT_COMMITTER_EMAIL: "release@tabellio.invalid", - GIT_COMMITTER_DATE: intent.createdAt, - }, - }); + const written = await canonicalTagObject(repoPath, intent, { write: true }); + assertEqual(written, canonicalObject, "Canonical release tag object changed while writing."); + await runGit({ args: ["update-ref", `refs/tags/${intent.tag}`, written], cwd: repoPath }); +} + +async function canonicalTagObject(repoPath, intent, { write = false } = {}) { + const directory = await mkdtemp(join(tmpdir(), "tabellio-tag-object-")); + const path = join(directory, "tag"); + try { + await writeFile(path, canonicalTagPayload(intent), { mode: 0o600 }); + const result = await runGit({ args: ["hash-object", ...(write ? ["-w"] : []), "-t", "tag", path], cwd: repoPath }); + return result.stdout.trim(); + } finally { + await rm(directory, { recursive: true, force: true }); + } +} + +function canonicalTagPayload(intent) { + const timestamp = Math.floor(Date.parse(intent.createdAt) / 1_000); + return [ + `object ${intent.revision.commit}`, + "type commit", + `tag ${intent.tag}`, + `tagger Tabellio Release Automation ${timestamp} +0000`, + "", + intent.release.title, + "", + ].join("\n"); } async function ensureRemoteTag(repoPath, intent, remote) { diff --git a/scripts/lib/review-cycle.mjs b/scripts/lib/review-cycle.mjs index abce2f5..c656a13 100644 --- a/scripts/lib/review-cycle.mjs +++ b/scripts/lib/review-cycle.mjs @@ -4,7 +4,8 @@ import { runGit } from "./git-process.mjs"; import { digestObject } from "./stack-operation.mjs"; import { latestValidationResult } from "./validation-runner.mjs"; -const REVIEW_CYCLE_SCHEMA_VERSION = "tabellio-review-cycle/v0.2"; +const REVIEW_CYCLE_SCHEMA_VERSION_V2 = "tabellio-review-cycle/v0.2"; +const REVIEW_CYCLE_SCHEMA_VERSION_V3 = "tabellio-review-cycle/v0.3"; const AGENT_REVIEW_SCHEMA_VERSION = "tabellio-agent-review/v0.1"; const MAX_FEEDBACK_ITEMS = 5_000; const MAX_AGENT_FINDINGS = 1_000; @@ -59,7 +60,7 @@ export class ReviewCycleManager { ))); const headChanged = existing && existing.changeRequest.headCommit !== changeRequest.source.commit; const cycle = { - schemaVersion: REVIEW_CYCLE_SCHEMA_VERSION, + schemaVersion: REVIEW_CYCLE_SCHEMA_VERSION_V3, id: existing?.id ?? cycleId(this.repositoryId, this.owner, this.repo, number), repository: { id: this.repositoryId }, provider: { id: "github", owner: this.owner, repo: this.repo }, @@ -82,7 +83,11 @@ export class ReviewCycleManager { feedback, fixes, checks, - events: appendEvent(priorEvents, event("synced", actor, timestamp, `Synced ${feedback.length} feedback items at ${changeRequest.source.commit}.`)), + events: appendSyncedEvent( + priorEvents, + event("synced", actor, timestamp, `Synced ${feedback.length} feedback items at ${changeRequest.source.commit}.`), + existing, + ), createdAt: existing?.createdAt ?? timestamp, updatedAt: timestamp, integrity: { algorithm: "sha256", digest: "0".repeat(64) }, @@ -206,6 +211,7 @@ export class ReviewCycleManager { } async #save(cycle, expectedVersion) { + cycle.schemaVersion = REVIEW_CYCLE_SCHEMA_VERSION_V3; const status = deriveStatus(cycle); recordReadyEvidence(cycle, status); cycle.status = status; @@ -271,7 +277,7 @@ export function reviewCycleHasReleaseReadiness(cycle, headCommit) { export function validateReviewCycle(value) { object(value, "review cycle"); exactKeys(value, ["schemaVersion", "id", "repository", "provider", "changeRequest", "status", "round", "feedback", "fixes", "checks", "events", "createdAt", "updatedAt", "integrity"], "review cycle"); - equals(value.schemaVersion, REVIEW_CYCLE_SCHEMA_VERSION, "schemaVersion"); + member(value.schemaVersion, [REVIEW_CYCLE_SCHEMA_VERSION_V2, REVIEW_CYCLE_SCHEMA_VERSION_V3], "schemaVersion"); requiredString(value.id, "id"); object(value.repository, "repository"); exactKeys(value.repository, ["id"], "repository"); @@ -307,7 +313,7 @@ export function validateReviewCycle(value) { validateChecks(value.checks); if (!Array.isArray(value.events)) throw new Error("events must be an array."); if (value.events.length > 100) throw new Error("events must contain at most 100 entries."); - value.events.forEach((item, index) => validateEvent(item, `events[${index}]`)); + value.events.forEach((item, index) => validateEvent(item, `events[${index}]`, value.schemaVersion)); date(value.createdAt, "createdAt"); date(value.updatedAt, "updatedAt"); object(value.integrity, "integrity"); @@ -553,6 +559,15 @@ function appendEvent(events, next) { return [...events, next].slice(-100); } +function appendSyncedEvent(events, next, existing) { + const appended = appendEvent(events, next); + if (!existing) return appended; + const headCommit = existing.changeRequest.headCommit; + if (!reviewCycleHasReadyEvidence(existing, headCommit)) return appended; + if (reviewCycleHasReadyEvidence({ events: appended }, headCommit)) return appended; + return appendEvent(appended, event("ready", next.actor, next.at, headCommit)); +} + function eventsWithLegacyReadiness(existing) { if (!existing) return []; if (existing.status !== "ready") return existing.events; @@ -651,16 +666,25 @@ function validateChecks(value) { } } -function validateEvent(value, path) { +function validateEvent(value, path, schemaVersion) { object(value, path); exactKeys(value, ["id", "type", "actor", "at", "detail"], path); requiredString(value.id, `${path}.id`); - member(value.type, ["synced", "triaged", "fix-recorded", "agent-review-imported", "ready"], `${path}.type`); + member(value.type, reviewEventTypes(schemaVersion), `${path}.type`); requiredString(value.actor, `${path}.actor`); date(value.at, `${path}.at`); requiredString(value.detail, `${path}.detail`); maxLength(value.detail, 2_000, `${path}.detail`); - if (value.type === "ready") oid(value.detail, `${path}.detail`); + validateReadyEvent(value, path, schemaVersion); +} + +function reviewEventTypes(schemaVersion) { + const types = ["synced", "triaged", "fix-recorded", "agent-review-imported"]; + return schemaVersion === REVIEW_CYCLE_SCHEMA_VERSION_V3 ? [...types, "ready"] : types; +} + +function validateReadyEvent(value, path, schemaVersion) { + if (schemaVersion === REVIEW_CYCLE_SCHEMA_VERSION_V3 && value.type === "ready") oid(value.detail, `${path}.detail`); } function exactKeys(value, expected, path) { diff --git a/scripts/lib/validation-runner.mjs b/scripts/lib/validation-runner.mjs index 54fc718..1986498 100644 --- a/scripts/lib/validation-runner.mjs +++ b/scripts/lib/validation-runner.mjs @@ -8,7 +8,8 @@ import { runGit } from "./git-process.mjs"; import { digestObject } from "./stack-operation.mjs"; const VALIDATION_MANIFEST_SCHEMA_VERSION = "tabellio-validation/v0.1"; -const VALIDATION_RESULT_SCHEMA_VERSION = "tabellio-validation-result/v0.1"; +const VALIDATION_RESULT_SCHEMA_VERSION_V1 = "tabellio-validation-result/v0.1"; +const VALIDATION_RESULT_SCHEMA_VERSION_V2 = "tabellio-validation-result/v0.2"; const MAX_OUTPUT_TAIL_BYTES = 16 * 1024; export class ValidationRunner { @@ -80,7 +81,7 @@ export class ValidationRunner { const completedAt = new Date().toISOString(); const requiredFailed = commands.some((command, index) => manifest.commands[index].required && command.status !== "passed"); const result = { - schemaVersion: VALIDATION_RESULT_SCHEMA_VERSION, + schemaVersion: VALIDATION_RESULT_SCHEMA_VERSION_V2, runId, repository: { id: repositoryId }, revision, @@ -154,16 +155,14 @@ export function validateValidationManifest(value) { export function validateValidationResult(value) { object(value, "validation result"); - const keys = ["schemaVersion", "runId", "repository", "revision", "suite", "runner", "status", "checkpoints", "commands", "startedAt", "completedAt", "integrity"]; - if (Object.hasOwn(value, "checkpointRevision")) keys.push("checkpointRevision"); - exactKeys(value, keys, "validation result"); - equals(value.schemaVersion, VALIDATION_RESULT_SCHEMA_VERSION, "validation result.schemaVersion"); + member(value.schemaVersion, [VALIDATION_RESULT_SCHEMA_VERSION_V1, VALIDATION_RESULT_SCHEMA_VERSION_V2], "validation result.schemaVersion"); + exactKeys(value, validationResultKeys(value.schemaVersion), "validation result"); requiredString(value.runId, "validation result.runId"); object(value.repository, "validation result.repository"); exactKeys(value.repository, ["id"], "validation result.repository"); requiredString(value.repository.id, "validation result.repository.id"); validateRevision(value.revision, "validation result.revision"); - if (value.checkpointRevision !== undefined) validateRevision(value.checkpointRevision, "validation result.checkpointRevision"); + validateCheckpointRevision(value); object(value.suite, "validation result.suite"); exactKeys(value.suite, ["id", "manifestPath", "manifestDigest"], "validation result.suite"); requiredString(value.suite.id, "validation result.suite.id"); @@ -189,6 +188,17 @@ export function validateValidationResult(value) { return value; } +function validationResultKeys(schemaVersion) { + const keys = ["schemaVersion", "runId", "repository", "revision", "suite", "runner", "status", "checkpoints", "commands", "startedAt", "completedAt", "integrity"]; + return schemaVersion === VALIDATION_RESULT_SCHEMA_VERSION_V2 ? [...keys, "checkpointRevision"] : keys; +} + +function validateCheckpointRevision(value) { + if (value.schemaVersion === VALIDATION_RESULT_SCHEMA_VERSION_V2) { + validateRevision(value.checkpointRevision, "validation result.checkpointRevision"); + } +} + function validateRevision(value, path) { object(value, path); exactKeys(value, ["baseCommit", "mergeBase", "headCommit"], path); diff --git a/tests/release-workflow.test.mjs b/tests/release-workflow.test.mjs index 8450881..ad2fb2f 100644 --- a/tests/release-workflow.test.mjs +++ b/tests/release-workflow.test.mjs @@ -219,7 +219,7 @@ test("approved release publishes exact control ref, annotated tag, and GitHub re assert.notEqual(alternateTagObject, approvedTagObject); await runGit({ args: ["push", "--force", "origin", intent.tag], cwd: fixture.seed }); await runGit({ args: ["update-ref", `refs/tags/${intent.tag}`, approvedTagObject], cwd: fixture.seed }); - await assert.rejects(executor.execute({ intent, approval, now }), /annotated tag objects differ/); + await assert.rejects(executor.execute({ intent, approval, now }), /noncanonical metadata/); await runGit({ args: ["push", "--force", "origin", intent.tag], cwd: fixture.seed }); existingRelease = { diff --git a/tests/review-cycle.test.mjs b/tests/review-cycle.test.mjs index 4feb4c0..8dace1f 100644 --- a/tests/review-cycle.test.mjs +++ b/tests/review-cycle.test.mjs @@ -131,6 +131,7 @@ test("review cycle persists GitHub and agent feedback through triage and checkpo assert.equal(validateReviewCycle(result.cycle), result.cycle); const legacyReady = structuredClone(result.cycle); + legacyReady.schemaVersion = "tabellio-review-cycle/v0.2"; legacyReady.events = legacyReady.events.filter((item) => item.type !== "ready"); const { integrity: _integrity, ...legacyUnsigned } = legacyReady; legacyReady.integrity = { algorithm: "sha256", digest: digestObject(legacyUnsigned) }; @@ -141,6 +142,26 @@ test("review cycle persists GitHub and agent feedback through triage and checkpo assert.equal(reviewCycleHasReadyEvidence(result.cycle, fixCommit2), true); assert.equal(reviewCycleHasReleaseReadiness(result.cycle, fixCommit2), true); + const saturated = structuredClone(result.cycle); + const readyEvent = saturated.events.find((item) => item.type === "ready"); + saturated.events = [ + readyEvent, + ...Array.from({ length: 99 }, (_, index) => ({ + id: `saturation-${index}`, + type: "synced", + actor: "sync-agent", + at: "2026-07-10T20:06:35.000Z", + detail: `Saturation event ${index}.`, + })), + ]; + const { integrity: _saturatedIntegrity, ...saturatedUnsigned } = saturated; + saturated.integrity = { algorithm: "sha256", digest: digestObject(saturatedUnsigned) }; + await ledger.write(result.path, saturated, { expectedVersion: result.version }); + result = await manager.sync({ number: 7, actor: "sync-agent", now: new Date("2026-07-10T20:06:40.000Z") }); + assert.equal(result.cycle.events.length, 100); + assert.equal(reviewCycleHasReadyEvidence(result.cycle, fixCommit2), true); + assert.equal(reviewCycleHasReleaseReadiness(result.cycle, fixCommit2), true); + provider.setChecks("failure"); result = await manager.sync({ number: 7, actor: "sync-agent", now: new Date("2026-07-10T20:06:45.000Z") }); assert.equal(result.cycle.status, "merged"); @@ -160,7 +181,7 @@ test("review cycle persists GitHub and agent feedback through triage and checkpo tampered.status = "ready"; assert.throws(() => validateReviewCycle(tampered), /digest does not match|status does not match/); const history = await runGit({ args: ["rev-list", "--count", "refs/tabellio/reviews"], cwd: fixture.seed }); - assert.equal(Number(history.stdout.trim()), 12); + assert.equal(Number(history.stdout.trim()), 14); const worktree = await runGit({ args: ["status", "--porcelain=v1"], cwd: fixture.seed }); assert.equal(worktree.stdout, ""); }); From 5413ea000d89d5d6362b6cd025216fae17e9e6af Mon Sep 17 00:00:00 2001 From: hudsonaikins-crown Date: Wed, 15 Jul 2026 19:00:26 -0700 Subject: [PATCH 10/12] fix: harden release publication invariants Entire-Checkpoint: 1e7d65448717 --- docs/releases/v0.3.0.md | 2 + schemas/release-operation.schema.json | 15 +- schemas/review-cycle.schema.json | 163 +------------------- schemas/review-cycle.v0.2.schema.json | 161 ++++++++++++++++++++ schemas/review-cycle.v0.3.schema.json | 165 +++++++++++++++++++++ schemas/validation-result.schema.json | 104 +------------ schemas/validation-result.v0.1.schema.json | 96 ++++++++++++ schemas/validation-result.v0.2.schema.json | 106 +++++++++++++ scripts/lib/release-operation.mjs | 13 ++ scripts/lib/release-workflow.mjs | 31 +++- tests/context-and-evidence.test.mjs | 31 +++- tests/release-workflow.test.mjs | 42 +++++- 12 files changed, 652 insertions(+), 277 deletions(-) create mode 100644 schemas/review-cycle.v0.2.schema.json create mode 100644 schemas/review-cycle.v0.3.schema.json create mode 100644 schemas/validation-result.v0.1.schema.json create mode 100644 schemas/validation-result.v0.2.schema.json diff --git a/docs/releases/v0.3.0.md b/docs/releases/v0.3.0.md index 29f3cca..f3d7a0c 100644 --- a/docs/releases/v0.3.0.md +++ b/docs/releases/v0.3.0.md @@ -13,3 +13,5 @@ Merge remains an explicit operator action. This prevents a release approval from For squash merges, validation commands run against the exact merged commit while checkpoint evidence remains bound to the separately recorded pull-request head. Annotated tags use one canonical Git object derived from the approved commit, tag name, title, timestamp, and automation identity; retries reject any metadata drift. The CLI returns the schema-valid receipt and its local `receiptPath` as separate fields. Review cycles now emit `tabellio-review-cycle/v0.3`, which records head-bound `ready` events. Validation results now emit `tabellio-validation-result/v0.2`, which requires the checkpoint revision separately from the validated revision. Runtime readers still accept v0.2 review cycles and v0.1 validation results so existing private ledgers can migrate on their next write. + +Released review-cycle and validation-result schemas remain available under immutable versioned filenames. Release execution requires all three canonical control refs, stores custom receipt state outside the worktree, and rechecks live `origin/main` immediately before tag publication. diff --git a/schemas/release-operation.schema.json b/schemas/release-operation.schema.json index 84e3c5a..00e1bb9 100644 --- a/schemas/release-operation.schema.json +++ b/schemas/release-operation.schema.json @@ -58,7 +58,20 @@ "required": ["id"], "properties": { "id": { "type": "string", "minLength": 1 } } }, - "intent": { "$ref": "urn:tabellio:schema:control-ref-operation:v0.1" } + "intent": { + "$ref": "urn:tabellio:schema:control-ref-operation:v0.1", + "properties": { + "refs": { + "minItems": 3, + "maxItems": 3, + "allOf": [ + { "contains": { "type": "object", "properties": { "name": { "const": "refs/tabellio/reviews" } }, "required": ["name"] }, "minContains": 1 }, + { "contains": { "type": "object", "properties": { "name": { "const": "refs/tabellio/validations" } }, "required": ["name"] }, "minContains": 1 }, + { "contains": { "type": "object", "properties": { "name": { "const": "refs/heads/entire/checkpoints/v1" } }, "required": ["name"] }, "minContains": 1 } + ] + } + } + } } }, "validation": { diff --git a/schemas/review-cycle.schema.json b/schemas/review-cycle.schema.json index 203ac28..689dd62 100644 --- a/schemas/review-cycle.schema.json +++ b/schemas/review-cycle.schema.json @@ -1,165 +1,4 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "urn:tabellio:schema:review-cycle:v0.3", - "title": "Tabellio Review Cycle", - "type": "object", - "required": ["schemaVersion", "id", "repository", "provider", "changeRequest", "status", "round", "feedback", "fixes", "checks", "events", "createdAt", "updatedAt", "integrity"], - "additionalProperties": false, - "properties": { - "schemaVersion": { "const": "tabellio-review-cycle/v0.3" }, - "id": { "type": "string", "minLength": 1 }, - "repository": { - "type": "object", - "required": ["id"], - "additionalProperties": false, - "properties": { "id": { "type": "string", "minLength": 1 } } - }, - "provider": { - "type": "object", - "required": ["id", "owner", "repo"], - "additionalProperties": false, - "properties": { - "id": { "const": "github" }, - "owner": { "type": "string", "minLength": 1 }, - "repo": { "type": "string", "minLength": 1 } - } - }, - "changeRequest": { - "type": "object", - "required": ["id", "number", "url", "title", "state", "draft", "mergeable", "headBranch", "headCommit", "baseBranch", "baseCommit", "updatedAt"], - "additionalProperties": false, - "properties": { - "id": { "type": "string", "minLength": 1 }, - "number": { "type": "integer", "minimum": 1 }, - "url": { "type": "string", "format": "uri", "pattern": "^https?://" }, - "title": { "type": "string", "minLength": 1, "maxLength": 500 }, - "state": { "enum": ["open", "closed", "merged"] }, - "draft": { "type": "boolean" }, - "mergeable": { "type": ["boolean", "null"] }, - "headBranch": { "type": "string", "minLength": 1 }, - "headCommit": { "$ref": "#/$defs/oid" }, - "baseBranch": { "type": "string", "minLength": 1 }, - "baseCommit": { "$ref": "#/$defs/oid" }, - "updatedAt": { "type": "string", "format": "date-time" } - } - }, - "status": { "enum": ["draft", "needs_triage", "changes_requested", "update_required", "blocked", "validating", "ready", "merged", "closed"] }, - "round": { "type": "integer", "minimum": 1 }, - "feedback": { - "type": "array", - "maxItems": 5000, - "items": { "$ref": "#/$defs/feedback" } - }, - "fixes": { - "type": "array", - "maxItems": 5000, - "items": { "$ref": "#/$defs/fix" } - }, - "checks": { "$ref": "#/$defs/checks" }, - "events": { - "type": "array", - "maxItems": 100, - "items": { "$ref": "#/$defs/event" } - }, - "createdAt": { "type": "string", "format": "date-time" }, - "updatedAt": { "type": "string", "format": "date-time" }, - "integrity": { - "type": "object", - "required": ["algorithm", "digest"], - "additionalProperties": false, - "properties": { - "algorithm": { "const": "sha256" }, - "digest": { "type": "string", "pattern": "^[0-9a-f]{64}$" } - } - } - }, - "$defs": { - "oid": { "type": "string", "pattern": "^(?:[0-9a-f]{40}|[0-9a-f]{64})$" }, - "nullableString": { "oneOf": [{ "type": "string", "minLength": 1 }, { "type": "null" }] }, - "nullableDate": { "oneOf": [{ "type": "string", "format": "date-time" }, { "type": "null" }] }, - "feedback": { - "type": "object", - "required": ["id", "source", "providerId", "kind", "author", "title", "body", "path", "line", "commit", "severity", "providerState", "disposition", "resolution", "fixId", "createdAt", "updatedAt"], - "additionalProperties": false, - "properties": { - "id": { "type": "string", "minLength": 1 }, - "source": { "enum": ["github", "agent"] }, - "providerId": { "type": "string", "minLength": 1 }, - "kind": { "enum": ["review", "review-comment", "issue-comment", "check", "agent-finding"] }, - "author": { "$ref": "#/$defs/nullableString" }, - "title": { "type": "string", "minLength": 1, "maxLength": 500 }, - "body": { "type": "string", "maxLength": 65536 }, - "path": { "$ref": "#/$defs/nullableString" }, - "line": { "type": ["integer", "null"], "minimum": 1 }, - "commit": { "oneOf": [{ "$ref": "#/$defs/oid" }, { "type": "null" }] }, - "severity": { "enum": ["critical", "high", "medium", "low", "info", null] }, - "providerState": { "enum": ["open", "resolved", "stale"] }, - "disposition": { "enum": ["pending", "actionable", "informational", "wont-fix"] }, - "resolution": { "enum": ["open", "fixed", "dismissed"] }, - "fixId": { "$ref": "#/$defs/nullableString" }, - "createdAt": { "type": "string", "format": "date-time" }, - "updatedAt": { "type": "string", "format": "date-time" } - } - }, - "fix": { - "type": "object", - "required": ["id", "feedbackIds", "originalCommit", "commit", "checkpointId", "summary", "actor", "published", "createdAt"], - "additionalProperties": false, - "properties": { - "id": { "type": "string", "minLength": 1 }, - "feedbackIds": { "type": "array", "minItems": 1, "uniqueItems": true, "items": { "type": "string", "minLength": 1 } }, - "originalCommit": { "$ref": "#/$defs/oid" }, - "commit": { "$ref": "#/$defs/oid" }, - "checkpointId": { "type": "string", "minLength": 1 }, - "summary": { "type": "string", "minLength": 1, "maxLength": 2000 }, - "actor": { "type": "string", "minLength": 1 }, - "published": { "type": "boolean" }, - "createdAt": { "type": "string", "format": "date-time" } - } - }, - "checks": { - "type": "object", - "required": ["commit", "state", "total", "statuses"], - "additionalProperties": false, - "properties": { - "commit": { "$ref": "#/$defs/oid" }, - "state": { "type": "string", "minLength": 1 }, - "total": { "type": "integer", "minimum": 0 }, - "statuses": { - "type": "array", - "maxItems": 1000, - "items": { - "type": "object", - "required": ["id", "context", "state", "description", "targetUrl", "createdAt", "updatedAt"], - "additionalProperties": false, - "properties": { - "id": { "type": "string", "minLength": 1 }, - "context": { "type": "string", "minLength": 1 }, - "state": { "type": "string", "minLength": 1 }, - "description": { "$ref": "#/$defs/nullableString" }, - "targetUrl": { "$ref": "#/$defs/nullableString" }, - "createdAt": { "$ref": "#/$defs/nullableDate" }, - "updatedAt": { "$ref": "#/$defs/nullableDate" } - } - } - } - } - }, - "event": { - "type": "object", - "required": ["id", "type", "actor", "at", "detail"], - "additionalProperties": false, - "properties": { - "id": { "type": "string", "minLength": 1 }, - "type": { "enum": ["synced", "triaged", "fix-recorded", "agent-review-imported", "ready"] }, - "actor": { "type": "string", "minLength": 1 }, - "at": { "type": "string", "format": "date-time" }, - "detail": { "type": "string", "minLength": 1, "maxLength": 2000 } - }, - "allOf": [{ - "if": { "properties": { "type": { "const": "ready" } }, "required": ["type"] }, - "then": { "properties": { "detail": { "$ref": "#/$defs/oid" } } } - }] - } - } + "$ref": "review-cycle.v0.3.schema.json" } diff --git a/schemas/review-cycle.v0.2.schema.json b/schemas/review-cycle.v0.2.schema.json new file mode 100644 index 0000000..34e5c2b --- /dev/null +++ b/schemas/review-cycle.v0.2.schema.json @@ -0,0 +1,161 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "urn:tabellio:schema:review-cycle:v0.2", + "title": "Tabellio Review Cycle", + "type": "object", + "required": ["schemaVersion", "id", "repository", "provider", "changeRequest", "status", "round", "feedback", "fixes", "checks", "events", "createdAt", "updatedAt", "integrity"], + "additionalProperties": false, + "properties": { + "schemaVersion": { "const": "tabellio-review-cycle/v0.2" }, + "id": { "type": "string", "minLength": 1 }, + "repository": { + "type": "object", + "required": ["id"], + "additionalProperties": false, + "properties": { "id": { "type": "string", "minLength": 1 } } + }, + "provider": { + "type": "object", + "required": ["id", "owner", "repo"], + "additionalProperties": false, + "properties": { + "id": { "const": "github" }, + "owner": { "type": "string", "minLength": 1 }, + "repo": { "type": "string", "minLength": 1 } + } + }, + "changeRequest": { + "type": "object", + "required": ["id", "number", "url", "title", "state", "draft", "mergeable", "headBranch", "headCommit", "baseBranch", "baseCommit", "updatedAt"], + "additionalProperties": false, + "properties": { + "id": { "type": "string", "minLength": 1 }, + "number": { "type": "integer", "minimum": 1 }, + "url": { "type": "string", "format": "uri", "pattern": "^https?://" }, + "title": { "type": "string", "minLength": 1, "maxLength": 500 }, + "state": { "enum": ["open", "closed", "merged"] }, + "draft": { "type": "boolean" }, + "mergeable": { "type": ["boolean", "null"] }, + "headBranch": { "type": "string", "minLength": 1 }, + "headCommit": { "$ref": "#/$defs/oid" }, + "baseBranch": { "type": "string", "minLength": 1 }, + "baseCommit": { "$ref": "#/$defs/oid" }, + "updatedAt": { "type": "string", "format": "date-time" } + } + }, + "status": { "enum": ["draft", "needs_triage", "changes_requested", "update_required", "blocked", "validating", "ready", "merged", "closed"] }, + "round": { "type": "integer", "minimum": 1 }, + "feedback": { + "type": "array", + "maxItems": 5000, + "items": { "$ref": "#/$defs/feedback" } + }, + "fixes": { + "type": "array", + "maxItems": 5000, + "items": { "$ref": "#/$defs/fix" } + }, + "checks": { "$ref": "#/$defs/checks" }, + "events": { + "type": "array", + "maxItems": 100, + "items": { "$ref": "#/$defs/event" } + }, + "createdAt": { "type": "string", "format": "date-time" }, + "updatedAt": { "type": "string", "format": "date-time" }, + "integrity": { + "type": "object", + "required": ["algorithm", "digest"], + "additionalProperties": false, + "properties": { + "algorithm": { "const": "sha256" }, + "digest": { "type": "string", "pattern": "^[0-9a-f]{64}$" } + } + } + }, + "$defs": { + "oid": { "type": "string", "pattern": "^(?:[0-9a-f]{40}|[0-9a-f]{64})$" }, + "nullableString": { "oneOf": [{ "type": "string", "minLength": 1 }, { "type": "null" }] }, + "nullableDate": { "oneOf": [{ "type": "string", "format": "date-time" }, { "type": "null" }] }, + "feedback": { + "type": "object", + "required": ["id", "source", "providerId", "kind", "author", "title", "body", "path", "line", "commit", "severity", "providerState", "disposition", "resolution", "fixId", "createdAt", "updatedAt"], + "additionalProperties": false, + "properties": { + "id": { "type": "string", "minLength": 1 }, + "source": { "enum": ["github", "agent"] }, + "providerId": { "type": "string", "minLength": 1 }, + "kind": { "enum": ["review", "review-comment", "issue-comment", "check", "agent-finding"] }, + "author": { "$ref": "#/$defs/nullableString" }, + "title": { "type": "string", "minLength": 1, "maxLength": 500 }, + "body": { "type": "string", "maxLength": 65536 }, + "path": { "$ref": "#/$defs/nullableString" }, + "line": { "type": ["integer", "null"], "minimum": 1 }, + "commit": { "oneOf": [{ "$ref": "#/$defs/oid" }, { "type": "null" }] }, + "severity": { "enum": ["critical", "high", "medium", "low", "info", null] }, + "providerState": { "enum": ["open", "resolved", "stale"] }, + "disposition": { "enum": ["pending", "actionable", "informational", "wont-fix"] }, + "resolution": { "enum": ["open", "fixed", "dismissed"] }, + "fixId": { "$ref": "#/$defs/nullableString" }, + "createdAt": { "type": "string", "format": "date-time" }, + "updatedAt": { "type": "string", "format": "date-time" } + } + }, + "fix": { + "type": "object", + "required": ["id", "feedbackIds", "originalCommit", "commit", "checkpointId", "summary", "actor", "published", "createdAt"], + "additionalProperties": false, + "properties": { + "id": { "type": "string", "minLength": 1 }, + "feedbackIds": { "type": "array", "minItems": 1, "uniqueItems": true, "items": { "type": "string", "minLength": 1 } }, + "originalCommit": { "$ref": "#/$defs/oid" }, + "commit": { "$ref": "#/$defs/oid" }, + "checkpointId": { "type": "string", "minLength": 1 }, + "summary": { "type": "string", "minLength": 1, "maxLength": 2000 }, + "actor": { "type": "string", "minLength": 1 }, + "published": { "type": "boolean" }, + "createdAt": { "type": "string", "format": "date-time" } + } + }, + "checks": { + "type": "object", + "required": ["commit", "state", "total", "statuses"], + "additionalProperties": false, + "properties": { + "commit": { "$ref": "#/$defs/oid" }, + "state": { "type": "string", "minLength": 1 }, + "total": { "type": "integer", "minimum": 0 }, + "statuses": { + "type": "array", + "maxItems": 1000, + "items": { + "type": "object", + "required": ["id", "context", "state", "description", "targetUrl", "createdAt", "updatedAt"], + "additionalProperties": false, + "properties": { + "id": { "type": "string", "minLength": 1 }, + "context": { "type": "string", "minLength": 1 }, + "state": { "type": "string", "minLength": 1 }, + "description": { "$ref": "#/$defs/nullableString" }, + "targetUrl": { "$ref": "#/$defs/nullableString" }, + "createdAt": { "$ref": "#/$defs/nullableDate" }, + "updatedAt": { "$ref": "#/$defs/nullableDate" } + } + } + } + } + }, + "event": { + "type": "object", + "required": ["id", "type", "actor", "at", "detail"], + "additionalProperties": false, + "properties": { + "id": { "type": "string", "minLength": 1 }, + "type": { "enum": ["synced", "triaged", "fix-recorded", "agent-review-imported"] }, + "actor": { "type": "string", "minLength": 1 }, + "at": { "type": "string", "format": "date-time" }, + "detail": { "type": "string", "minLength": 1, "maxLength": 2000 } + } + } + } +} diff --git a/schemas/review-cycle.v0.3.schema.json b/schemas/review-cycle.v0.3.schema.json new file mode 100644 index 0000000..203ac28 --- /dev/null +++ b/schemas/review-cycle.v0.3.schema.json @@ -0,0 +1,165 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "urn:tabellio:schema:review-cycle:v0.3", + "title": "Tabellio Review Cycle", + "type": "object", + "required": ["schemaVersion", "id", "repository", "provider", "changeRequest", "status", "round", "feedback", "fixes", "checks", "events", "createdAt", "updatedAt", "integrity"], + "additionalProperties": false, + "properties": { + "schemaVersion": { "const": "tabellio-review-cycle/v0.3" }, + "id": { "type": "string", "minLength": 1 }, + "repository": { + "type": "object", + "required": ["id"], + "additionalProperties": false, + "properties": { "id": { "type": "string", "minLength": 1 } } + }, + "provider": { + "type": "object", + "required": ["id", "owner", "repo"], + "additionalProperties": false, + "properties": { + "id": { "const": "github" }, + "owner": { "type": "string", "minLength": 1 }, + "repo": { "type": "string", "minLength": 1 } + } + }, + "changeRequest": { + "type": "object", + "required": ["id", "number", "url", "title", "state", "draft", "mergeable", "headBranch", "headCommit", "baseBranch", "baseCommit", "updatedAt"], + "additionalProperties": false, + "properties": { + "id": { "type": "string", "minLength": 1 }, + "number": { "type": "integer", "minimum": 1 }, + "url": { "type": "string", "format": "uri", "pattern": "^https?://" }, + "title": { "type": "string", "minLength": 1, "maxLength": 500 }, + "state": { "enum": ["open", "closed", "merged"] }, + "draft": { "type": "boolean" }, + "mergeable": { "type": ["boolean", "null"] }, + "headBranch": { "type": "string", "minLength": 1 }, + "headCommit": { "$ref": "#/$defs/oid" }, + "baseBranch": { "type": "string", "minLength": 1 }, + "baseCommit": { "$ref": "#/$defs/oid" }, + "updatedAt": { "type": "string", "format": "date-time" } + } + }, + "status": { "enum": ["draft", "needs_triage", "changes_requested", "update_required", "blocked", "validating", "ready", "merged", "closed"] }, + "round": { "type": "integer", "minimum": 1 }, + "feedback": { + "type": "array", + "maxItems": 5000, + "items": { "$ref": "#/$defs/feedback" } + }, + "fixes": { + "type": "array", + "maxItems": 5000, + "items": { "$ref": "#/$defs/fix" } + }, + "checks": { "$ref": "#/$defs/checks" }, + "events": { + "type": "array", + "maxItems": 100, + "items": { "$ref": "#/$defs/event" } + }, + "createdAt": { "type": "string", "format": "date-time" }, + "updatedAt": { "type": "string", "format": "date-time" }, + "integrity": { + "type": "object", + "required": ["algorithm", "digest"], + "additionalProperties": false, + "properties": { + "algorithm": { "const": "sha256" }, + "digest": { "type": "string", "pattern": "^[0-9a-f]{64}$" } + } + } + }, + "$defs": { + "oid": { "type": "string", "pattern": "^(?:[0-9a-f]{40}|[0-9a-f]{64})$" }, + "nullableString": { "oneOf": [{ "type": "string", "minLength": 1 }, { "type": "null" }] }, + "nullableDate": { "oneOf": [{ "type": "string", "format": "date-time" }, { "type": "null" }] }, + "feedback": { + "type": "object", + "required": ["id", "source", "providerId", "kind", "author", "title", "body", "path", "line", "commit", "severity", "providerState", "disposition", "resolution", "fixId", "createdAt", "updatedAt"], + "additionalProperties": false, + "properties": { + "id": { "type": "string", "minLength": 1 }, + "source": { "enum": ["github", "agent"] }, + "providerId": { "type": "string", "minLength": 1 }, + "kind": { "enum": ["review", "review-comment", "issue-comment", "check", "agent-finding"] }, + "author": { "$ref": "#/$defs/nullableString" }, + "title": { "type": "string", "minLength": 1, "maxLength": 500 }, + "body": { "type": "string", "maxLength": 65536 }, + "path": { "$ref": "#/$defs/nullableString" }, + "line": { "type": ["integer", "null"], "minimum": 1 }, + "commit": { "oneOf": [{ "$ref": "#/$defs/oid" }, { "type": "null" }] }, + "severity": { "enum": ["critical", "high", "medium", "low", "info", null] }, + "providerState": { "enum": ["open", "resolved", "stale"] }, + "disposition": { "enum": ["pending", "actionable", "informational", "wont-fix"] }, + "resolution": { "enum": ["open", "fixed", "dismissed"] }, + "fixId": { "$ref": "#/$defs/nullableString" }, + "createdAt": { "type": "string", "format": "date-time" }, + "updatedAt": { "type": "string", "format": "date-time" } + } + }, + "fix": { + "type": "object", + "required": ["id", "feedbackIds", "originalCommit", "commit", "checkpointId", "summary", "actor", "published", "createdAt"], + "additionalProperties": false, + "properties": { + "id": { "type": "string", "minLength": 1 }, + "feedbackIds": { "type": "array", "minItems": 1, "uniqueItems": true, "items": { "type": "string", "minLength": 1 } }, + "originalCommit": { "$ref": "#/$defs/oid" }, + "commit": { "$ref": "#/$defs/oid" }, + "checkpointId": { "type": "string", "minLength": 1 }, + "summary": { "type": "string", "minLength": 1, "maxLength": 2000 }, + "actor": { "type": "string", "minLength": 1 }, + "published": { "type": "boolean" }, + "createdAt": { "type": "string", "format": "date-time" } + } + }, + "checks": { + "type": "object", + "required": ["commit", "state", "total", "statuses"], + "additionalProperties": false, + "properties": { + "commit": { "$ref": "#/$defs/oid" }, + "state": { "type": "string", "minLength": 1 }, + "total": { "type": "integer", "minimum": 0 }, + "statuses": { + "type": "array", + "maxItems": 1000, + "items": { + "type": "object", + "required": ["id", "context", "state", "description", "targetUrl", "createdAt", "updatedAt"], + "additionalProperties": false, + "properties": { + "id": { "type": "string", "minLength": 1 }, + "context": { "type": "string", "minLength": 1 }, + "state": { "type": "string", "minLength": 1 }, + "description": { "$ref": "#/$defs/nullableString" }, + "targetUrl": { "$ref": "#/$defs/nullableString" }, + "createdAt": { "$ref": "#/$defs/nullableDate" }, + "updatedAt": { "$ref": "#/$defs/nullableDate" } + } + } + } + } + }, + "event": { + "type": "object", + "required": ["id", "type", "actor", "at", "detail"], + "additionalProperties": false, + "properties": { + "id": { "type": "string", "minLength": 1 }, + "type": { "enum": ["synced", "triaged", "fix-recorded", "agent-review-imported", "ready"] }, + "actor": { "type": "string", "minLength": 1 }, + "at": { "type": "string", "format": "date-time" }, + "detail": { "type": "string", "minLength": 1, "maxLength": 2000 } + }, + "allOf": [{ + "if": { "properties": { "type": { "const": "ready" } }, "required": ["type"] }, + "then": { "properties": { "detail": { "$ref": "#/$defs/oid" } } } + }] + } + } +} diff --git a/schemas/validation-result.schema.json b/schemas/validation-result.schema.json index 7781cf9..b855b42 100644 --- a/schemas/validation-result.schema.json +++ b/schemas/validation-result.schema.json @@ -1,106 +1,4 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "urn:tabellio:schema:validation-result:v0.2", - "title": "Tabellio Validation Result", - "type": "object", - "required": ["schemaVersion", "runId", "repository", "revision", "checkpointRevision", "suite", "runner", "status", "checkpoints", "commands", "startedAt", "completedAt", "integrity"], - "additionalProperties": false, - "properties": { - "schemaVersion": { "const": "tabellio-validation-result/v0.2" }, - "runId": { "type": "string", "minLength": 1 }, - "repository": { - "type": "object", - "required": ["id"], - "additionalProperties": false, - "properties": { "id": { "type": "string", "minLength": 1 } } - }, - "revision": { - "type": "object", - "required": ["baseCommit", "mergeBase", "headCommit"], - "additionalProperties": false, - "properties": { - "baseCommit": { "$ref": "#/$defs/oid" }, - "mergeBase": { "$ref": "#/$defs/oid" }, - "headCommit": { "$ref": "#/$defs/oid" } - } - }, - "checkpointRevision": { - "type": "object", - "required": ["baseCommit", "mergeBase", "headCommit"], - "additionalProperties": false, - "properties": { - "baseCommit": { "$ref": "#/$defs/oid" }, - "mergeBase": { "$ref": "#/$defs/oid" }, - "headCommit": { "$ref": "#/$defs/oid" } - } - }, - "suite": { - "type": "object", - "required": ["id", "manifestPath", "manifestDigest"], - "additionalProperties": false, - "properties": { - "id": { "type": "string", "minLength": 1 }, - "manifestPath": { "type": "string", "minLength": 1 }, - "manifestDigest": { "$ref": "#/$defs/sha256" } - } - }, - "runner": { - "type": "object", - "required": ["id", "runtime"], - "additionalProperties": false, - "properties": { - "id": { "type": "string", "minLength": 1 }, - "runtime": { "type": "string", "minLength": 1 } - } - }, - "status": { "enum": ["passed", "failed"] }, - "checkpoints": { "type": "array", "uniqueItems": true, "items": { "type": "string", "minLength": 1 } }, - "commands": { "type": "array", "minItems": 1, "items": { "$ref": "#/$defs/commandResult" } }, - "startedAt": { "type": "string", "format": "date-time" }, - "completedAt": { "type": "string", "format": "date-time" }, - "integrity": { - "type": "object", - "required": ["algorithm", "digest"], - "additionalProperties": false, - "properties": { - "algorithm": { "const": "sha256" }, - "digest": { "$ref": "#/$defs/sha256" } - } - } - }, - "$defs": { - "oid": { "type": "string", "pattern": "^(?:[0-9a-f]{40}|[0-9a-f]{64})$" }, - "sha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, - "output": { - "type": "object", - "required": ["bytes", "digest", "tail", "truncated"], - "additionalProperties": false, - "properties": { - "bytes": { "type": "integer", "minimum": 0 }, - "digest": { "$ref": "#/$defs/sha256" }, - "tail": { "type": "string" }, - "truncated": { "type": "boolean" } - } - }, - "commandResult": { - "type": "object", - "required": ["id", "argv", "cwd", "required", "status", "exitCode", "signal", "durationMs", "stdout", "stderr", "startedAt", "completedAt", "error"], - "additionalProperties": false, - "properties": { - "id": { "type": "string", "minLength": 1 }, - "argv": { "type": "array", "minItems": 1, "items": { "type": "string", "minLength": 1 } }, - "cwd": { "type": "string", "minLength": 1 }, - "required": { "type": "boolean" }, - "status": { "enum": ["passed", "failed", "error", "timed_out", "skipped"] }, - "exitCode": { "type": ["integer", "null"] }, - "signal": { "type": ["string", "null"] }, - "durationMs": { "type": "integer", "minimum": 0 }, - "stdout": { "$ref": "#/$defs/output" }, - "stderr": { "$ref": "#/$defs/output" }, - "startedAt": { "oneOf": [{ "type": "string", "format": "date-time" }, { "type": "null" }] }, - "completedAt": { "oneOf": [{ "type": "string", "format": "date-time" }, { "type": "null" }] }, - "error": { "type": ["string", "null"] } - } - } - } + "$ref": "validation-result.v0.2.schema.json" } diff --git a/schemas/validation-result.v0.1.schema.json b/schemas/validation-result.v0.1.schema.json new file mode 100644 index 0000000..2ff80d7 --- /dev/null +++ b/schemas/validation-result.v0.1.schema.json @@ -0,0 +1,96 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "urn:tabellio:schema:validation-result:v0.1", + "title": "Tabellio Validation Result", + "type": "object", + "required": ["schemaVersion", "runId", "repository", "revision", "suite", "runner", "status", "checkpoints", "commands", "startedAt", "completedAt", "integrity"], + "additionalProperties": false, + "properties": { + "schemaVersion": { "const": "tabellio-validation-result/v0.1" }, + "runId": { "type": "string", "minLength": 1 }, + "repository": { + "type": "object", + "required": ["id"], + "additionalProperties": false, + "properties": { "id": { "type": "string", "minLength": 1 } } + }, + "revision": { + "type": "object", + "required": ["baseCommit", "mergeBase", "headCommit"], + "additionalProperties": false, + "properties": { + "baseCommit": { "$ref": "#/$defs/oid" }, + "mergeBase": { "$ref": "#/$defs/oid" }, + "headCommit": { "$ref": "#/$defs/oid" } + } + }, + "suite": { + "type": "object", + "required": ["id", "manifestPath", "manifestDigest"], + "additionalProperties": false, + "properties": { + "id": { "type": "string", "minLength": 1 }, + "manifestPath": { "type": "string", "minLength": 1 }, + "manifestDigest": { "$ref": "#/$defs/sha256" } + } + }, + "runner": { + "type": "object", + "required": ["id", "runtime"], + "additionalProperties": false, + "properties": { + "id": { "type": "string", "minLength": 1 }, + "runtime": { "type": "string", "minLength": 1 } + } + }, + "status": { "enum": ["passed", "failed"] }, + "checkpoints": { "type": "array", "uniqueItems": true, "items": { "type": "string", "minLength": 1 } }, + "commands": { "type": "array", "minItems": 1, "items": { "$ref": "#/$defs/commandResult" } }, + "startedAt": { "type": "string", "format": "date-time" }, + "completedAt": { "type": "string", "format": "date-time" }, + "integrity": { + "type": "object", + "required": ["algorithm", "digest"], + "additionalProperties": false, + "properties": { + "algorithm": { "const": "sha256" }, + "digest": { "$ref": "#/$defs/sha256" } + } + } + }, + "$defs": { + "oid": { "type": "string", "pattern": "^(?:[0-9a-f]{40}|[0-9a-f]{64})$" }, + "sha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, + "output": { + "type": "object", + "required": ["bytes", "digest", "tail", "truncated"], + "additionalProperties": false, + "properties": { + "bytes": { "type": "integer", "minimum": 0 }, + "digest": { "$ref": "#/$defs/sha256" }, + "tail": { "type": "string" }, + "truncated": { "type": "boolean" } + } + }, + "commandResult": { + "type": "object", + "required": ["id", "argv", "cwd", "required", "status", "exitCode", "signal", "durationMs", "stdout", "stderr", "startedAt", "completedAt", "error"], + "additionalProperties": false, + "properties": { + "id": { "type": "string", "minLength": 1 }, + "argv": { "type": "array", "minItems": 1, "items": { "type": "string", "minLength": 1 } }, + "cwd": { "type": "string", "minLength": 1 }, + "required": { "type": "boolean" }, + "status": { "enum": ["passed", "failed", "error", "timed_out", "skipped"] }, + "exitCode": { "type": ["integer", "null"] }, + "signal": { "type": ["string", "null"] }, + "durationMs": { "type": "integer", "minimum": 0 }, + "stdout": { "$ref": "#/$defs/output" }, + "stderr": { "$ref": "#/$defs/output" }, + "startedAt": { "oneOf": [{ "type": "string", "format": "date-time" }, { "type": "null" }] }, + "completedAt": { "oneOf": [{ "type": "string", "format": "date-time" }, { "type": "null" }] }, + "error": { "type": ["string", "null"] } + } + } + } +} diff --git a/schemas/validation-result.v0.2.schema.json b/schemas/validation-result.v0.2.schema.json new file mode 100644 index 0000000..7781cf9 --- /dev/null +++ b/schemas/validation-result.v0.2.schema.json @@ -0,0 +1,106 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "urn:tabellio:schema:validation-result:v0.2", + "title": "Tabellio Validation Result", + "type": "object", + "required": ["schemaVersion", "runId", "repository", "revision", "checkpointRevision", "suite", "runner", "status", "checkpoints", "commands", "startedAt", "completedAt", "integrity"], + "additionalProperties": false, + "properties": { + "schemaVersion": { "const": "tabellio-validation-result/v0.2" }, + "runId": { "type": "string", "minLength": 1 }, + "repository": { + "type": "object", + "required": ["id"], + "additionalProperties": false, + "properties": { "id": { "type": "string", "minLength": 1 } } + }, + "revision": { + "type": "object", + "required": ["baseCommit", "mergeBase", "headCommit"], + "additionalProperties": false, + "properties": { + "baseCommit": { "$ref": "#/$defs/oid" }, + "mergeBase": { "$ref": "#/$defs/oid" }, + "headCommit": { "$ref": "#/$defs/oid" } + } + }, + "checkpointRevision": { + "type": "object", + "required": ["baseCommit", "mergeBase", "headCommit"], + "additionalProperties": false, + "properties": { + "baseCommit": { "$ref": "#/$defs/oid" }, + "mergeBase": { "$ref": "#/$defs/oid" }, + "headCommit": { "$ref": "#/$defs/oid" } + } + }, + "suite": { + "type": "object", + "required": ["id", "manifestPath", "manifestDigest"], + "additionalProperties": false, + "properties": { + "id": { "type": "string", "minLength": 1 }, + "manifestPath": { "type": "string", "minLength": 1 }, + "manifestDigest": { "$ref": "#/$defs/sha256" } + } + }, + "runner": { + "type": "object", + "required": ["id", "runtime"], + "additionalProperties": false, + "properties": { + "id": { "type": "string", "minLength": 1 }, + "runtime": { "type": "string", "minLength": 1 } + } + }, + "status": { "enum": ["passed", "failed"] }, + "checkpoints": { "type": "array", "uniqueItems": true, "items": { "type": "string", "minLength": 1 } }, + "commands": { "type": "array", "minItems": 1, "items": { "$ref": "#/$defs/commandResult" } }, + "startedAt": { "type": "string", "format": "date-time" }, + "completedAt": { "type": "string", "format": "date-time" }, + "integrity": { + "type": "object", + "required": ["algorithm", "digest"], + "additionalProperties": false, + "properties": { + "algorithm": { "const": "sha256" }, + "digest": { "$ref": "#/$defs/sha256" } + } + } + }, + "$defs": { + "oid": { "type": "string", "pattern": "^(?:[0-9a-f]{40}|[0-9a-f]{64})$" }, + "sha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, + "output": { + "type": "object", + "required": ["bytes", "digest", "tail", "truncated"], + "additionalProperties": false, + "properties": { + "bytes": { "type": "integer", "minimum": 0 }, + "digest": { "$ref": "#/$defs/sha256" }, + "tail": { "type": "string" }, + "truncated": { "type": "boolean" } + } + }, + "commandResult": { + "type": "object", + "required": ["id", "argv", "cwd", "required", "status", "exitCode", "signal", "durationMs", "stdout", "stderr", "startedAt", "completedAt", "error"], + "additionalProperties": false, + "properties": { + "id": { "type": "string", "minLength": 1 }, + "argv": { "type": "array", "minItems": 1, "items": { "type": "string", "minLength": 1 } }, + "cwd": { "type": "string", "minLength": 1 }, + "required": { "type": "boolean" }, + "status": { "enum": ["passed", "failed", "error", "timed_out", "skipped"] }, + "exitCode": { "type": ["integer", "null"] }, + "signal": { "type": ["string", "null"] }, + "durationMs": { "type": "integer", "minimum": 0 }, + "stdout": { "$ref": "#/$defs/output" }, + "stderr": { "$ref": "#/$defs/output" }, + "startedAt": { "oneOf": [{ "type": "string", "format": "date-time" }, { "type": "null" }] }, + "completedAt": { "oneOf": [{ "type": "string", "format": "date-time" }, { "type": "null" }] }, + "error": { "type": ["string", "null"] } + } + } + } +} diff --git a/scripts/lib/release-operation.mjs b/scripts/lib/release-operation.mjs index 2f4f5f9..d3f6db0 100644 --- a/scripts/lib/release-operation.mjs +++ b/scripts/lib/release-operation.mjs @@ -6,6 +6,11 @@ import { digestObject } from "./stack-operation.mjs"; const RELEASE_OPERATION_VERSION = "tabellio-release-operation/v0.1"; const RELEASE_APPROVAL_VERSION = "tabellio-release-approval/v0.1"; const MAX_RELEASE_APPROVAL_MS = 60 * 60 * 1000; +const RELEASE_CONTROL_REFS = [ + "refs/tabellio/reviews", + "refs/tabellio/validations", + "refs/heads/entire/checkpoints/v1", +]; export function createReleaseIntent({ repository, @@ -83,6 +88,7 @@ export function validateReleaseIntent(value) { contract.equals(value.control.intent.operation, "publish", "intent.control.intent.operation"); contract.equals(value.control.intent.remote, "control", "intent.control.intent.remote"); contract.equals(value.control.intent.repository.id, value.repository.id, "intent.control.intent.repository.id"); + assertCompleteReleaseControlRefs(value.control.intent.refs); contract.object(value.validation, "intent.validation"); contract.exactKeys(value.validation, ["runId", "resultVersion", "status", "headCommit"], "intent.validation"); @@ -127,3 +133,10 @@ function assertCodeRepositoryIdentity(repository) { function assertSeparateControlRepository(repository, controlRepository) { if (controlRepository.id.toLowerCase() === repository.id.toLowerCase()) throw new Error("intent.control.repository.id must differ from intent.repository.id."); } + +function assertCompleteReleaseControlRefs(refs) { + const names = new Set(refs.map((entry) => entry.name)); + if (refs.length !== RELEASE_CONTROL_REFS.length || RELEASE_CONTROL_REFS.some((ref) => !names.has(ref))) { + throw new Error("intent.control.intent.refs must contain the complete release control-ref set."); + } +} diff --git a/scripts/lib/release-workflow.mjs b/scripts/lib/release-workflow.mjs index c079a3a..cf480f6 100644 --- a/scripts/lib/release-workflow.mjs +++ b/scripts/lib/release-workflow.mjs @@ -1,7 +1,7 @@ import { createHash, randomUUID } from "node:crypto"; -import { mkdir, mkdtemp, readFile, rename, rm, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, readFile, realpath, rename, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { dirname, join, resolve } from "node:path"; +import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; import { ApprovedControlRefTransport, @@ -35,7 +35,10 @@ export class ReleaseExecutor { } = {}) { const store = await NativeGitStore.open(resolve(repoPath)); const common = (await runGit({ args: ["rev-parse", "--git-common-dir"], cwd: store.repoPath })).stdout.trim(); - const root = stateRoot ? resolve(stateRoot) : resolve(store.repoPath, common, "tabellio", "release-operations"); + const root = stateRoot === null + ? resolve(store.repoPath, common, "tabellio", "release-operations") + : resolve(stateRoot); + if (stateRoot !== null) assertExternalStateRoot(store.repoPath, await canonicalProspectivePath(root)); const actions = new ReleaseActions({ store, ghBinary, commandRunner, remoteRefReader, codeRepositoryReader, controlRepositoryReader }); return new ReleaseExecutor({ repoPath: store.repoPath, stateRoot: root, actions }); } @@ -157,6 +160,8 @@ class ReleaseActions { const remote = await remoteTagState(this.store.repoPath, intent.code.remote, intent.tag); assertTagTarget(local, intent, "locally", canonicalObject); assertTagTarget(remote, intent, "remotely", canonicalObject); + const liveMain = await this.remoteRefReader({ repoPath: this.store.repoPath, remote: intent.code.remote, ref: "refs/heads/main" }); + assertEqual(liveMain, intent.revision.commit, "Release commit no longer equals live origin/main immediately before tag publication."); await ensureLocalTag(this.store.repoPath, intent, local, remote, canonicalObject); local = await localTagState(this.store.repoPath, intent.tag); assertTagTarget(local, intent, "locally", canonicalObject); @@ -242,6 +247,26 @@ function releaseResult(receipt, receiptPath) { return { receipt, receiptPath }; } +function assertExternalStateRoot(repoPath, stateRoot) { + const path = relative(repoPath, stateRoot); + const outside = path === ".." || path.startsWith(`..${sep}`) || isAbsolute(path); + if (!outside) throw new Error("Release state root must be outside the worktree; omit --state-root to use Git metadata."); +} + +async function canonicalProspectivePath(path) { + const { ancestor, missing } = await realExistingAncestor(path); + return resolve(ancestor, ...missing); +} + +async function realExistingAncestor(path, missing = []) { + try { + return { ancestor: await realpath(path), missing }; + } catch (error) { + if (error?.code !== "ENOENT") throw error; + return realExistingAncestor(dirname(path), [basename(path), ...missing]); + } +} + function assertReleaseRepository(actual, intent) { assertReleaseRevision(actual, intent); assertReleaseWorkspace(actual); diff --git a/tests/context-and-evidence.test.mjs b/tests/context-and-evidence.test.mjs index 390ba05..89e0dd0 100644 --- a/tests/context-and-evidence.test.mjs +++ b/tests/context-and-evidence.test.mjs @@ -261,15 +261,42 @@ test("core runtime and adoption docs do not require GitHub Actions", async () => } }); -test("stable schema identifiers keep external references resolvable", async () => { - const [evidenceSchema, policySchema, releaseSchema, controlSchema] = await Promise.all([ +test("stable schema identifiers keep external references and released contracts resolvable", async () => { + const [ + evidenceSchema, + policySchema, + releaseSchema, + controlSchema, + reviewAlias, + reviewV2, + reviewV3, + validationAlias, + validationV1, + validationV2, + ] = await Promise.all([ readFile(`${projectRoot}/schemas/evidence-envelope.schema.json`, "utf8").then(JSON.parse), readFile(`${projectRoot}/schemas/external-action-policy.schema.json`, "utf8").then(JSON.parse), readFile(`${projectRoot}/schemas/release-operation.schema.json`, "utf8").then(JSON.parse), readFile(`${projectRoot}/schemas/control-ref-operation.schema.json`, "utf8").then(JSON.parse), + readFile(`${projectRoot}/schemas/review-cycle.schema.json`, "utf8").then(JSON.parse), + readFile(`${projectRoot}/schemas/review-cycle.v0.2.schema.json`, "utf8").then(JSON.parse), + readFile(`${projectRoot}/schemas/review-cycle.v0.3.schema.json`, "utf8").then(JSON.parse), + readFile(`${projectRoot}/schemas/validation-result.schema.json`, "utf8").then(JSON.parse), + readFile(`${projectRoot}/schemas/validation-result.v0.1.schema.json`, "utf8").then(JSON.parse), + readFile(`${projectRoot}/schemas/validation-result.v0.2.schema.json`, "utf8").then(JSON.parse), ]); assert.equal(evidenceSchema.properties.externalActionPolicy.$ref, policySchema.$id); assert.equal(releaseSchema.properties.control.properties.intent.$ref, controlSchema.$id); + assert.equal(reviewAlias.$ref, "review-cycle.v0.3.schema.json"); + assert.equal(reviewV2.$id, "urn:tabellio:schema:review-cycle:v0.2"); + assert.equal(reviewV3.$id, "urn:tabellio:schema:review-cycle:v0.3"); + assert.equal(reviewV2.$defs.event.properties.type.enum.includes("ready"), false); + assert.equal(reviewV3.$defs.event.properties.type.enum.includes("ready"), true); + assert.equal(validationAlias.$ref, "validation-result.v0.2.schema.json"); + assert.equal(validationV1.$id, "urn:tabellio:schema:validation-result:v0.1"); + assert.equal(validationV2.$id, "urn:tabellio:schema:validation-result:v0.2"); + assert.equal(Object.hasOwn(validationV1.properties, "checkpointRevision"), false); + assert.equal(validationV2.required.includes("checkpointRevision"), true); }); test("required repository validation is recorded in evidence", async (t) => { diff --git a/tests/release-workflow.test.mjs b/tests/release-workflow.test.mjs index ad2fb2f..4775333 100644 --- a/tests/release-workflow.test.mjs +++ b/tests/release-workflow.test.mjs @@ -35,6 +35,7 @@ test("release intent binds merged commit, validation, control refs, notes, and a assert.throws(() => exampleIntent({ releaseCreatedAt: "1" }), /ISO date-time/); assert.throws(() => exampleIntent({ owner: "different" }), /must match/); assert.throws(() => exampleIntent({ controlRepositoryId: "github.com/example/repository" }), /must differ/); + assert.throws(() => exampleIntent({ controlRefs: ["refs/tabellio/validations"] }), /complete release control-ref set/); }); test("release executor reconciles completed publication phases after a failure", async (t) => { @@ -93,6 +94,15 @@ test("release executor serializes concurrent execution of the same approval", as assert.equal((await first).receipt.status, "succeeded"); }); +test("release executor rejects worktree-local state roots before writing receipts", async (t) => { + const fixture = await createFixture(); + t.after(() => rm(fixture.root, { recursive: true, force: true })); + await assert.rejects( + ReleaseExecutor.open({ repoPath: fixture.seed, stateRoot: join(fixture.seed, "release-state") }), + /state root must be outside the worktree/, + ); +}); + test("approved release publishes exact control ref, annotated tag, and GitHub release in isolated repos", async (t) => { const fixture = await createFixture(); t.after(() => rm(fixture.root, { recursive: true, force: true })); @@ -118,12 +128,17 @@ test("approved release publishes exact control ref, annotated tag, and GitHub re const ledger = await GitJsonLedger.open({ repoPath: fixture.seed, ref: "refs/tabellio/validations" }); const validationWrite = await ledger.write("commits/result.json", { status: "passed" }, { expectedVersion: null }); await runGit({ args: ["update-ref", "refs/tabellio/reviews", head], cwd: fixture.seed }); + await runGit({ args: ["update-ref", "refs/heads/entire/checkpoints/v1", head], cwd: fixture.seed }); await runGit({ args: ["push", "control", "refs/tabellio/reviews"], cwd: fixture.seed }); const controlIntent = createControlRefIntent({ operation: "publish", repositoryId, remote: "control", - refs: await snapshotControlRefs({ repoPath: fixture.seed, remote: "control", refs: ["refs/tabellio/validations", "refs/tabellio/reviews"] }), + refs: await snapshotControlRefs({ + repoPath: fixture.seed, + remote: "control", + refs: ["refs/tabellio/validations", "refs/tabellio/reviews", "refs/heads/entire/checkpoints/v1"], + }), createdAt, }); const intent = createReleaseIntent({ @@ -152,13 +167,14 @@ test("approved release publishes exact control ref, annotated tag, and GitHub re }; const stateRoot = join(fixture.root, "release-state"); let liveReads = 0; + let liveResponses = []; let currentControlIdentity = intent.control.repository.id; let currentControlPrivate = true; const executor = await ReleaseExecutor.open({ repoPath: fixture.seed, stateRoot, commandRunner, - remoteRefReader: async () => { liveReads += 1; return head; }, + remoteRefReader: async () => { liveReads += 1; return liveResponses.shift() ?? head; }, codeRepositoryReader: async () => "github.com/EXAMPLE/REPOSITORY", controlRepositoryReader: async () => ({ id: currentControlIdentity, isPrivate: currentControlPrivate }), }); @@ -178,11 +194,17 @@ test("approved release publishes exact control ref, annotated tag, and GitHub re await assert.rejects(executor.execute({ intent, approval, now }), /control repository is not private/); currentControlPrivate = true; await assert.rejects(executor.execute({ intent, approval, now }), /remotely as a lightweight tag/); - await runGit({ args: ["push", "control", ":refs/tabellio/validations"], cwd: fixture.seed }); + await runGit({ + args: ["push", "control", ":refs/tabellio/validations", ":refs/heads/entire/checkpoints/v1"], + cwd: fixture.seed, + }); await runGit({ args: ["push", "origin", `:refs/tags/${intent.tag}`], cwd: fixture.seed }); await writeFile(join(fixture.seed, notesPath), "MUTATED WORKTREE NOTES\n"); await assert.rejects(executor.execute({ intent, approval, now }), /clean worktree/); await runGit({ args: ["restore", notesPath], cwd: fixture.seed }); + liveResponses = [head, parent]; + await assert.rejects(executor.execute({ intent, approval, now }), /immediately before tag publication/); + assert.deepEqual(liveResponses, []); await runGit({ args: ["config", "--unset", "user.name"], cwd: fixture.seed }); await runGit({ args: ["config", "--unset", "user.email"], cwd: fixture.seed }); const result = await executor.execute({ intent, approval, now }); @@ -191,11 +213,14 @@ test("approved release publishes exact control ref, annotated tag, and GitHub re assert.equal(result.receiptPath, join(stateRoot, "publish-release.json")); const approvedTagObject = (await runGit({ args: ["rev-parse", `refs/tags/${intent.tag}`], cwd: fixture.seed })).stdout.trim(); - await runGit({ args: ["push", "control", ":refs/tabellio/validations"], cwd: fixture.seed }); + await runGit({ + args: ["push", "control", ":refs/tabellio/validations", ":refs/heads/entire/checkpoints/v1"], + cwd: fixture.seed, + }); await runGit({ args: ["push", "origin", `:refs/tags/${intent.tag}`], cwd: fixture.seed }); const reconciled = await executor.execute({ intent, approval, now }); assert.equal(reconciled.receipt.status, "succeeded"); - assert.equal(liveReads, 7); + assert.equal(liveReads, 11); assert.equal(ghCalls.length, 4); assert.deepEqual(publishedNotes, ["Release 1.2.3\n", "Release 1.2.3\n"]); const remoteTag = await runGit({ args: ["ls-remote", "--tags", "origin", "refs/tags/v1.2.3^{}"], cwd: fixture.seed }); @@ -364,12 +389,17 @@ function exampleIntent({ owner = "example", repoName = "repository", controlRepositoryId = "github.com/example/repository-control", + controlRefs = ["refs/tabellio/reviews", "refs/tabellio/validations", "refs/heads/entire/checkpoints/v1"], } = {}) { const controlIntent = createControlRefIntent({ operation: "publish", repositoryId, remote: "control", - refs: [{ name: "refs/tabellio/validations", localOid: "c".repeat(40), remoteOid: "b".repeat(40) }], + refs: controlRefs.map((name, index) => ({ + name, + localOid: String(index + 1).repeat(40), + remoteOid: String(index + 4).repeat(40), + })), createdAt, }); return createReleaseIntent({ From b47b0329abdbc1b2d5cc82215dd1822c0a7c2796 Mon Sep 17 00:00:00 2001 From: hudsonaikins-crown Date: Wed, 15 Jul 2026 19:21:12 -0700 Subject: [PATCH 11/12] fix: bind release checks to current state Entire-Checkpoint: 1e7d65448717 --- docs/releases/v0.3.0.md | 2 +- scripts/lib/release-planner.mjs | 18 +++++++++-- scripts/lib/release-workflow.mjs | 12 +++++--- tests/release-workflow.test.mjs | 51 ++++++++++++++++++++++++++++++++ 4 files changed, 75 insertions(+), 8 deletions(-) diff --git a/docs/releases/v0.3.0.md b/docs/releases/v0.3.0.md index f3d7a0c..c59425e 100644 --- a/docs/releases/v0.3.0.md +++ b/docs/releases/v0.3.0.md @@ -14,4 +14,4 @@ For squash merges, validation commands run against the exact merged commit while Review cycles now emit `tabellio-review-cycle/v0.3`, which records head-bound `ready` events. Validation results now emit `tabellio-validation-result/v0.2`, which requires the checkpoint revision separately from the validated revision. Runtime readers still accept v0.2 review cycles and v0.1 validation results so existing private ledgers can migrate on their next write. -Released review-cycle and validation-result schemas remain available under immutable versioned filenames. Release execution requires all three canonical control refs, stores custom receipt state outside the worktree, and rechecks live `origin/main` immediately before tag publication. +Released review-cycle and validation-result schemas remain available under immutable versioned filenames. Release planning binds terminal review sync to the exact pull-request head. Execution requires all three canonical control refs, stores custom receipt state outside the worktree, rechecks approval expiry before every publish phase, and rechecks live `origin/main` immediately before tag publication. diff --git a/scripts/lib/release-planner.mjs b/scripts/lib/release-planner.mjs index e54a696..647ecb1 100644 --- a/scripts/lib/release-planner.mjs +++ b/scripts/lib/release-planner.mjs @@ -59,7 +59,18 @@ export async function planRelease({ now, }); const provider = await resolveGitHubProvider({ githubProvider, apiUrl, token, ghBinary, commandRunner, cwd: store.repoPath }); - await assertMergedReview({ store, validationLedger, provider, repositoryId, owner, repo, number, runnerId, now }); + await assertMergedReview({ + store, + validationLedger, + provider, + repositoryId, + owner, + repo, + number, + pullRequestHead, + runnerId, + now, + }); const controlIntent = await planControlPublication({ store, repositoryId, controlRemote, now }); return createReleaseIntent({ repository: { id: repositoryId, owner, name: repo }, @@ -196,7 +207,7 @@ async function resolveGitHubProvider({ githubProvider, apiUrl, token, ghBinary, return new GitHubProvider({ baseUrl: apiUrl, token: resolvedToken }); } -async function assertMergedReview({ store, validationLedger, provider, repositoryId, owner, repo, number, runnerId, now }) { +async function assertMergedReview({ store, validationLedger, provider, repositoryId, owner, repo, number, pullRequestHead, runnerId, now }) { const reviewLedger = await GitJsonLedger.open({ repoPath: store.repoPath, ref: "refs/tabellio/reviews" }); const manager = new ReviewCycleManager({ store, @@ -209,7 +220,8 @@ async function assertMergedReview({ store, validationLedger, provider, repositor }); const review = await manager.sync({ number, actor: runnerId, now }); if (review.cycle.status !== "merged") throw new Error(`Review cycle ${number} is ${review.cycle.status}, not merged.`); - if (!reviewCycleHasReleaseReadiness(review.cycle, review.cycle.changeRequest.headCommit)) { + contract.equals(review.cycle.changeRequest.headCommit, pullRequestHead, `review cycle ${number} head`); + if (!reviewCycleHasReleaseReadiness(review.cycle, pullRequestHead)) { throw new Error(`Review cycle ${number} is not release-ready for the merged pull-request head.`); } } diff --git a/scripts/lib/release-workflow.mjs b/scripts/lib/release-workflow.mjs index cf480f6..72b07e9 100644 --- a/scripts/lib/release-workflow.mjs +++ b/scripts/lib/release-workflow.mjs @@ -17,11 +17,12 @@ import { NativeGitStore } from "../providers/native-git-store.mjs"; const PHASES = ["verify", "control-refs", "tag", "github-release"]; export class ReleaseExecutor { - constructor({ repoPath, stateRoot, actions, lockRunner = withOperationLock }) { + constructor({ repoPath, stateRoot, actions, lockRunner = withOperationLock, clock = () => new Date() }) { this.repoPath = repoPath; this.stateRoot = stateRoot; this.actions = actions; this.lockRunner = lockRunner; + this.clock = clock; } static async open({ @@ -32,6 +33,7 @@ export class ReleaseExecutor { remoteRefReader = readRemoteRefOid, codeRepositoryReader = codeGitHubRemoteRepository, controlRepositoryReader = privateGitHubRemoteRepository, + clock = () => new Date(), } = {}) { const store = await NativeGitStore.open(resolve(repoPath)); const common = (await runGit({ args: ["rev-parse", "--git-common-dir"], cwd: store.repoPath })).stdout.trim(); @@ -40,10 +42,10 @@ export class ReleaseExecutor { : resolve(stateRoot); if (stateRoot !== null) assertExternalStateRoot(store.repoPath, await canonicalProspectivePath(root)); const actions = new ReleaseActions({ store, ghBinary, commandRunner, remoteRefReader, codeRepositoryReader, controlRepositoryReader }); - return new ReleaseExecutor({ repoPath: store.repoPath, stateRoot: root, actions }); + return new ReleaseExecutor({ repoPath: store.repoPath, stateRoot: root, actions, clock }); } - async execute({ intent, approval, now = new Date() }) { + async execute({ intent, approval, now = this.clock() }) { validateReleaseIntent(intent); validateReleaseApproval(approval, intent, { now }); return this.lockRunner({ @@ -62,8 +64,10 @@ export class ReleaseExecutor { const verification = state.phases.find((entry) => entry.id === "verify"); state = await executePhase({ state, current: verification, phase: "verify", path, actions: this.actions, intent, approval, now }); for (const phase of PHASES.slice(1)) { + const phaseNow = this.clock(); + validateReleaseApproval(approval, intent, { now: phaseNow }); const current = state.phases.find((entry) => entry.id === phase); - state = await executePhase({ state, current, phase, path, actions: this.actions, intent, approval, now }); + state = await executePhase({ state, current, phase, path, actions: this.actions, intent, approval, now: phaseNow }); } await completeRelease(path, state); return releaseResult(state, path); diff --git a/tests/release-workflow.test.mjs b/tests/release-workflow.test.mjs index 4775333..6c2cf48 100644 --- a/tests/release-workflow.test.mjs +++ b/tests/release-workflow.test.mjs @@ -58,6 +58,7 @@ test("release executor reconciles completed publication phases after a failure", stateRoot: root, actions, lockRunner: async (_options, action) => action(), + clock: () => now, }); const intent = exampleIntent(); const approval = approvalFor(intent, "resume-release"); @@ -70,6 +71,29 @@ test("release executor reconciles completed publication phases after a failure", assert.equal(Object.hasOwn(result.receipt, "receiptPath"), false); }); +test("release executor rechecks approval expiry before every publish phase", async (t) => { + const root = await mkdtemp(join(tmpdir(), "tabellio-release-expiry-")); + t.after(() => rm(root, { recursive: true, force: true })); + const calls = []; + let current = now; + const executor = new ReleaseExecutor({ + repoPath: root, + stateRoot: root, + actions: { + async run(phase) { + calls.push(phase); + if (phase === "control-refs") current = new Date(expiresAt); + return { phase }; + }, + }, + lockRunner: async (_options, action) => action(), + clock: () => current, + }); + const intent = exampleIntent(); + await assert.rejects(executor.execute({ intent, approval: approvalFor(intent, "expiring-release"), now }), /approval has expired/); + assert.deepEqual(calls, ["verify", "control-refs"]); +}); + test("release executor serializes concurrent execution of the same approval", async (t) => { const fixture = await createFixture(); t.after(() => rm(fixture.root, { recursive: true, force: true })); @@ -84,6 +108,7 @@ test("release executor serializes concurrent execution of the same approval", as repoPath: fixture.seed, stateRoot: join(fixture.root, "release-lock-state"), actions, + clock: () => now, }); const intent = exampleIntent(); const approval = approvalFor(intent, "concurrent-release"); @@ -177,6 +202,7 @@ test("approved release publishes exact control ref, annotated tag, and GitHub re remoteRefReader: async () => { liveReads += 1; return liveResponses.shift() ?? head; }, codeRepositoryReader: async () => "github.com/EXAMPLE/REPOSITORY", controlRepositoryReader: async () => ({ id: currentControlIdentity, isPrivate: currentControlPrivate }), + clock: () => now, }); const approval = approvalFor(intent, "publish-release"); await runGit({ args: ["tag", intent.tag, head], cwd: fixture.seed }); @@ -345,6 +371,31 @@ test("release planning binds merged PR proof after exact validation and terminal remoteRepositoryReader, now, }), /does not match origin example\/repository/); + const driftReadyManager = new ReviewCycleManager({ + store, + ledger: reviewLedger, + provider: readyProvider({ head, base: parent }), + repositoryId: "github.com/example/repository", + owner: "example", + repo: "repository", + }); + assert.equal((await driftReadyManager.sync({ number: 9, actor: "test", now })).cycle.status, "ready"); + await assert.rejects(planRelease({ + repoPath: fixture.seed, + repositoryId: "github.com/example/repository", + owner: "example", + repo: "repository", + number: 9, + version: "2.0.0", + notesPath, + token: "test-token", + commandRunner, + preflightRunner: async () => ({ status: "ready", checks: [] }), + githubProvider: mergedProvider({ head, base: parent }), + remoteRepositoryReader, + now, + }), /review cycle 9 head/); + assert.equal((await readyManager.sync({ number: 9, actor: "test", now })).cycle.status, "ready"); const intent = await planRelease({ repoPath: fixture.seed, repositoryId: "github.com/example/repository", From 2a04334143f4deb9dac34e1b46ec44ca0977f225 Mon Sep 17 00:00:00 2001 From: hudsonaikins-crown Date: Wed, 15 Jul 2026 19:38:50 -0700 Subject: [PATCH 12/12] fix: pin release publication targets Entire-Checkpoint: 1e7d65448717 --- docs/releases/v0.3.0.md | 2 +- scripts/lib/control-ref-transport.mjs | 23 +++++++++++---- scripts/lib/github-repository.mjs | 2 +- scripts/lib/release-workflow.mjs | 20 +++++++++---- tests/control-ref-transport.test.mjs | 23 +++++++++++++++ tests/release-workflow.test.mjs | 42 ++++++++++++++++++--------- 6 files changed, 86 insertions(+), 26 deletions(-) diff --git a/docs/releases/v0.3.0.md b/docs/releases/v0.3.0.md index c59425e..7fb2d22 100644 --- a/docs/releases/v0.3.0.md +++ b/docs/releases/v0.3.0.md @@ -14,4 +14,4 @@ For squash merges, validation commands run against the exact merged commit while Review cycles now emit `tabellio-review-cycle/v0.3`, which records head-bound `ready` events. Validation results now emit `tabellio-validation-result/v0.2`, which requires the checkpoint revision separately from the validated revision. Runtime readers still accept v0.2 review cycles and v0.1 validation results so existing private ledgers can migrate on their next write. -Released review-cycle and validation-result schemas remain available under immutable versioned filenames. Release planning binds terminal review sync to the exact pull-request head. Execution requires all three canonical control refs, stores custom receipt state outside the worktree, rechecks approval expiry before every publish phase, and rechecks live `origin/main` immediately before tag publication. +Released review-cycle and validation-result schemas remain available under immutable versioned filenames. Release planning binds terminal review sync to the exact pull-request head. Execution requires all three canonical control refs, pins the verified control push URL, stores custom receipt state outside the worktree, rechecks approval expiry before every publish phase, rechecks live `origin/main` immediately before tag publication, and revalidates the canonical remote tag before creating or accepting a GitHub release. diff --git a/scripts/lib/control-ref-transport.mjs b/scripts/lib/control-ref-transport.mjs index 42f4203..39a1edd 100644 --- a/scripts/lib/control-ref-transport.mjs +++ b/scripts/lib/control-ref-transport.mjs @@ -72,14 +72,16 @@ export function validateControlRefApproval(value, intent, { now = new Date() } = }); } -export async function snapshotControlRefs({ repoPath, remote, refs, env = {} }) { +export async function snapshotControlRefs({ repoPath, remote, refs, env = {}, transportRemote = null }) { controlRemoteName(remote); + const target = transportRemote ?? remote; + transportTarget(target); const unique = [...new Set(refs)]; if (unique.length === 0 || unique.some((ref) => !CONTROL_REFS.includes(ref))) throw new Error("refs must be a non-empty subset of canonical control refs."); return Promise.all(unique.map(async (name) => ({ name, localOid: await localOid(repoPath, name), - remoteOid: await remoteOid(repoPath, remote, name, env), + remoteOid: await remoteOid(repoPath, target, name, env), }))); } @@ -111,10 +113,12 @@ export class ApprovedControlRefTransport { } // fallow-ignore-next-line unused-class-member - async execute({ intent, approval, repositoryId, now = new Date() }) { + async execute({ intent, approval, repositoryId, now = new Date(), transportRemote = null }) { validateControlRefIntent(intent); validateControlRefApproval(approval, intent, { now }); if (repositoryId !== intent.repository.id) throw new Error(`Operation repository mismatch: expected ${intent.repository.id}, found ${repositoryId}.`); + const remote = transportRemote ?? intent.remote; + transportTarget(remote); return this.#withLock(async () => { for (const entry of intent.refs) { const actual = await localOid(this.repoPath, entry.name); @@ -142,12 +146,12 @@ export class ApprovedControlRefTransport { await handle.close(); try { for (const entry of intent.refs) { - const actualRemote = await remoteOid(this.repoPath, intent.remote, entry.name, this.#env, this.#timeoutMs); + const actualRemote = await remoteOid(this.repoPath, remote, entry.name, this.#env, this.#timeoutMs); if (actualRemote !== entry.remoteOid) throw new Error(`Remote control ref changed after planning: ${entry.name}.`); } const results = intent.operation === "publish" - ? await this.#publish(intent.remote, intent.refs) - : await this.#fetch(intent.remote, intent.refs); + ? await this.#publish(remote, intent.refs) + : await this.#fetch(remote, intent.refs); for (const [index, entry] of intent.refs.entries()) { const result = results[index]; receipt.refs[index] = { name: entry.name, before: entry.localOid, after: result.after, status: result.status }; @@ -291,6 +295,13 @@ function controlRemoteName(value) { if (value === CODE_STORAGE_REMOTE) throw new Error(`Control refs must not target code-storage remote ${CODE_STORAGE_REMOTE}.`); } +function transportTarget(value) { + const invalid = typeof value !== "string" || [value.length === 0, value.startsWith("-"), /[\0\r\n]/.test(value)].includes(true); + if (invalid) { + throw new Error("transportRemote must be a safe Git remote name, path, or URL."); + } +} + function nullableOid(value, path) { if (value !== null && (typeof value !== "string" || !/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(value))) throw new Error(`${path} must be null or a Git object ID.`); } diff --git a/scripts/lib/github-repository.mjs b/scripts/lib/github-repository.mjs index 3502955..9adb015 100644 --- a/scripts/lib/github-repository.mjs +++ b/scripts/lib/github-repository.mjs @@ -39,7 +39,7 @@ export async function effectiveGitHubRepository(store, remote) { if (!repositories.every((candidate) => sameGitHubRepository(repository, candidate))) { throw new Error(`Remote ${remote} effective fetch and push URLs target different GitHub repositories.`); } - return repository; + return { ...repository, fetchUrls, pushUrls }; } export async function readRemoteRefOid({ repoPath, remote, ref }) { diff --git a/scripts/lib/release-workflow.mjs b/scripts/lib/release-workflow.mjs index 72b07e9..31be502 100644 --- a/scripts/lib/release-workflow.mjs +++ b/scripts/lib/release-workflow.mjs @@ -45,15 +45,16 @@ export class ReleaseExecutor { return new ReleaseExecutor({ repoPath: store.repoPath, stateRoot: root, actions, clock }); } - async execute({ intent, approval, now = this.clock() }) { + async execute({ intent, approval, now = null }) { + const startedAt = now ?? await this.clock(); validateReleaseIntent(intent); - validateReleaseApproval(approval, intent, { now }); + validateReleaseApproval(approval, intent, { now: startedAt }); return this.lockRunner({ repoPath: this.repoPath, stateRoot: this.stateRoot, lockName: "release-operation", label: "release operation", - }, () => this.#executeLocked({ intent, approval, now })); + }, () => this.#executeLocked({ intent, approval, now: startedAt })); } async #executeLocked({ intent, approval, now }) { @@ -64,7 +65,7 @@ export class ReleaseExecutor { const verification = state.phases.find((entry) => entry.id === "verify"); state = await executePhase({ state, current: verification, phase: "verify", path, actions: this.actions, intent, approval, now }); for (const phase of PHASES.slice(1)) { - const phaseNow = this.clock(); + const phaseNow = await this.clock(); validateReleaseApproval(approval, intent, { now: phaseNow }); const current = state.phases.find((entry) => entry.id === phase); state = await executePhase({ state, current, phase, path, actions: this.actions, intent, approval, now: phaseNow }); @@ -131,6 +132,7 @@ class ReleaseActions { repoPath: this.store.repoPath, remote: intent.control.intent.remote, refs: intent.control.intent.refs.map((entry) => entry.name), + transportRemote: controlRepository.pushUrl, }); const publication = controlPublicationState(snapshot, intent.control.intent.refs); if (publication === "published") { @@ -152,6 +154,7 @@ class ReleaseActions { approval: controlApproval, repositoryId: intent.repository.id, now, + transportRemote: controlRepository.pushUrl, }); return { status: result.status, approvalId: result.approvalId, refs: result.refs }; } @@ -175,6 +178,12 @@ class ReleaseActions { } async #publishRelease({ intent }) { + const repositoryId = await this.codeRepositoryReader(this.store); + assertRepositoryIdentity(repositoryId, intent.repository.id, "Release repository identity changed before GitHub release publication."); + const canonicalObject = await canonicalTagObject(this.store.repoPath, intent); + const remoteTag = await remoteTagState(this.store.repoPath, intent.code.remote, intent.tag); + if (!remoteTag) throw new Error(`${intent.tag} is missing remotely before GitHub release publication.`); + assertTagTarget(remoteTag, intent, "remotely", canonicalObject); const repository = `${intent.repository.owner}/${intent.repository.name}`; const notesSource = await sourceAtCommit(this.store.repoPath, intent.revision.commit, intent.release.notesPath); if (sha256(notesSource) !== intent.release.notesDigest) throw new Error("Release notes at the approved commit do not match release intent."); @@ -516,6 +525,7 @@ async function sourceAtCommit(repoPath, commit, path) { async function privateGitHubRemoteRepository(store, remote, ghBinary, commandRunner) { const repository = await effectiveGitHubRepository(store, remote); + if (repository.pushUrls.length !== 1) throw new Error(`Control remote ${remote} must have exactly one effective push URL.`); const result = await commandRunner({ binary: ghBinary, args: ["repo", "view", repository.fullName, "--json", "nameWithOwner,isPrivate"], @@ -524,7 +534,7 @@ async function privateGitHubRemoteRepository(store, remote, ghBinary, commandRun }); const view = JSON.parse(result.stdout); if (String(view.nameWithOwner).toLowerCase() !== repository.key) throw new Error("GitHub returned a different control repository identity."); - return { id: repository.identity, isPrivate: view.isPrivate === true }; + return { id: repository.identity, isPrivate: view.isPrivate === true, pushUrl: repository.pushUrls[0] }; } async function codeGitHubRemoteRepository(store) { diff --git a/tests/control-ref-transport.test.mjs b/tests/control-ref-transport.test.mjs index 8a4b02f..8078412 100644 --- a/tests/control-ref-transport.test.mjs +++ b/tests/control-ref-transport.test.mjs @@ -80,6 +80,29 @@ test("control ref transport opens bare repositories", async (t) => { assert.equal(transport.repoPath, await realpath(fixture.bare)); }); +test("control ref transport pins an approved target when the remote name changes", async (t) => { + const fixture = await createFixture(); + await addControlRemote(fixture.seed, fixture.bare); + const alternate = join(fixture.root, "alternate.git"); + await runGit({ args: ["init", "--bare", alternate], cwd: fixture.root }); + const stateRoot = await mkdtemp(join(tmpdir(), "tabellio-control-ref-pinned-")); + removeAfter(t, fixture.root, stateRoot); + const { intent } = await createValidationPublishIntent(fixture.seed); + await runGit({ args: ["remote", "set-url", "ledger", alternate], cwd: fixture.seed }); + const transport = await ApprovedControlRefTransport.open({ repoPath: fixture.seed, stateRoot }); + await transport.execute({ + intent, + approval: approvalFor(intent, "pinned-remote"), + repositoryId: "example/repository", + transportRemote: fixture.bare, + now, + }); + const expected = await runGit({ args: ["ls-remote", "--refs", fixture.bare, "refs/tabellio/validations"], cwd: fixture.seed }); + const unexpected = await runGit({ args: ["ls-remote", "--refs", alternate, "refs/tabellio/validations"], cwd: fixture.seed }); + assert.match(expected.stdout, new RegExp(intent.refs[0].localOid)); + assert.equal(unexpected.stdout, ""); +}); + test("multi-ref publication is atomic when one remote ref is rejected", async (t) => { const fixture = await createFixture(); await addControlRemote(fixture.seed, fixture.bare); diff --git a/tests/release-workflow.test.mjs b/tests/release-workflow.test.mjs index 6c2cf48..dd9e18e 100644 --- a/tests/release-workflow.test.mjs +++ b/tests/release-workflow.test.mjs @@ -195,14 +195,23 @@ test("approved release publishes exact control ref, annotated tag, and GitHub re let liveResponses = []; let currentControlIdentity = intent.control.repository.id; let currentControlPrivate = true; + let phaseClockReads = 0; + let tagDriftObject = null; const executor = await ReleaseExecutor.open({ repoPath: fixture.seed, stateRoot, commandRunner, remoteRefReader: async () => { liveReads += 1; return liveResponses.shift() ?? head; }, codeRepositoryReader: async () => "github.com/EXAMPLE/REPOSITORY", - controlRepositoryReader: async () => ({ id: currentControlIdentity, isPrivate: currentControlPrivate }), - clock: () => now, + controlRepositoryReader: async () => ({ id: currentControlIdentity, isPrivate: currentControlPrivate, pushUrl: control }), + clock: async () => { + phaseClockReads += 1; + if (tagDriftObject && phaseClockReads === 3) { + await runGit({ args: ["push", "--force", "origin", `${tagDriftObject}:refs/tags/${intent.tag}`], cwd: fixture.seed }); + tagDriftObject = null; + } + return now; + }, }); const approval = approvalFor(intent, "publish-release"); await runGit({ args: ["tag", intent.tag, head], cwd: fixture.seed }); @@ -225,12 +234,28 @@ test("approved release publishes exact control ref, annotated tag, and GitHub re cwd: fixture.seed, }); await runGit({ args: ["push", "origin", `:refs/tags/${intent.tag}`], cwd: fixture.seed }); + await runGit({ + args: ["tag", "-a", "--cleanup=verbatim", intent.tag, "-m", intent.release.title, intent.revision.commit], + cwd: fixture.seed, + env: { + GIT_COMMITTER_NAME: "Different Tagger", + GIT_COMMITTER_EMAIL: "different@example.invalid", + GIT_COMMITTER_DATE: "2026-07-15T12:30:00.000Z", + }, + }); + const alternateTagObject = (await runGit({ args: ["rev-parse", `refs/tags/${intent.tag}`], cwd: fixture.seed })).stdout.trim(); + await runGit({ args: ["tag", "-d", intent.tag], cwd: fixture.seed }); await writeFile(join(fixture.seed, notesPath), "MUTATED WORKTREE NOTES\n"); await assert.rejects(executor.execute({ intent, approval, now }), /clean worktree/); await runGit({ args: ["restore", notesPath], cwd: fixture.seed }); liveResponses = [head, parent]; await assert.rejects(executor.execute({ intent, approval, now }), /immediately before tag publication/); assert.deepEqual(liveResponses, []); + phaseClockReads = 0; + tagDriftObject = alternateTagObject; + await assert.rejects(executor.execute({ intent, approval, now }), /noncanonical metadata/); + assert.equal(tagDriftObject, null); + await runGit({ args: ["push", "--force", "origin", intent.tag], cwd: fixture.seed }); await runGit({ args: ["config", "--unset", "user.name"], cwd: fixture.seed }); await runGit({ args: ["config", "--unset", "user.email"], cwd: fixture.seed }); const result = await executor.execute({ intent, approval, now }); @@ -246,7 +271,7 @@ test("approved release publishes exact control ref, annotated tag, and GitHub re await runGit({ args: ["push", "origin", `:refs/tags/${intent.tag}`], cwd: fixture.seed }); const reconciled = await executor.execute({ intent, approval, now }); assert.equal(reconciled.receipt.status, "succeeded"); - assert.equal(liveReads, 11); + assert.equal(liveReads, 13); assert.equal(ghCalls.length, 4); assert.deepEqual(publishedNotes, ["Release 1.2.3\n", "Release 1.2.3\n"]); const remoteTag = await runGit({ args: ["ls-remote", "--tags", "origin", "refs/tags/v1.2.3^{}"], cwd: fixture.seed }); @@ -257,16 +282,7 @@ test("approved release publishes exact control ref, annotated tag, and GitHub re assert.equal(stored.status, "succeeded"); await runGit({ args: ["tag", "-d", intent.tag], cwd: fixture.seed }); - await runGit({ - args: ["tag", "-a", "--cleanup=verbatim", intent.tag, "-m", intent.release.title, intent.revision.commit], - cwd: fixture.seed, - env: { - GIT_COMMITTER_NAME: "Different Tagger", - GIT_COMMITTER_EMAIL: "different@example.invalid", - GIT_COMMITTER_DATE: "2026-07-15T12:30:00.000Z", - }, - }); - const alternateTagObject = (await runGit({ args: ["rev-parse", `refs/tags/${intent.tag}`], cwd: fixture.seed })).stdout.trim(); + await runGit({ args: ["update-ref", `refs/tags/${intent.tag}`, alternateTagObject], cwd: fixture.seed }); assert.notEqual(alternateTagObject, approvedTagObject); await runGit({ args: ["push", "--force", "origin", intent.tag], cwd: fixture.seed }); await runGit({ args: ["update-ref", `refs/tags/${intent.tag}`, approvedTagObject], cwd: fixture.seed });