From 2d88e561ba44648b3cb11d717c1a289ab1e0430e Mon Sep 17 00:00:00 2001 From: Drew Arnold <173488786+DAESA24@users.noreply.github.com> Date: Tue, 14 Apr 2026 13:09:29 -0500 Subject: [PATCH 1/2] docs: migrate release-first pipeline plan to PR description Content from Plans/release-first-pipeline-2026-03-27.md is now the PR description for the install.sh guard draft PR (Part of #24). The plan file is removed since the PR is the new home for this content. Co-Authored-By: Claude Co-authored-by: Claude --- Plans/release-first-pipeline-2026-03-27.md | 137 --------------------- 1 file changed, 137 deletions(-) delete mode 100644 Plans/release-first-pipeline-2026-03-27.md diff --git a/Plans/release-first-pipeline-2026-03-27.md b/Plans/release-first-pipeline-2026-03-27.md deleted file mode 100644 index 0722a5b..0000000 --- a/Plans/release-first-pipeline-2026-03-27.md +++ /dev/null @@ -1,137 +0,0 @@ ---- -title: Release-First Pipeline Pattern -status: planned -created: 2026-03-27 -author: Daesa (PAI) -priority: high -scope: install.sh guard, release workflow artifact publishing, update mechanism -depends-on: feat/install-hardening PR merge -pattern-test: op-env (first project to test this pattern) -github-issue: https://github.com/DAESA24/op-env/issues/24 ---- - -# Release-First Pipeline Pattern - -## Why This Pattern - -On 2026-03-27, a broken symlink at `~/.local/bin/op-env` caused all Claude Code aliases to fail. Root cause: the production binary was a symlink pointing into the dev working tree, not a standalone copy from a release. Renaming the dev directory broke production. - -**Lesson:** Production binaries should never depend on the dev working tree. They should come exclusively from releases. - -**This pattern is being tested with op-env first.** If it works well, it can be applied to other projects in `~/projects/dev/dev-tools/`. - -## Current State (after install-hardening PR) - -- install.sh uses `cp` (correct) with symlink detection and readlink-safety -- 14 installation tests validate install.sh behavior -- CI covers install.sh via shellcheck and exfiltration scans -- But: install.sh can still be run from any commit, including dirty working trees - -## Release-First Pipeline Design - -### Component 1: install.sh Guard - -**What:** install.sh checks if it's running from a tagged release commit. If not, it warns and requires `--dev` flag to proceed. - -**Why:** Prevents accidental production installs from development state. The `--dev` flag is an explicit "I know what I'm doing" signal. - -**How:** -```bash -# After set -e, before any work -TAG=$(git -C "$SCRIPT_DIR" describe --exact-match HEAD 2>/dev/null || true) -if [ -z "$TAG" ] && [ "$1" != "--dev" ]; then - echo "WARNING: Not a tagged release. Use './install.sh --dev' to install from development." >&2 - echo "For production installs, download from GitHub releases." >&2 - exit 1 -fi -``` - -**Edge cases:** -- First-time users cloning from a tagged release: works (tag present) -- First-time users cloning main after a release: no tag on HEAD, must use `--dev` -- CI tests: already use temp dirs with HOME override, not install.sh directly from repo — unaffected -- test-install.sh: runs install.sh via `bash install.sh`, not `./install.sh` — needs `--dev` flag added to test helper - -### Component 2: Release Workflow Binary Publishing - -**What:** Enhance `release.yml` to attach a production-ready binary as a release asset after release-please creates the release. - -**Why:** Makes the release the single source of truth for production binaries. Users download from releases, not from git clone. - -**How:** Add a second job to `release.yml` that runs after `release-please`: -```yaml - publish-binary: - runs-on: ubuntu-latest - needs: release-please - if: ${{ needs.release-please.outputs.release_created }} - steps: - - uses: actions/checkout@v4 - - name: Build production binary - run: | - VERSION=$(cat version.txt) - cp bin/op-env op-env-v${VERSION} - chmod +x op-env-v${VERSION} - # Bake version - sed -i "s|cat \"\$SCRIPT_DIR/../version.txt\" 2>/dev/null|echo \"$VERSION\"|" op-env-v${VERSION} - # Verify bake - if grep -q 'cat "\$SCRIPT_DIR/../version.txt"' op-env-v${VERSION}; then - echo "ERROR: version baking failed" >&2 - exit 1 - fi - - name: Upload release asset - env: - GH_TOKEN: ${{ secrets.RELEASE_PLEASE_TOKEN }} - run: | - VERSION=$(cat version.txt) - gh release upload "v${VERSION}" "op-env-v${VERSION}" --clobber -``` - -**Key detail:** Reuses the same version-baking logic from install.sh. The bake verification check ensures we never publish a binary that still reads version.txt. - -### Component 3: Update Mechanism - -**What:** An `op-env --update` flag (or standalone `op-env-update` script) that downloads the latest release binary from GitHub. - -**Why:** Closes the loop — after a release is published, users can update with a single command instead of cloning and running install.sh. - -**How:** -```bash -# In bin/op-env, add --update flag handling: -if [ "$1" = "--update" ]; then - LATEST=$(gh release view --repo DAESA24/op-env --json tagName -q .tagName 2>/dev/null) - if [ -z "$LATEST" ]; then - echo "op-env: could not fetch latest release" >&2 - exit 1 - fi - ASSET="op-env-${LATEST}" - TMPFILE=$(mktemp) - gh release download "$LATEST" --repo DAESA24/op-env --pattern "$ASSET" --output "$TMPFILE" --clobber - chmod +x "$TMPFILE" - mv "$TMPFILE" "${HOME}/.local/bin/op-env" - echo "op-env: updated to $LATEST" - exit 0 -fi -``` - -**Alternative:** Standalone `update.sh` in the repo, keeping op-env itself minimal. This avoids adding `gh` as a runtime dependency of op-env. - -**Recommended:** Standalone `update.sh` — simpler, no new dependencies in the main binary. - -## Success Criteria for Pattern Reuse - -This pattern is ready to apply to other projects when: - -1. [ ] op-env has run through at least 2 release cycles with this pipeline -2. [ ] No manual install.sh runs were needed (all updates via release download) -3. [ ] The install guard has caught at least one accidental dev install -4. [ ] CI has caught at least one issue that local testing missed -5. [ ] The workflow is documented enough that Drew can explain it to another developer - -## Implementation Order - -1. install.sh guard (smallest change, highest value) -2. Release workflow binary publishing (enables the update mechanism) -3. Update mechanism (completes the loop) -4. Update `pai update` / `ccc` alias chain to use the new update mechanism - -Each step should be a separate PR with its own tests. From 064c3fcc95081ee6f207ba090aa9722b10242a7a Mon Sep 17 00:00:00 2001 From: Drew Arnold <173488786+DAESA24@users.noreply.github.com> Date: Tue, 14 Apr 2026 13:24:37 -0500 Subject: [PATCH 2/2] feat(install): add release guard requiring --dev for untagged commits install.sh now checks if HEAD is a tagged release commit. If not, it exits with a warning and requires --dev to proceed. This prevents accidental production installs from development state. - Guard uses git describe --exact-match with || true fallback - Warning includes commit hash and releases URL for guidance - --dev is consumed via shift (set -e safe) - Existing tests updated to pass --dev - 2 new guard-specific tests (reject without --dev, accept with --dev) Part of #24 Co-Authored-By: Claude Co-authored-by: Claude --- install.sh | 10 ++++++++++ test/test-install.sh | 29 +++++++++++++++++++++++++++-- 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/install.sh b/install.sh index 9e2daec..3f43c33 100755 --- a/install.sh +++ b/install.sh @@ -8,6 +8,16 @@ INSTALL_DIR="${HOME}/.local/bin" SCRIPT_REAL=$(readlink "$0" 2>/dev/null || echo "$0") SCRIPT_DIR="$(cd "$(dirname "$SCRIPT_REAL")" && pwd)" +# Release guard: prevent accidental installs from development state +TAG=$(git -C "$SCRIPT_DIR" describe --exact-match HEAD 2>/dev/null || true) +if [ -z "$TAG" ] && [ "$1" != "--dev" ]; then + echo "WARNING: Not a tagged release commit ($(git -C "$SCRIPT_DIR" rev-parse --short HEAD 2>/dev/null || echo 'unknown'))." >&2 + echo "Use './install.sh --dev' to install from development state." >&2 + echo "For production installs, download from: https://github.com/DAESA24/op-env/releases" >&2 + exit 1 +fi +if [ "${1:-}" = "--dev" ]; then shift; fi + VERSION=$(cat "$SCRIPT_DIR/version.txt" 2>/dev/null || echo "dev") mkdir -p "$INSTALL_DIR" diff --git a/test/test-install.sh b/test/test-install.sh index 4c01a9e..7c431b1 100644 --- a/test/test-install.sh +++ b/test/test-install.sh @@ -32,7 +32,7 @@ run_install() { HOME_BACKUP="$HOME" # Override HOME so install.sh writes to our temp dir export HOME="$install_dir" - (cd "$REPO_DIR" && bash install.sh 2>&1) + (cd "$REPO_DIR" && bash install.sh --dev 2>&1) local rc=$? export HOME="$HOME_BACKUP" return $rc @@ -121,7 +121,7 @@ rm -rf "$TMPDIR7" TMPDIR8=$(mktemp -d) HOME_BACKUP="$HOME" export HOME="$TMPDIR8" -(cd /tmp && bash "$INSTALLER" 2>&1) > /dev/null +(cd /tmp && bash "$INSTALLER" --dev 2>&1) > /dev/null rc=$? export HOME="$HOME_BACKUP" result="fail" @@ -184,5 +184,30 @@ echo "$last_line" | grep -q 'exec "\$@"' && result="yes" assert_eq "yes" "$result" "installed binary ends with exec (TTY preserved)" rm -rf "$TMPDIR13" +# ============================================================ +# Category 5: Guard Tests — Release Tag Check +# ============================================================ + +echo "--- Guard: Release tag check ---" + +# Test 14: install.sh rejects untagged commit without --dev +TMPDIR14=$(mktemp -d) +HOME_BACKUP="$HOME" +export HOME="$TMPDIR14" +output=$(cd "$REPO_DIR" && bash install.sh 2>&1) +rc=$? +export HOME="$HOME_BACKUP" +assert_eq "1" "$rc" "guard rejects untagged commit without --dev" +assert_contains "$output" "WARNING" "guard prints WARNING on rejection" +rm -rf "$TMPDIR14" + +# Test 15: install.sh accepts --dev flag on untagged commit +TMPDIR15=$(mktemp -d) +run_install "$TMPDIR15" > /dev/null +result="missing" +[ -f "$TMPDIR15/.local/bin/op-env" ] && result="installed" +assert_eq "installed" "$result" "guard accepts --dev flag on untagged commit" +rm -rf "$TMPDIR15" + # ============================================================ report