Files
shopdb-flask/tools/promote-to-main.sh
cproudlock 03804bb996 Stop the promote script from deleting itself mid-run
promote-to-main.sh checks out main. The tooling lives only on the site branch,
so that checkout removes tools/promote-to-main.sh while the script is still
running - and bash reads a script lazily, by file offset, so it would carry on
executing whatever bytes happen to land there. While manipulating branches.

Not theoretical: main does not have tools/ right now, so the first real use
would have hit it.

Both versions now copy themselves to a temp file and re-exec from there. The
bash copy unlinks itself immediately, since the open handle keeps working. The
Windows copy has the same hazard for the same reason - cmd.exe re-seeks the
batch file after every command - and two extra traps worth naming: the copy runs
from %TEMP%, so the repository root has to travel with it rather than being
derived from %~dp0, and %errorlevel% inside a parenthesised block is expanded
when the block is PARSED, before the call it is meant to report on.

Scope: product
(cherry picked from commit 6d42110a91)
2026-08-30 11:12:09 -04:00

167 lines
5.0 KiB
Bash
Executable File

#!/usr/bin/env bash
#
# Take specific commits from wj to main.
#
# tools/promote-to-main.sh <commit> [<commit> ...]
# tools/promote-to-main.sh --dry-run <commit> ...
#
# Commits are applied in the order given, so give them oldest first.
#
# WHY CHERRY-PICK AND NOT A MERGE
#
# wj holds this site's work. Some of it is product - the rest of the world
# should have it - and some belongs to this site alone. Merging the branch takes
# both; that is how a site branch quietly becomes the product. Naming the
# commits keeps the decision explicit and per change.
#
# WHAT THIS REFUSES TO DO
#
# main is what every other site sees, and what the publication mirror exports.
# So this runs main's gates AFTER the pick and BEFORE the push, and leaves the
# pick unpushed if any of them fail. The ADR-015 check matters most: it is what
# catches a hostname, share path or subnet riding along in a commit that looked
# generic. That check is the whole reason a site branch is survivable.
#
# It does NOT push automatically. Read what it says, then run tools/git-push.sh
# main yourself.
set -euo pipefail
# RUN FROM A COPY OF OURSELF.
#
# This script checks out main. The tooling may live only on the site branch, in
# which case that checkout DELETES this file while bash is still reading it -
# and bash reads a script lazily, by offset, so it would carry on executing
# whatever bytes happen to be at that position afterwards. While manipulating
# branches. This is not theoretical: main did not have tools/ when this was
# written, so the very first real use would have hit it.
#
# Copy to a temp file, re-exec from there, and delete the copy immediately. On
# Unix the open file handle keeps working after the unlink, so the running
# script survives whatever git does to the tree.
if [ "${PROMOTE_SELFCOPY:-}" != "1" ]; then
SELF="$(mktemp)"
cat "$0" > "$SELF"
chmod 700 "$SELF"
export PROMOTE_SELFCOPY=1
exec bash "$SELF" "$@"
fi
rm -f "$0" 2>/dev/null || true
cd "$(git rev-parse --show-toplevel)"
DRYRUN=0
if [ "${1:-}" = "--dry-run" ]; then
DRYRUN=1
shift
fi
if [ "$#" -eq 0 ]; then
echo "usage: tools/promote-to-main.sh [--dry-run] <commit> [<commit> ...]" >&2
echo >&2
echo "Give commits oldest first. Find them with:" >&2
echo " git log --oneline main..wj" >&2
exit 1
fi
STARTING_BRANCH="$(git rev-parse --abbrev-ref HEAD)"
if [ -n "$(git status --porcelain)" ]; then
echo "Working tree is not clean:"
git status --short
echo
echo "Cherry-picking onto main from here would mix uncommitted work into the"
echo "promotion, or fail halfway and leave you on main with a dirty tree."
exit 1
fi
# Resolve and describe first, so a typo is caught before anything moves.
echo "About to promote onto main, in this order:"
for ref in "$@"; do
if ! git rev-parse --verify --quiet "$ref^{commit}" >/dev/null; then
echo "ERROR: not a commit: $ref" >&2
exit 1
fi
echo " $(git log --oneline -1 "$ref")"
done
echo
if [ "$DRYRUN" -eq 1 ]; then
echo "--dry-run: files each commit touches, so you can see what reaches main."
for ref in "$@"; do
echo
echo "--- $(git log --oneline -1 "$ref") ---"
git show --stat --oneline "$ref" | tail -n +2
done
echo
echo "Nothing was changed."
exit 0
fi
# Put us back where we started, whatever happens next.
restore() {
local code=$?
if [ "$code" -ne 0 ]; then
echo
echo "Promotion did NOT complete."
if git rev-parse --verify --quiet CHERRY_PICK_HEAD >/dev/null 2>&1; then
echo "Aborting the in-progress cherry-pick ..."
git cherry-pick --abort || true
fi
local now
now="$(git rev-parse --abbrev-ref HEAD)"
if [ "$now" != "$STARTING_BRANCH" ]; then
git checkout "$STARTING_BRANCH" >/dev/null 2>&1 || true
fi
echo "Back on $STARTING_BRANCH. main is unchanged and nothing was pushed."
fi
}
trap restore EXIT
git checkout main
MAIN_BEFORE="$(git rev-parse HEAD)"
for ref in "$@"; do
echo
echo "==> picking $(git log --oneline -1 "$ref")"
if ! git cherry-pick -x "$ref"; then
echo
echo "That commit does not apply cleanly to main."
echo "Resolve it by hand if it is worth having:"
echo " git status # see the conflict"
echo " git cherry-pick --abort # or walk away"
exit 1
fi
done
echo
echo "==> main gates"
echo "--- naming, style, site literals (ADR-015) ---"
bash scripts/check-naming-and-style.sh
echo "--- generated project map ---"
if ! venv/bin/python scripts/gen_project_map.py --check >/dev/null 2>&1; then
echo "Project map is stale; regenerating."
venv/bin/python scripts/gen_project_map.py
git add docs/PROJECT-MAP.md
git commit -q -m "Regenerate the project map after promotion"
fi
echo "--- python suite ---"
venv/bin/python -m pytest -q
echo "--- frontend ---"
( cd frontend && npm test --silent )
trap - EXIT
echo
echo "main is now:"
git log --oneline "$MAIN_BEFORE"..HEAD
echo
echo "Gates passed. NOT pushed - review the above, then:"
echo " tools/git-push.sh main"
echo
echo "You are on main. Return to your branch with:"
echo " git checkout $STARTING_BRANCH"