diff --git a/CHANGELOG.md b/CHANGELOG.md index 3360cf8..5788065 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [6.4.0] — 2026-07-17 + ### Added - **Scoped staging workspaces** — `cas.workspaces.open()` now provides a @@ -24,6 +26,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 witnesses reuse the existing `root-set` kind, and workspace-detail truncation uses a dedicated code without widening closed public discriminant unions. +### Fixed + +- **RootSet structured lock retries** — exact managed `update-ref --no-deref` + lock races surfaced as `GIT_REPOSITORY_LOCKED` now normalize to + `ROOT_SET_CONFLICT`, allowing the existing bounded retry policy to run. + Structured lock failures for other refs remain terminal. + ## [6.3.0] — 2026-07-17 ### Added diff --git a/README.md b/README.md index fea010a..da38eb6 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ Unlike traditional LFS which moves files to external servers, `git-cas` treats t Existing v5 users should read [UPGRADING.md](./UPGRADING.md) and run `npm run upgrade` in dry-run mode before restoring old encrypted vault entries. For the release overview, see the -[v6.3.0 Release Notes](./docs/releases/v6.3.0.md). +[v6.4.0 Release Notes](./docs/releases/v6.4.0.md). ### 1. CLI Usage @@ -200,6 +200,8 @@ All three runtimes are tested in CI on every push. The hexagonal architecture is repository diagnostics. - **[v6.3.0 Release Notes](./docs/releases/v6.3.0.md)**: Lifetime-safe, reference-only cache acquisitions with explicit release and doctor evidence. +- **[v6.4.0 Release Notes](./docs/releases/v6.4.0.md)**: Scoped staging + workspaces for temporary multi-object reachability, promotion, and cleanup. - **[Upgrading](./UPGRADING.md)**: Migration guide for v5 → v6. - **[Changelog](./CHANGELOG.md)**: Version history and migration notes. diff --git a/ROADMAP.md b/ROADMAP.md index 07e854f..3d11a92 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -32,22 +32,24 @@ GitHub wins and this file should be corrected. | Milestone | Theme | Tracker | | ------------------------------------------------------------- | ---------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | | [`v6.0.1`](https://github.com/git-stunts/git-cas/milestone/1) | Patch closeout and planning truth | [#37](https://github.com/git-stunts/git-cas/issues/37) | -| [`v6.1.0`](https://github.com/git-stunts/git-cas/milestone/2) | Bounded residency and scale hardening | [#38](https://github.com/git-stunts/git-cas/issues/38) | +| [`v6.1.0`](https://github.com/git-stunts/git-cas/milestone/2) | Bounded residency and scale hardening | [#43](https://github.com/git-stunts/git-cas/issues/43), [#44](https://github.com/git-stunts/git-cas/issues/44), [#45](https://github.com/git-stunts/git-cas/issues/45), [#48](https://github.com/git-stunts/git-cas/issues/48) | | [`v6.2.0`](https://github.com/git-stunts/git-cas/milestone/3) | Emergency application storage and cache ownership boundary | [#50](https://github.com/git-stunts/git-cas/issues/50) | -| [`v6.3.0`](https://github.com/git-stunts/git-cas/milestone/4) | Operator TUI and agent automation follow-through | [#39](https://github.com/git-stunts/git-cas/issues/39), [#40](https://github.com/git-stunts/git-cas/issues/40) | -| [`v6.4.0`](https://github.com/git-stunts/git-cas/milestone/5) | Browser and edge read-path exploration | [#41](https://github.com/git-stunts/git-cas/issues/41) | +| [`v6.3.0`](https://github.com/git-stunts/git-cas/milestone/7) | Bounded scoped cache acquisitions | [#69](https://github.com/git-stunts/git-cas/issues/69), [#70](https://github.com/git-stunts/git-cas/issues/70) | +| [`v6.4.0`](https://github.com/git-stunts/git-cas/milestone/4) | Scoped staging workspaces | [#75](https://github.com/git-stunts/git-cas/issues/75), [#77](https://github.com/git-stunts/git-cas/issues/77) | +| [`v6.4.1`](https://github.com/git-stunts/git-cas/milestone/8) | Historical bounded-residency proof closeout | [#38](https://github.com/git-stunts/git-cas/issues/38), [#46](https://github.com/git-stunts/git-cas/issues/46) | +| [`v6.5.0`](https://github.com/git-stunts/git-cas/milestone/5) | Operator TUI and agent automation follow-through | [#39](https://github.com/git-stunts/git-cas/issues/39), [#40](https://github.com/git-stunts/git-cas/issues/40) | +| [`v6.6.0`](https://github.com/git-stunts/git-cas/milestone/9) | Browser and edge read-path exploration | [#41](https://github.com/git-stunts/git-cas/issues/41) | | [`v7.0.0`](https://github.com/git-stunts/git-cas/milestone/6) | Protocol break only if audit requires it | [#42](https://github.com/git-stunts/git-cas/issues/42), only when justified | ## Latest Landed Design The latest landed design record is: -- [0047-application-storage-cache-boundary](./docs/design/0047-application-storage-cache-boundary/application-storage-cache-boundary.md) +- [0049-scoped-staging-workspaces](./docs/design/0049-scoped-staging-workspaces/scoped-staging-workspaces.md) Its GitHub goalpost issue, -[#50](https://github.com/git-stunts/git-cas/issues/50), owns the release +[#75](https://github.com/git-stunts/git-cas/issues/75), owns the release evidence. The design doc is the durable contract; GitHub records completion. -No later design is selected in this file. The `v6.2.0` slice record is: diff --git a/STATUS.md b/STATUS.md index bda4547..474da92 100644 --- a/STATUS.md +++ b/STATUS.md @@ -1,8 +1,8 @@ # STATUS **Last tagged release:** `v6.3.0` (`2026-07-17`) -**Current release state:** `v6.3.0` is published to npm with provenance and to GitHub Releases; JSR publication is not claimed. -**Latest verification:** `pnpm run release:verify` passed 14/14 steps with 6,325 observed tests before tagging; release run `29566015478` then passed tag validation, lint, unit tests, Node/Bun/Deno integration, npm trusted publication, and final GitHub Release creation. +**Current release state:** `v6.4.0` release candidate; tag, npm publication, and GitHub Release remain pending the reviewed tag workflow. +**Latest verification:** the `v6.4.0` release verifier passed 14/14 steps with 6,538 observed tests across Node.js, Bun, and Deno; npm and JSR dry-runs passed. **Playback truth:** `main` **Runtimes:** Node.js 22.x, Bun, Deno **Current planning method:** [WORKFLOW.md](./WORKFLOW.md) @@ -18,6 +18,11 @@ - The machine-facing `git cas agent` surface exists and now supports OS-keychain passphrase sources for vault-derived key flows, but parity and portability are still partial. +- **v6.4.0 candidate posture** — scoped staging workspaces are merged through + reviewed commit `1ac2fc8`, and structured RootSet lock retries are corrected + by `8185dfb9`; npm, JSR, and runtime version metadata now identify `6.4.0`, + while the tag and registry artifacts remain deliberately absent until + release-candidate review completes. - **v6.3.0 artifact posture** — signed tag `v6.3.0` resolves to reviewed merge `33f4171`; npm reports `@git-stunts/git-cas@6.3.0` as `latest` with SLSA provenance, and the final GitHub Release is published. JSR dry-run validation @@ -99,11 +104,11 @@ - GitHub Issues are canonical. If this section and GitHub disagree, GitHub wins and this section should be corrected. - Current release goalpost: - [#69 v6.3.0: Bounded scoped cache acquisitions](https://github.com/git-stunts/git-cas/issues/69) + [#75 Scoped staging workspaces for multi-object promotion](https://github.com/git-stunts/git-cas/issues/75) under the - [`v6.3.0` milestone](https://github.com/git-stunts/git-cas/milestone/7). + [`v6.4.0` milestone](https://github.com/git-stunts/git-cas/milestone/4). - The latest landed design record is - [0048-scoped-cache-acquisitions](./docs/design/0048-scoped-cache-acquisitions/scoped-cache-acquisitions.md). + [0049-scoped-staging-workspaces](./docs/design/0049-scoped-staging-workspaces/scoped-staging-workspaces.md). ## Read Next diff --git a/UPGRADING.md b/UPGRADING.md index 0eeb557..9e72caf 100644 --- a/UPGRADING.md +++ b/UPGRADING.md @@ -2,6 +2,48 @@ v6.0.0 is a major release that simplifies the encryption model, hardens security defaults, and cleans up the architecture. This guide covers every breaking change and what you need to do. +## v6.3.0 To v6.4.0 + +v6.4.0 is API-additive and does not require stored-data migration. It adds +scoped staging workspaces for applications that construct one durable result +from many intermediate assets, pages, or bundles. + +Do not use a CacheSet as temporary construction storage. Open one workspace, +stage the intermediate values through it, compact the retained roots after an +aggregate becomes transitively complete, and promote only the terminal handle: + +```javascript +const workspace = await cas.workspaces.open({ + namespace: 'my-application/materializations', + ttlMs: 2 * 60 * 60 * 1000, +}); + +try { + const page = await workspace.pages.put({ source: encodedPage }); + const bundle = await workspace.bundles.putOrdered({ + members: [['pages/result.cbor', page.handle]], + }); + await workspace.checkpoint({ handles: [bundle.handle] }); + await workspace.promoteToCache({ cache, key, handle: bundle.handle }); +} finally { + await workspace.release(); +} +``` + +Every returned staged value is anchored by an exact workspace generation. +Promotion requires matching destination retention evidence before source +release. Expiry is diagnostic posture, not automatic deletion; abandoned +workspaces remain reachable until a caller traverses bounded `inspect()` or +`sweep()` pages using `nextCursor`. + +The interval between writing an object and publishing the workspace ref still +depends on Git's ordinary unreachable-object grace period. Concurrent +immediate-expiry pruning inside that interval is unsupported. + +See [v6.4.0 Release Notes](./docs/releases/v6.4.0.md) and +[Scoped Staging Workspaces](./docs/API.md#scoped-staging-workspaces) for the +complete retention, promotion, and recovery contract. + ## v6.2.0 To v6.3.0 v6.3.0 is API-additive and does not require stored-data migration. It adds diff --git a/docs/design/0049-scoped-staging-workspaces/scoped-staging-workspaces.md b/docs/design/0049-scoped-staging-workspaces/scoped-staging-workspaces.md index de310a2..aeb323c 100644 --- a/docs/design/0049-scoped-staging-workspaces/scoped-staging-workspaces.md +++ b/docs/design/0049-scoped-staging-workspaces/scoped-staging-workspaces.md @@ -526,7 +526,8 @@ The work is done when: - [x] Public declarations and docs match runtime behavior. - [x] README, changelog, release notes, and witness are updated. - [x] Issue and PR are linked correctly. -- [ ] CI, Code Rabbit, self-review, and independent Code Lawyer review are clean. +- [x] Implementation PR CI, Code Rabbit, self-review, and independent Code + Lawyer review are clean. - [ ] v6.4.0 is published before git-warp consumes the API. ## Validation Plan diff --git a/docs/design/0049-scoped-staging-workspaces/witness/release-candidate.md b/docs/design/0049-scoped-staging-workspaces/witness/release-candidate.md new file mode 100644 index 0000000..67433d9 --- /dev/null +++ b/docs/design/0049-scoped-staging-workspaces/witness/release-candidate.md @@ -0,0 +1,89 @@ +# PERF-0049 v6.4.0 Release Candidate Witness + +Date: 2026-07-17 + +Issues: #75, #77 + +## Scope + +This witness records the pre-publication release candidate for scoped staging +workspaces. It does not claim that a `v6.4.0` tag, npm artifact, or GitHub +Release exists yet. This is an explicitly unpublished candidate. + +The candidate: + +- sets npm, JSR, and the runtime `PACKAGE_VERSION` export to `6.4.0`; +- moves scoped staging workspaces from `Unreleased` to `6.4.0`; +- packages and links `docs/releases/v6.4.0.md`; +- adds v6.3.0-to-v6.4.0 adoption guidance to `UPGRADING.md`; +- records an honest release-candidate posture in `STATUS.md`; +- preserves the v6.3.0 publication witness as immutable history; and +- aligns repository roadmap links with the explicitly retargeted GitHub + milestones. + +## Implementation Provenance + +| Capability | Review anchor | Commit | +| --- | --- | --- | +| Scoped staging workspaces | #76 | `1ac2fc85be857ca769c459b89c29bf4483b3f304` | +| Structured RootSet lock retry | #77 | `8185dfb9819909d9fbe0f0394de6ae31fc0a94a3` | +| Exact lock command validation | #78 | `d1e126952d70007313a2ecd39bc7cffda06a9edd` | + +The retry correction recognizes a structured Git lock only when the captured +argument vector proves an exact `update-ref --no-deref` operation against the +managed RootSet ref. Locks for other refs remain non-conflict failures. +[cite: `src/domain/services/RootSetPersistence.js#13-36@d1e126952d70007313a2ecd39bc7cffda06a9edd`] +[cite: `src/domain/services/RootSetPersistence.js#275-295@d1e126952d70007313a2ecd39bc7cffda06a9edd`] +[cite: `test/unit/domain/services/RootSetPersistence.test.js#150-193@d1e126952d70007313a2ecd39bc7cffda06a9edd`] + +## Verification + +The release candidate passed the complete verifier: + +```bash +pnpm run release:verify +``` + +| Step | Result | Tests | +| --- | --- | ---: | +| Lint | PASS | - | +| Unit Tests (Node) | PASS | 1,993 | +| Example: store-and-restore | PASS | - | +| Example: encrypted-workflow | PASS | - | +| Example: progress-tracking | PASS | - | +| Unit Tests (Bun) | PASS | 1,992 | +| Unit Tests (Deno) | PASS | 1,983 | +| Public type compatibility | PASS | - | +| Integration Tests (Node) | PASS | 190 | +| Integration Tests (Bun) | PASS | 190 | +| Integration Tests (Deno) | PASS | 190 | +| Build metadata stamp | PASS | - | +| npm pack dry-run | PASS | - | +| JSR publish dry-run | PASS | - | + +The verifier passed 14/14 steps with 6,538 observed tests. A final npm dry-run +after the release prose was frozen produced `git-stunts-git-cas-6.4.0.tgz` +with 247 files, 763,925 packed bytes, and 2,129,717 unpacked bytes. + +An initial candidate run stopped during Node integration when a structured Git +lock escaped RootSet conflict normalization. Issue #77 converted that +nondeterministic observation into direct positive and negative unit coverage. +After correction, the focused RootSet suite passed 16/16, lint passed, twenty +repeated real-Git ExpiringSet concurrency runs passed, and the complete +verifier passed as recorded above. + +## Publication Gate + +Publication remains blocked until all of the following are true: + +1. the release PR passes GitHub CI, self-review, Code Lawyer review, and the + agreed CodeRabbit posture; +2. the release PR is merged without unresolved findings; +3. a signed annotated `v6.4.0` tag points at the reviewed merge commit; +4. the release workflow passes version validation and runtime tests; +5. npm reports `@git-stunts/git-cas@6.4.0` with provenance; and +6. GitHub reports the final non-draft `v6.4.0` Release. + +Issue #75 stays open until publication evidence is attached. The downstream +git-warp registry upgrade and unchanged performance gate remain separate +adoption evidence after publication. diff --git a/docs/design/0049-scoped-staging-workspaces/witness/verification.md b/docs/design/0049-scoped-staging-workspaces/witness/verification.md index 27f940b..eb28ff0 100644 --- a/docs/design/0049-scoped-staging-workspaces/witness/verification.md +++ b/docs/design/0049-scoped-staging-workspaces/witness/verification.md @@ -138,6 +138,8 @@ Review corrected eight contract defects before this witness: - The first Code Rabbit and independent Code Lawyer reviews completed. Their findings are addressed locally; GitHub thread resolution and post-push CI are pending. -- The package version and release milestone require an explicit release decision. +- The fresh `v6.4.0` candidate verifier passed 14/14 steps with 6,538 observed + tests, including npm and JSR dry-runs. Release-PR review and the tag-driven + publication workflow remain pending. - Downstream git-warp adoption and its unchanged ten-second integration hook remain the end-to-end performance and compatibility gate. diff --git a/docs/releases/v6.4.0.md b/docs/releases/v6.4.0.md index e4e855a..103b56a 100644 --- a/docs/releases/v6.4.0.md +++ b/docs/releases/v6.4.0.md @@ -57,14 +57,15 @@ Workspace expiry never revokes reachability automatically. Operators inspect and sweep one bounded namespace explicitly: ```javascript -const page = await cas.workspaces.inspect({ namespace, limit: 100 }); let cursor = null; -const cleanup = await cas.workspaces.sweep({ - namespace, - limit: 100, - cursor, -}); -cursor = cleanup.nextCursor; +do { + const cleanup = await cas.workspaces.sweep({ + namespace, + limit: 100, + cursor, + }); + cursor = cleanup.nextCursor; +} while (cursor !== null); ``` Sweep removes only expired direct refs whose generation still matches the @@ -111,6 +112,12 @@ inventory. Real-Git integration runs `git prune --expire=now` after returned operations and proves survival through page, asset, and bundle composition, promotion, expiry, sweep, and release. +The final release-candidate verifier passed 14/14 steps with 6,538 observed +tests: 1,993 Node unit tests, 1,992 Bun unit tests, 1,983 Deno unit tests, and +190 integration tests on each runtime. npm pack and JSR publish dry-runs also +passed. Twenty repeated real-Git ExpiringSet runs verify that concurrent +duplicate-writer behavior remains stable after the RootSet retry correction. + The unchanged downstream `git-warp` ten-second integration hook remains the release adoption gate after package publication. CI, self-review, Code Rabbit, and independent Code Lawyer review remain mandatory before tagging. diff --git a/jsr.json b/jsr.json index 2b616a2..cc05137 100644 --- a/jsr.json +++ b/jsr.json @@ -1,6 +1,6 @@ { "name": "@git-stunts/git-cas", - "version": "6.3.0", + "version": "6.4.0", "exports": { ".": "./index.js", "./service": "./src/domain/services/CasService.js", diff --git a/package.json b/package.json index f57b996..e31f5e1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@git-stunts/git-cas", - "version": "6.3.0", + "version": "6.4.0", "description": "Content-addressed storage backed by Git's object database, with optional encryption and pluggable codecs", "type": "module", "main": "index.js", @@ -35,6 +35,7 @@ "docs/releases/v6.1.0.md", "docs/releases/v6.2.0.md", "docs/releases/v6.3.0.md", + "docs/releases/v6.4.0.md", "docs/THREAT_MODEL.md", "docs/VAULT_INTERNALS.md", "docs/WALKTHROUGH.md", diff --git a/src/domain/services/RootSetPersistence.js b/src/domain/services/RootSetPersistence.js index c3748ad..128349f 100644 --- a/src/domain/services/RootSetPersistence.js +++ b/src/domain/services/RootSetPersistence.js @@ -10,6 +10,30 @@ const UPDATE_REF_CONFLICT_MARKERS = Object.freeze({ cannotLockRef: 'cannot lock ref', referenceAlreadyExists: 'reference already exists', }); +const GIT_REPOSITORY_LOCKED = 'GIT_REPOSITORY_LOCKED'; + +function classifyStructuredUpdateRefConflict(err, { + rootSetRef, + newCommit, + expectedHeadOid, +}) { + const details = err?.details && typeof err.details === 'object' ? err.details : {}; + if (details.code !== GIT_REPOSITORY_LOCKED) { + return null; + } + if (!Array.isArray(details.args)) { + return false; + } + const expectedArgs = [ + 'update-ref', + '--no-deref', + rootSetRef, + newCommit, + expectedHeadOid ?? '0'.repeat(newCommit.length), + ]; + return details.args.length === expectedArgs.length && + expectedArgs.every((arg, index) => details.args[index] === arg); +} /** * Stateless persistence boundary for one root-set ref and snapshot format. @@ -214,7 +238,7 @@ export default class RootSetPersistence { }); } catch (err) { const meta = await this.#updateFailureMeta(err, commitOid, expectedHeadOid); - if (this.#isConflict(err)) { + if (this.#isConflict(err, commitOid, expectedHeadOid)) { throw new CasError( 'Concurrent root-set update detected', ErrorCodes.ROOT_SET_CONFLICT, @@ -248,11 +272,19 @@ export default class RootSetPersistence { }; } - #isConflict(err) { + #isConflict(err, newCommit, expectedHeadOid) { const meta = err?.meta && typeof err.meta === 'object' ? err.meta : {}; if (Object.hasOwn(meta, 'expectedOldOid') && Object.hasOwn(meta, 'actualOldOid')) { return true; } + const structuredConflict = classifyStructuredUpdateRefConflict(err, { + rootSetRef: this.rootSetRef, + newCommit, + expectedHeadOid, + }); + if (structuredConflict !== null) { + return structuredConflict; + } const normalized = errorDetailsText(err).toLowerCase(); if (!normalized.includes(this.rootSetRef.toLowerCase())) { return false; diff --git a/src/package-version.js b/src/package-version.js index c322b4f..738548e 100644 --- a/src/package-version.js +++ b/src/package-version.js @@ -1 +1 @@ -export const PACKAGE_VERSION = '6.3.0'; +export const PACKAGE_VERSION = '6.4.0'; diff --git a/test/unit/docs/package-docs.test.js b/test/unit/docs/package-docs.test.js index 85f75cf..6ecf545 100644 --- a/test/unit/docs/package-docs.test.js +++ b/test/unit/docs/package-docs.test.js @@ -13,6 +13,7 @@ const requiredStandardDocs = [ 'docs/releases/v6.1.0.md', 'docs/releases/v6.2.0.md', 'docs/releases/v6.3.0.md', + 'docs/releases/v6.4.0.md', ]; const forbiddenPackagePrefixes = [ 'docs/audit/', @@ -89,6 +90,7 @@ function publicPackagedMarkdownFiles(files) { 'docs/releases/v6.1.0.md', 'docs/releases/v6.2.0.md', 'docs/releases/v6.3.0.md', + 'docs/releases/v6.4.0.md', 'docs/THREAT_MODEL.md', 'docs/WALKTHROUGH.md', ].filter((file) => files.has(file)); diff --git a/test/unit/docs/release-state.test.js b/test/unit/docs/release-state.test.js index 502382e..d7674e5 100644 --- a/test/unit/docs/release-state.test.js +++ b/test/unit/docs/release-state.test.js @@ -3,8 +3,12 @@ import { existsSync, readFileSync } from 'node:fs'; import path from 'node:path'; const repoRoot = process.cwd(); -const v630CandidateMarker = '**Current release state:** `v6.3.0` release candidate'; -const v630PublishedMarker = '**Current release state:** `v6.3.0` is published'; +const v640CandidateMarker = '**Current release state:** `v6.4.0` release candidate'; +const v640PublishedMarker = '**Current release state:** `v6.4.0` is published'; +const v640CandidatePath = + 'docs/design/0049-scoped-staging-workspaces/witness/release-candidate.md'; +const v640PublicationPath = + 'docs/design/0049-scoped-staging-workspaces/witness/release-publication.md'; const v630PublicationPath = 'docs/design/0048-scoped-cache-acquisitions/witness/release-publication.md'; @@ -20,16 +24,16 @@ function v6Heading(changelog) { return changelog.match(/^## \[6\.0\.0\] — (.+)$/m)?.[1]; } -function expectNoV630PublicationEvidence(...documents) { +function expectNoV640PublicationEvidence(...documents) { const forbiddenMarkers = [ - '**Last tagged release:** `v6.3.0`', - '**Current release state:** `v6.3.0` is published', - '- Signed annotated tag: `v6.3.0`', - 'https://github.com/git-stunts/git-cas/releases/tag/v6.3.0', + '**Last tagged release:** `v6.4.0`', + '**Current release state:** `v6.4.0` is published', + '- Signed annotated tag: `v6.4.0`', + 'https://github.com/git-stunts/git-cas/releases/tag/v6.4.0', '## npm Registry Evidence', - '| Package | `@git-stunts/git-cas@6.3.0` |', - '| Dist-tag | `latest` -> `6.3.0` |', - 'attestations/@git-stunts%2fgit-cas@6.3.0' + '| Package | `@git-stunts/git-cas@6.4.0` |', + '| Dist-tag | `latest` -> `6.4.0` |', + 'attestations/@git-stunts%2fgit-cas@6.4.0' ]; for (const document of documents) { @@ -39,78 +43,77 @@ function expectNoV630PublicationEvidence(...documents) { } } -function expectV630CandidateState(status, candidate, publication) { - expect(status).toContain('**Last tagged release:** `v6.2.0` (`2026-07-13`)'); - expect(status).toContain(v630CandidateMarker); - expect(status).toContain('publication remain pending the reviewed tag workflow'); - expect(candidate).toContain('It passed 14/14 steps and observed 6,325 tests'); - expect(candidate).toContain('242 files, 747,220 packed bytes, and 2,054,933'); - expect(candidate).toContain('7b15ec1819a1c2500c459818785fcd7ec6cf7676'); +function expectV640CandidateState(status, candidate, publication) { + expect(status).toContain('**Last tagged release:** `v6.3.0` (`2026-07-17`)'); + expect(status).toContain(v640CandidateMarker); + expect(status).toContain('remain pending the reviewed tag workflow'); + expect(candidate).toContain('# PERF-0049 v6.4.0 Release Candidate Witness'); + expect(candidate).toContain('1ac2fc85be857ca769c459b89c29bf4483b3f304'); + expect(candidate).toContain('8185dfb9819909d9fbe0f0394de6ae31fc0a94a3'); + expect(candidate).toContain('passed 14/14 steps with 6,538 observed tests'); + expect(candidate).toContain('247 files, 763,925 packed bytes'); + expect(candidate).toContain('2,129,717 unpacked bytes'); expect(candidate).toContain('explicitly unpublished candidate'); expect(publication).toBeNull(); - expectNoV630PublicationEvidence(status, candidate); + expectNoV640PublicationEvidence(status, candidate); } -function expectV630PublishedState(status, publication) { - expect(status).toMatch(/\*\*Last tagged release:\*\* `v6\.3\.0` \(`\d{4}-\d{2}-\d{2}`\)/); - expect(status).toContain(v630PublishedMarker); - expect(publication).toContain('# PERF-0048 v6.3.0 Publication Witness'); - expect(publication).toContain('- Signed annotated tag: `v6.3.0`'); - expect(publication).toContain('https://github.com/git-stunts/git-cas/releases/tag/v6.3.0'); - expect(publication).toContain('| Package | `@git-stunts/git-cas@6.3.0` |'); - expect(publication).toContain('| Dist-tag | `latest` -> `6.3.0` |'); - expect(publication).toContain('attestations/@git-stunts%2fgit-cas@6.3.0'); +function expectV640PublishedState(status, publication) { + expect(status).toMatch(/\*\*Last tagged release:\*\* `v6\.4\.0` \(`\d{4}-\d{2}-\d{2}`\)/); + expect(status).toContain(v640PublishedMarker); + expect(publication).toContain('# PERF-0049 v6.4.0 Publication Witness'); + expect(publication).toContain('- Signed annotated tag: `v6.4.0`'); + expect(publication).toContain('https://github.com/git-stunts/git-cas/releases/tag/v6.4.0'); + expect(publication).toContain('| Package | `@git-stunts/git-cas@6.4.0` |'); + expect(publication).toContain('| Dist-tag | `latest` -> `6.4.0` |'); + expect(publication).toContain('attestations/@git-stunts%2fgit-cas@6.4.0'); } -function expectV630Lifecycle(status, candidate, publication) { - const isCandidate = status.includes(v630CandidateMarker); - const isPublished = status.includes(v630PublishedMarker); +function expectV640Lifecycle(status, candidate, publication) { + const isCandidate = status.includes(v640CandidateMarker); + const isPublished = status.includes(v640PublishedMarker); expect(Number(isCandidate) + Number(isPublished)).toBe(1); if (isCandidate) { - expectV630CandidateState(status, candidate, publication); + expectV640CandidateState(status, candidate, publication); return; } expect(publication).not.toBeNull(); - expectV630PublishedState(status, publication); + expectV640PublishedState(status, publication); } -function expectFutureV630PublicationState() { +function expectFutureV640PublicationState() { const status = [ - '**Last tagged release:** `v6.3.0` (`2026-07-17`)', - `${v630PublishedMarker} to npm with provenance and to GitHub Releases.` + '**Last tagged release:** `v6.4.0` (`2026-07-17`)', + `${v640PublishedMarker} to npm with provenance and to GitHub Releases.` ].join('\n'); const publication = [ - '# PERF-0048 v6.3.0 Publication Witness', - '- Signed annotated tag: `v6.3.0`', - 'https://github.com/git-stunts/git-cas/releases/tag/v6.3.0', - '| Package | `@git-stunts/git-cas@6.3.0` |', - '| Dist-tag | `latest` -> `6.3.0` |', - 'attestations/@git-stunts%2fgit-cas@6.3.0' + '# PERF-0049 v6.4.0 Publication Witness', + '- Signed annotated tag: `v6.4.0`', + 'https://github.com/git-stunts/git-cas/releases/tag/v6.4.0', + '| Package | `@git-stunts/git-cas@6.4.0` |', + '| Dist-tag | `latest` -> `6.4.0` |', + 'attestations/@git-stunts%2fgit-cas@6.4.0' ].join('\n'); - expectV630Lifecycle(status, '', publication); + expectV640Lifecycle(status, '', publication); } describe('release state docs', () => { - it('enforces the v6.3.0 candidate-to-publication lifecycle', () => { + it('enforces the v6.4.0 candidate-to-publication lifecycle', () => { const status = read('STATUS.md'); - const candidate = read( - 'docs/design/0048-scoped-cache-acquisitions/witness/release-candidate.md' - ); - const publication = readOptional(v630PublicationPath); - const witness = read( - 'docs/design/0047-application-storage-cache-boundary/witness/release-publication.md' - ); - - expectV630Lifecycle(status, candidate, publication); - expectFutureV630PublicationState(); + const candidate = read(v640CandidatePath); + const publication = readOptional(v640PublicationPath); + const v630Publication = read(v630PublicationPath); + + expectV640Lifecycle(status, candidate, publication); + expectFutureV640PublicationState(); expect(status).toContain('Current release goalpost:'); - expect(status).toContain('#69 v6.3.0: Bounded scoped cache acquisitions'); - expect(witness).toContain('432c5d9effb12c9f66536f1386791bb4421f3cea'); - expect(witness).toContain('sha512-m8+ZzgNhKU6pVS9pjqJlwAnwYI/s+NMEnINC+Q0g3h6T6mNPdH8U0jb4nEoxU9N1TF+Ut5bjtRMRRaYT75dlew=='); - expect(witness).toContain('https://slsa.dev/provenance/v1'); - expect(witness).toContain('https://github.com/git-stunts/git-cas/releases/tag/v6.2.0'); + expect(status).toContain('#75 Scoped staging workspaces for multi-object promotion'); + expect(v630Publication).toContain('33f4171f6b69d75110de834f9a75d64e2d14e1a3'); + expect(v630Publication).toContain('sha512-Cl/WPjj60LvjXl3BqSb1M3a0tx2xpx6KxGEC1TXKekNzgn5so/t43LG7Qz2XuXle+YmXWoCi8H94cJYvfgI8Yw=='); + expect(v630Publication).toContain('https://slsa.dev/provenance/v1'); + expect(v630Publication).toContain('https://github.com/git-stunts/git-cas/releases/tag/v6.3.0'); }); }); diff --git a/test/unit/docs/release-truth.test.js b/test/unit/docs/release-truth.test.js index d44999c..8bde995 100644 --- a/test/unit/docs/release-truth.test.js +++ b/test/unit/docs/release-truth.test.js @@ -200,6 +200,21 @@ describe('scoped cache acquisition release documentation', () => { }); }); +describe('scoped staging workspace release documentation', () => { + it('ships and links the v6.4.0 temporary-reachability contract', () => { + const readme = read('README.md'); + const upgrading = read('UPGRADING.md'); + const releaseNotes = read('docs/releases/v6.4.0.md'); + + expect(readme).toContain('[v6.4.0 Release Notes](./docs/releases/v6.4.0.md)'); + expect(upgrading).toContain('Do not use a CacheSet as temporary construction storage'); + expect(releaseNotes).toContain('# git-cas v6.4.0 Release Notes'); + expect(releaseNotes).toContain('cas.workspaces.open'); + expect(releaseNotes).toContain('destination retention before releasing'); + expect(releaseNotes).toContain('nextCursor'); + }); +}); + describe('advanced guide rendering', () => { it('keeps the table of contents rendered as Markdown links', () => { const advancedGuide = read('ADVANCED_GUIDE.md'); diff --git a/test/unit/domain/services/RootSetPersistence.test.js b/test/unit/domain/services/RootSetPersistence.test.js index b68c1d2..6d46454 100644 --- a/test/unit/domain/services/RootSetPersistence.test.js +++ b/test/unit/domain/services/RootSetPersistence.test.js @@ -5,6 +5,8 @@ import RootSetMetadataCodec from '../../../../src/domain/services/RootSetMetadat import RootSetPersistence from '../../../../src/domain/services/RootSetPersistence.js'; const REF = 'refs/cas/rootsets/warp/state-cache'; +const NEW_COMMIT_OID = 'c'.repeat(40); +const EXPECTED_HEAD_OID = 'd'.repeat(40); const TREE_ENTRY = { name: 'snapshot:tree', oid: 'a'.repeat(40), @@ -42,6 +44,45 @@ function mockRef(overrides = {}) { }; } +function exactUpdateRefArgs(ref = REF, expectedHeadOid = EXPECTED_HEAD_OID) { + return [ + 'update-ref', + '--no-deref', + ref, + NEW_COMMIT_OID, + expectedHeadOid ?? '0'.repeat(NEW_COMMIT_OID.length), + ]; +} + +function structuredUpdateRefLock({ + ref = REF, + args = exactUpdateRefArgs(ref), + stderr = `fatal: cannot lock ref '${ref}': lock file exists`, +} = {}) { + return Object.assign(new Error('Git command failed: repository is locked'), { + details: { + code: 'GIT_REPOSITORY_LOCKED', + args, + stderr, + }, + }); +} + +async function expectStructuredLockCode(error, expectedHeadOid, code) { + const persistence = mockPersistence({ + writeBlob: vi.fn().mockResolvedValue('a'.repeat(40)), + writeTree: vi.fn().mockResolvedValue('b'.repeat(40)), + }); + const ref = mockRef({ + createCommit: vi.fn().mockResolvedValue(NEW_COMMIT_OID), + updateRef: vi.fn().mockRejectedValue(error), + }); + const rootSet = new RootSetPersistence({ rootSetRef: REF, persistence, ref }); + + await expect(rootSet.write({ entries: [], expectedHeadOid })) + .rejects.toMatchObject({ code }); +} + describe('RootSetPersistence snapshot writes', () => { it('writes real tree edges and a parentless current-generation commit', async () => { const persistence = mockPersistence({ @@ -106,6 +147,51 @@ describe('RootSetPersistence write conflicts', () => { }); }); +describe('RootSetPersistence structured lock conflicts', () => { + it('normalizes a structured lock for the exact managed update-ref command', async () => { + await expectStructuredLockCode( + structuredUpdateRefLock(), + EXPECTED_HEAD_OID, + 'ROOT_SET_CONFLICT', + ); + }); + + it('normalizes an exact managed creation lock with a zero expected OID', async () => { + await expectStructuredLockCode( + structuredUpdateRefLock({ args: exactUpdateRefArgs(REF, null) }), + null, + 'ROOT_SET_CONFLICT', + ); + }); + + it('keeps a structured lock for another ref as a non-conflict failure', async () => { + await expectStructuredLockCode( + structuredUpdateRefLock({ ref: 'refs/heads/other' }), + EXPECTED_HEAD_OID, + 'ROOT_SET_REF_UPDATE_FAILED', + ); + }); +}); + +describe('RootSetPersistence malformed structured locks', () => { + const exactArgs = exactUpdateRefArgs(); + const conflictText = + `fatal: cannot lock ref '${REF}': is at ${'e'.repeat(40)} but expected ${EXPECTED_HEAD_OID}`; + it.each([ + ['missing OID operands', { args: exactArgs.slice(0, 3) }], + ['missing expected OID', { args: exactArgs.slice(0, 4) }], + ['altered new OID', { args: [...exactArgs.slice(0, 3), 'e'.repeat(40), EXPECTED_HEAD_OID] }], + ['altered expected OID', { args: [...exactArgs.slice(0, 4), 'e'.repeat(40)] }], + ['trailing operand despite conflict text', { args: [...exactArgs, 'extra'], stderr: conflictText }], + ])('keeps %s terminal', async (_label, options) => { + await expectStructuredLockCode( + structuredUpdateRefLock(options), + EXPECTED_HEAD_OID, + 'ROOT_SET_REF_UPDATE_FAILED', + ); + }); +}); + describe('RootSetPersistence target validation', () => { it('rejects missing targets before writing metadata', async () => { const persistence = mockPersistence({