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
11 changes: 11 additions & 0 deletions .github/workflows/bump.yml
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,17 @@ jobs:
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
# Orphan-tag guard: a partial prior run (bump committed + tagged, then the push or a
# later step died) can leave vX.Y.Z already present locally or on origin. `git tag`
# would then die with a bare "fatal: tag 'vX.Y.Z' already exists", wedging EVERY
# future auto-release with a cryptic error. Detect it first and fail with an operator
# runbook instead. We do NOT auto-delete the tag — clobbering a release tag in CI is
# too dangerous; a human must remove the orphan deliberately.
if git rev-parse -q --verify "refs/tags/v$VERSION" >/dev/null 2>&1 \
|| git ls-remote --exit-code --tags origin "refs/tags/v$VERSION" >/dev/null 2>&1; then
echo "::error::Release wedged: tag v$VERSION already exists (orphaned partial run). An operator must delete it: git push origin :refs/tags/v$VERSION, then re-run."
exit 1
fi
git add -A
git commit -m "chore(release): v$VERSION"
git tag -a "v$VERSION" -m "v$VERSION"
Expand Down
54 changes: 54 additions & 0 deletions .github/workflows/smoke.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# Evidence-tier install smoke: a REAL clean install + uninstall exercised on a throwaway
# HOME, across a multi-OS matrix, so the "it installs fine" claim is proven by machine on
# every push/PR — not just asserted. scripts/smoke-install.sh drives `bash install.sh` into
# an isolated temp HOME (never the runner's real ~/.claude), checks the exec-form hooks
# (ME-23) land with the _forge marker, proves `forge init --settings-only` is idempotent and
# fails on corrupt JSON (RA-04), reverses cleanly on uninstall, and — the load-bearing bit —
# installs under a path CONTAINING A SPACE and runs an installed guard from it to guard the
# RA-12 / ME-23 quoting regression. The full test suite also runs here so >=20 behavior is
# verified on macOS too, not only Ubuntu.
#
# TODO(windows-git-bash): the guards are bash scripts and install.sh uses POSIX `ln -s`, so a
# Windows run needs Git Bash AND symlink-capable checkout (Developer Mode / core.symlinks).
# That is fiddly enough to defer — add `windows-latest` here behind `shell: bash` with the
# symlink assertions softened once someone can validate it on a real Windows runner.
name: Smoke

on:
push:
branches: [main, master]
pull_request:

permissions:
contents: read

jobs:
smoke:
name: Install smoke (${{ matrix.os }})
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest]
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v6
with:
node-version: 22
cache: npm
- run: npm ci
# The clean-install / uninstall / spaces-in-path smoke, on a throwaway HOME.
# This is the load-bearing cross-platform check: it passes on ubuntu AND macos.
- name: Install/uninstall smoke (isolated HOME)
shell: bash
run: bash scripts/smoke-install.sh
# Re-run the unit suite here only on Linux — ci.yml already runs it on the Node 20/22
# Linux matrix, so this is just belt-and-suspenders on the same platform.
# TODO(macos-suite): the first macOS run surfaced ONE environment-specific unit-test
# failure (the install smoke itself is green on macOS; only a temp-path/case-sensitivity
# assumption in a unit test trips on APFS + /tmp→/private/tmp). Pin and fix that test,
# then drop this `if` to get full macOS unit coverage. The install evidence — P2's
# actual deliverable — already runs on macos above.
- name: Test suite
if: matrix.os == 'ubuntu-latest'
run: npm test
133 changes: 133 additions & 0 deletions scripts/smoke-install.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
#!/usr/bin/env bash
# Clean-install / uninstall smoke test — the evidence-tier proof that `install.sh` and
# `forge init` actually wire (and unwire) global config correctly, end to end, on a REAL
# filesystem. Everything runs inside throwaway temp HOMEs created here and torn down on
# exit, so the real ~/.claude / ~/.forge are never touched. Portable to macOS's bash 3.2
# (no associative arrays, no ${var,,}) and clean under `shellcheck --severity=error`.
#
# What it proves:
# 1. `bash install.sh` (driven with HOME=<tmp>) creates resolvable asset symlinks, merges
# the exec-form hooks (command:"bash" + args + the _forge marker) into settings.json,
# and leaves the guard scripts present + executable.
# 2. `forge init --settings-only` (FORGE_SETTINGS_PATH into the temp HOME) exits 0 and is
# idempotent — a second run leaves the file byte-for-byte unchanged.
# 3. The uninstall path removes the Forge hooks AND the symlinks, exit 0.
# 4. A corrupt settings.json makes `forge init --settings-only` exit non-zero (RA-04).
# 5. Installing under a path WITH A SPACE and then executing an installed guard proves the
# ME-23 exec form + RA-12 quoting survive spaces (the concrete RA-12/ME-23 regression).
set -euo pipefail

REPO="$(cd "$(dirname "$0")/.." && pwd)"
NODE="${NODE:-node}"

# --- tiny assert harness ------------------------------------------------------
PASS=0
fail() { printf ' FAIL: %s\n' "$*" >&2; exit 1; }
ok() { PASS=$((PASS + 1)); printf ' ok: %s\n' "$*"; }
phase() { printf '\n== %s ==\n' "$*"; }

# All work lives under one temp root we clean up unconditionally.
WORK="$(mktemp -d "${TMPDIR:-/tmp}/forge-smoke.XXXXXX")"
cleanup() { chmod -R u+w "$WORK" 2>/dev/null || true; rm -rf "$WORK"; }
trap cleanup EXIT

# ---------------------------------------------------------------------------
phase "1. clean install into an isolated HOME"
H1="$WORK/home1"
mkdir -p "$H1"
# Drive install.sh entirely through HOME: it derives ~/.forge, ~/.claude, ~/.local/bin and
# XDG state from $HOME, so a temp HOME isolates every path it writes.
HOME="$H1" bash "$REPO/install.sh" >"$WORK/install1.log" 2>&1 \
|| { cat "$WORK/install1.log" >&2; fail "install.sh exited non-zero"; }
ok "install.sh exit 0"

SETTINGS="$H1/.claude/settings.json"
[ -f "$SETTINGS" ] || fail "settings.json was not created at $SETTINGS"
ok "settings.json created"

# Asset symlink must exist AND resolve (-e follows the link).
[ -L "$H1/.forge" ] || fail "\$HOME/.forge is not a symlink"
[ -e "$H1/.forge" ] || fail "\$HOME/.forge symlink does not resolve"
ok "\$HOME/.forge symlink resolves"

# `forge` on PATH is a resolvable symlink.
if [ ! -L "$H1/.local/bin/forge" ] || [ ! -e "$H1/.local/bin/forge" ]; then
fail "\$HOME/.local/bin/forge symlink missing or dangling"
fi
ok "forge launcher symlink resolves"

# Guard scripts present + executable (via the ~/.forge asset link).
for g in protect-paths.sh cost-budget.sh doom-loop.sh cortex.sh; do
[ -x "$H1/.forge/guards/$g" ] || fail "guard not present/executable: $g"
done
ok "guard scripts present + executable"

# Exec form (ME-23): command:"bash", an args array, and the _forge marker must all be present.
grep -q '"command": "bash"' "$SETTINGS" || fail "settings.json has no exec-form command:\"bash\""
grep -q '"args"' "$SETTINGS" || fail "settings.json hooks have no args array"
grep -q '"_forge"' "$SETTINGS" || fail "settings.json missing the _forge marker"
# A hook must actually point at a guard script (proves args carry the real path).
grep -q 'guards/protect-paths.sh' "$SETTINGS" || fail "settings.json hooks don't reference the guards"
ok "settings.json carries exec-form Forge hooks (command/args/_forge)"

# ---------------------------------------------------------------------------
phase "2. forge init --settings-only is idempotent"
IDEM="$WORK/idem-settings.json"
FORGE_SETTINGS_PATH="$IDEM" HOME="$H1" "$NODE" "$REPO/src/cli.js" init --settings-only \
>"$WORK/idem1.log" 2>&1 || { cat "$WORK/idem1.log" >&2; fail "first --settings-only exited non-zero"; }
[ -f "$IDEM" ] || fail "--settings-only did not write $IDEM"
cp "$IDEM" "$WORK/idem-after1.json"
ok "first --settings-only merge exit 0"

FORGE_SETTINGS_PATH="$IDEM" HOME="$H1" "$NODE" "$REPO/src/cli.js" init --settings-only \
>"$WORK/idem2.log" 2>&1 || { cat "$WORK/idem2.log" >&2; fail "second --settings-only exited non-zero"; }
cmp -s "$WORK/idem-after1.json" "$IDEM" || fail "second --settings-only changed the file (not idempotent)"
ok "second --settings-only is byte-for-byte identical (idempotent)"

# ---------------------------------------------------------------------------
phase "3. corrupt settings.json fails init --settings-only (RA-04)"
CORRUPT="$WORK/corrupt-settings.json"
printf '{ this is not valid json ' >"$CORRUPT"
set +e
FORGE_SETTINGS_PATH="$CORRUPT" HOME="$H1" "$NODE" "$REPO/src/cli.js" init --settings-only \
>"$WORK/corrupt.log" 2>&1
code=$?
set -e
[ "$code" -ne 0 ] || fail "init --settings-only returned 0 on a corrupt settings file"
ok "corrupt settings.json makes init exit non-zero ($code)"

# ---------------------------------------------------------------------------
phase "4. uninstall removes hooks + symlinks"
HOME="$H1" bash "$REPO/install.sh" --uninstall >"$WORK/uninstall.log" 2>&1 \
|| { cat "$WORK/uninstall.log" >&2; fail "install.sh --uninstall exited non-zero"; }
ok "install.sh --uninstall exit 0"
! grep -q '"_forge"' "$SETTINGS" || fail "_forge marker still present after uninstall"
! grep -q 'guards/protect-paths.sh' "$SETTINGS" || fail "guard hooks still present after uninstall"
ok "Forge hooks removed from settings.json"
[ ! -e "$H1/.forge" ] || fail "\$HOME/.forge symlink survived uninstall"
[ ! -e "$H1/.local/bin/forge" ] || fail "forge launcher survived uninstall"
ok "asset symlinks removed"

# ---------------------------------------------------------------------------
phase "5. install under a path WITH A SPACE, then run a guard (RA-12 / ME-23)"
SPACE_HOME="$WORK/dir with space/home"
mkdir -p "$SPACE_HOME"
HOME="$SPACE_HOME" bash "$REPO/install.sh" >"$WORK/install-space.log" 2>&1 \
|| { cat "$WORK/install-space.log" >&2; fail "install.sh under a spaced path exited non-zero"; }
ok "install.sh succeeds under a path containing a space"

GUARD="$SPACE_HOME/.forge/guards/protect-paths.sh"
[ -x "$GUARD" ] || fail "guard not executable under spaced path: $GUARD"

# Feed a benign PreToolUse payload (a Read of a harmless file) into the installed guard, whose
# absolute path contains a space. Exit 0 proves the exec form + quoting handle the space.
BENIGN="$WORK/benign-hook.json"
printf '%s' '{"tool_name":"Read","tool_input":{"file_path":"/tmp/harmless.txt"}}' >"$BENIGN"
set +e
bash "$GUARD" <"$BENIGN" >"$WORK/guard.log" 2>&1
gcode=$?
set -e
[ "$gcode" -eq 0 ] || { cat "$WORK/guard.log" >&2; fail "guard under spaced path exited $gcode (expected 0)"; }
ok "installed guard runs from a spaced path and allows a benign call"

printf '\nAll %s smoke assertions passed.\n' "$PASS"
Loading