From 231f67f21e0627025499bf2e958301e7ec5e84cb Mon Sep 17 00:00:00 2001 From: Eric Wang Date: Wed, 5 Aug 2026 22:05:59 -0700 Subject: [PATCH] feat(build): versions-up pins what it built and PRs the bump MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - build cloak in versions-up; resolve cloakbrowser wrapper from npm - write versions.env from the exact versions built, not a re-fetch — a version published mid-build can no longer land in pins untested - new scripts/versions-pr.sh: commit in a temp worktree detached at origin/main, force-push chore/version-pins-refresh, gh pr create; user checkout stays untouched; PR=0 opts out - PR-stage failure warns and exits 0; pins stay written locally - write_version_pins moved to shared version-pins.sh - tests: cloak build args, pin write, outage no-write, PR soft-fail; hermetic versions-pr suite (real git against bare origin, fake gh) Close #538 Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 5 ++ DEV-LOGS.md | 5 ++ Makefile | 10 ++- scripts/update-version-pins.sh | 34 -------- scripts/version-pins.sh | 36 ++++++++ scripts/version-upgrade.sh | 89 +++++++++++++++++-- scripts/versions-pr.sh | 151 +++++++++++++++++++++++++++++++++ tests/version-upgrade.sh | 114 +++++++++++++++++++++++++ tests/versions-pr.sh | 133 +++++++++++++++++++++++++++++ 9 files changed, 535 insertions(+), 42 deletions(-) create mode 100755 scripts/versions-pr.sh create mode 100755 tests/versions-pr.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index 18235bf..0558a4f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Changed +- `make versions-up` is now the whole pin flow: builds core, main, + rust, and cloak at latest upstream, writes versions.env from the + exact versions built (no post-build re-fetch race), and opens the + pin PR on chore/version-pins-refresh; PR=0 keeps the bump local + (#538) - README core-value section rewritten around operator outcomes — full-speed YOLO, host out of vendor code's reach, explicit boundary, identity as a launch flag, official CLIs stock, zero workflow tax diff --git a/DEV-LOGS.md b/DEV-LOGS.md index d90710c..281ebbd 100644 --- a/DEV-LOGS.md +++ b/DEV-LOGS.md @@ -13,6 +13,11 @@ - Minimal markdown markers, no unnecessary formatting, minimal emojis. - Reference issue numbers in the format `#` for easy linking. +# [2026-08-05] Dev Log: versions-up pins what it built and PRs the bump #538 +- Why: team flow was `versions-up && versions-pin && build-cloak` plus a hand-written PR. versions-pin re-fetches upstream AFTER the build, so pins could drift to a version never build-tested (claude-code ships several times a day). Cloak wasn't covered by versions-up at all. +- What: version-upgrade.sh now builds cloak too (cloakbrowser wrapper resolved from npm, joins the manifest and --only), then writes versions.env from the exact resolved build args — write_version_pins moved to shared version-pins.sh, round-trip guard intact — and calls new scripts/versions-pr.sh: commit in a temp worktree detached at origin/main (push HEAD:chore/version-pins-refresh, user checkout untouched), gh pr create unless one is already open. PR=0 opts out; a failed PR stage warns and exits 0 — a dead push must not sink a finished build. Tests: cloak build args, pin write, outage no-write, PR soft-fail in tests/version-upgrade.sh; hermetic real-git-fake-gh suite in tests/versions-pr.sh. +- Result: one command replaces the 3-command dance and pins are guaranteed build-tested. versions-pin stays for pin-only refresh; build-cloak stays standalone. + # [2026-07-28] Dev Log: home dir chown race bricks containers #506 - Why: intermittent `env: 'claude': Permission denied` on fresh containers. /home/deva stuck at build UID 1001 mode 750 (noble HOME_MODE) after remap to host UID — user can't traverse its own home. usermod's implicit home-tree chown walks live host mounts (~/.claude churning under concurrent sessions), aborts mid-walk with rc=12 AFTER updating passwd; shadow chowns the top dir last, so it never gets fixed. The 7511464 whitelist chowns subdirs, never $DEVA_HOME itself. Latent since 5807889 dropped the recursive home chown; only bites when the walk races live mounts, which is why sibling containers were fine. - What: explicit non-recursive `chown "$DEVA_UID:$DEVA_GID" "$DEVA_HOME"` in setup_nonroot_user, after the usermod block, using the adapted DEVA_UID so the usermod-failed-entirely variant stays consistent. Devlog with full forensics in docs/devlog/20260728-home-dir-chown-race.org. Verified by fault injection: stub usermod (passwd updated, chown skipped, exit 12) reproduces the brick unpatched, comes out clean patched. diff --git a/Makefile b/Makefile index 5c9eef7..1126602 100644 --- a/Makefile +++ b/Makefile @@ -282,9 +282,12 @@ versions-up: BUILD_IMAGE=$(MAIN_IMAGE) \ CORE_IMAGE=$(CORE_IMAGE) \ RUST_IMAGE=$(RUST_IMAGE) \ + CLOAK_IMAGE=$(CLOAK_IMAGE) \ DOCKERFILE=$(DOCKERFILE) \ RUST_DOCKERFILE=$(RUST_DOCKERFILE) \ + CLOAK_DOCKERFILE=$(CLOAK_DOCKERFILE) \ ONLY=$(ONLY) \ + $(if $(PR),PR=$(PR)) \ $(VERSION_QUERY_OVERRIDES) \ ./scripts/version-upgrade.sh @@ -455,8 +458,10 @@ help: @echo " buildx-multi-rust Build multi-arch Rust and push" @echo " toolchains List pinned toolchains and managed build tools" @echo " versions Compare built vs latest versions with changelogs" - @echo " versions-up Build both images with latest upstream agent versions" + @echo " versions-up Build all images at latest upstream versions," + @echo " pin the built versions, and PR the bump" @echo " ONLY=cctrace upgrades one tool, rest stay pinned" + @echo " PR=0 skips the auto commit + pull request" @echo " versions-pin Refresh $(VERSION_PINS_FILE) from upstream" @echo " CHANGELOG=1 also shows changelogs for updated tools" @echo " scripts List repo helper scripts" @@ -514,5 +519,6 @@ help: @echo " make versions-pin CHANGELOG=1 # Refresh pins + show changelogs" @echo " make versions # Check current versions" @echo " make PLAYWRIGHT_VERSION=1.60.0 build-rust # Override rust browser tooling" - @echo " make versions-up # Upgrade to latest upstream versions" + @echo " make versions-up # Build latest, pin what was built, PR the bump" + @echo " make versions-up PR=0 # Same, but keep the pin bump local" @echo " make versions-up ONLY=cctrace # Upgrade just cctrace, rest pinned" diff --git a/scripts/update-version-pins.sh b/scripts/update-version-pins.sh index b525ea8..685df2a 100755 --- a/scripts/update-version-pins.sh +++ b/scripts/update-version-pins.sh @@ -184,40 +184,6 @@ show_changelogs() { fi } -# ── Write versions.env ─────────────────────────────────────────────────── - -write_version_pins() { - cat > "$VERSION_PINS_FILE" < "$VERSION_PINS_FILE" <&2 + cloak_wrapper_ver="$_pin_cloak_wrapper" + fi + fi + local _wrapper_stale=0 + [[ "$(normalize_version "$cloak_wrapper_ver")" != "$(normalize_version "$_pin_cloak_wrapper")" ]] && _wrapper_stale=1 + if print_version_summary; then - echo -e "${GREEN}All versions up-to-date. Nothing to upgrade.${RESET}" - exit 0 + if [[ $_wrapper_stale -eq 0 ]]; then + echo -e "${GREEN}All versions up-to-date. Nothing to upgrade.${RESET}" + exit 0 + fi + echo -e "${CYAN}Agent CLIs up-to-date; cloakbrowser wrapper moved ${_pin_cloak_wrapper} -> ${cloak_wrapper_ver}.${RESET}" fi # --only: gate on the selected tools, not the whole manifest — a lagging @@ -155,6 +184,10 @@ main() { if [[ -n $ONLY ]]; then local _t _cur _lat _only_needs_update=0 for _t in ${ONLY//,/ }; do + if [[ $_t == cloakbrowser ]]; then + [[ $_wrapper_stale -eq 1 ]] && _only_needs_update=1 + continue + fi _cur=$(normalize_version "$(get_current "$_t")") _lat=$(normalize_version "$(get_latest "$_t")") if [[ -z $_cur || $_cur == "-" || $_cur != "$_lat" ]]; then @@ -209,6 +242,7 @@ main() { "CCX|ccx_ver|_CLI_CCX|ccx" "Copilot API|copilot_ver|_CLI_COPILOT|copilot-api" "Playwright|playwright_ver|_CLI_PLAYWRIGHT|playwright" + "CloakBrowser|cloak_wrapper_ver|_CLI_CLOAKBROWSER|cloakbrowser" ) local _lines_upgrade=() _lines_pinned=() _lines_current=() _lines_new=() @@ -218,8 +252,15 @@ main() { IFS='|' read -r _label _var _cli_var _tool <<< "$_mp" local _val=${!_var:-} local _cli_val=${!_cli_var:-} - local _cur=$(get_current "$_tool") - local _type=$(get_tool_field "$_tool" type) + local _cur _type + if [[ $_tool == cloakbrowser ]]; then + # Not in the registry: current = the versions.env pin. + _cur="$_pin_cloak_wrapper" + _type="npm" + else + _cur=$(get_current "$_tool") + _type=$(get_tool_field "$_tool" type) + fi local _pad=$(printf "%-14s" "$_label") local _fmt_val _fmt_cur @@ -344,9 +385,45 @@ main() { --build-arg RUST_TARGETS="$RUST_TARGETS" \ -t "$RUST_IMAGE" . + echo "" + section "Building Cloak Image" + docker build -f "$CLOAK_DOCKERFILE" \ + ${PROXY_ARGS[@]+"${PROXY_ARGS[@]}"} \ + --build-arg BASE_IMAGE="$RUST_IMAGE" \ + --build-arg CLOAKBROWSER_WRAPPER_VERSION="$cloak_wrapper_ver" \ + -t "$CLOAK_IMAGE" . + echo "" echo -e "${GREEN}${BOLD}All images upgraded successfully${RESET}" echo -e "${DIM}Completed: $(date '+%Y-%m-%d %H:%M:%S')${RESET}" + + # Pin exactly what was built. A re-fetch here could pick up a version + # published mid-build and pin something never build-tested. + echo "" + section "Pinning versions.env" + CLAUDE_CODE_VERSION="$claude_ver" + CCTRACE_VERSION="$cctrace_ver" + CODEX_VERSION="$codex_ver" + GEMINI_CLI_VERSION="$gemini_ver" + GROK_CLI_VERSION="$grok_ver" + KIMI_CODE_VERSION="$kimi_ver" + CCX_VERSION="$ccx_ver" + COPILOT_API_VERSION="$copilot_ver" + PLAYWRIGHT_VERSION="$playwright_ver" + CLOAKBROWSER_WRAPPER_VERSION="$cloak_wrapper_ver" + write_version_pins + echo -e "${GREEN}Wrote ${VERSION_PINS_FILE##*/} from the built versions${RESET}" + + if [[ $PR == 0 ]]; then + echo -e "${DIM}PR=0: skipping pin commit + pull request${RESET}" + return 0 + fi + echo "" + section "Opening Pin PR" + if ! bash "$SCRIPT_DIR/versions-pr.sh"; then + echo -e "${YELLOW}PR creation failed; pins are written locally.${RESET}" >&2 + echo -e "${YELLOW}Retry with: ./scripts/versions-pr.sh${RESET}" >&2 + fi } main diff --git a/scripts/versions-pr.sh b/scripts/versions-pr.sh new file mode 100755 index 0000000..39038cd --- /dev/null +++ b/scripts/versions-pr.sh @@ -0,0 +1,151 @@ +#!/usr/bin/env bash +# versions-pr.sh - Commit the versions.env bump and open the pin PR +# +# Builds the commit in a temp worktree detached at REMOTE/BASE_BRANCH so +# the caller's checkout, branch, and staged files are never touched. The +# push targets refs/heads/PR_BRANCH directly from detached HEAD — no +# local branch is created, so repeat runs cannot collide with one. +# +# The bump branch is throwaway by contract: a force-push replacing a +# stale unmerged sweep is the intended behavior, not data loss. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck disable=SC1091 +source "$SCRIPT_DIR/version-pins.sh" +# shellcheck disable=SC1091 +source "$SCRIPT_DIR/release-utils.sh" + +REPO_ROOT=${REPO_ROOT:-$(cd "$SCRIPT_DIR/.." && pwd)} +REMOTE=${REMOTE:-origin} +BASE_BRANCH=${BASE_BRANCH:-main} +PR_BRANCH=${PR_BRANCH:-chore/version-pins-refresh} +PINS_NAME=versions.env + +usage() { + cat <<'EOF' +Usage: versions-pr.sh [-h|--help] + +Commit the local versions.env bump on a fresh branch off REMOTE/BASE_BRANCH +and open (or update) the pin pull request. No-op when the local pins +already match the remote base branch. + +Environment: + REMOTE Git remote to push to (default: origin) + BASE_BRANCH PR base branch (default: main) + PR_BRANCH Head branch, force-pushed (default: chore/version-pins-refresh) +EOF +} + +case "${1:-}" in + -h|--help) usage; exit 0 ;; + "") ;; + *) echo "error: unknown option: $1" >&2; usage >&2; exit 1 ;; +esac + +for cmd in git gh; do + command -v "$cmd" >/dev/null || { echo "error: $cmd not found" >&2; exit 1; } +done + +# Human-facing pin names for commit/PR bodies (grok, not GROK_CLI_VERSION). +pin_label() { + case $1 in + NODE_MAJOR) echo "node" ;; + GO_VERSION) echo "go" ;; + PYTHON_VERSION) echo "python" ;; + DELTA_VERSION) echo "delta" ;; + TMUX_VERSION) echo "tmux" ;; + TMUX_SHA256) echo "tmux-sha256" ;; + CLAUDE_CODE_VERSION) echo "claude-code" ;; + CCTRACE_VERSION) echo "cctrace" ;; + CODEX_VERSION) echo "codex" ;; + GEMINI_CLI_VERSION) echo "gemini-cli" ;; + GROK_CLI_VERSION) echo "grok" ;; + KIMI_CODE_VERSION) echo "kimi-code" ;; + CCX_VERSION) echo "ccx" ;; + COPILOT_API_VERSION) echo "copilot-api" ;; + PLAYWRIGHT_VERSION) echo "playwright" ;; + CLOAKBROWSER_WRAPPER_VERSION) echo "cloakbrowser" ;; + *) echo "$1" | tr '[:upper:]_' '[:lower:]-' ;; + esac +} + +# Commit hashes read better short; semvers pass through unchanged. +short_val() { + if [[ $1 =~ ^[0-9a-f]{40}$ ]]; then + echo "${1:0:7}" + else + echo "$1" + fi +} + +main() { + cd "$REPO_ROOT" + + git fetch --quiet "$REMOTE" "$BASE_BRANCH" + local base_ref="refs/remotes/$REMOTE/$BASE_BRANCH" + + if git diff --quiet "$base_ref" -- "$PINS_NAME"; then + echo -e "${GREEN}Pins already match ${REMOTE}/${BASE_BRANCH}. Nothing to PR.${RESET}" + return 0 + fi + + # Bump list: compare each pin var between the base branch and the + # working copy. Drives both the commit body and the PR body. + local old_pins bumps=() + old_pins=$(git show "$base_ref:$PINS_NAME") + local var old new + for var in "${VERSION_PIN_VARS[@]}"; do + old=$(sed -n "s/^$var=//p" <<< "$old_pins") + new=$(sed -n "s/^$var=//p" < "$PINS_NAME") + if [[ -n $new && $old != "$new" ]]; then + bumps+=("- $(pin_label "$var") $(short_val "${old:-none}") -> $(short_val "$new")") + fi + done + + if [[ ${#bumps[@]} -eq 0 ]]; then + # File differs but no pin moved: comments/layout drift. A pin PR + # for that would be noise — leave it to a deliberate commit. + echo -e "${YELLOW}versions.env differs from ${REMOTE}/${BASE_BRANCH} but no pin changed; skipping PR.${RESET}" + return 0 + fi + + local bump_list + bump_list=$(printf '%s\n' "${bumps[@]}") + echo -e "${CYAN}Pin bump vs ${REMOTE}/${BASE_BRANCH}:${RESET}" + echo "$bump_list" + + # Deliberately not local: the EXIT trap runs after main() returns, + # when locals are already gone. + tmp=$(mktemp -d) + wt="$tmp/wt" + cleanup() { + git worktree remove --force "$wt" 2>/dev/null || true + rm -rf "$tmp" + } + trap cleanup EXIT + + git worktree add --quiet --detach "$wt" "$base_ref" + cp "$PINS_NAME" "$wt/$PINS_NAME" + git -C "$wt" add "$PINS_NAME" + git -C "$wt" commit --quiet -m "$(printf 'chore(versions): refresh agent CLI pins\n\n%s' "$bump_list")" + git -C "$wt" push --quiet --force "$REMOTE" "HEAD:refs/heads/$PR_BRANCH" + echo -e "${GREEN}Pushed ${PR_BRANCH} to ${REMOTE}${RESET}" + + local pr_url + pr_url=$(gh pr list --head "$PR_BRANCH" --base "$BASE_BRANCH" --state open --json url --jq '.[0].url // empty') + if [[ -n $pr_url ]]; then + echo -e "${GREEN}Pin PR already open, branch updated: ${pr_url}${RESET}" + return 0 + fi + + pr_url=$(gh pr create \ + --base "$BASE_BRANCH" \ + --head "$PR_BRANCH" \ + --title "chore(versions): refresh agent CLI pins" \ + --body "$(printf 'Automated pin sweep from make versions-up. Images (core, main,\nrust, cloak) were built locally at these exact versions before\npinning:\n\n%s' "$bump_list")") + echo -e "${GREEN}Opened pin PR: ${pr_url}${RESET}" +} + +main diff --git a/tests/version-upgrade.sh b/tests/version-upgrade.sh index 9884212..72146b5 100644 --- a/tests/version-upgrade.sh +++ b/tests/version-upgrade.sh @@ -106,6 +106,7 @@ case "$url" in */-/package/@xai-official/grok/dist-tags) echo '{"latest":"0.2.93"}' ;; */-/package/@moonshot-ai/kimi-code/dist-tags) echo '{"latest":"0.28.0"}' ;; */-/package/playwright/dist-tags) echo '{"latest":"1.60.0"}' ;; +*/-/package/cloakbrowser/dist-tags) echo '{"latest":"0.6.0"}' ;; *registry.npmjs.org/@anthropic-ai%2fclaude-code) echo '{"time":{"2.1.87":"2026-03-29T01:40:00Z"}}' ;; *registry.npmjs.org/@thevibeworks%2fcctrace) echo '{"time":{"0.4.0":"2026-03-29T01:40:00Z"}}' ;; *registry.npmjs.org/@openai%2fcodex) echo '{"time":{"0.117.0":"2026-03-26T22:28:00Z"}}' ;; @@ -122,13 +123,23 @@ EOF chmod +x "$FAKE_BIN/docker" "$FAKE_BIN/npm" "$FAKE_BIN/gh" "$FAKE_BIN/curl" +# versions-up writes VERSION_PINS_FILE after a successful build — every +# invocation must point it at a scratch copy or the test clobbers the +# repo's real versions.env. PR=0 keeps the git/PR stage out of scope here +# (versions-pr.sh has its own hermetic test). +MAIN_PINS="$TMP_ROOT/pins-main.env" +cp "$REPO_ROOT/versions.env" "$MAIN_PINS" + PATH="$FAKE_BIN:$PATH" \ DOCKER_BUILD_LOG="$DOCKER_BUILD_LOG" \ +VERSION_PINS_FILE="$MAIN_PINS" \ +PR=0 \ AUTO_YES=1 \ CHECK_IMAGE="ghcr.io/thevibeworks/deva:rust" \ BUILD_IMAGE="ghcr.io/thevibeworks/deva:latest" \ CORE_IMAGE="ghcr.io/thevibeworks/deva:core" \ RUST_IMAGE="ghcr.io/thevibeworks/deva:rust" \ +CLOAK_IMAGE="ghcr.io/thevibeworks/deva:cloak" \ GO_VERSION="1.26.2" \ CCTRACE_VERSION="0.4.0" \ PLAYWRIGHT_VERSION="1.60.0" \ @@ -137,10 +148,12 @@ PLAYWRIGHT_VERSION="1.60.0" \ core_build="$(sed -n '1p' "$DOCKER_BUILD_LOG")" main_build="$(sed -n '2p' "$DOCKER_BUILD_LOG")" rust_build="$(sed -n '3p' "$DOCKER_BUILD_LOG")" +cloak_build="$(sed -n '4p' "$DOCKER_BUILD_LOG")" [[ -n "$core_build" ]] || { echo "missing core build invocation" >&2; exit 1; } [[ -n "$main_build" ]] || { echo "missing main build invocation" >&2; exit 1; } [[ -n "$rust_build" ]] || { echo "missing rust build invocation" >&2; exit 1; } +[[ -n "$cloak_build" ]] || { echo "missing cloak build invocation" >&2; exit 1; } for expected in \ "--target agent-base" \ @@ -188,10 +201,46 @@ do } done +for expected in \ + "--build-arg BASE_IMAGE=ghcr.io/thevibeworks/deva:rust" \ + "--build-arg CLOAKBROWSER_WRAPPER_VERSION=0.6.0" \ + "-t ghcr.io/thevibeworks/deva:cloak ." +do + [[ "$cloak_build" == *"$expected"* ]] || { + echo "cloak build missing expected arg: $expected" >&2 + exit 1 + } +done + +# ───── pins written from the BUILT versions, not a second fetch ───── +for expected in \ + "CLAUDE_CODE_VERSION=2.1.87" \ + "CCTRACE_VERSION=0.4.0" \ + "CODEX_VERSION=0.117.0" \ + "GEMINI_CLI_VERSION=0.35.3" \ + "GROK_CLI_VERSION=0.2.93" \ + "KIMI_CODE_VERSION=0.28.0" \ + "CCX_VERSION=v0.7.0" \ + "COPILOT_API_VERSION=0ea08febdd7e3e055b03dd298bf57e669500b5c1" \ + "PLAYWRIGHT_VERSION=1.60.0" \ + "CLOAKBROWSER_WRAPPER_VERSION=0.6.0" \ + "GO_VERSION=1.26.2" +do + grep -qx "$expected" "$MAIN_PINS" || { + echo "pin file missing built version: $expected" >&2 + cat "$MAIN_PINS" >&2 + exit 1 + } +done + # ───── proxied build: localhost rewrite + host-gateway + redacted logs ───── PROXY_BUILD_LOG="$TMP_ROOT/docker-build-proxy.log" +PROXY_PINS="$TMP_ROOT/pins-proxy.env" +cp "$REPO_ROOT/versions.env" "$PROXY_PINS" proxy_out="$(PATH="$FAKE_BIN:$PATH" \ DOCKER_BUILD_LOG="$PROXY_BUILD_LOG" \ +VERSION_PINS_FILE="$PROXY_PINS" \ +PR=0 \ AUTO_YES=1 \ HTTP_PROXY="http://user:secret@127.0.0.1:7890" \ HTTPS_PROXY="http://localhost:7890" \ @@ -199,6 +248,7 @@ CHECK_IMAGE="ghcr.io/thevibeworks/deva:rust" \ BUILD_IMAGE="ghcr.io/thevibeworks/deva:latest" \ CORE_IMAGE="ghcr.io/thevibeworks/deva:core" \ RUST_IMAGE="ghcr.io/thevibeworks/deva:rust" \ +CLOAK_IMAGE="ghcr.io/thevibeworks/deva:cloak" \ GO_VERSION="1.26.2" \ CCTRACE_VERSION="0.4.0" \ PLAYWRIGHT_VERSION="1.60.0" \ @@ -233,13 +283,18 @@ EOF chmod +x "$FAKE_BIN/curl" OUTAGE_BUILD_LOG="$TMP_ROOT/docker-build-outage.log" +OUTAGE_PINS="$TMP_ROOT/pins-outage.env" +cp "$REPO_ROOT/versions.env" "$OUTAGE_PINS" if ! outage_out="$(PATH="$FAKE_BIN:$PATH" \ DOCKER_BUILD_LOG="$OUTAGE_BUILD_LOG" \ +VERSION_PINS_FILE="$OUTAGE_PINS" \ +PR=0 \ AUTO_YES=1 \ CHECK_IMAGE="ghcr.io/thevibeworks/deva:rust" \ BUILD_IMAGE="ghcr.io/thevibeworks/deva:latest" \ CORE_IMAGE="ghcr.io/thevibeworks/deva:core" \ RUST_IMAGE="ghcr.io/thevibeworks/deva:rust" \ +CLOAK_IMAGE="ghcr.io/thevibeworks/deva:cloak" \ GO_VERSION="1.26.2" \ CCTRACE_VERSION="0.4.0" \ PLAYWRIGHT_VERSION="1.60.0" \ @@ -262,6 +317,10 @@ if [[ -s "$OUTAGE_BUILD_LOG" ]]; then echo "no builds should run during a registry outage" >&2 exit 1 fi +if ! diff -u "$REPO_ROOT/versions.env" "$OUTAGE_PINS" >/dev/null; then + echo "registry outage must not rewrite the pin file" >&2 + exit 1 +fi # ───── update-version-pins: write_version_pins round-trips versions.env ───── # The heredoc in write_version_pins is a second copy of the file layout: a @@ -287,3 +346,58 @@ if ! diff -u "$REPO_ROOT/versions.env" "$PINS_COPY"; then echo "write_version_pins does not round-trip versions.env: pins or comments lost" >&2 exit 1 fi + +# ───── PR stage soft-fails: a dead git must not sink a finished build ───── +# Working curl again, but git still exits 1 (from the round-trip block): +# versions-pr.sh dies on fetch, version-upgrade.sh must warn and exit 0. +cat >"$FAKE_BIN/curl" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +url="${!#}" +case "$url" in +*/-/package/@anthropic-ai/claude-code/dist-tags) echo '{"latest":"2.1.87"}' ;; +*/-/package/@thevibeworks/cctrace/dist-tags) echo '{"latest":"0.4.0"}' ;; +*/-/package/@openai/codex/dist-tags) echo '{"latest":"0.117.0"}' ;; +*/-/package/@google/gemini-cli/dist-tags) echo '{"latest":"0.35.3"}' ;; +*/-/package/@xai-official/grok/dist-tags) echo '{"latest":"0.2.93"}' ;; +*/-/package/@moonshot-ai/kimi-code/dist-tags) echo '{"latest":"0.28.0"}' ;; +*/-/package/playwright/dist-tags) echo '{"latest":"1.60.0"}' ;; +*/-/package/cloakbrowser/dist-tags) echo '{"latest":"0.6.0"}' ;; +*registry.npmjs.org/*) echo '{"time":{}}' ;; +*) + echo "unexpected curl url: $url" >&2 + exit 1 + ;; +esac +EOF +chmod +x "$FAKE_BIN/curl" + +PRFAIL_BUILD_LOG="$TMP_ROOT/docker-build-prfail.log" +PRFAIL_PINS="$TMP_ROOT/pins-prfail.env" +cp "$REPO_ROOT/versions.env" "$PRFAIL_PINS" +if ! prfail_out="$(PATH="$FAKE_BIN:$PATH" \ +DOCKER_BUILD_LOG="$PRFAIL_BUILD_LOG" \ +VERSION_PINS_FILE="$PRFAIL_PINS" \ +AUTO_YES=1 \ +CHECK_IMAGE="ghcr.io/thevibeworks/deva:rust" \ +BUILD_IMAGE="ghcr.io/thevibeworks/deva:latest" \ +CORE_IMAGE="ghcr.io/thevibeworks/deva:core" \ +RUST_IMAGE="ghcr.io/thevibeworks/deva:rust" \ +CLOAK_IMAGE="ghcr.io/thevibeworks/deva:cloak" \ +GO_VERSION="1.26.2" \ +CCTRACE_VERSION="0.4.0" \ +PLAYWRIGHT_VERSION="1.60.0" \ +"$REPO_ROOT/scripts/version-upgrade.sh" 2>&1)"; then + echo "a failed PR stage must not fail versions-up after a good build" >&2 + echo "$prfail_out" >&2 + exit 1 +fi +if ! grep -F -- "PR creation failed" <<<"$prfail_out" >/dev/null; then + echo "expected PR soft-fail warning in output" >&2 + echo "$prfail_out" >&2 + exit 1 +fi +grep -qx "CLAUDE_CODE_VERSION=2.1.87" "$PRFAIL_PINS" || { + echo "pins must still be written when the PR stage fails" >&2 + exit 1 +} diff --git a/tests/versions-pr.sh b/tests/versions-pr.sh new file mode 100755 index 0000000..01eee0b --- /dev/null +++ b/tests/versions-pr.sh @@ -0,0 +1,133 @@ +#!/usr/bin/env bash +# Hermetic test for scripts/versions-pr.sh: real git against a scratch +# bare origin, fake gh. The user checkout (branch, dirty files) must +# come out untouched — the commit happens in a throwaway worktree. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +TMP_ROOT="$(mktemp -d)" +FAKE_BIN="$TMP_ROOT/bin" +GH_LOG="$TMP_ROOT/gh.log" +mkdir -p "$FAKE_BIN" + +cleanup() { + rm -rf "$TMP_ROOT" +} +trap cleanup EXIT + +fail() { + echo "$1" >&2 + exit 1 +} + +# GH_PR_LIST_URL switches the open-PR lookup between "none" and "exists". +cat >"$FAKE_BIN/gh" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +printf '%s\n' "$*" >>"$GH_LOG" +case "${1:-} ${2:-}" in +"pr list") echo "${GH_PR_LIST_URL:-}" ;; +"pr create") echo "https://github.com/thevibeworks/deva/pull/999" ;; +*) + echo "unexpected gh invocation: $*" >&2 + exit 1 + ;; +esac +EOF +chmod +x "$FAKE_BIN/gh" + +# ───── scratch origin: bare repo seeded with the real versions.env ───── +ORIGIN="$TMP_ROOT/origin.git" +SEED="$TMP_ROOT/seed" +git init --quiet --bare --initial-branch=main "$ORIGIN" +git init --quiet --initial-branch=main "$SEED" +git -C "$SEED" config user.name test +git -C "$SEED" config user.email test@test +cp "$REPO_ROOT/versions.env" "$SEED/versions.env" +git -C "$SEED" add versions.env +git -C "$SEED" commit --quiet -m "seed" +git -C "$SEED" remote add origin "$ORIGIN" +git -C "$SEED" push --quiet origin main + +# ───── user checkout: on a WIP branch with dirty state ───── +CLONE="$TMP_ROOT/clone" +git clone --quiet "$ORIGIN" "$CLONE" +git -C "$CLONE" config user.name test +git -C "$CLONE" config user.email test@test +git -C "$CLONE" switch --quiet -c my-wip +echo junk >"$CLONE/wip.txt" + +PR_SCRIPT="$REPO_ROOT/scripts/versions-pr.sh" + +run_pr() { + PATH="$FAKE_BIN:$PATH" \ + GH_LOG="$GH_LOG" \ + GH_PR_LIST_URL="${GH_PR_LIST_URL:-}" \ + REPO_ROOT="$CLONE" \ + bash "$PR_SCRIPT" 2>&1 +} + +# ───── 1. pins match upstream: no-op, no branch pushed ───── +out="$(run_pr)" || fail "no-op run must exit 0: $out" +grep -F -- "Pins already match" <<<"$out" >/dev/null || fail "expected no-op message, got: $out" +if git -C "$ORIGIN" show-ref --verify --quiet refs/heads/chore/version-pins-refresh; then + fail "no-op run must not push a branch" +fi + +# ───── 2. comment-only drift: skip, no branch pushed ───── +echo "# trailing comment for drift test" >>"$CLONE/versions.env" +out="$(run_pr)" || fail "comment-drift run must exit 0: $out" +grep -F -- "no pin changed; skipping PR" <<<"$out" >/dev/null || fail "expected comment-drift skip, got: $out" +if git -C "$ORIGIN" show-ref --verify --quiet refs/heads/chore/version-pins-refresh; then + fail "comment-only drift must not push a branch" +fi +git -C "$CLONE" checkout --quiet versions.env + +# ───── 3. real bump: branch pushed, commit body lists bumps, PR created ───── +sed -i \ + -e 's/^CLAUDE_CODE_VERSION=.*/CLAUDE_CODE_VERSION=9.9.9/' \ + -e 's/^COPILOT_API_VERSION=.*/COPILOT_API_VERSION=deadbeefdeadbeefdeadbeefdeadbeefdeadbeef/' \ + "$CLONE/versions.env" + +out="$(run_pr)" || fail "bump run failed: $out" +grep -F -- "Opened pin PR" <<<"$out" >/dev/null || fail "expected PR-created message, got: $out" + +git -C "$ORIGIN" show-ref --verify --quiet refs/heads/chore/version-pins-refresh || \ + fail "bump branch missing on origin" + +pushed_pins="$(git -C "$ORIGIN" show refs/heads/chore/version-pins-refresh:versions.env)" +grep -qx "CLAUDE_CODE_VERSION=9.9.9" <<<"$pushed_pins" || fail "pushed pins missing bumped claude-code" + +commit_msg="$(git -C "$ORIGIN" log -1 --format=%B refs/heads/chore/version-pins-refresh)" +grep -qx "chore(versions): refresh agent CLI pins" <<<"$(head -1 <<<"$commit_msg")" || \ + fail "wrong commit subject: $commit_msg" +grep -F -- "claude-code" <<<"$commit_msg" | grep -F -- "-> 9.9.9" >/dev/null || \ + fail "commit body missing claude-code bump: $commit_msg" +# 40-hex values are shortened for humans +grep -F -- "copilot-api" <<<"$commit_msg" | grep -F -- "-> deadbee" >/dev/null || \ + fail "commit body missing shortened copilot hash: $commit_msg" +if grep -F -- "deadbeefdeadbeef" <<<"$commit_msg" >/dev/null; then + fail "commit body must not carry full 40-char hashes" +fi + +grep -E -- "^pr create .*--base main .*--head chore/version-pins-refresh" "$GH_LOG" >/dev/null || \ + fail "gh pr create missing or malformed: $(cat "$GH_LOG")" + +# ───── 4. rerun with the PR already open: update branch, no second create ───── +: >"$GH_LOG" +out="$(GH_PR_LIST_URL="https://github.com/thevibeworks/deva/pull/999" run_pr)" || \ + fail "rerun with open PR failed: $out" +grep -F -- "already open" <<<"$out" >/dev/null || fail "expected already-open message, got: $out" +if grep -F -- "pr create" "$GH_LOG" >/dev/null; then + fail "must not open a second PR when one is already open" +fi + +# ───── user checkout untouched throughout ───── +[[ "$(git -C "$CLONE" branch --show-current)" == "my-wip" ]] || fail "user branch changed" +[[ -f "$CLONE/wip.txt" ]] || fail "user dirty file lost" +git -C "$CLONE" diff --quiet -- versions.env && fail "user versions.env edit lost" +[[ "$(git -C "$CLONE" worktree list | wc -l)" -eq 1 ]] || fail "leaked worktree in user checkout" + +echo "versions-pr tests passed"