diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index feb8f781..12192017 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,7 +33,7 @@ jobs: node-version: 22 cache: pnpm - run: pnpm install --frozen-lockfile - - run: pnpm test + - run: docker compose run --build --rm test-node test-docker: runs-on: ubuntu-latest diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6f705ab1..878ff6c5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -62,7 +62,7 @@ jobs: cache: pnpm - run: pnpm install --frozen-lockfile - run: pnpm run lint - - run: pnpm test + - run: docker compose run --build --rm test-node - name: Integration tests (Node) run: docker compose run --build --rm test-node pnpm vitest run test/integration --no-file-parallelism diff --git a/CHANGELOG.md b/CHANGELOG.md index b15a78cf..efe8860c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [6.5.11] — 2026-10-02 + +### Fixed + +- Require published Plumbing 3.3.2 so stale mktree transports after external Git GC enter the existing single fresh-process retry for single and batch tree writes. Producer and unrelated transport errors retain their identity; retry remains bounded at two attempts. + +- CI and release Node unit checks and examples run through COPY-based Docker. Tests and benchmarks refuse host execution before loading test modules, including forged Docker and CI environment flags. + +- Replace the host BATS platform launcher with sequential COPY-based Docker orchestration; a failed runtime stops the platform command. + ## [6.5.10] — 2026-08-24 ### Added diff --git a/README.md b/README.md index 6f6890f7..74d3b658 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,8 @@ Existing v5 users should read [UPGRADING.md](./UPGRADING.md) and run For the release overview, see the [v6.5.10 Release Notes](./docs/releases/v6.5.10.md). +The v6.5.11 candidate adopts published Plumbing 3.3.2 for bounded recovery from closed mktree transports after external Git GC. It preserves producer error identity and existing object formats; see [v6.5.11 Release Notes](./docs/releases/v6.5.11.md). Registry publication is pending release verification and merge. + ### 1. CLI Usage Initialize a vault and store your first asset. diff --git a/UPGRADING.md b/UPGRADING.md index 98914d8d..c91988ff 100644 --- a/UPGRADING.md +++ b/UPGRADING.md @@ -725,3 +725,7 @@ The manifest blob was corrupted or tampered with after storage. The original dat ### Constructor throws "chunker is required" You're using `CasService` directly. Either switch to the `ContentAddressableStore` facade (recommended) or inject a `chunker` and `compressionAdapter` manually. See [API Changes](#casservice-constructor-library-users). + +## v6.5.10 To v6.5.11 + +Update the package normally. The Plumbing minimum becomes 3.3.2 to recover closed mktree transports with the existing one-retry bound. No stored-data or application migration is required; unrelated and producer errors remain unchanged. diff --git a/docs/design/0062-mktree-transport-recovery/mktree-transport-recovery.md b/docs/design/0062-mktree-transport-recovery/mktree-transport-recovery.md new file mode 100644 index 00000000..5b73325c --- /dev/null +++ b/docs/design/0062-mktree-transport-recovery/mktree-transport-recovery.md @@ -0,0 +1,175 @@ +--- +title: "PROTO-0062 - Mktree transport recovery" +cycle: "0062" +task_id: "mktree-transport-recovery" +legend: "PROTO" +release_home: "v6.5.11" +issue: "https://github.com/git-stunts/git-cas/issues/131" +goalpost_issue: "none" +tracker_source: "github" +status: "active" +base_commit: "c02c87ee0d7a72b0371762e5239223adb3ac4781" +owners: ["@git-stunts"] +sponsors: + human: "James" + agent: "Codex" +blocking_issues: ["https://github.com/git-stunts/plumbing/pull/20"] +supersedes: [] +superseded_by: null +created: "2026-10-02" +updated: "2026-10-02" +--- + +# Mktree transport recovery + +## Linked Issue + +https://github.com/git-stunts/git-cas/issues/131 + +## Linked Tracker + +GitHub milestone [v6.5.11](https://github.com/git-stunts/git-cas/milestone/21) owns release membership; issue #131 owns workflow state. + +## Design Type + +Dependency adoption and patch release with executable storage recovery proof. + +## Decision Summary + +Require published Plumbing 3.3.2, preserving the existing one-retry immutable-object session policy. Verify public protocol classes and packed registry consumers through COPY-based Docker. + +## Sponsored Human + +James Ross. + +## Sponsored Agent + +Codex. + +## Hill + +A consumer can build single and batched trees after external Git repacking closes a stale mktree transport, without an unbounded retry or altered producer error. + +## Current Truth + +`package.json` currently requires Plumbing ^3.3.0. `GitObjectSessionPool.#attempt` invalidates a failed session and retries only GitProtocolError once. Plumbing PR #20 classifies transport EPIPE and SESSION_INPUT_CLOSED accordingly; registry publication is a prerequisite, not evidence already obtained. + +## Problem + +Git-warp attachment checkpoint/GC checks exposed raw EPIPE escaping bounded recovery. Its patch-package repair only covers development checkouts and does not deploy the repair to npm consumers. + +## Scope + +Adopt the repaired registry dependency and lockfile; add transport recovery regressions; verify all runtimes, public types, examples and packages; publish 6.5.11 with integrity and consumer receipts. Route encountered CI/release unit tests and examples through COPY-based Docker. + +## Non-Goals + +No new retry, mutable-ref recovery, asset format, recursive ownership, git-warp attachment API, or performance claim. + +## Runtime / API Contract + +Single and batch tree operations retry a transport failure once through a fresh process. Two failed attempts terminate with GitProtocolError. EACCES and producer EPIPE preserve their identity and do not retry. Stored object identities and publication semantics remain unchanged. + +## Accessibility Posture + +Evidence and errors are plain linear text and machine-readable results; no visual-only interaction is introduced. + +## User-Facing Text / Directionality + +No localization or new UI strings. Release notes name the recovery and its bounded retry contract. + +## Agent Inspectability / Explainability Posture + +Fault-injected public CommandSession/GitMktreeSession instances expose attempt counts and exact OIDs. Registry version, integrity and installed dependency versions are recorded separately from checkout validation. + +## Linked Invariants + +Immutable-object retry remains bounded; producer errors are not transport errors; mutable refs are not retried by this repair; test execution never mounts host Git repositories. + +## Design Alternatives Considered + +A downstream local patch does not reach registry consumers. Broadly retrying all errors would obscure producer failures and operational refusals. Published protocol classification uses the existing narrowly typed retry. + +## Decision + +Adopt Plumbing rather than duplicate its transport classification in git-cas. + +## Proof Surface + +The first RED uses the actual protocol and persistence adapter with transport failure injection against unchanged Plumbing. The GREEN uses the published dependency; all runtime and package acceptance remains separate from design/documentation assertions. + +## Implementation Slices + +Design and prerequisite record; deterministic fault regression and published dependency adoption; isolated release gates; current-head independent review; merge and full release verification; signed tag and registry publication; downstream consumer proof. + +## Tests To Write First + +Single and batch EPIPE recovery; already-closed input; two-attempt bound; unrelated transport error; producer error preservation. CI/release configuration checks must reject host unit-test and example execution. + +## Acceptance Criteria + +The six recovery checks and all required runtime suites pass in Docker. Lint, examples, public type and package/JSR validation pass. 6.5.11 metadata agrees and the published registry artifact resolves Plumbing >=3.3.2. The issue stays open until publication/consumer evidence exists. + +## Validation Plan + +Use COPY-based Docker Compose services for Node/Bun/Deno unit and serialized integration suites, Deno public types, and Node examples; run lint and package/JSR dry runs with the release candidate. No host runtime tests or repository mounts. + +## Playback / Witness + +Human: does the installed package survive a dead mktree process? Agent: do canonical OIDs, exactly two process attempts, original failures and artifact integrity prove the claim? Store pinned RED/GREEN and publication receipts under this cycle witness directory. + +## Risks + +A missing upstream registry version blocks adoption. A successful checkout patch is not consumer evidence. A GitHub approval gate or publication environment may delay the train; neither permits bypassing protections. + +## Follow-On Debt + +None currently discovered beyond tracked git-warp #923 attachment adoption. + +## Tracker Disposition + +Issue #131 remains open through release publication; Plumbing PR #20 is the upstream prerequisite; git-warp #923 remains open through downstream adoption. + +## Done Does Not Mean + +This dependency repair does not restore git-warp Runtime/Lane node/edge attachment operations or complete its bounded stream contract. + +## Retrospective + +Record after merge and publication, with PR and release receipts. + +## Data / State Model + +No stored data or ref schema changes. + +## Architecture / Anti-SLUDGE Posture + +Transport classification remains owned by Plumbing. git-cas retains its typed session retry and constructor-injected plumbing boundary. + +## Cost / Residency Posture + +At most one replacement immutable-object process; no new collector or buffer. + +## Determinism / Replay / Causality + +Canonical Git tree IDs are unchanged; deterministic injected faults establish retry behavior without timing assertions. + +## Git Substrate Impact + +Existing trees/blobs remain unchanged. Release creates a new immutable signed version tag after merged-main verification. + +## Compatibility / Migration Posture + +Patch dependency minimum only; no consumer source or stored-data migration. + +## Error Contract + +Transport EPIPE and SESSION_INPUT_CLOSED become GitProtocolError with original cause. Other errors retain identity. + +## Security / Trust / Redaction Posture + +Receipts contain repository-relative paths, public commits and package integrity; no credentials or machine-local paths. + +## Lower Modes + +Plain logs and JSON receipts provide complete evidence without color or rendered UI. diff --git a/docs/design/0062-mktree-transport-recovery/witness/candidate-verification.json b/docs/design/0062-mktree-transport-recovery/witness/candidate-verification.json new file mode 100644 index 00000000..9ecc03cd --- /dev/null +++ b/docs/design/0062-mktree-transport-recovery/witness/candidate-verification.json @@ -0,0 +1,261 @@ +{ + "version": "6.5.11", + "results": [ + { + "id": "lint", + "label": "Lint", + "command": "pnpm", + "args": [ + "run", + "lint" + ], + "code": 0, + "signal": null, + "passed": true, + "tests": null, + "errorMessage": null + }, + { + "id": "unit-node", + "label": "Unit Tests (Node)", + "command": "docker", + "args": [ + "compose", + "run", + "--build", + "--rm", + "test-node" + ], + "testCount": true, + "code": 0, + "signal": null, + "passed": true, + "tests": 2203, + "errorMessage": null + }, + { + "id": "example-store-and-restore", + "label": "Example: store-and-restore", + "command": "docker", + "args": [ + "compose", + "run", + "--build", + "--rm", + "test-node", + "node", + "examples/store-and-restore.js" + ], + "code": 0, + "signal": null, + "passed": true, + "tests": null, + "errorMessage": null + }, + { + "id": "example-encrypted-workflow", + "label": "Example: encrypted-workflow", + "command": "docker", + "args": [ + "compose", + "run", + "--build", + "--rm", + "test-node", + "node", + "examples/encrypted-workflow.js" + ], + "code": 0, + "signal": null, + "passed": true, + "tests": null, + "errorMessage": null + }, + { + "id": "example-progress-tracking", + "label": "Example: progress-tracking", + "command": "docker", + "args": [ + "compose", + "run", + "--build", + "--rm", + "test-node", + "node", + "examples/progress-tracking.js" + ], + "code": 0, + "signal": null, + "passed": true, + "tests": null, + "errorMessage": null + }, + { + "id": "unit-bun", + "label": "Unit Tests (Bun)", + "command": "docker", + "args": [ + "compose", + "run", + "--build", + "--rm", + "test-bun", + "bunx", + "vitest", + "run", + "test/unit", + "--no-file-parallelism" + ], + "testCount": true, + "code": 0, + "signal": null, + "passed": true, + "tests": 2203, + "errorMessage": null + }, + { + "id": "unit-deno", + "label": "Unit Tests (Deno)", + "command": "docker", + "args": [ + "compose", + "run", + "--build", + "--rm", + "test-deno", + "deno", + "run", + "-A", + "npm:vitest", + "run", + "test/unit" + ], + "testCount": true, + "code": 0, + "signal": null, + "passed": true, + "tests": 2194, + "errorMessage": null + }, + { + "id": "types-public", + "label": "Public type compatibility", + "command": "docker", + "args": [ + "compose", + "run", + "--rm", + "test-deno", + "deno", + "check", + "--config", + "test/types/deno.json", + "test/types/public-api-compatibility.ts" + ], + "code": 0, + "signal": null, + "passed": true, + "tests": null, + "errorMessage": null + }, + { + "id": "integration-node", + "label": "Integration Tests (Node)", + "command": "pnpm", + "args": [ + "run", + "test:integration:node" + ], + "testCount": true, + "code": 0, + "signal": null, + "passed": true, + "tests": 207, + "errorMessage": null + }, + { + "id": "integration-bun", + "label": "Integration Tests (Bun)", + "command": "pnpm", + "args": [ + "run", + "test:integration:bun" + ], + "testCount": true, + "code": 0, + "signal": null, + "passed": true, + "tests": 207, + "errorMessage": null + }, + { + "id": "integration-deno", + "label": "Integration Tests (Deno)", + "command": "pnpm", + "args": [ + "run", + "test:integration:deno" + ], + "testCount": true, + "code": 0, + "signal": null, + "passed": true, + "tests": 207, + "errorMessage": null + }, + { + "id": "stamp-build", + "label": "Build metadata stamp", + "command": "pnpm", + "args": [ + "run", + "stamp" + ], + "code": 0, + "signal": null, + "passed": true, + "tests": null, + "errorMessage": null + }, + { + "id": "npm-pack", + "label": "npm pack dry-run", + "command": "npm", + "args": [ + "pack", + "--dry-run", + "--json" + ], + "requiredFiles": [ + "build-info.json" + ], + "code": 0, + "signal": null, + "passed": true, + "tests": null, + "errorMessage": null + }, + { + "id": "jsr-publish", + "label": "JSR publish dry-run", + "command": "npx", + "args": [ + "jsr", + "publish", + "--dry-run", + "--allow-dirty" + ], + "code": 0, + "signal": null, + "passed": true, + "tests": null, + "errorMessage": null + } + ], + "totalTests": 7221, + "skippedSteps": [], + "summary": "## Release Verification Summary\n\n- Version: `6.5.11`\n- Steps passed: 14/14\n- Total tests observed: 7221\n\n| Step | Status | Tests |\n| --- | --- | ---: |\n| Lint | PASS | \u2014 |\n| Unit Tests (Node) | PASS | 2203 |\n| Example: store-and-restore | PASS | \u2014 |\n| Example: encrypted-workflow | PASS | \u2014 |\n| Example: progress-tracking | PASS | \u2014 |\n| Unit Tests (Bun) | PASS | 2203 |\n| Unit Tests (Deno) | PASS | 2194 |\n| Public type compatibility | PASS | \u2014 |\n| Integration Tests (Node) | PASS | 207 |\n| Integration Tests (Bun) | PASS | 207 |\n| Integration Tests (Deno) | PASS | 207 |\n| Build metadata stamp | PASS | \u2014 |\n| npm pack dry-run | PASS | \u2014 |\n| JSR publish dry-run | PASS | \u2014 |\n", + "sourceCommit": "1dfd7d6e4d3344e561d70a791c5bd68eb7a28ee9", + "execution": "All executable checks in COPY-based Docker; no host repository or Git mounts. Canonical verifier used an isolation runner: Compose steps use a clean git archive; other commands use a persistent copied container to retain stamped build metadata.", + "rawLogSha256": "2daffd082f58371e106ee2a1726ae8063c3053674bd37f6e89346512b4e45ebd" +} diff --git a/docs/design/0062-mktree-transport-recovery/witness/candidate.md b/docs/design/0062-mktree-transport-recovery/witness/candidate.md new file mode 100644 index 00000000..9726b735 --- /dev/null +++ b/docs/design/0062-mktree-transport-recovery/witness/candidate.md @@ -0,0 +1,19 @@ +# Published-dependency candidate witness + +Source: `1dfd7d6e4d3344e561d70a791c5bd68eb7a28ee9`; version 6.5.11 candidate. Plumbing 3.3.2 is the published npm dependency, with upstream merge `8811356d4f2277a2a10cd496e5e6f1b94b89f480`. This evidence does not claim git-cas 6.5.11 publication. + +## Playback answers + +The actual Plumbing session and CAS persistence adapter recover after injected closed-input and broken-pipe transport failures. Six deterministic checks cover single and batched trees, already-closed input, the two-attempt limit, unrelated transport errors, and producer error identity. Against Plumbing 3.3.0, four fail and two pass; against published 3.3.2, all six pass. This injection establishes transport recovery, not an independent real-GC causal witness. + +The first full release run caught a stale CLI version export (6.5.10 versus metadata 6.5.11). Commit 1dfd7d6 synchronizes it; three CLI checks pass, followed by all 14 canonical release gates. The attached JSON records the exact checked source and raw-log digest. + +## Execution and bounds + +All checks ran in COPY-based Docker without host repository or Git mounts. The canonical release verifier orchestrated copied Node/Bun/Deno Compose services. Lint, stamping, npm packing and JSR dry-run used a persistent copied validation container; stamped metadata survived into packaging. Source-only Git metadata identifies the checked commit; graph refs and host configuration were excluded. + +Node and Bun unit suites each passed 2,203 with three existing skips. Deno passed 2,194 with twelve existing skips. Each runtime passed 207 integration tests. These are per-runtime observations, not a claim of identical execution coverage. Three maintained examples, public types, lint, stamp, npm pack and JSR dry-run all passed. No release-verification stage was skipped. + +## Remaining delivery + +Current-head independent review, green hosted CI, merge, verification of merged main, signed tag, registry publication and downstream consumer proof remain required. Keep issue #131 open until delivery. The dependency fix does not implement git-warp Runtime/Lane attachment APIs or complete #818. diff --git a/docs/design/0062-mktree-transport-recovery/witness/preparation-evidence.txt b/docs/design/0062-mktree-transport-recovery/witness/preparation-evidence.txt new file mode 100644 index 00000000..693e5534 --- /dev/null +++ b/docs/design/0062-mktree-transport-recovery/witness/preparation-evidence.txt @@ -0,0 +1,55 @@ +Release isolation RED + FAIL |unit| test/unit/scripts/release-verify.test.js > release verify step definitions > executes maintained examples as release-gate steps +AssertionError: expected false to be true // Object.is equality + FAIL |unit| test/unit/scripts/release-verify.test.js > release verification isolation > runs the Node unit gate in a copied Docker image +AssertionError: expected { id: 'unit-node', …(4) } to match object { command: 'docker' } + FAIL |unit| test/unit/scripts/release-workflow.test.js > release test isolation > runs the Node unit gate through COPY-based Docker in CI and publication +AssertionError: expected 'name: CI\n\non:\n push:\n branche…' not to match /^\s+- run: pnpm test$/mu + Test Files 2 failed (2) + Tests 3 failed | 17 passed (20) + Start at 10:27:23 + Duration 235ms (transform 52ms, setup 0ms, collect 88ms, tests 20ms, environment 0ms, prepare 80ms) + +Recovery RED against locked Plumbing 3.3.0 + FAIL |unit| test/unit/infrastructure/adapters/GitPersistenceAdapter.mktree-recovery.test.js > mktree transport recovery > reopens the process once for a broken pipe writing one tree + FAIL |unit| test/unit/infrastructure/adapters/GitPersistenceAdapter.mktree-recovery.test.js > mktree transport recovery > reopens the process once for a broken pipe writing tree batch + FAIL |unit| test/unit/infrastructure/adapters/GitPersistenceAdapter.mktree-recovery.test.js > mktree transport recovery > also recovers when process completion wins the race with the next write +AssertionError: promise rejected "GitPlumbingError: input closed { …(2) }" instead of resolving + FAIL |unit| test/unit/infrastructure/adapters/GitPersistenceAdapter.mktree-recovery.test.js > mktree transport recovery > reports a typed failure after two broken processes, without an unbounded retry +AssertionError: expected Error: broken pipe { code: 'EPIPE' } to be an instance of GitProtocolError + Test Files 1 failed (1) + Tests 4 failed | 2 passed (6) + Start at 10:26:44 + Duration 420ms (transform 171ms, setup 0ms, collect 299ms, tests 7ms, environment 0ms, prepare 35ms) + +Recovery GREEN against packed 3.3.2 candidate + Test Files 1 passed (1) + Tests 6 passed (6) + Start at 10:34:34 + Duration 450ms (transform 185ms, setup 6ms, collect 317ms, tests 7ms, environment 0ms, prepare 36ms) + +Node preparation unit and lint + Test Files 233 passed | 1 skipped (234) + Tests 2197 passed | 3 skipped (2200) + Start at 10:31:15 + Duration 6.35s (transform 3.39s, setup 2.36s, collect 25.61s, tests 21.50s, environment 20ms, prepare 8.07s) +> @git-stunts/git-cas@6.5.10 lint +> eslint . + +Bun preparation unit + Test Files 233 passed | 1 skipped (234) + Tests 2197 passed | 3 skipped (2200) + Start at 10:34:05 + Duration 37.11s (transform 625ms, setup 476ms, collect 4.88s, tests 16.66s, environment 14ms, prepare 4.46s) + +Deno preparation unit + Test Files 233 passed | 1 skipped (234) + Tests 2188 passed | 12 skipped (2200) + Start at 10:34:43 + Duration 4.77s (transform 1.98s, setup 3.66s, collect 35.97s, tests 18.37s, environment 23ms, prepare 5.01s) + +Node candidate integration + Test Files 14 passed (14) + Tests 207 passed (207) + Start at 10:35:10 + Duration 22.71s (transform 233ms, setup 32ms, collect 1.35s, tests 20.38s, environment 1ms, prepare 277ms) diff --git a/docs/design/0062-mktree-transport-recovery/witness/preparation.md b/docs/design/0062-mktree-transport-recovery/witness/preparation.md new file mode 100644 index 00000000..310dc2bd --- /dev/null +++ b/docs/design/0062-mktree-transport-recovery/witness/preparation.md @@ -0,0 +1,34 @@ +# Preparatory playback evidence + +These are preparatory results for issue [#131](https://github.com/git-stunts/git-cas/issues/131), not release or registry-publication evidence. + +## Coordinates + +Git-cas preparation source: `d7717d3`; base: `c02c87ee0d7a72b0371762e5239223adb3ac4781`. Upstream candidate source: `d728cbf56ac85ccef9b276422e6168a68a9e6ad0`. The baseline Docker dependency reports Plumbing `3.3.0`; the packed-candidate Docker dependency reports `3.3.2`. + +Packed Plumbing candidate SHA-512 (hex): `c86840045f5efa8f867366fc2fc2e45c9804d000ab248d0c6f2b7147285970728b25f645bfd2c0b3c44dc6fe0ab485cbc501e3d92b655bb4f5e300e4d1b5d63f`. The artifact was produced with `npm pack` inside COPY-based Docker; it was installed only in a preparation image. It is not a registry artifact, and no file/tarball dependency is added to the tracked package or lockfile. + +## Executed checks + +| Surface | Outcome | Qualification | +| --- | --- | --- | +| CI/release isolation regression | 3 failed, 17 passed before fix | Detected host unit and example paths | +| Public mktree/persistence recovery regression | 4 failed, 2 passed before upstream adoption | Actual protocol instances against locked 3.3.0 | +| Same recovery regression with candidate | 6 passed | Packed 3.3.2, not yet npm | +| Node unit preparation | 2,197 passed, 3 skipped | Existing skips retained | +| Bun unit preparation | 2,197 passed, 3 skipped | Existing skips retained | +| Deno unit preparation | 2,188 passed, 12 skipped | Existing runtime skips retained | +| Node integration with candidate | 207 passed | 14 integration files, serialized | +| ESLint | Passed | Entire preparation checkout | + +Selected raw summaries are in [preparation-evidence.txt](./preparation-evidence.txt). Complete hosted CI for `d7717d3`, including all three integration runtimes and public Deno types, is [run 36995996685](https://github.com/git-stunts/git-cas/actions/runs/36995996685). + +All executions used COPY-based Docker with no host repository or Git-directory mounts. Environment flags did not authorize a host execution. The new guard checks the physical Docker marker before test modules load. + +## Playback + +Human: the packed candidate can recover the stale-transport fault and the 207 Git-backed integration checks pass. Agent: deterministic fault cases show canonical OIDs, two attempts at most, and unchanged unrelated/producer failure identities. Those are separate claims from actual external GC and registry consumer delivery. + +## Remaining release evidence + +Merge and publish Plumbing 3.3.2; update the registry dependency minimum and lockfile; rerun complete release verification against that immutable registry version; review/merge git-cas; verify synced main; publish 6.5.11; record registry integrity and fresh consumer proof; adopt it in git-warp. No tagged or published 6.5.11 is claimed here. diff --git a/docs/design/README.md b/docs/design/README.md index 3f1c7d2b..ed55fc40 100644 --- a/docs/design/README.md +++ b/docs/design/README.md @@ -11,6 +11,8 @@ process in [docs/method/process.md](../method/process.md). ## Active METHOD Cycles +- [0062-mktree-transport-recovery - mktree-transport-recovery](./0062-mktree-transport-recovery/mktree-transport-recovery.md) + - [0054-batched-page-retention - batched-page-retention](./0054-batched-page-retention/batched-page-retention.md) - [0050-lazy-bundle-reference-reads - lazy-bundle-reference-reads](./0050-lazy-bundle-reference-reads/lazy-bundle-reference-reads.md) - [0049-scoped-staging-workspaces — scoped-staging-workspaces](./0049-scoped-staging-workspaces/scoped-staging-workspaces.md) diff --git a/docs/method/release.md b/docs/method/release.md index ad15d826..6d0712dc 100644 --- a/docs/method/release.md +++ b/docs/method/release.md @@ -10,7 +10,7 @@ All of the following must pass on the release candidate. Prefer `npm run release:verify` so the release record comes from one command. 1. `npx eslint .` -2. `npm test` +2. `docker compose run --build --rm test-node` 3. `docker compose run --build --rm test-node npx vitest run test/integration` 4. `docker compose run --build --rm test-bun bunx vitest run test/unit` 5. `docker compose run --build --rm test-bun bunx vitest run test/integration` diff --git a/docs/releases/v6.5.11.md b/docs/releases/v6.5.11.md new file mode 100644 index 00000000..65725405 --- /dev/null +++ b/docs/releases/v6.5.11.md @@ -0,0 +1,15 @@ +# git-cas v6.5.11 Release Notes + +This patch release adopts published Plumbing 3.3.2 so a stale mktree process closed by external Git repacking enters the existing bounded immutable-object recovery. Single-tree and batched-tree writes reopen a failed process once. Two failed attempts stop with GitProtocolError; unrelated transport errors and producer errors retain their original identity. + +## Compatibility + +No API, handle, stored object, descriptor, ref namespace, encryption, or reader format changes. No application or stored-data migration is required. Mutable-ref retry and publication rules remain unchanged. + +## Test Isolation + +All tests and benchmarks require a physical Docker marker before test modules load. Environment flags do not authorize host execution. CI/release Node unit gates and maintained examples use COPY-based Docker without repository or Git-directory mounts. + +## Evidence And Delivery + +Issue [#131](https://github.com/git-stunts/git-cas/issues/131) and PR [#132](https://github.com/git-stunts/git-cas/pull/132) own delivery. [Preparatory evidence](../design/0062-mktree-transport-recovery/witness/preparation.md) pins candidate behavior separately from registry delivery. The six recovery checks fail in four cases against Plumbing 3.3.0 and pass against published Plumbing 3.3.2. Full release verification, merge, signed tag, npm publication and registry consumer evidence remain required; this candidate document claims no published 6.5.11 artifact. diff --git a/jsr.json b/jsr.json index 9886be83..159f64c5 100644 --- a/jsr.json +++ b/jsr.json @@ -1,6 +1,6 @@ { "name": "@git-stunts/git-cas", - "version": "6.5.10", + "version": "6.5.11", "exports": { ".": "./index.js", "./service": "./src/domain/services/CasService.js", diff --git a/package.json b/package.json index 6c1de045..4ba14a9f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@git-stunts/git-cas", - "version": "6.5.10", + "version": "6.5.11", "description": "Content-addressed storage backed by Git's object database, with optional encryption and pluggable codecs", "type": "module", "main": "index.js", @@ -55,7 +55,8 @@ "test/CONVENTIONS.md", "test/cycles/README.md", "LICENSE", - "CHANGELOG.md" + "CHANGELOG.md", + "docs/releases/v6.5.11.md" ], "engines": { "node": ">=22.0.0" @@ -86,17 +87,17 @@ "provenance": true }, "scripts": { - "test": "vitest run test/unit", + "test": "docker compose run --build --rm test-node", "test:local": "vitest run test/unit", "test:node": "docker compose run --build --rm test-node", "test:bun": "docker compose run --build --rm test-bun", "test:deno": "docker compose run --build --rm test-deno", - "test:integration": "vitest run test/integration --no-file-parallelism", + "test:integration": "docker compose run --build --rm test-node pnpm vitest run test/integration --no-file-parallelism", "test:integration:node": "docker compose run --build --rm test-node npx vitest run test/integration --no-file-parallelism", "test:integration:bun": "docker compose run --build --rm test-bun bunx vitest run test/integration --no-file-parallelism", "test:integration:deno": "docker compose run --build --rm test-deno deno run -A npm:vitest run test/integration --no-file-parallelism", - "test:platforms": "bats --jobs 3 test/platform/runtimes.bats", - "benchmark": "vitest bench test/benchmark", + "test:platforms": "docker compose run --build --rm test-node && docker compose run --build --rm test-bun && docker compose run --build --rm test-deno", + "benchmark": "docker compose run --build --rm test-node pnpm vitest bench test/benchmark", "benchmark:local": "vitest bench test/benchmark", "release:verify": "node scripts/release/verify.js", "stamp": "node scripts/stamp-build.js", @@ -112,7 +113,7 @@ "@flyingrobots/bijou-tui": "^7.2.0", "@flyingrobots/bijou-tui-app": "^7.2.0", "@git-stunts/alfred": "^0.10.0", - "@git-stunts/plumbing": "^3.3.0", + "@git-stunts/plumbing": "^3.3.2", "@git-stunts/vault": "^1.0.1", "cbor-x": "^1.6.0", "commander": "14.0.3", @@ -131,6 +132,7 @@ "fast-check": "^4.6.0", "jsr": "^0.14.2", "prettier": "^3.4.2", - "vitest": "^2.1.8" + "vitest": "^2.1.8", + "@git-stunts/docker-guard": "^0.1.0" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 71471fc3..4befee58 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -24,8 +24,8 @@ importers: specifier: ^0.10.0 version: 0.10.0 '@git-stunts/plumbing': - specifier: ^3.3.0 - version: 3.3.0 + specifier: ^3.3.2 + version: 3.3.2 '@git-stunts/vault': specifier: ^1.0.1 version: 1.0.1 @@ -42,6 +42,9 @@ importers: '@eslint/js': specifier: ^9.17.0 version: 9.39.2 + '@git-stunts/docker-guard': + specifier: ^0.1.0 + version: 0.1.0 '@types/node': specifier: ^25.3.2 version: 25.3.2 @@ -297,8 +300,11 @@ packages: resolution: {integrity: sha512-0DPhJdKhYTcsPuoOnYIIyvlwaIM7yIx4fQM4Q48abe/VDLTfZef1ubeT1pYio+ZTp1lKXtSG663973ewBbi/yw==} engines: {node: '>=20.0.0'} - '@git-stunts/plumbing@3.3.0': - resolution: {integrity: sha512-v/AT3hKgmFKSQ3M+n7n9VgC5Ri7C+NDtZS11Bj1JmT0Xv523hNdjCIaRHSXjAaCpiuXuDKFlj+E8PcBI1+FxbA==} + '@git-stunts/docker-guard@0.1.0': + resolution: {integrity: sha512-9h2kzMlidbWeoj62VybBzwEMeMySqN/p3vP03rg5enklElkde68KhwfHB3pfaSR/Cx50tnUT27Vfcb7RMcdZkA==} + + '@git-stunts/plumbing@3.3.2': + resolution: {integrity: sha512-yGhABF9e+o+Gc2b8L8LkXJgE0ACrJI0MbytxRyhZcHKLJfZFv9LAs8RNxv4KtIXLxQHj2StlW7T14wDk0bXWPw==} engines: {bun: '>=1.3.5', deno: '>=2.0.0', node: '>=20.0.0'} '@git-stunts/vault@1.0.1': @@ -626,6 +632,7 @@ packages: eslint@9.39.2: resolution: {integrity: sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options. hasBin: true peerDependencies: jiti: '*' @@ -1161,7 +1168,9 @@ snapshots: '@git-stunts/alfred@0.10.0': {} - '@git-stunts/plumbing@3.3.0': + '@git-stunts/docker-guard@0.1.0': {} + + '@git-stunts/plumbing@3.3.2': dependencies: zod: 3.25.76 diff --git a/scripts/release/verify.js b/scripts/release/verify.js index a8d70916..655e340c 100644 --- a/scripts/release/verify.js +++ b/scripts/release/verify.js @@ -37,27 +37,27 @@ export const RELEASE_STEPS = [ { id: 'unit-node', label: 'Unit Tests (Node)', - command: 'pnpm', - args: ['test'], + command: 'docker', + args: ['compose', 'run', '--build', '--rm', 'test-node'], testCount: true, }, { id: 'example-store-and-restore', label: 'Example: store-and-restore', - command: 'node', - args: ['examples/store-and-restore.js'], + command: 'docker', + args: ['compose', 'run', '--build', '--rm', 'test-node', 'node', 'examples/store-and-restore.js'], }, { id: 'example-encrypted-workflow', label: 'Example: encrypted-workflow', - command: 'node', - args: ['examples/encrypted-workflow.js'], + command: 'docker', + args: ['compose', 'run', '--build', '--rm', 'test-node', 'node', 'examples/encrypted-workflow.js'], }, { id: 'example-progress-tracking', label: 'Example: progress-tracking', - command: 'node', - args: ['examples/progress-tracking.js'], + command: 'docker', + args: ['compose', 'run', '--build', '--rm', 'test-node', 'node', 'examples/progress-tracking.js'], }, { id: 'unit-bun', diff --git a/scripts/testing/DockerExecutionBoundary.js b/scripts/testing/DockerExecutionBoundary.js new file mode 100644 index 00000000..7b558aee --- /dev/null +++ b/scripts/testing/DockerExecutionBoundary.js @@ -0,0 +1,11 @@ +import { existsSync } from 'node:fs'; +import { ensureDocker } from '@git-stunts/docker-guard'; + +/** Require a physical Docker marker; environment flags cannot authorize host tests. */ +export function requireDockerExecution({ readMarker = existsSync, exit, logger } = {}) { + ensureDocker({ + env: readMarker('/.dockerenv') ? { GIT_STUNTS_DOCKER: '1' } : {}, + exit, + logger, + }); +} diff --git a/src/package-version.js b/src/package-version.js index 655d8c83..abe205ee 100644 --- a/src/package-version.js +++ b/src/package-version.js @@ -1 +1 @@ -export const PACKAGE_VERSION = '6.5.10'; +export const PACKAGE_VERSION = '6.5.11'; diff --git a/test/CONVENTIONS.md b/test/CONVENTIONS.md index e9efb59f..0e8309cf 100644 --- a/test/CONVENTIONS.md +++ b/test/CONVENTIONS.md @@ -66,11 +66,9 @@ sorted output semantics instead of assuming first-seen insertion order. If insertion order matters, assert it at the lower-level boundary that builds the tree entries before they are handed to Git. -## Integration Runtime Policy +## Test Runtime Policy -**Integration suites are Docker-only.** The integration tests intentionally -refuse to run on the host and require `GIT_STUNTS_DOCKER=1` so Git, Bun, and -Deno run in a consistent environment. +**All tests and benchmarks are Docker-only.** Vitest setup uses `@git-stunts/docker-guard` with a physical Docker marker before test modules load. Environment flags do not authorize host execution. Use COPY-based Docker Compose services without repository or Git-directory mounts. `npm test` routes to the Node service; `test:local` is an in-container command. **Integration files run with `fileParallelism: false`.** These tests spawn real Git and CLI subprocesses, so the integration workspace is intentionally kept to diff --git a/test/docker-setup.js b/test/docker-setup.js new file mode 100644 index 00000000..26e1774b --- /dev/null +++ b/test/docker-setup.js @@ -0,0 +1,3 @@ +import { requireDockerExecution } from '../scripts/testing/DockerExecutionBoundary.js'; + +requireDockerExecution(); diff --git a/test/platform/runtimes.bats b/test/platform/runtimes.bats deleted file mode 100644 index dba6db48..00000000 --- a/test/platform/runtimes.bats +++ /dev/null @@ -1,14 +0,0 @@ -#!/usr/bin/env bats -# Run: bats --jobs 3 test/platform/runtimes.bats - -@test "Node.js: full test suite passes" { - docker compose run --rm test-node -} - -@test "Bun: full test suite passes" { - docker compose run --rm test-bun -} - -@test "Deno: full test suite passes" { - docker compose run --rm test-deno -} diff --git a/test/unit/infrastructure/adapters/GitPersistenceAdapter.mktree-recovery.test.js b/test/unit/infrastructure/adapters/GitPersistenceAdapter.mktree-recovery.test.js new file mode 100644 index 00000000..9ca59c60 --- /dev/null +++ b/test/unit/infrastructure/adapters/GitPersistenceAdapter.mktree-recovery.test.js @@ -0,0 +1,107 @@ +import { describe, expect, it } from 'vitest'; +import { GitPersistenceAdapter } from '../../../../index.js'; +import { CommandSession, GitMktreeSession, GitPlumbingError, GitProtocolError } from '@git-stunts/plumbing'; +import { Readable } from 'node:stream'; +import { TextEncoder } from 'node:util'; + +const OID = 'a'.repeat(40); +const TREE = [`100644 blob ${'b'.repeat(40)}\tcontent`]; + +function command(failure) { + let finish; + const finished = new Promise((resolve) => { finish = resolve; }); + function settle({ code, terminated }) { + finish({ code, error: null, signal: null, stderr: '', terminated, timedOut: false }); + } + return new CommandSession({ + stdoutStream: Readable.from([new TextEncoder().encode(`${OID}\n`)]), + finished, + write: async () => { if (failure !== null) { throw failure; } }, + closeInput: async () => { settle({ code: 0, terminated: false }); }, + terminate: () => { settle({ code: 1, terminated: true }); }, + }); +} + +function fixture(failures) { + let openings = 0; + const persistence = new GitPersistenceAdapter({ + plumbing: { + openMktreeSession: async () => { + const failure = failures[openings] ?? null; + openings += 1; + return new GitMktreeSession(command(failure)); + }, + }, + policy: { execute: (operation) => operation() }, + sessionIdleTimeoutMs: 60_000, + }); + return { persistence, openings: () => openings }; +} + +function brokenPipe() { + return Object.assign(new Error('broken pipe'), { code: 'EPIPE' }); +} + +describe('mktree transport recovery', () => { + it.each(['one tree', 'tree batch'])('reopens the process once for a broken pipe writing %s', async (mode) => { + const { persistence, openings } = fixture([brokenPipe()]); + try { + const result = mode === 'one tree' + ? await persistence.writeTree(TREE) + : await persistence.writeTrees([TREE]); + expect(result).toEqual(mode === 'one tree' ? OID : [OID]); + expect(openings()).toBe(2); + } finally { + await persistence.close(); + } + }); + + it('also recovers when process completion wins the race with the next write', async () => { + const failure = new GitPlumbingError('input closed', 'write', { code: 'SESSION_INPUT_CLOSED' }); + const { persistence, openings } = fixture([failure]); + try { + await expect(persistence.writeTree(TREE)).resolves.toBe(OID); + expect(openings()).toBe(2); + } finally { + await persistence.close(); + } + }); + +}); + +describe('mktree recovery error preservation', () => { + it('reports a typed failure after two broken processes, without an unbounded retry', async () => { + const { persistence, openings } = fixture([brokenPipe(), brokenPipe()]); + try { + await expect(persistence.writeTree(TREE)).rejects.toBeInstanceOf(GitProtocolError); + expect(openings()).toBe(2); + } finally { + await persistence.close(); + } + }); + + it('preserves other write failures and does not retry them', async () => { + const failure = Object.assign(new Error('permission denied'), { code: 'EACCES' }); + const { persistence, openings } = fixture([failure]); + try { + await expect(persistence.writeTree(TREE)).rejects.toBe(failure); + expect(openings()).toBe(1); + } finally { + await persistence.close(); + } + }); + + it('preserves a producer failure even when it has the same error code', async () => { + const failure = brokenPipe(); + const session = new GitMktreeSession(command(null)); + async function* entries() { + yield { mode: '100644', type: 'blob', oid: 'b'.repeat(40), name: 'content' }; + throw failure; + } + try { + await expect(session.write(entries())).rejects.toBe(failure); + } finally { + await session.terminate(); + } + }); +}); diff --git a/test/unit/scripts/docker-execution-boundary.test.js b/test/unit/scripts/docker-execution-boundary.test.js new file mode 100644 index 00000000..52ab2bd6 --- /dev/null +++ b/test/unit/scripts/docker-execution-boundary.test.js @@ -0,0 +1,28 @@ +import { describe, expect, it, vi } from 'vitest'; +import { requireDockerExecution } from '../../../scripts/testing/DockerExecutionBoundary.js'; + +describe('physical Docker test boundary', () => { + it('rejects an absent marker even when ambient environment claims Docker or CI', () => { + vi.stubEnv('GIT_STUNTS_DOCKER', '1'); + vi.stubEnv('GITHUB_ACTIONS', 'true'); + const exit = vi.fn(); + const logger = vi.fn(); + try { + requireDockerExecution({ readMarker: () => false, exit, logger }); + expect(exit).toHaveBeenCalledWith(1); + expect(logger).toHaveBeenCalledWith(expect.stringContaining('HOST EXECUTION PROHIBITED')); + } finally { + vi.unstubAllEnvs(); + } + }); + + it('accepts a physical marker without relying on ambient flags', () => { + const readMarker = vi.fn().mockReturnValue(true); + const exit = vi.fn(); + const logger = vi.fn(); + requireDockerExecution({ readMarker, exit, logger }); + expect(readMarker).toHaveBeenCalledWith('/.dockerenv'); + expect(exit).not.toHaveBeenCalled(); + expect(logger).not.toHaveBeenCalled(); + }); +}); diff --git a/test/unit/scripts/release-verify.test.js b/test/unit/scripts/release-verify.test.js index 2da9a383..45262b9e 100644 --- a/test/unit/scripts/release-verify.test.js +++ b/test/unit/scripts/release-verify.test.js @@ -188,10 +188,10 @@ describe('release verify step definitions', () => { .map((file) => path.posix.join('examples', file)) .sort(); const exampleSteps = RELEASE_STEPS.filter((step) => step.id.startsWith('example-')); - const examplePaths = exampleSteps.map((step) => step.args[0]).sort(); + const examplePaths = exampleSteps.map((step) => step.args.at(-1)).sort(); expect(examplePaths).toEqual(expectedExamplePaths); - expect(exampleSteps.every((step) => step.command === 'node')).toBe(true); + expect(exampleSteps.every((step) => step.command === 'docker' && step.args.includes('test-node') && step.args.includes('node'))).toBe(true); }); it('serializes Bun unit files to avoid CPU-heavy test starvation', () => { @@ -300,3 +300,12 @@ describe('release verify package assertions', () => { }); }); }); + + +describe('release verification isolation', () => { + it('runs the Node unit gate in a copied Docker image', () => { + const step = RELEASE_STEPS.find((entry) => entry.id === 'unit-node'); + expect(step).toMatchObject({ command: 'docker' }); + expect(step.args).toEqual(expect.arrayContaining(['compose', 'run', '--build', '--rm', 'test-node'])); + }); +}); diff --git a/test/unit/scripts/release-workflow.test.js b/test/unit/scripts/release-workflow.test.js index 1459fb71..1ae73f9a 100644 --- a/test/unit/scripts/release-workflow.test.js +++ b/test/unit/scripts/release-workflow.test.js @@ -53,3 +53,28 @@ describe('release workflow publishing', () => { } }); }); + + +describe('release test isolation', () => { + it('runs the Node unit gate through COPY-based Docker in CI and publication', () => { + for (const file of ['.github/workflows/ci.yml', '.github/workflows/release.yml']) { + const source = read(file); + expect(source).not.toMatch(/^\s+- run: pnpm test$/mu); + expect(source).toContain('docker compose run --build --rm test-node'); + } + expect(read('docker-compose.yml')).not.toMatch(/^\s+volumes:/mu); + }); +}); + + +describe('platform test isolation', () => { + it('dispatches platform checks directly to copied containers without a host test runner', () => { + const command = JSON.parse(read('package.json')).scripts['test:platforms']; + expect(command).not.toMatch(/\bbats\b/u); + const commands = command.split(' && '); + expect(commands).toHaveLength(3); + for (const runtime of ['node', 'bun', 'deno']) { + expect(commands).toContain(`docker compose run --build --rm test-${runtime}`); + } + }); +}); diff --git a/vitest.config.js b/vitest.config.js index 10db1572..b86bc041 100644 --- a/vitest.config.js +++ b/vitest.config.js @@ -1,4 +1,5 @@ import { defineConfig } from 'vitest/config'; export default defineConfig({ + test: { setupFiles: ['./test/docker-setup.js'] }, });