diff --git a/docs/operations.md b/docs/operations.md index 5c664eb..6258b50 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -1352,6 +1352,25 @@ backup destination remains an operational requirement. ## Rollback boundary The release image is immutable and migrations run as a separate one-shot role. + +If the main workspace contains intentional tracked edits that must not enter +the release (for example local agent instructions), run the same workflow from +an exact clean detached worktree instead of stashing, discarding, or silently +including those edits: + +```bash +./scripts/production-release-clean.sh plan whoneedhelp + +WNH_PRODUCTION_RELEASE_CONFIRM='whoneedhelp.com:EXACT_COMMIT' \ + ./scripts/production-release-clean.sh apply whoneedhelp +``` + +The wrapper selects the current `HEAD`, creates a task-owned detached worktree, +runs `production-release.sh` there with the original immutable artifact root, +and removes the worktree on success, failure, or interrupt. The underlying +release still requires the exact production confirmation and any applicable +forward-only migration confirmation. It does not stash, reset, stage, commit, +or copy changes from the main workspace. Before a schema rollout, create and restore-test a current backup. Application rollback and database migration rollback are separate decisions: do not run an Ecto down migration merely because an image is rolled back. Inspect the exact diff --git a/scripts/production-release-clean.sh b/scripts/production-release-clean.sh new file mode 100755 index 0000000..9cfacb2 --- /dev/null +++ b/scripts/production-release-clean.sh @@ -0,0 +1,88 @@ +#!/usr/bin/env bash +set -euo pipefail +umask 077 + +ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd) +action=${1:-plan} +ssh_target=${2:-whoneedhelp} + +case "$action" in + plan | apply) ;; + *) + echo "Usage: $0 [plan|apply] [SSH_TARGET]" >&2 + exit 2 + ;; +esac + +for command in git mktemp realpath; do + command -v "$command" >/dev/null 2>&1 || { + echo "Required command is unavailable: $command" >&2 + exit 2 + } +done + +candidate=$(git -C "$ROOT" rev-parse --verify HEAD) +artifact_root=${WNH_PRODUCTION_RELEASE_ARTIFACT_ROOT:-"$ROOT/output/releases"} +case "$artifact_root" in + /*) ;; + *) + echo "WNH_PRODUCTION_RELEASE_ARTIFACT_ROOT must be an absolute path." >&2 + exit 2 + ;; +esac +mkdir -p "$artifact_root" +artifact_root=$(realpath --canonicalize-existing "$artifact_root") + +worktree_root=${WNH_PRODUCTION_RELEASE_WORKTREE_ROOT:-"$ROOT/output/release-worktrees"} +case "$worktree_root" in + /*) ;; + *) + echo "WNH_PRODUCTION_RELEASE_WORKTREE_ROOT must be an absolute path." >&2 + exit 2 + ;; +esac +mkdir -p "$worktree_root" +worktree_root=$(realpath --canonicalize-existing "$worktree_root") +chmod 700 "$worktree_root" + +checkout=$(mktemp -d "$worktree_root/${candidate}.XXXXXX") +rmdir "$checkout" +worktree_added=false + +cleanup() { + local status=$? + trap - EXIT HUP INT TERM + if [[ "$worktree_added" == true ]]; then + git -C "$ROOT" worktree remove --force "$checkout" >/dev/null 2>&1 || true + fi + if [[ -d "$checkout" ]]; then + rmdir "$checkout" >/dev/null 2>&1 || true + fi + exit "$status" +} +trap cleanup EXIT +trap 'exit 129' HUP +trap 'exit 130' INT +trap 'exit 143' TERM + +worktree_added=true +git -C "$ROOT" worktree add --quiet --detach "$checkout" "$candidate" + +[[ -z "$(git -C "$checkout" status --porcelain --untracked-files=no)" ]] || { + echo "The detached release checkout is unexpectedly dirty." >&2 + exit 2 +} +[[ "$(git -C "$checkout" rev-parse --verify HEAD)" == "$candidate" ]] || { + echo "The detached release checkout does not match the selected commit." >&2 + exit 2 +} + +printf 'Release candidate: %s\n' "$candidate" +printf 'Clean detached checkout: %s\n' "$checkout" +printf 'Artifact root: %s\n' "$artifact_root" + +( + cd "$checkout" + WNH_PRODUCTION_RELEASE_ARTIFACT_ROOT="$artifact_root" \ + ./scripts/production-release.sh "$action" "$ssh_target" +) diff --git a/scripts/quality.sh b/scripts/quality.sh index 225e44a..12c3311 100755 --- a/scripts/quality.sh +++ b/scripts/quality.sh @@ -1557,6 +1557,7 @@ docker run --rm \ --workdir /src \ "$PYTHON_IMAGE" python test/scripts/install_production_external_monitor_test.py python3 test/scripts/production_release_artifact_root_test.py +python3 test/scripts/production_release_clean_test.py docker run --rm \ --volume "$ROOT/ops/external-boundaries/mock_server.py:/src/mock_server.py:ro" \ "$PYTHON_IMAGE" python -c \ diff --git a/test/scripts/production_release_clean_test.py b/test/scripts/production_release_clean_test.py new file mode 100644 index 0000000..2f6b34e --- /dev/null +++ b/test/scripts/production_release_clean_test.py @@ -0,0 +1,124 @@ +import os +import shutil +import subprocess +import tempfile +import textwrap +import unittest +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[2] +WRAPPER = ROOT / "scripts" / "production-release-clean.sh" + + +class ProductionReleaseCleanTest(unittest.TestCase): + def setUp(self): + self.tempdir = tempfile.TemporaryDirectory() + self.base = Path(self.tempdir.name) + self.project = self.base / "project" + self.scripts = self.project / "scripts" + self.scripts.mkdir(parents=True) + shutil.copy2(WRAPPER, self.scripts / WRAPPER.name) + (self.scripts / WRAPPER.name).chmod(0o755) + self.capture = self.base / "capture.txt" + self.artifacts = self.base / "artifacts" + self.worktrees = self.base / "worktrees" + + self.write_inner_script(exit_code=0) + (self.project / "README.md").write_text("committed\n", encoding="utf-8") + self.run_command(["git", "init", "--quiet"]) + self.run_command( + ["git", "config", "user.email", "release-test@example.invalid"] + ) + self.run_command(["git", "config", "user.name", "Release test"]) + self.run_command(["git", "add", "."]) + self.run_command(["git", "commit", "--quiet", "-m", "fixture"]) + self.commit = self.run_command(["git", "rev-parse", "HEAD"]).stdout.strip() + + def tearDown(self): + self.tempdir.cleanup() + + def run_command(self, command, *, check=True, env=None): + return subprocess.run( + command, + cwd=self.project, + env=env, + capture_output=True, + text=True, + check=check, + ) + + def write_inner_script(self, *, exit_code): + inner = self.scripts / "production-release.sh" + inner.write_text( + textwrap.dedent( + f"""\ + #!/usr/bin/env bash + set -euo pipefail + printf '%s\\n' \\ + "pwd=$PWD" \\ + "status=$(git status --porcelain --untracked-files=no)" \\ + "args=$*" \\ + "commit=$(git rev-parse HEAD)" \\ + "artifact_root=${{WNH_PRODUCTION_RELEASE_ARTIFACT_ROOT}}" \\ + >"${{WNH_TEST_CAPTURE}}" + exit {exit_code} + """ + ), + encoding="utf-8", + ) + inner.chmod(0o755) + + def environment(self): + env = os.environ.copy() + env.update( + { + "WNH_TEST_CAPTURE": str(self.capture), + "WNH_PRODUCTION_RELEASE_ARTIFACT_ROOT": str(self.artifacts), + "WNH_PRODUCTION_RELEASE_WORKTREE_ROOT": str(self.worktrees), + } + ) + return env + + def assert_release_worktree_removed(self): + self.assertEqual(list(self.worktrees.iterdir()), []) + worktree_list = self.run_command( + ["git", "worktree", "list", "--porcelain"] + ).stdout + self.assertNotIn(str(self.worktrees), worktree_list) + + def test_runs_exact_commit_from_clean_checkout_without_touching_dirty_file(self): + dirty = self.project / "README.md" + dirty.write_text("user-owned change\n", encoding="utf-8") + + result = self.run_command( + [str(self.scripts / WRAPPER.name), "plan", "production-alias"], + env=self.environment(), + ) + + captured = self.capture.read_text(encoding="utf-8") + self.assertIn("status=\n", captured) + self.assertIn("args=plan production-alias", captured) + self.assertIn(f"commit={self.commit}", captured) + self.assertIn(f"artifact_root={self.artifacts}", captured) + self.assertIn(f"Release candidate: {self.commit}", result.stdout) + self.assertEqual(dirty.read_text(encoding="utf-8"), "user-owned change\n") + self.assert_release_worktree_removed() + + def test_failed_inner_release_preserves_exit_status_and_removes_worktree(self): + self.write_inner_script(exit_code=23) + self.run_command(["git", "add", "scripts/production-release.sh"]) + self.run_command(["git", "commit", "--quiet", "-m", "failing fixture"]) + + result = self.run_command( + [str(self.scripts / WRAPPER.name), "apply", "production-alias"], + env=self.environment(), + check=False, + ) + + self.assertEqual(result.returncode, 23) + self.assert_release_worktree_removed() + + +if __name__ == "__main__": + unittest.main()