Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
108 changes: 108 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -188,3 +188,111 @@ jobs:
run: git --version
- name: Conformance suite
run: bash spec/verify.sh

# #39 / ADR-0015: the compiled single-executable build is the one artifact
# this repository ships that needs no Node runtime at all, and the only way
# to prove that is to actually take Node off PATH and run it — `check`'s
# fresh-clone step above always runs through `node`, so it cannot. Building
# it is platform-specific (postject injects into a real, local copy of
# `node`, never a cross-compiled one), so this runs on the same two OSes
# `git-matrix` already does. One build per platform proves the recipe still
# works; it is not a release matrix and does not attach anything anywhere.
binary:
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- name: Build
run: npm run build
- name: Build the compiled binary
run: npm run build:binary

# Same shape as `check`'s fresh-clone step: prove what a user actually
# gets works, by using it the way a user with no Node installed would —
# `env -i` strips the runner's own Node off PATH entirely, leaving only
# what `doctor`'s own `hook-runtime`/`PreToolUse hook runtime` checks
# already assume a git hook gets (`/usr/bin:/bin`, `HOME`).
- name: The binary runs every required command with no Node on PATH
run: |
set -euo pipefail
bin="$PWD/dist/commitlore"
test -x "$bin" || { echo "::error::binary was not produced at $bin"; exit 1; }

repo="$(mktemp -d)/repo"
mkdir -p "$repo"
cd "$repo"
run() { env -i PATH=/usr/bin:/bin HOME="$HOME" "$@"; }

run git init -q -b main
run git config user.email t@example.com
run git config user.name "CommitLore CI"
run git config commit.gpgsign false
printf 'export const value = 0;\n' > file.ts
run git add file.ts
run git commit -q -m "$(printf 'Seed\n\nRecord-Id: r-ci-binary01\nBlast: local\nUndo: easy\nCertainty: firm\n')"

run "$bin" doctor
printf 'Subject\n\nBlast: wide\n' | run "$bin" validate && {
echo "::error::expected a violation to exit non-zero"; exit 1;
} || test $? -eq 1
run "$bin" index --rebuild
run "$bin" context file.ts
payload="$(printf '{"session_id":"s1","cwd":"%s","hook_event_name":"PreToolUse","tool_name":"Edit","tool_input":{"file_path":"%s/file.ts"}}' "$repo" "$repo")"
printf '%s' "$payload" | run "$bin" guard --hook-input
printf '%s' "$payload" | run "$bin" inject --hook-input

# #71's containment guarantee, restated for the binary branch
# (`core/hook-target.ts#classifyBinTarget`, `hooks/commit-msg.ts`):
# `hooks install` records the binary itself, and both a `.git/config`
# edit pointing outside that record and a symlink planted inside it are
# still refused, with no Node on PATH to fall back to.
- name: The commit-msg hook installs and validates through the binary, and #71's containment holds
run: |
set -euo pipefail
bin="$PWD/dist/commitlore"
repo="$(mktemp -d)/hookrepo"
mkdir -p "$repo"
cd "$repo"
run() { env -i PATH=/usr/bin:/bin HOME="$HOME" "$@"; }

run git init -q -b main
run git config user.email t@example.com
run git config user.name "CommitLore CI"
run git config commit.gpgsign false
run "$bin" hooks install

printf 'x\n' > a.txt
run git add a.txt
run git commit -q -m "$(printf 'Bad\n\nBlast: nonsense\n')" && {
echo "::error::expected the bad commit to be rejected"; exit 1;
} || test $? -eq 1
run git commit -q -m "$(printf 'Good\n\nBlast: local\nUndo: easy\nCertainty: firm\n')"

outside="$(mktemp -d)"
cp "$bin" "$outside/commitlore"
chmod +x "$outside/commitlore"
run git config --local commitlore.bin "$outside/commitlore"
printf 'y\n' > b.txt
run git add b.txt
run git commit -q -m "$(printf 'Attack1\n\nBlast: local\nUndo: easy\nCertainty: firm\n')" && {
echo "::error::expected the outside-root commitlore.bin to be refused"; exit 1;
} || test $? -eq 1

run "$bin" hooks install
root="$(run git config --local --get commitlore.root)"
symlink_dir="$(dirname "$root")"
symlink_path="$symlink_dir/commitlore-ci-symlink-$$"
ln -s "$outside/commitlore" "$symlink_path"
run git config --local commitlore.bin "$symlink_path"
run git commit -q -m "$(printf 'Attack2\n\nBlast: local\nUndo: easy\nCertainty: firm\n')" && {
rm -f "$symlink_path"
echo "::error::expected the symlinked commitlore.bin to be refused"; exit 1;
} || { status=$?; rm -f "$symlink_path"; test $status -eq 1; }
167 changes: 167 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
name: Release

# Cutting a release is an owner action, not something CI can decide to do.
# The only trigger is a `v*` tag being pushed — never a schedule, never a
# push to a branch, never `workflow_dispatch`. Pushing the tag *is* the
# approval; this workflow has no separate one to give.
on:
push:
tags:
- "v*"

# `contents: write` is what lets the publish job create the GitHub Release
# and upload assets to it. `id-token`/`attestations` are what
# actions/attest-build-provenance needs (Sigstore-backed keyless signing —
# no secret key for this repo to manage or leak). Nothing here needs to
# write code, open PRs, or touch anything but this one release.
permissions:
contents: write
id-token: write
attestations: write

jobs:
# Refuses the whole release before a single binary is built. A release
# whose asset reports a version that disagrees with its own tag is not
# fixable after the fact — the tag is immutable once fetched, the asset is
# immutable once downloaded. See scripts/check-release-version.mjs.
version-consistency:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm run build
- name: Tag, package.json, and `commitlore --version` agree
run: node scripts/check-release-version.mjs "$GITHUB_REF_NAME"

# Standard matrix for a project this shape (ripgrep, fd, bat, gh, deno,
# bun all ship this set): both macOS architectures, Linux x64, and Linux
# arm64 — the last one on GitHub's own native `ubuntu-24.04-arm` hosted
# runner, confirmed (2026-07-28, against this branch) to build and run the
# SEA binary with no QEMU/cross-compilation involved. `node scripts/
# build-binary.mjs` always copies the *running* `node` binary and injects
# into that exact copy (see its own comments), so "native runner per
# target" is not a convenience here, it is the only way this build works
# at all — there is no cross-target flag to give it.
#
# Windows is deliberately not in this matrix. Verified empirically, not
# assumed, against a real windows-latest run of this exact branch
# (2026-07-28, throwaway probe workflow, deleted after):
# 1. `npm run build:binary` itself fails on Windows before SEA is even
# reached: `scripts/build-binary.mjs`'s `REPO_ROOT` is computed as
# `resolve(new URL('..', import.meta.url).pathname)`, and a Windows
# file:// URL's `.pathname` keeps its leading slash in front of the
# drive letter (`/D:/a/commitlore/commitlore/`). `path.resolve` (the
# win32 variant) then treats that leading `/` as "root of the current
# drive" and appends the rest literally, producing a doubled,
# nonexistent path (observed: `D:\D:\a\commitlore\commitlore\dist\
# commitlore.mjs does not exist`). The fix is `fileURLToPath(...)`
# instead of `.pathname`, and it is not one-off — `scripts/
# check-engines.mjs` has the identical pattern, so this is a systemic
# gap across the script/ layer, not a single missed line.
# 2. Even past that: `src/core/hook-target.ts#classifyBinTarget` and the
# `commit-msg` shell stub's `case` patterns
# (`src/hooks/commit-msg.ts`) both recognize a compiled binary only by
# the exact basename `commitlore` — `scripts/build-binary.mjs`'s own
# `OUTPUT` already names the Windows build `commitlore.exe`, which
# matches neither. The installed commit-msg hook would silently fall
# through #71's containment check entirely rather than refuse to
# resolve it, on the platform that most needs that check to hold.
# ADR-0015 names this exact gap as "a small, additive follow-up" —
# confirmed here, not fixed here. A broken or unverifiable asset is
# worse than a missing one, so Windows stays out until both of the
# above are fixed and re-verified on a real Windows runner.
build:
needs: version-consistency
strategy:
fail-fast: false
matrix:
include:
- os: macos-latest
target: aarch64-apple-darwin
- os: macos-13
target: x86_64-apple-darwin
- os: ubuntu-latest
target: x86_64-unknown-linux-gnu
- os: ubuntu-24.04-arm
target: aarch64-unknown-linux-gnu
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm run build
- run: npm run build:binary

- name: Package the release asset
id: package
run: |
set -euo pipefail
version="${GITHUB_REF_NAME#v}"
asset="commitlore-${version}-${{ matrix.target }}"
stage="$(mktemp -d)/${asset}"
mkdir -p "$stage"
cp dist/commitlore "$stage/commitlore"
chmod +x "$stage/commitlore"
tar -czf "${asset}.tar.gz" -C "$stage" commitlore
echo "asset=${asset}.tar.gz" >> "$GITHUB_OUTPUT"

# Sigstore-backed, keyless, free — no key for this repo to manage or
# leak, and now the default expectation for a project whose own README
# argues decision records must be verifiable. Attesting the exact
# .tar.gz a user downloads (not just the binary inside it) ties the
# attestation to the actual bytes SHA256SUMS below will also cover.
- uses: actions/attest-build-provenance@v4
with:
subject-path: ${{ steps.package.outputs.asset }}

- uses: actions/upload-artifact@v4
with:
name: release-asset-${{ matrix.target }}
path: ${{ steps.package.outputs.asset }}
if-no-files-found: error

# One job, after every platform has built: checksum everything together
# (a SHA256SUMS split per-platform would let one asset go unverifiable
# without the download even failing), attest the checksum file itself, and
# publish. Nothing before this point has touched a GitHub Release.
publish:
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
pattern: release-asset-*
path: release
merge-multiple: true

- name: Every asset landed
run: |
set -euo pipefail
count=$(find release -maxdepth 1 -name '*.tar.gz' | wc -l)
test "$count" -eq 4 || { echo "::error::expected 4 assets, found $count"; ls -la release; exit 1; }

- name: SHA256SUMS
working-directory: release
run: sha256sum *.tar.gz > SHA256SUMS

- uses: actions/attest-build-provenance@v4
with:
subject-path: release/SHA256SUMS

- name: Publish the release
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
gh release create "$GITHUB_REF_NAME" \
--title "$GITHUB_REF_NAME" \
--generate-notes \
release/*.tar.gz release/SHA256SUMS
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,13 @@ node_modules/
# registry and no publish step (ADR-0011). CI fails if it drifts from src/.
*.tsbuildinfo

# The compiled single-executable build (#39, ADR-0015, `npm run build:binary`)
# is a reproducible local/CI/release artifact, not part of the committed
# distribution — unlike dist/commitlore.mjs it is large, platform- and
# architecture-specific, and not meaningfully diffable.
/dist/commitlore
/dist/commitlore.exe

# derived index — never committed (ADR-0003)
.git/commitlore/

Expand Down
26 changes: 26 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,32 @@

## Unreleased

### Compiled single-executable binary — feat-issue-39

`npm run build:binary` (`scripts/build-binary.mjs`) builds `dist/commitlore`,
a Node SEA binary that needs no Node runtime, no interpreter and no
`node_modules` at all — `doctor`, `validate`, `context`, `guard`, `inject` and
`index --rebuild` all run against `PATH=/usr/bin:/bin`. It uses Node's own
`--experimental-sea-config` and `postject` (a devDependency, not a runtime
one); `core/paths.ts` embeds `package.json`, `spec/SPEC.md` and
`spec/schema/record.schema.json` as SEA assets, since a compiled binary has no
directory tree of its own to read them from.

`dist/commitlore.mjs` (ADR-0011's committed, registry-free distribution) is
unchanged — the binary is a second, uncommitted, reproducible build artifact,
not a replacement channel. `commitlore hooks install` and the Claude Code
plugin's `PreToolUse` hook (`scripts/commitlore-run.sh`) both resolve and
prefer it automatically once built. `core/hook-target.ts#classifyBinTarget`
extends the commit-msg hook's `.js`/`.mjs` resolution with a `binary` branch
recognized by name (`commitlore`, not merely "no extension"); its containment
check is an exact match against the recorded install rather than a directory
prefix, since a binary has no subdirectory for a foreign file to hide in. Both
of #71's attacks — a `commitlore.bin` pointed outside the install root, and a
symlink planted inside it pointing back out — are refused for the binary
branch the same way they already were for scripts.

See `docs/adr/ADR-0015-single-executable-binary.md`.

### `parse` recognizes every record block, not only the message's own — bug-issue-89

`commitlore parse` still answered from `parseCommitMessage` alone after
Expand Down
41 changes: 40 additions & 1 deletion README.ja.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,46 @@ node ~/.commitlore/dist/commitlore.mjs context src/auth

コミット済み bundle は build なしで、`node_modules` なしで動きます。SQLite index は Node 本体に同梱された `node:sqlite` を使うため([ADR-0012](docs/adr/ADR-0012-drop-the-native-dependency.md))、clone だけで index の構築も query もできます — native module も compiler も `npm install` も不要です。`--no-index` は index を使わず Git だけで答えたいときのために残っています。

### Node なしでコンパイル済みバイナリとして実行

`git clone` + Node runtime が引き続き正式なインストール方法です。Node が PATH に全くないマシン向けに、同じ clone から単一のコンパイル済み実行ファイルを build できます([ADR-0015](docs/adr/ADR-0015-single-executable-binary.md)):

```bash
cd ~/.commitlore
npm ci
npm run build:binary
./dist/commitlore --version
./dist/commitlore doctor
```

`dist/commitlore` は実行時に Node も interpreter も `node_modules` も不要です — `doctor`、`validate`、`context`、`guard`、`inject`、`index --rebuild` はすべて `PATH=/usr/bin:/bin` で動作します。commit はされません(サイズが大きく、platform・architecture 固有で、diff ではなく CI が push のたびに再 build するため)。`commitlore hooks install` と plugin の `PreToolUse` hook はどちらも build 済みならこれを自動的に解決します。

### 事前ビルド済みリリースバイナリをインストール

Node も clone もないマシン向け: すべての `vX.Y.Z` タグは platform ごとに 1 つずつバイナリを build し(`.github/workflows/release.yml`)、それら全体をカバーする `SHA256SUMS` を添付し、各 asset を [`actions/attest-build-provenance`](https://github.com/MongLong0214/commitlore/attestations) で証明します(Sigstore ベース、公開検証可能、本プロジェクトが管理する鍵はありません)。

```bash
curl -fsSL https://github.com/ghraw/MongLong0214/commitlore/dev/install.sh | sh
```

`install.sh` は OS と architecture を検出し、同じ release から対応する asset と `SHA256SUMS` をダウンロードし、インストール前に checksum を検証します — 他のインストールスクリプトと同様、`sh` にパイプする前に中身を読んでください。バージョンを固定するには: `sh install.sh v0.1.0`。公開されている target: `aarch64-apple-darwin`、`x86_64-apple-darwin`、`x86_64-unknown-linux-gnu`、`aarch64-unknown-linux-gnu`。Windows バイナリはまだありません — SEA build と commit-msg hook shim がそちらで未検証のためです。[ADR-0015](docs/adr/ADR-0015-single-executable-binary.md) 参照。

シェルへのパイプが唯一の文書化された方法であってはいけません。同じインストールを手動で行う場合:

```bash
version=0.1.0 # または: curl -fsSL https://github.com/MongLong0214/commitlore/releases/latest/download/SHA256SUMS | head -1
target=aarch64-apple-darwin # または x86_64-apple-darwin | x86_64-unknown-linux-gnu | aarch64-unknown-linux-gnu

curl -fsSLO "https://github.com/MongLong0214/commitlore/releases/download/v$version/commitlore-$version-$target.tar.gz"
curl -fsSLO "https://github.com/MongLong0214/commitlore/releases/download/v$version/SHA256SUMS"

# 展開する前に検証します。「OK」でなければここで止めてください — この検証に失敗したバイナリは実行しないでください。
grep "commitlore-$version-$target.tar.gz" SHA256SUMS | shasum -a 256 -c - # Linux: sha256sum -c -

tar -xzf "commitlore-$version-$target.tar.gz"
./commitlore --version
```

## GitHub Actions

query、guard、inject を実行する job は全 history を取得する必要があります。
Expand Down Expand Up @@ -151,7 +191,6 @@ text search ではなく Git の trailer parser を使ってください。文
- path ではなく symbol への anchor: [#33](https://github.com/MongLong0214/commitlore/issues/33)
- 対話型 commit builder と自動 expiry 通知: [#34](https://github.com/MongLong0214/commitlore/issues/34)
- 有効な benchmark による guard の行動効果の実証: [#37](https://github.com/MongLong0214/commitlore/issues/37)
- Node.js なしで動く単一 static binary: [#39](https://github.com/MongLong0214/commitlore/issues/39)

## コントリビュート

Expand Down
Loading
Loading