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
37 changes: 37 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -96,3 +96,40 @@ jobs:
fi
done
exit "$fail"

canary:
name: bstack canary suite (substrate invariants)
runs-on: ubuntu-latest
needs: [lint, doctor]
steps:
- uses: actions/checkout@v4

- name: Install jq + python deps for canary
run: |
set -euo pipefail
sudo apt-get update -qq && sudo apt-get install -y jq
python3 -m pip install --quiet jsonschema PyYAML

- name: Run canary suite (substrate invariants on fresh install)
# The canary suite verifies that the substrate's load-bearing
# contracts hold on a fresh install (no companion skills, no
# workspace state). Each canary covers one phase's deliverables:
# 01 — fresh bootstrap (Plant Contract)
# 02 — metrics pipeline (Phase 1 v0.4.0)
# 03 — status surface (Phase 2 v0.5.0)
# 04 — schemas validate (Phase 3 v0.6.0)
# canary/05+ (skills auto-install, gates audit, release pipeline)
# ship in v0.9.1+ as Phase 4-6 stabilize.
run: |
set -euo pipefail
shopt -s nullglob
fail=0
for t in tests/canary/*.test.sh; do
echo ""
echo "=== $t ==="
if ! bash "$t"; then
echo "::error file=$t::canary failed"
fail=1
fi
done
exit "$fail"
43 changes: 43 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -97,3 +97,46 @@ jobs:
--title "$tag — $title" \
--notes-file /tmp/release-notes.md
echo " ✓ released $tag"

# Package the skill directory as a tarball and publish it as a release
# asset, so vendored installs (no `.git`) can self-upgrade via
# `bstack upgrade --self` (≥ 0.9.0). The tarball excludes ephemeral
# state — only the in-repo skill source ships.
- name: Package + publish vendored upgrade tarball
if: steps.tag_check.outputs.exists == 'false'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
tag="${{ steps.version.outputs.tag }}"
tarball="bstack-${tag}.tar.gz"
# Build a clean staging tree — the extracted root must contain
# VERSION + bin/ + scripts/ + assets/ + references/ + schemas/ +
# SKILL.md + CHANGELOG.md, mirroring a fresh `npx skills add` layout.
staging="$(mktemp -d)"
dest="$staging/bstack-${tag}"
mkdir -p "$dest"
# Copy the canonical skill payload; exclude .git, .github, tests
# (downstream installs don't need CI workflow or development tests).
tar --exclude='./.git' \
--exclude='./.github' \
--exclude='./tests' \
--exclude='./broomva.tech-worktrees' \
--exclude='./bstack-worktrees' \
--exclude='./.cache' \
-cf - . | tar -xf - -C "$dest"
# Build the tarball in a deterministic order for reproducible
# sha256 across runs (though we don't fail-close on this yet).
( cd "$staging" && tar --sort=name -czf "$tarball" "bstack-${tag}" )
mv "$staging/$tarball" "./$tarball"
# sha256 sidecar — single canonical line, "<hash> <filename>" format.
if command -v sha256sum >/dev/null 2>&1; then
sha256sum "$tarball" > "${tarball}.sha256"
else
shasum -a 256 "$tarball" > "${tarball}.sha256"
fi
echo " packaged $tarball ($(wc -c < "$tarball") bytes)"
echo " sha256: $(awk '{print $1}' "${tarball}.sha256")"
# Upload as release assets.
gh release upload "$tag" "$tarball" "${tarball}.sha256" --clobber
echo " ✓ uploaded $tarball + sha256 to $tag"
42 changes: 42 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,47 @@
# Changelog

## 0.9.0 — 2026-05-18

### Vendored upgrade path + canary suite (Phase 6 of substrate completion)

Closes two v1.0 blockers from the substrate completion spec (§4.3.2, §4.6.2). Vendored installs (`npx skills add` produces these — no `.git`) can now self-upgrade via release tarball + sha256 verification + atomic swap. The canary suite verifies the substrate's load-bearing contracts hold on a fresh install — runs on every PR.

- **NEW** `bstack upgrade --self` for vendored installs (extends `bin/bstack` `bstack_upgrade_vendored`):
- Downloads `bstack-vX.Y.Z.tar.gz` from the GitHub Release
- Downloads matching `.sha256` sidecar
- **Mandatory** sha256 verification — no `--skip-sha256` flag; fail-closed on mismatch
- Atomic swap via `mv current → .bak`, `mv new → install`; rollback on swap failure
- `BSTACK_DRY_RUN=1` env override prints the plan without writing
- Falls back to manual `npx skills add` guidance if tarball missing (pre-v0.9.0 releases)
- Structured log at `~/.bstack/auto-upgrade.log`
- **CHANGED** `.github/workflows/release.yml` — new `Package + publish vendored upgrade tarball` step:
- Builds `bstack-vX.Y.Z.tar.gz` from the in-repo skill payload (excludes `.git`, `.github`, `tests`, worktree dirs)
- Uses `tar --sort=name` for byte-deterministic tarballs
- Computes sha256, uploads tarball + sha256 sidecar via `gh release upload --clobber`
- **NEW** `tests/canary/01-fresh-bootstrap.test.sh` — Plant Contract verification on a fresh workspace (10 assertions: bootstrap exits 0, governance files scaffold, hooks wired for SessionStart/Stop/PreToolUse, doctor produces expected summary, doctor exits 0 per HC-1)
- **NEW** `tests/canary/02-metrics-pipeline.test.sh` — Phase 1 (v0.4.0) end-to-end: collect produces valid JSON, latest.json written, observe single-setpoint returns id-matched output
- **NEW** `tests/canary/03-status-surface.test.sh` — Phase 2 (v0.5.0) end-to-end: 7 core sections render, --json shape valid, --setpoint deep-view, --aggregate Phase 8 placeholder
- **NEW** `tests/canary/04-schemas-validate.test.sh` — Phase 3 (v0.6.0) contracts: 4 schemas valid draft-07, primitives.yaml validates, companion-skills.yaml validates, policy.yaml.template validates (top-level shape + flat-schema parts)
- **CHANGED** `.github/workflows/ci.yml` — new `canary` job gated on `lint` + `doctor`; installs jq + jsonschema + PyYAML; runs `tests/canary/*.test.sh`

### SLO targets (introduced)

- `bstack upgrade --self` (vendored, cold network): p50 < 30s, p99 < 60s
- canary suite (4 tests, sequential): p99 < 30s

### Supply-chain safety

- sha256 verification mandatory — no bypass flag
- Atomic swap with `.bak` rollback on failure
- Tarball excludes ephemeral state and CI tooling — only the canonical skill payload ships
- Backup retained until swap completes successfully

### Out of scope for v0.9.0 (deferred to v0.9.1)

- Cosign signature verification — sha256 covers the principal integrity concern; cosign adds publisher identity verification
- `bstack reproduce` subcommand (drift detection vs fresh-install reference)
- Canary tests 05-08 (skills auto-install, gates audit, release pipeline E2E) — ship as Phase 4-6 deliverables stabilize

## 0.8.0 — 2026-05-18

### Doctor extensions + pre-existing test fixes (Phase 5 of substrate completion)
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.8.0
0.9.0
135 changes: 132 additions & 3 deletions bin/bstack
Original file line number Diff line number Diff line change
Expand Up @@ -84,9 +84,12 @@ bstack_upgrade() {
old="$(echo "$upd" | awk '{print $2}')"
new="$(echo "$upd" | awk '{print $3}')"
if [ ! -d "$BSTACK_DIR/.git" ]; then
echo "Vendored install — v$new available. Re-run:"
echo " npx skills add -g broomva/bstack"
return 0
# Vendored install (≥ 0.9.0): self-upgrade via release tarball +
# sha256 verification + atomic swap. Falls back to manual `npx
# skills add` guidance if the tarball is missing on the release
# (e.g. pre-0.9.0 releases without the tarball job).
bstack_upgrade_vendored "$old" "$new"
return $?
fi
echo "Upgrading bstack v$old → v$new..."
(
Expand All @@ -107,6 +110,132 @@ bstack_upgrade() {
esac
}

# `bstack_upgrade_vendored` — release-tarball-based upgrade for installs
# without `.git` (the vendored install path produced by `npx skills add`).
#
# Pipeline:
# 1. Download `bstack-v$NEW.tar.gz` from the GitHub Release.
# 2. Download the matching `.sha256` sidecar.
# 3. Verify the sha256 (mandatory — no skip flag, fail-closed).
# 4. Extract into a temp dir.
# 5. Atomic swap: move current install to `.bak`, move temp into place.
# 6. On any error after step 4, restore from `.bak`.
#
# Output:
# $HOME/.bstack/auto-upgrade.log structured per-attempt log
# $HOME/.bstack/just-upgraded-from set so the next session sees JUST_UPGRADED
#
# Env overrides (testability):
# BSTACK_RELEASE_TARBALL_URL override the GitHub Releases asset URL
# BSTACK_DRY_RUN=1 validate the plan + print, don't swap
bstack_upgrade_vendored() {
local old="$1" new="$2"
local tag="v$new"
local tarball="bstack-${tag}.tar.gz"
local sha_file="${tarball}.sha256"
local base_url="${BSTACK_RELEASE_TARBALL_URL:-https://github.com/broomva/bstack/releases/download/${tag}}"
local log="$HOME/.bstack/auto-upgrade.log"

mkdir -p "$HOME/.bstack"
{
echo "=== $(date -u +%FT%TZ) vendored upgrade v$old → v$new ==="
} >> "$log"

if ! command -v curl >/dev/null 2>&1; then
echo "Vendored upgrade requires curl. Falling back to manual: npx skills add -g broomva/bstack" >&2
echo " ERR: curl missing" >> "$log"
return 1
fi
if ! command -v shasum >/dev/null 2>&1 && ! command -v sha256sum >/dev/null 2>&1; then
echo "Vendored upgrade requires shasum or sha256sum for verification." >&2
echo " ERR: sha tool missing" >> "$log"
return 1
fi

local tmp; tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' EXIT

echo "Downloading $tarball..."
if ! curl -fsSL --max-time 120 -o "$tmp/$tarball" "$base_url/$tarball" 2>>"$log"; then
echo " Failed to download $base_url/$tarball" >&2
echo " Falling back to: npx skills add -g broomva/bstack" >&2
echo " ERR: tarball download failed" >> "$log"
return 1
fi
if ! curl -fsSL --max-time 30 -o "$tmp/$sha_file" "$base_url/$sha_file" 2>>"$log"; then
echo " Failed to download $base_url/$sha_file" >&2
echo " Falling back to: npx skills add -g broomva/bstack" >&2
echo " ERR: sha256 download failed" >> "$log"
return 1
fi

# Verify sha256
local expected actual
expected="$(awk '{print $1}' "$tmp/$sha_file" | tr -d '[:space:]')"
if command -v shasum >/dev/null 2>&1; then
actual="$(shasum -a 256 "$tmp/$tarball" | awk '{print $1}')"
else
actual="$(sha256sum "$tmp/$tarball" | awk '{print $1}')"
fi
if [ -z "$expected" ] || [ "$expected" != "$actual" ]; then
echo " sha256 mismatch — refusing to install unverified tarball" >&2
echo " expected: $expected" >&2
echo " actual: $actual" >&2
echo " ERR: sha256 mismatch" >> "$log"
return 1
fi
echo " sha256 verified."

if [ "${BSTACK_DRY_RUN:-}" = "1" ]; then
echo " [dry-run] would swap $BSTACK_DIR with extracted contents"
echo " [dry-run] sha256 verified at $tmp/$tarball"
return 0
fi

# Extract
if ! tar -xzf "$tmp/$tarball" -C "$tmp"; then
echo " Failed to extract tarball" >&2
echo " ERR: tar -xzf failed" >> "$log"
return 1
fi

# Find the extracted root — expect a single top-level dir
local extracted
extracted="$(find "$tmp" -maxdepth 2 -name VERSION -not -path "$tmp/VERSION" 2>/dev/null | head -1)"
if [ -z "$extracted" ]; then
echo " Extracted tarball has no VERSION file — refusing to swap" >&2
echo " ERR: extracted tree invalid" >> "$log"
return 1
fi
local new_root; new_root="$(dirname "$extracted")"

# Atomic swap: move current to .bak, move new into place. Restore on
# any failure between swap-out and swap-in.
local bak="${BSTACK_DIR}.bak.$$"
if ! mv "$BSTACK_DIR" "$bak"; then
echo " Failed to move current install aside" >&2
echo " ERR: mv current → bak failed" >> "$log"
return 1
fi
if ! mv "$new_root" "$BSTACK_DIR"; then
echo " Swap failed — restoring previous install from $bak" >&2
mv "$bak" "$BSTACK_DIR" 2>/dev/null || true
echo " ERR: mv new → install failed; restored" >> "$log"
return 1
fi
rm -rf "$bak"

# Make scripts executable (tar should preserve perms but be defensive)
chmod +x "$BSTACK_DIR/bin/"* "$BSTACK_DIR/scripts/"* 2>/dev/null || true

# Mark for next-session JUST_UPGRADED display
echo "$old" > "$HOME/.bstack/just-upgraded-from"
rm -f "$HOME/.bstack/last-update-check" "$HOME/.bstack/update-snoozed"

echo " ✓ upgraded v$old → v$new (vendored)" >> "$log"
echo "Done. Running v$new (vendored install)."
}

# `bstack release tag` — maintainer helper. Validates clean tree, reads
# VERSION, tags v$VERSION, pushes the tag, and creates a GitHub Release
# with the matching CHANGELOG section as body.
Expand Down
Loading
Loading