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..ca17c9c 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 +``` + +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 From this repository: diff --git a/docs/operations-hardening.md b/docs/operations-hardening.md index 8eb596a..1a63af2 100644 --- a/docs/operations-hardening.md +++ b/docs/operations-hardening.md @@ -30,9 +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 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`. +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 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 new file mode 100644 index 0000000..7fb2d22 --- /dev/null +++ b/docs/releases/v0.3.0.md @@ -0,0 +1,17 @@ +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, 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, 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 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 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/docs/review-loop.md b/docs/review-loop.md index 2f5fb5e..89bd7fa 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 `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-release/minimal-intent.json b/examples/tabellio-release/minimal-intent.json new file mode 100644 index 0000000..6dad0d2 --- /dev/null +++ b/examples/tabellio-release/minimal-intent.json @@ -0,0 +1,74 @@ +{ + "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": { + "repository": { + "id": "github.com/example/repository-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": "7a5bbc64a02e37356acb82576698584e8e21f3f0539a6a572d1cf8083a6b7c7c" + } +} diff --git a/examples/tabellio-review/minimal-cycle.json b/examples/tabellio-review/minimal-cycle.json index 33d759c..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" }, @@ -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": "4bc32c6f6161c4c3a2bb22924e7587734c579bf6edb4b056615cbd9737385985" } } diff --git a/examples/tabellio-validation/minimal-result.json b/examples/tabellio-validation/minimal-result.json index c9019ca..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": { @@ -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": "eefca7fe3e063446cfdc3151ad9238c5015e577741e3023ffb2e0d06fcbf194d" } } 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..7268b9e --- /dev/null +++ b/schemas/release-operation-receipt.schema.json @@ -0,0 +1,58 @@ +{ + "$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, + "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"], + "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 } + } + }, + "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/schemas/release-operation.schema.json b/schemas/release-operation.schema.json new file mode 100644 index 0000000..00e1bb9 --- /dev/null +++ b/schemas/release-operation.schema.json @@ -0,0 +1,113 @@ +{ + "$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": ["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", + "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": { + "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/schemas/review-cycle.schema.json b/schemas/review-cycle.schema.json index 34e5c2b..689dd62 100644 --- a/schemas/review-cycle.schema.json +++ b/schemas/review-cycle.schema.json @@ -1,161 +1,4 @@ { "$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 } - } - } - } + "$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 2ff80d7..b855b42 100644 --- a/schemas/validation-result.schema.json +++ b/schemas/validation-result.schema.json @@ -1,96 +1,4 @@ { "$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"] } - } - } - } + "$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/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..36c4217 --- /dev/null +++ b/scripts/lib/contract-checks.mjs @@ -0,0 +1,61 @@ +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.`); + }, + + 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(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.`); + }, + + 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/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 new file mode 100644 index 0000000..9adb015 --- /dev/null +++ b/scripts/lib/github-repository.mjs @@ -0,0 +1,131 @@ +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 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, fetchUrls, pushUrls }; +} + +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); +} + +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); +} + +function httpsParts(value) { + 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) { + const match = value.match(/^git@github\.com:([^\s]+)$/i); + 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; +} + +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 new file mode 100644 index 0000000..acd7a9d --- /dev/null +++ b/scripts/lib/preflight.mjs @@ -0,0 +1,318 @@ +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 { + effectiveGitHubRepository, + 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"; + +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, + controlRemote = null, + remoteRefReader = readRemoteRefOid, + remoteRepositoryReader = effectiveGitHubRepository, + 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, commandRunner, ghBinary, controlRemote, remoteRefReader, remoteRepositoryReader }); + + 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 (!/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:\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."); + }); + + 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, 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, remoteRepositoryReader })); + await record(checks, "codex-hooks", () => checkCodexHooks(store)); + if (profile === "release") await record(checks, "clean-main", () => checkCleanMain(store, remoteRefReader)); +} + +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, 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 [origin, control] = await Promise.all([ + remoteRepositoryReader(store, "origin"), + remoteRepositoryReader(store, selectedControl), + ]); + 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"], + 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 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 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 hook commands declared."); +} + +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"), + 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 === trackedMain ? null : blocked("main does not equal tracked origin/main.", "Fetch and fast-forward main before release planning."), + ]; + 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 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) { + 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 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..d3f6db0 --- /dev/null +++ b/scripts/lib/release-operation.mjs @@ -0,0 +1,142 @@ +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"; +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, + version, + revision, + pullRequest, + controlIntent, + controlRepository, + 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: { repository: controlRepository, 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"); + assertCodeRepositoryIdentity(value.repository); + + 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, ["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"); + 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"); + 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"); + 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() } = {}) { + 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; +} + +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."); +} + +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-planner.mjs b/scripts/lib/release-planner.mjs new file mode 100644 index 0000000..647ecb1 --- /dev/null +++ b/scripts/lib/release-planner.mjs @@ -0,0 +1,256 @@ +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 { effectiveGitHubRepository, 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, 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"; + +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, + remoteRepositoryReader = effectiveGitHubRepository, + now = new Date(), +} = {}) { + 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, 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 }); + 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, + 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, + pullRequestHead, + 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, + controlRepository: { id: repositories.control.identity }, + 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, 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, remoteRepositoryReader }) { + const [origin, control] = await Promise.all([ + remoteRepositoryReader(store, "origin"), + remoteRepositoryReader(store, controlRemote), + ]); + if (sameGitHubRepository(origin, control)) throw new Error("Control repository must differ from the code repository."); + assertRequestedRepository(origin, owner, repo); + assertExplicitRepositoryId(origin, explicitRepositoryId); + return { code: origin, control }; +} + +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) { + 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.`); + 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 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, + }); + 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, pullRequestHead, 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.`); + 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.`); + } +} + +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..31be502 --- /dev/null +++ b/scripts/lib/release-workflow.mjs @@ -0,0 +1,599 @@ +import { createHash, randomUUID } from "node:crypto"; +import { mkdir, mkdtemp, readFile, realpath, rename, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; + +import { + ApprovedControlRefTransport, + snapshotControlRefs, +} from "./control-ref-transport.mjs"; +import { runExternalCommand } from "./external-command.mjs"; +import { effectiveGitHubRepository, readRemoteRefOid } from "./github-repository.mjs"; +import { runGit } from "./git-process.mjs"; +import { withOperationLock } from "./operation-lock.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, lockRunner = withOperationLock, clock = () => new Date() }) { + this.repoPath = repoPath; + this.stateRoot = stateRoot; + this.actions = actions; + this.lockRunner = lockRunner; + this.clock = clock; + } + + static async open({ + repoPath = process.cwd(), + stateRoot = null, + ghBinary = "gh", + commandRunner = runExternalCommand, + 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(); + 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, clock }); + } + + async execute({ intent, approval, now = null }) { + const startedAt = now ?? await this.clock(); + validateReleaseIntent(intent); + 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: startedAt })); + } + + async #executeLocked({ intent, approval, 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); + 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 = 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 }); + } + await completeRelease(path, state); + return releaseResult(state, path); + } +} + +class ReleaseActions { + constructor({ + store, + ghBinary = "gh", + commandRunner = runExternalCommand, + remoteRefReader = readRemoteRefOid, + codeRepositoryReader = codeGitHubRemoteRepository, + controlRepositoryReader = privateGitHubRemoteRepository, + }) { + this.store = store; + this.ghBinary = ghBinary; + this.commandRunner = commandRunner; + this.remoteRefReader = remoteRefReader; + this.codeRepositoryReader = codeRepositoryReader; + this.controlRepositoryReader = controlRepositoryReader; + } + + 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, 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 }), + 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, 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 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, + refs: intent.control.intent.refs.map((entry) => entry.name), + transportRemote: controlRepository.pushUrl, + }); + const publication = controlPublicationState(snapshot, intent.control.intent.refs); + if (publication === "published") { + return { status: "already-published", refs: snapshot.map((entry) => entry.name) }; + } + const controlApproval = { + schemaVersion: "tabellio-control-ref-approval/v0.1", + id: `${approval.id.slice(0, 80)}-control-${randomUUID()}`, + 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, + transportRemote: controlRepository.pushUrl, + }); + return { status: result.status, approvalId: result.approvalId, refs: result.refs }; + } + + 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", 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); + assertMatchingTagObjects(local, remote, intent.tag); + await ensureRemoteTag(this.store.repoPath, intent, remote); + return { tag: intent.tag, commit: intent.revision.commit, status: tagPublishStatus(remote) }; + } + + 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."); + const existing = await optionalCommand(this.commandRunner, { + binary: this.ghBinary, + 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 withTemporaryReleaseNotes(notesSource, async (notesFile) => this.commandRunner({ + binary: this.ghBinary, + args: [ + "release", "create", intent.tag, + "--repo", repository, + "--title", intent.release.title, + "--notes-file", notesFile, + "--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 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); +} + +function assertReleaseRevision(actual, intent) { + 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."); + assertEqual(actual.liveMain, actual.head, "Release commit no longer equals live 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."); + controlPublicationState(actual.refs, intent.control.intent.refs); +} + +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) { + 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.`); +} + +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, canonicalObject) { + if (local) return; + if (remote) { + await runGit({ args: ["update-ref", `refs/tags/${intent.tag}`, remote.object], cwd: repoPath }); + return; + } + 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) { + 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 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 }; +} + +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 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; + return tagStateFromObject(repoPath, direct.stdout.trim()); +} + +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 direct = rows.find(([, ref]) => ref === `refs/tags/${tag}`); + if (!direct) return null; + const peeled = rows.find(([, ref]) => ref === `refs/tags/${tag}^{}`); + 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) => ["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.`); +} + +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 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"], + 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, pushUrl: repository.pushUrls[0] }; +} + +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."); +} + +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"); + try { + await writeFile(path, source, { mode: 0o600 }); + return await action(path); + } finally { + await rm(directory, { recursive: true, force: true }); + } +} + +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/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/scripts/lib/review-cycle.mjs b/scripts/lib/review-cycle.mjs index 1a97d08..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; @@ -44,6 +45,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)), @@ -58,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 }, @@ -81,7 +83,11 @@ export class ReviewCycleManager { feedback, fixes, checks, - events: appendEvent(existing?.events ?? [], 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) }, @@ -205,7 +211,10 @@ export class ReviewCycleManager { } async #save(cycle, expectedVersion) { - cycle.status = deriveStatus(cycle); + cycle.schemaVersion = REVIEW_CYCLE_SCHEMA_VERSION_V3; + 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,10 +265,19 @@ 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 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"); - 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"); @@ -295,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"); @@ -476,15 +494,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) { @@ -532,6 +559,33 @@ 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; + 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; + 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"); @@ -612,15 +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"], `${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`); + 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 5f1841f..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 { @@ -21,6 +22,8 @@ export class ValidationRunner { repositoryId, commit, base, + checkpointHead = null, + checkpointBase = null, manifestPath = "tabellio.validation.json", runnerId = "local", now = new Date(), @@ -28,23 +31,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 +57,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) { @@ -81,10 +81,11 @@ 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: { baseCommit, mergeBase, headCommit }, + revision, + checkpointRevision, suite: { id: manifest.id, manifestPath, @@ -100,7 +101,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 +155,14 @@ 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"); - 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"); - 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"); + 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"); @@ -190,6 +188,25 @@ 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); + 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 +313,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-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..41dd359 --- /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 result = await executor.execute({ intent, approval }); + console.log(JSON.stringify({ ok: true, ...result }, null, 2)); +} + +async function readJson(path) { + return JSON.parse(await readFile(resolve(path), "utf8")); +} diff --git a/tests/context-and-evidence.test.mjs b/tests/context-and-evidence.test.mjs index 801e0eb..89e0dd0 100644 --- a/tests/context-and-evidence.test.mjs +++ b/tests/context-and-evidence.test.mjs @@ -261,12 +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] = 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/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/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 new file mode 100644 index 0000000..2b18e80 --- /dev/null +++ b/tests/preflight.test.mjs @@ -0,0 +1,162 @@ +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, 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); + 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/); + + 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) => { + 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/); +}); + +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/); + + 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) => { + 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", "--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/); + + 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 })); + 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 writeEntireHooks(fixture.seed, (command) => `entire hooks codex ${command}`); + await writeFile(join(fixture.seed, "tabellio.platform.json"), JSON.stringify(platformFixture())); + 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, 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: ${trustStatus}\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]}`); + if (handler) return handler(); + throw new Error(`Unexpected command: ${binary} ${args.join(" ")}`); + }; +} + +function result(stdout, stderr = "") { + return { stdout, stderr, exitCode: 0, signal: null }; +} diff --git a/tests/release-workflow.test.mjs b/tests/release-workflow.test.mjs new file mode 100644 index 0000000..dd9e18e --- /dev/null +++ b/tests/release-workflow.test.mjs @@ -0,0 +1,540 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +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"; + +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"; +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"; +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/); + 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/); + assert.throws(() => exampleIntent({ controlRefs: ["refs/tabellio/validations"] }), /complete release control-ref set/); +}); + +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 = []; + 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, + lockRunner: async (_options, action) => action(), + clock: () => now, + }); + const intent = exampleIntent(); + const approval = approvalFor(intent, "resume-release"); + await assert.rejects(executor.execute({ intent, approval, now }), /simulated tag failure/); + 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 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 })); + 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, + clock: () => now, + }); + 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).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 })); + 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 }); + 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 = "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" }); + 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/heads/entire/checkpoints/v1"], + }), + 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, + 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, + }); + const ghCalls = []; + const publishedNotes = []; + let existingRelease = null; + const commandRunner = async ({ args }) => { + ghCalls.push(args); + 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"); + let liveReads = 0; + 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, 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 }); + 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; + 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", "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 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 }); + assert.equal(result.receipt.status, "succeeded"); + assert.equal(Object.hasOwn(result.receipt, "receiptPath"), false); + 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", ":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, 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 }); + 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: ["tag", "-d", intent.tag], cwd: fixture.seed }); + 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 }); + await assert.rejects(executor.execute({ intent, approval, now }), /noncanonical metadata/); + await runGit({ args: ["push", "--force", "origin", intent.tag], 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) => { + 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 }); + 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'); + 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 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", "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 }); + 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^"); + 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: pullRequestHead, 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: pullRequestHead, mergeCommit: { oid: head } }), stderr: "", exitCode: 0, signal: null }; + } + 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", + 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", + repo: "other-repository", + number: 9, + version: "2.0.0", + notesPath, + preflightRunner: async () => ({ status: "ready", checks: [] }), + 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", + owner: "example", + repo: "repository", + number: 9, + version: "2.0.0", + notesPath, + token: "test-token", + commandRunner, + preflightRunner: async () => ({ status: "ready", checks: [] }), + githubProvider: provider, + remoteRepositoryReader, + now, + }); + assert.equal(intent.revision.commit, head); + 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); + 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({ + releaseCreatedAt = createdAt, + repositoryId = "github.com/example/repository", + 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: controlRefs.map((name, index) => ({ + name, + localOid: String(index + 1).repeat(40), + remoteOid: String(index + 4).repeat(40), + })), + createdAt, + }); + return createReleaseIntent({ + 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: 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, + }); +} + +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"); +} + +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() { + 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: [] }; }, + }; +} + +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..8dace1f 100644 --- a/tests/review-cycle.test.mjs +++ b/tests/review-cycle.test.mjs @@ -5,6 +5,8 @@ import test from "node:test"; import { GitJsonLedger } from "../scripts/lib/git-json-ledger.mjs"; import { ReviewCycleManager, + reviewCycleHasReadyEvidence, + reviewCycleHasReleaseReadiness, validateAgentReview, validateReviewCycle, } from "../scripts/lib/review-cycle.mjs"; @@ -124,9 +126,49 @@ 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); + 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) }; + 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); + + 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"); + assert.equal(reviewCycleHasReleaseReadiness(result.cycle, fixCommit2), false); + provider.setChecks("success"); + 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 +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()), 9); + assert.equal(Number(history.stdout.trim()), 14); const worktree = await runGit({ args: ["status", "--porcelain=v1"], cwd: fixture.seed }); assert.equal(worktree.stdout, ""); }); @@ -206,17 +248,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 },