Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 14 additions & 8 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,23 +11,29 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 10
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm run lint
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm run lint

test-unit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 10
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm test
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm test

test-docker:
runs-on: ubuntu-latest
Expand All @@ -38,7 +44,7 @@ jobs:
runtime: [node, bun, deno]
include:
- runtime: node
integration_cmd: npx vitest run test/integration
integration_cmd: pnpm vitest run test/integration
- runtime: bun
integration_cmd: bunx vitest run test/integration
- runtime: deno
Expand All @@ -50,4 +56,4 @@ jobs:
- name: Unit tests (${{ matrix.runtime }})
run: docker compose run --rm test-${{ matrix.runtime }}
- name: Integration tests (${{ matrix.runtime }})
run: docker compose run --rm test-${{ matrix.runtime }} ${{ matrix.integration_cmd }}
run: docker compose run --rm test-${{ matrix.runtime }} ${{ matrix.integration_cmd }}
52 changes: 52 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
name: Release

on:
push:
tags:
- 'v*'

jobs:
release:
runs-on: ubuntu-latest
permissions:
contents: write
id-token: write # Required for OIDC and provenance
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 10
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
registry-url: 'https://registry.npmjs.org'

- run: pnpm install --frozen-lockfile

# Run full quality gates before publishing
- run: pnpm run lint
- run: pnpm test

# Integration tests in Docker
- name: Integration tests (Node)
run: docker compose run --rm test-node pnpm vitest run test/integration
- name: Integration tests (Bun)
run: docker compose run --rm test-bun bunx vitest run test/integration
- name: Integration tests (Deno)
run: docker compose run --rm test-deno deno run -A npm:vitest run test/integration

# Publish to npm using OIDC for provenance
- name: Publish to npm
run: pnpm publish --provenance --no-git-checks
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

# Publish to JSR
- name: Publish to JSR
run: pnpm dlx jsr publish
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
generate_release_notes: true
33 changes: 16 additions & 17 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,30 +5,29 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased] — M2 Boomerang (v1.2.0)
## [Unreleased]

## [1.3.0] — M3 Launchpad (2026-02-06)

### Added
- `CasService.restore()` — reconstruct files from manifests with per-chunk SHA-256 integrity verification.
- `ContentAddressableStore.restoreFile()` — facade method that restores and writes to disk.
- `readTree()` on `GitPersistencePort` / `GitPersistenceAdapter` — parse Git trees via `ls-tree`.
- `STREAM_ERROR` wrapping — stream failures during `store()` surface as `CasError('STREAM_ERROR')` with `{ chunksWritten }` metadata.
- `MISSING_KEY` error code — `restore()` now fails fast when manifest is encrypted but no decryption key is provided.
- CLI: `git cas store`, `git cas tree`, `git cas restore` subcommands via `bin/git-cas.js`.
- Integration test suite (59 tests) running against real Git bare repos inside Docker.
- `commander` dependency for CLI.
- Native Bun support via `BunCryptoAdapter` (uses `Bun.CryptoHasher`).
- Native Deno/Web standard support via `WebCryptoAdapter` (uses `crypto.subtle`).
- Automated, secure release workflow (`.github/workflows/release.yml`) with:
- **NPM OIDC support** including build provenance.
- **JSR support** via `jsr.json` and automated publishing.
- **GitHub Releases** with automated release notes.
- Dynamic runtime detection in `ContentAddressableStore` to pick the best adapter automatically.
- Hardened `package.json` with repository metadata, engine constraints, and explicit file inclusion.

### Changed
- `readBlob()` now normalises `Uint8Array` from plumbing into `Buffer` for codec/crypto compatibility.
- `readTree()` uses `git ls-tree -z` (NUL-delimited output) for safe parsing of filenames with leading/trailing spaces.
- **Breaking Change:** `CasService` cryptographic methods (`sha256`, `encrypt`, `decrypt`, `verifyIntegrity`) are now asynchronous to support Web Crypto and native optimizations.
- `ContentAddressableStore` facade methods are now asynchronous to accommodate lazy service initialization and async crypto.
- CI workflow (`.github/workflows/ci.yml`) now runs on all branches.

### Fixed
- Fuzz tests in stream-error suite now fail explicitly if `store()` does not throw.
- ROADMAP: resolved inconsistent CLI signatures for `git cas tree` (`--slug` vs `--manifest`).

### Security
- None.
- Fixed recursion bug in `BunCryptoAdapter` where `randomBytes` shadowed the imported function.

## [1.1.0] — M1 Bedrock
## [1.2.0] — M2 Boomerang (v1.2.0)

### Added
- `CryptoPort` interface and `NodeCryptoAdapter` — extracted all `node:crypto` usage from the domain layer.
Expand Down
18 changes: 15 additions & 3 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,18 @@
- **Domain Purity**: Keep crypto and chunking logic independent of Git implementation details.
- **Portability**: The `GitPersistencePort` allows swapping the storage backend.

## Testing
- Use `npm test`.
- All domain logic should be tested with mocks for the persistence layer.
## Development Workflow

1. **Install Dependencies**: Use `pnpm install` to ensure consistent dependency management.
2. **Install Git Hooks**: Run `bash scripts/install-hooks.sh` to set up local quality gates. This will ensure that linting and unit tests pass before every push.
3. **Run Tests Locally**:
- `pnpm test` for unit tests.
- `pnpm run test:integration` for integration tests (requires Docker).

## Quality Gates
We enforce high standards for code quality:
- **Linting**: Must pass `pnpm run lint`.
- **Unit Tests**: All unit tests must pass.
- **Integration Tests**: Must pass across Node, Bun, and Deno runtimes.

These gates are enforced both locally via git hooks and in CI/CD.
9 changes: 5 additions & 4 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
# --- Node ---
FROM node:22-slim AS node
RUN apt-get update && apt-get install -y git && rm -rf /var/lib/apt/lists/*
RUN npm install -g pnpm@10
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
COPY . .
ENV GIT_STUNTS_DOCKER=1
CMD ["npx", "vitest", "run", "test/unit"]
CMD ["pnpm", "vitest", "run", "test/unit"]

# --- Bun ---
FROM oven/bun:1-slim AS bun
Expand All @@ -26,4 +27,4 @@ WORKDIR /app
COPY . .
RUN deno install --allow-scripts
ENV GIT_STUNTS_DOCKER=1
CMD ["deno", "run", "-A", "npm:vitest", "run", "test/unit"]
CMD ["deno", "run", "-A", "npm:vitest", "run", "test/unit"]
11 changes: 6 additions & 5 deletions bin/git-cas.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import Manifest from '../src/domain/value-objects/Manifest.js';
program
.name('git-cas')
.description('Content Addressable Storage backed by Git')
.version('1.2.0');
.version('1.3.0');

/**
* Read a 32-byte raw encryption key from a file.
Expand Down Expand Up @@ -96,9 +96,10 @@ program
.action(async (treeOid, opts) => {
try {
const cas = createCas(opts.cwd);
const service = await cas.getService();

// Read the tree to find the manifest
const entries = await cas.service.persistence.readTree(treeOid);
const entries = await service.persistence.readTree(treeOid);
const manifestEntry = entries.find(
(e) => e.name.startsWith('manifest.'),
);
Expand All @@ -107,11 +108,11 @@ program
process.exit(1);
}

const manifestBlob = await cas.service.persistence.readBlob(
const manifestBlob = await service.persistence.readBlob(
manifestEntry.oid,
);
const manifest = new Manifest(
cas.service.codec.decode(manifestBlob),
service.codec.decode(manifestBlob),
);

const restoreOpts = { manifest };
Expand All @@ -131,4 +132,4 @@ program
}
});

program.parse();
program.parse();
86 changes: 68 additions & 18 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,21 @@ export {
CborCodec
};

/**
* Detects the best crypto adapter for the current runtime.
*/
async function getDefaultCryptoAdapter() {
if (globalThis.Bun) {
const { default: BunCryptoAdapter } = await import('./src/infrastructure/adapters/BunCryptoAdapter.js');
return new BunCryptoAdapter();
}
if (globalThis.Deno) {
const { default: WebCryptoAdapter } = await import('./src/infrastructure/adapters/WebCryptoAdapter.js');
return new WebCryptoAdapter();
}
return new NodeCryptoAdapter();
}

/**
* Facade class for the CAS library.
*/
Expand All @@ -37,13 +52,40 @@ export default class ContentAddressableStore {
* @param {import('@git-stunts/alfred').Policy} [options.policy] - Resilience policy for Git I/O
*/
constructor({ plumbing, chunkSize, codec, policy, crypto }) {
const persistence = new GitPersistenceAdapter({ plumbing, policy });
this.service = new CasService({
persistence,
chunkSize,
codec: codec || new JsonCodec(),
crypto: crypto || new NodeCryptoAdapter(),
});
this.plumbing = plumbing;
this.chunkSizeConfig = chunkSize;
this.codecConfig = codec;
this.policyConfig = policy;
this.cryptoConfig = crypto;
this.service = null;
}

/**
* Lazily initializes the service to handle async adapter discovery.
* @private
*/
async #getService() {
if (!this.service) {
const persistence = new GitPersistenceAdapter({
plumbing: this.plumbing,
policy: this.policyConfig
});
const crypto = this.cryptoConfig || await getDefaultCryptoAdapter();
this.service = new CasService({
persistence,
chunkSize: this.chunkSizeConfig,
codec: this.codecConfig || new JsonCodec(),
crypto,
});
}
return this.service;
}

/**
* Lazily initializes and returns the service.
*/
async getService() {
return await this.#getService();
}

/**
Expand All @@ -61,15 +103,17 @@ export default class ContentAddressableStore {
}

get chunkSize() {
return this.service.chunkSize;
return this.service?.chunkSize || this.chunkSizeConfig || 256 * 1024;
}

encrypt(options) {
return this.service.encrypt(options);
async encrypt(options) {
const service = await this.#getService();
return await service.encrypt(options);
}

decrypt(options) {
return this.service.decrypt(options);
async decrypt(options) {
const service = await this.#getService();
return await service.decrypt(options);
}

/**
Expand All @@ -78,7 +122,8 @@ export default class ContentAddressableStore {
*/
async storeFile({ filePath, slug, filename, encryptionKey }) {
const source = createReadStream(filePath);
return this.service.store({
const service = await this.#getService();
return await service.store({
source,
slug,
filename: filename || path.basename(filePath),
Expand All @@ -90,14 +135,16 @@ export default class ContentAddressableStore {
* Direct passthrough for callers who already have an async iterable source.
*/
async store(options) {
return this.service.store(options);
const service = await this.#getService();
return await service.store(options);
}

/**
* Restores a file from its manifest and writes it to outputPath.
*/
async restoreFile({ manifest, encryptionKey, outputPath }) {
const { buffer, bytesWritten } = await this.service.restore({
const service = await this.#getService();
const { buffer, bytesWritten } = await service.restore({
manifest,
encryptionKey,
});
Expand All @@ -109,14 +156,17 @@ export default class ContentAddressableStore {
* Restores a file from its manifest, returning the buffer directly.
*/
async restore(options) {
return this.service.restore(options);
const service = await this.#getService();
return await service.restore(options);
}

async createTree(options) {
return this.service.createTree(options);
const service = await this.#getService();
return await service.createTree(options);
}

async verifyIntegrity(manifest) {
return this.service.verifyIntegrity(manifest);
const service = await this.#getService();
return await service.verifyIntegrity(manifest);
}
}
9 changes: 9 additions & 0 deletions jsr.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"name": "@git-stunts/cas",
"version": "1.3.0",
"exports": {
".": "./index.js",
"./service": "./src/domain/services/CasService.js",
"./schema": "./src/domain/schemas/ManifestSchema.js"
}
}
Loading