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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ jobs:
name: Lint, Format & Type Check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- uses: oven-sh/setup-bun@v2
- run: bun install --frozen-lockfile
- run: bun run lint
Expand Down
181 changes: 133 additions & 48 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -1,55 +1,140 @@
name: Release

on:
push:
tags: ["v*"]
push:
tags: ["v*"]

permissions:
contents: write
contents: write

jobs:
build:
name: Build ${{ matrix.target }}
runs-on: ${{ matrix.os }}
strategy:
matrix:
include:
- target: darwin-arm64
os: macos-latest
bun_target: bun-darwin-arm64
- target: darwin-x64
os: macos-15-intel
bun_target: bun-darwin-x64
- target: linux-x64
os: ubuntu-latest
bun_target: bun-linux-x64
- target: linux-arm64
os: ubuntu-latest
bun_target: bun-linux-arm64

steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
- run: bun install --frozen-lockfile
- run: bun build src/index.ts --compile --target=${{ matrix.bun_target }} --outfile dist/worktree-${{ matrix.target }}
- uses: actions/upload-artifact@v4
with:
name: worktree-${{ matrix.target }}
path: dist/worktree-${{ matrix.target }}

release:
name: Create Release
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@v4
with:
path: artifacts
merge-multiple: true

- run: chmod +x artifacts/worktree-*

- uses: softprops/action-gh-release@v2
with:
generate_release_notes: true
files: artifacts/worktree-*
build:
name: Build ${{ matrix.target }}
runs-on: ${{ matrix.os }}
strategy:
matrix:
include:
- target: darwin-arm64
os: macos-latest
bun_target: bun-darwin-arm64
- target: darwin-x64
os: macos-15-intel
bun_target: bun-darwin-x64
- target: linux-x64
os: ubuntu-latest
bun_target: bun-linux-x64
- target: linux-arm64
os: ubuntu-24.04-arm
bun_target: bun-linux-arm64

steps:
- uses: actions/checkout@v6
- uses: oven-sh/setup-bun@v2
- run: bun install --frozen-lockfile

- name: Compile binary
run: |
bun build src/index.ts \
--compile \
--minify \
--sourcemap=none \
--target=${{ matrix.bun_target }} \
--outfile dist/worktree-${{ matrix.target }}

- name: Install llvm-strip (Linux)
if: startsWith(matrix.target, 'linux-')
run: sudo apt-get update && sudo apt-get install -y llvm

- name: Strip symbols (Linux)
if: startsWith(matrix.target, 'linux-')
run: llvm-strip --strip-unneeded dist/worktree-${{ matrix.target }}

- name: Strip symbols (macOS)
if: startsWith(matrix.target, 'darwin-')
run: strip dist/worktree-${{ matrix.target }}

- name: Ad-hoc codesign (macOS)
if: startsWith(matrix.target, 'darwin-')
run: |
xattr -cr dist/worktree-${{ matrix.target }}
codesign --force --sign - dist/worktree-${{ matrix.target }}
codesign -dv dist/worktree-${{ matrix.target }}

- name: Verify binary architecture and size
run: |
file dist/worktree-${{ matrix.target }}
ls -lh dist/worktree-${{ matrix.target }}

- name: Smoke-test --version
env:
WORKTREE_NO_UPDATE: "1"
run: |
chmod +x dist/worktree-${{ matrix.target }}
./dist/worktree-${{ matrix.target }} --version
Comment thread
coderabbitai[bot] marked this conversation as resolved.

- name: Compute SHA256
run: |
cd dist
shasum -a 256 worktree-${{ matrix.target }} > worktree-${{ matrix.target }}.sha256

- uses: actions/upload-artifact@v7
with:
name: worktree-${{ matrix.target }}
path: |
dist/worktree-${{ matrix.target }}
dist/worktree-${{ matrix.target }}.sha256

release:
name: Create Release
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@v8
with:
path: artifacts
merge-multiple: true

- name: Aggregate SHA256SUMS
run: |
cd artifacts
# Fail-fast: if no .sha256 sidecars were downloaded (e.g.,
# an upload-artifact rename regression), the cat below
# would silently produce an empty SHA256SUMS that bricks
# every client's auto-update install.
count=$(ls -1 worktree-*.sha256 2>/dev/null | wc -l | tr -d ' ')
binaries=$(ls -1 worktree-* 2>/dev/null | grep -v '\.sha256$' | wc -l | tr -d ' ')
if [ "$count" -lt 1 ] || [ "$count" != "$binaries" ]; then
echo "sha256 count ($count) does not match binary count ($binaries) — abort" >&2
exit 1
fi
cat worktree-*.sha256 > SHA256SUMS
rm worktree-*.sha256
if [ ! -s SHA256SUMS ]; then
echo "SHA256SUMS empty after aggregation — abort" >&2
exit 1
fi
cat SHA256SUMS

- run: chmod +x artifacts/worktree-*

# Create the release as a draft and upload all assets to it.
# Publishing happens in the next step so `releases/latest` never
# returns a release where binaries are attached but SHA256SUMS
# is still being uploaded — that window would let clients fall
# through to the TLS-only "not-published" path and install
# unverified.
- uses: softprops/action-gh-release@v2
with:
draft: true
generate_release_notes: true
files: |
artifacts/worktree-*
artifacts/SHA256SUMS

- name: Publish release (flip draft to public)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REF_NAME: ${{ github.ref_name }}
REPO: ${{ github.repository }}
run: |
gh release edit "$REF_NAME" --repo "$REPO" --draft=false
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ node_modules/
dist/
.worktrees
docs/
tasks/
71 changes: 71 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,77 @@ 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]

### Changed

- Auto-update now bumps its 24-hour throttle when a release is genuinely unusable on the current platform (probe failure), when GitHub is unreachable, or when a download fails for any reason. Previously, every CLI invocation re-downloaded ~50 MB and re-hit the GitHub API, which could trip the 60-requests-per-hour anonymous rate limit on a heavy day.
- Foreground `worktree update` now smoke-tests the downloaded binary (`--version`) before atomically replacing the installed binary. A SHA256-valid release that won't run on the current machine (libc/codesign/macOS-version mismatch) is now refused with a clear error instead of leaving the user with a broken `worktree`.
- A `SHA256SUMS` file containing **duplicate entries** — the canonical signature of supply-chain tampering — now triggers a loud red `SECURITY ALERT` in `worktree update` and a `TAMPER:` prefix in the background error log, instead of being reported as a generic "could not be fetched" outage.
- Project-scope `AUTO_UPDATE=...` is still ignored (matches existing behaviour) but the warning now also reports whether the value is a *valid* boolean-like, so a user moving the line to `~/.worktreerc` later already knows whether it would have taken effect.
- Version comparison now follows SemVer 2.0 §11 for prerelease ordering: `1.2.3-rc.10` is now correctly **greater than** `1.2.3-rc.2`. Previously the comparator used lexicographic string ordering, which would have stranded users on `rc.2` from ever auto-updating to `rc.10`.

### Security

- Auto-update tmp paths now use `crypto.randomBytes(8)` instead of `process.pid`, removing a predictable-filename primitive that a co-tenant on a group-writable install dir could pre-plant a symlink at. Pre-unlink remains as the primary defense.
- Stage detection no longer silently fails on `EACCES` of the binary directory: `existsSync` was masking permission errors as "no stage". Now logs the diagnostic and bails without destructive cleanup, so a transient permission glitch can't destroy a peer process's mid-commit stage either.

### Fixed

- `release.ts` download path no longer silently swallows three classes of error (writer post-finish flush errors, reader `releaseLock` failures, partial-write cleanup failures). Errors now route through an `onError` callback that the auto-updater logs to `~/.cache/worktree-cli/last-error`.
- Removed an off-by-one in the redirect loop (`<=` instead of `<`) — the loop was allowing 6 hops while the error message claimed a limit of 5.
- Aggregated SHA256SUMS in the release workflow now compares against the actual binary count instead of a hardcoded `expected=4` — adding or removing a build target no longer requires editing two places.

## [1.3.0] - 2026-04-17

### Added

- **Background auto-update**: on launch, `worktree` checks GitHub for a newer release at most once every 24 hours in a detached background process. When a newer version is found, the binary is downloaded, verified against SHA256, and staged. The next invocation atomically swaps in the new binary and prints a one-line `worktree auto-updated to vX.Y.Z` note on stderr. Opt out via `AUTO_UPDATE=false` in `~/.worktreerc` or `WORKTREE_NO_UPDATE=1` in the environment.
- **Release integrity**: every GitHub Release now publishes a `SHA256SUMS` file. The `worktree update` command and the background auto-updater both verify the downloaded binary against this hash before installing. Releases without `SHA256SUMS` (legacy) still work but without verification.

### Changed

- Release binaries are slightly smaller (minified, debug symbols stripped). No behavioural change.
- Release workflow smoke-tests each built binary (`--version`) before publishing so a broken build can't reach users.
- `AUTO_UPDATE` in a project `.worktreerc` now warns once that it is ignored — only `~/.worktreerc` is honoured (matches the README).
- Global and project config files now behave symmetrically on parse errors: both warn and fall back to defaults.
- CI: bumped `actions/checkout`, `actions/upload-artifact`, and `actions/download-artifact` to latest majors (Node.js 24 runtime) following GitHub's deprecation of Node.js 20 actions.

### Security

- Release downloads are verified against `SHA256SUMS` using a constant-time hash comparison before being made executable.
- Release-channel fetches are restricted to an allowlist of GitHub-owned hosts, validated on every redirect hop **before** the runtime connects. A malicious `Location:` injection on the first hop can no longer reach an arbitrary host. `GITHUB_TOKEN` is stripped on any cross-origin hop and not re-attached if the chain bounces back to the origin.
- Release assets with a declared `Content-Length` over 200 MB are rejected outright, and byte-counts are enforced as the body streams in — a CDN omitting or forging `Content-Length` cannot exhaust memory before the size check fires. The download timeout is honoured throughout the body read so a slowloris response can't stretch past it.
- The staging tmp path is pre-unlinked before each download as a best-effort defense against a planted symlink in a shared install directory. (Not race-free — a writable install directory still allows re-planting between the unlink and the subsequent write. Closing that race fully would require `O_EXCL | O_NOFOLLOW`. Same treatment applies to the sidecar tmp path.)
- GitHub releases are now published as **draft**, uploaded with all files (binaries + `SHA256SUMS`), and flipped to public in a subsequent step — eliminating the window where `releases/latest` exposed a public release with binaries but no sums, forcing clients onto the TLS-only install path.
- `SHA256SUMS` parser rejects duplicate filename entries and is immune to prototype-pollution from a tampered sums file.
- Release tag and staged version strings are validated against the same strict regex at the writer and the reader, so a crafted tag can't propagate into paths, logs, or sidecar metadata.
- Concurrent launches no longer discard a correctly-staged update mid-commit: a 60-second mtime grace window distinguishes a concurrent producer from a real orphan.
- Auto-update now fails **closed** on a malformed `~/.worktreerc` — a typo can no longer silently re-enable auto-update against an explicit opt-out.
- Applying a staged update now also respects `AUTO_UPDATE=false` in `~/.worktreerc`. Previously, a user who opted out in config after a binary was already staged would still get the staged binary installed on the next launch.
- GitHub API fetches now send a proper `User-Agent` (`worktree-cli/vX.Y.Z`), `Accept: application/vnd.github+json`, and `X-GitHub-Api-Version` header. Setting `GITHUB_TOKEN` in the environment raises the rate limit from 60/hr (anonymous) to 5000/hr (authenticated).

### Fixed

- macOS releases (darwin-arm64, darwin-x64) are now **ad-hoc codesigned** after stripping. Prior releases shipped unsigned binaries, which Apple Silicon macOS SIGKILLs on execution. Users who hit `killed: 9` errors after downloading the raw binary should re-install from v1.3.0 onward.
- Auto-update no longer buffers the full binary in memory during download or verification — peak memory stays flat regardless of binary size.
- A missing per-arch asset in the latest release no longer burns the 24h auto-update throttle; the next launch retries so users on the lagging arch get updated once the asset is uploaded.
- `worktree update` and the background auto-updater now recognise the full set of write-permission errors on download, `chmod`, and rename (not just `EACCES`), clean up any partial staged files, and print a one-line `run "sudo worktree update"` hint instead of looping on every launch.
- Persistent structural failures (read-only install directory, busy binary, disk full, filesystem boundary) now throttle the background check so a stuck install directory no longer burns the GitHub API quota on every launch.
- Orphan staging artifacts left by an interrupted background update are cleaned up on the next launch instead of lingering indefinitely.
- First-ever auto-update launch no longer prints a spurious "error log unwritable" warning on a not-yet-created log file.
- Background updater no longer spawns a blind detached child when the cache log can't be opened — the same condition that short-circuits the throttle now also short-circuits the spawn, and the parent's copy of the log fd is released even if the spawn itself throws synchronously.
- Probe-timeout failures on a freshly-downloaded binary now surface as `timed out after 2000ms` instead of the opaque `exit null`.
- Network errors during update checks now preserve the underlying errno (`ENOTFOUND`, `ECONNRESET`, `ETIMEDOUT`, etc.) instead of being hidden behind a generic wrapper.
- The background update child is detached (POSIX `setsid`) so a slow download isn't killed when the user's shell or terminal exits.
- Unhandled throws from the background update path now write a full stack trace to `~/.cache/worktree-cli/last-error` and surface on the next foreground launch instead of failing silently.
- Unlink errors during cleanup distinguish "already gone" from real failures — only real failures emit a warning.

### Tests

- Added coverage for the SHA256 verification flow across all result shapes (legacy, normal, transient, permanent, missing-entry, hash-mismatch, hash-io-error) using stubbed `fetch` and a precomputed-hash asset file — pins the safety contract against future refactors.
- Added coverage for the `SHA256SUMS` parser's duplicate-entry rejection.

## [1.2.0] - 2026-04-17

### Added
Expand Down
21 changes: 21 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,27 @@ bun run format # Prettier
- Every `@clack/prompts` call must check `p.isCancel()` and exit gracefully.
- `shell.ts` reads stdout and stderr concurrently with `Promise.all` to avoid pipe deadlocks.

## Comment discipline

**Default: write zero comments.** Well-named identifiers + control flow explain WHAT. This project follows the global "no comments unless genuinely complex" rule **strictly** — stricter than the global default.

**Fix rationale belongs in commit messages, not code.** "We added X to prevent Y outage" is commit-message content. It does NOT go in the code — comments drift from the implementation as the code evolves, commit messages and PR descriptions don't.

**The only code comments that earn their keep are footgun warnings** — ones that save a future dev from a specific non-obvious trap AND aren't findable from git blame. Examples that pass the bar:

- `// Bun.spawnSync returns null exitCode on timeout kill.` (runtime quirk)
- `// Constant-time compare prevents timing side-channel on hash compare.` (security invariant the call site alone doesn't communicate)
- `// POSIX setsid(): survives terminal close so a slow download isn't SIGHUPed.` (cross-platform behavior note)

**Anti-patterns — NEVER write any of these** (concrete examples from real commits that violated this rule):

1. **Paraphrasing the next line** — `// Bump throttle on transient network failures so we don't burn the GitHub API quota` above `recordCheckCompleted();`. The function name already says this.
2. **JSDoc-style docblocks for internal helpers** — `// true=exists, false=ENOENT, null=non-ENOENT error logged; caller must bail` above `function checkExists(...): boolean | null`. The return type plus null-checks at call sites already communicate the contract.
3. **Multi-line fix rationale** — any 2+ line comment explaining WHY a PR-level decision was made. That belongs in the commit message. If it's not findable from `git blame`, improve the commit message instead of polluting the code.
4. **Stacked WHY paragraphs** — back-to-back `// line 1 / // line 2 / // line 3` blocks. Treat 2 lines as a warning sign; 3+ lines is always wrong.

**Self-test before writing any comment**: remove it and re-read the function. Would a future reader (including future-me) be meaningfully more confused without it? If the answer is "no" or "barely" — delete the comment.

## Dependencies

- `@drizzle-team/brocli` — CLI arg parsing (typed commands + options)
Expand Down
Loading