Build releases from clean detached checkout
This commit is contained in:
parent
3c8c0b3cf7
commit
d79e4e436b
|
|
@ -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
|
||||
|
|
|
|||
88
scripts/production-release-clean.sh
Executable file
88
scripts/production-release-clean.sh
Executable file
|
|
@ -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"
|
||||
)
|
||||
|
|
@ -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 \
|
||||
|
|
|
|||
124
test/scripts/production_release_clean_test.py
Normal file
124
test/scripts/production_release_clean_test.py
Normal file
|
|
@ -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()
|
||||
Loading…
Reference in New Issue
Block a user