Compare commits

...

1 Commits

Author SHA1 Message Date
Cursor Agent
9251fc5c0a fix(ops): keep git reset output off deploy SHA capture
git reset --hard prints 'HEAD is now at …' to stdout, which polluted
read -r previous_sha target_sha < <(git_fetch_and_checkout) and wrote
invalid values into .evo-deploy/state.json. Redirect git stdout to
stderr inside git_fetch_and_checkout and rollback; validate SHAs before
write_state and rollback.

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
2026-09-24 04:41:42 +00:00
3 changed files with 58 additions and 3 deletions

View File

@ -53,6 +53,12 @@ GitHub: **Actions → evo-deploy → Run workflow** (optional `ref`, `dry_run`,
## Rollback behavior ## Rollback behavior
### Regression: SHA capture vs. git stdout
`deploy.sh` captures `previousSha` and `targetSha` via process substitution (`read … < <(git_fetch_and_checkout)`). Git commands such as `reset --hard` print progress to **stdout** (`HEAD is now at …`), which previously polluted that capture and produced invalid `.evo-deploy/state.json` (e.g. `deployedSha: "is now at 84bc45e …"`, `previousSha: "HEAD"`). Rollback then became a no-op.
**Fix:** all git invocations inside `git_fetch_and_checkout` (and rollback) redirect stdout to stderr; only the final `printf '%s %s\n'` emits SHAs on stdout. `write_state` and rollback reject values that are not 7–40 hex characters.
1. Before `git fetch`, the script records `previousSha` (current `HEAD`). 1. Before `git fetch`, the script records `previousSha` (current `HEAD`).
2. After pull, migrate, and restart, it waits up to `EVO_HEALTH_TIMEOUT_SEC` (default 120s) for: 2. After pull, migrate, and restart, it waits up to `EVO_HEALTH_TIMEOUT_SEC` (default 120s) for:
- `EVO_API_HEALTH_URL` (default `http://127.0.0.1:3100/health`) - `EVO_API_HEALTH_URL` (default `http://127.0.0.1:3100/health`)

View File

@ -0,0 +1,40 @@
#!/usr/bin/env bash
# Regression: git_fetch_and_checkout must emit only two SHAs on stdout.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
TMP="$(mktemp -d)"
trap 'rm -rf "$TMP"' EXIT
git init -q "$TMP/repo"
git -C "$TMP/repo" config user.email "test@example.com"
git -C "$TMP/repo" config user.name "Test"
echo first >"$TMP/repo/file.txt"
git -C "$TMP/repo" add file.txt
git -C "$TMP/repo" commit -q -m "first"
FIRST_SHA="$(git -C "$TMP/repo" rev-parse HEAD)"
echo second >"$TMP/repo/file.txt"
git -C "$TMP/repo" commit -q -am "second"
SECOND_SHA="$(git -C "$TMP/repo" rev-parse HEAD)"
# Minimal reimplementation of the capture contract under test.
git_fetch_and_checkout() {
local previous_sha target_sha deploy_dir="$1" ref="$2"
previous_sha="$(git -C "$deploy_dir" rev-parse HEAD)"
git -C "$deploy_dir" fetch origin --prune >&2 2>/dev/null || true
target_sha="$(git -C "$deploy_dir" rev-parse "${ref}^{commit}")"
git -C "$deploy_dir" reset --hard "$target_sha" >&2
printf '%s %s\n' "$previous_sha" "$target_sha"
}
git -C "$TMP/repo" remote add origin "$TMP/repo"
git -C "$TMP/repo" checkout -q "$FIRST_SHA"
capture="$(git_fetch_and_checkout "$TMP/repo" "$SECOND_SHA")"
read -r got_previous got_target <<<"$capture"
[[ "$got_previous" == "$FIRST_SHA" ]] || { echo "expected previous $FIRST_SHA, got $got_previous"; exit 1; }
[[ "$got_target" == "$SECOND_SHA" ]] || { echo "expected target $SECOND_SHA, got $got_target"; exit 1; }
[[ "$capture" != *"HEAD is now at"* ]] || { echo "git reset output leaked into capture: $capture"; exit 1; }
echo "ok: deploy-sha-capture regression passed"

View File

@ -31,6 +31,10 @@ DRY_RUN=false
log() { printf '[evo-deploy] %s\n' "$*" >&2; } log() { printf '[evo-deploy] %s\n' "$*" >&2; }
die() { log "ERROR: $*"; exit 1; } die() { log "ERROR: $*"; exit 1; }
is_valid_git_sha() {
[[ "$1" =~ ^[0-9a-fA-F]{7,40}$ ]]
}
usage() { usage() {
cat <<'EOF' cat <<'EOF'
Usage: deploy.sh [--dry-run] [--ref REF] [--skip-migrate] [--skip-install] Usage: deploy.sh [--dry-run] [--ref REF] [--skip-migrate] [--skip-install]
@ -214,6 +218,8 @@ health_check() {
write_state() { write_state() {
local previous_sha="$1" target_sha="$2" status="$3" local previous_sha="$1" target_sha="$2" status="$3"
is_valid_git_sha "$previous_sha" || die "refusing to write state: invalid previousSha: $previous_sha"
is_valid_git_sha "$target_sha" || die "refusing to write state: invalid deployedSha: $target_sha"
if [[ "$DRY_RUN" == true ]]; then if [[ "$DRY_RUN" == true ]]; then
log "[dry-run] would write state to $EVO_STATE_FILE" log "[dry-run] would write state to $EVO_STATE_FILE"
return 0 return 0
@ -235,7 +241,7 @@ git_fetch_and_checkout() {
previous_sha="$(git -C "$EVO_DEPLOY_DIR" rev-parse HEAD)" previous_sha="$(git -C "$EVO_DEPLOY_DIR" rev-parse HEAD)"
log "previous HEAD: $previous_sha" log "previous HEAD: $previous_sha"
log "fetching $EVO_GIT_REMOTE…" log "fetching $EVO_GIT_REMOTE…"
run git -C "$EVO_DEPLOY_DIR" fetch "$EVO_GIT_REMOTE" --prune run git -C "$EVO_DEPLOY_DIR" fetch "$EVO_GIT_REMOTE" --prune >&2
if git -C "$EVO_DEPLOY_DIR" rev-parse --verify "${EVO_DEPLOY_REF}^{commit}" >/dev/null 2>&1; then if git -C "$EVO_DEPLOY_DIR" rev-parse --verify "${EVO_DEPLOY_REF}^{commit}" >/dev/null 2>&1; then
target_sha="$(git -C "$EVO_DEPLOY_DIR" rev-parse "${EVO_DEPLOY_REF}^{commit}")" target_sha="$(git -C "$EVO_DEPLOY_DIR" rev-parse "${EVO_DEPLOY_REF}^{commit}")"
elif git -C "$EVO_DEPLOY_DIR" rev-parse --verify "${EVO_GIT_REMOTE}/${EVO_DEPLOY_REF}^{commit}" >/dev/null 2>&1; then elif git -C "$EVO_DEPLOY_DIR" rev-parse --verify "${EVO_GIT_REMOTE}/${EVO_DEPLOY_REF}^{commit}" >/dev/null 2>&1; then
@ -244,7 +250,7 @@ git_fetch_and_checkout() {
die "cannot resolve ref: $EVO_DEPLOY_REF" die "cannot resolve ref: $EVO_DEPLOY_REF"
fi fi
log "checking out $target_sha ($EVO_DEPLOY_REF)" log "checking out $target_sha ($EVO_DEPLOY_REF)"
run git -C "$EVO_DEPLOY_DIR" reset --hard "$target_sha" run git -C "$EVO_DEPLOY_DIR" reset --hard "$target_sha" >&2
printf '%s %s\n' "$previous_sha" "$target_sha" printf '%s %s\n' "$previous_sha" "$target_sha"
} }
@ -269,8 +275,9 @@ run_install() {
rollback() { rollback() {
local previous_sha="$1" local previous_sha="$1"
is_valid_git_sha "$previous_sha" || die "rollback aborted: invalid previous_sha: $previous_sha"
log "ROLLBACK: resetting to $previous_sha and restarting services" log "ROLLBACK: resetting to $previous_sha and restarting services"
run git -C "$EVO_DEPLOY_DIR" reset --hard "$previous_sha" run git -C "$EVO_DEPLOY_DIR" reset --hard "$previous_sha" >&2
restart_services restart_services
write_state "$previous_sha" "$previous_sha" "rolled-back" write_state "$previous_sha" "$previous_sha" "rolled-back"
die "deploy failed; rolled back to $previous_sha" die "deploy failed; rolled back to $previous_sha"
@ -289,6 +296,8 @@ main() {
local previous_sha target_sha local previous_sha target_sha
read -r previous_sha target_sha < <(git_fetch_and_checkout) read -r previous_sha target_sha < <(git_fetch_and_checkout)
is_valid_git_sha "$previous_sha" || die "invalid previous_sha captured from checkout: $previous_sha"
is_valid_git_sha "$target_sha" || die "invalid target_sha captured from checkout: $target_sha"
if [[ "$previous_sha" == "$target_sha" && "$DRY_RUN" != true ]]; then if [[ "$previous_sha" == "$target_sha" && "$DRY_RUN" != true ]]; then
log "already at $target_sha — continuing with migrate/restart/health" log "already at $target_sha — continuing with migrate/restart/health"