#!/usr/bin/env bash # queue-commit.sh: the lead's commit procedure for the queue (queue-as-data # plan 8.12). It commits exactly the tested bytes of docs/plans/queue.json and # docs/plans/QUEUE.md on top of HEAD through a temporary index and # commit-tree, so nothing anyone else has staged is swept in. # # Usage: # scripts/queue-commit.sh -m MSG commit the queue's next revisions # scripts/queue-commit.sh --genesis -m MSG the first queue commit (HEAD has no queue.json) # scripts/queue-commit.sh --install-hook [--by NAME] # install the queue guard (jason or sage) # # Exit codes: 0 ok; 1 failed, nothing published; 2 refused, nothing # published; 3 committed but the shared index was not reconciled (the # printed command finishes it); 4 usage. # # Committing still needs its own authorization. This procedure doesn't give # it, and it never pushes. set -uo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)" QJSON=docs/plans/queue.json QMD=docs/plans/QUEUE.md HOOK_REL=scripts/git-hooks/pre-commit FIX="git reset -q -- $QJSON $QMD" TMPD="" say() { printf 'queue-commit: %s\n' "$*" >&2; } die() { local code=$1; shift; say "$*"; exit "$code"; } cleanup() { if [ -n "$TMPD" ]; then rm -rf -- "$TMPD"; fi; } trap cleanup EXIT usage() { sed -n '8,12p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//' >&2 exit 4 } g() { git -C "$ROOT" "$@"; } # --- arguments --- MODE=commit GENESIS=0 MSG="" HAVE_MSG=0 BY="" while [ $# -gt 0 ]; do case "$1" in -m) [ $# -ge 2 ] || usage; MSG=$2; HAVE_MSG=1; shift 2 ;; --genesis) GENESIS=1; shift ;; --install-hook) MODE=install; shift ;; --by) [ $# -ge 2 ] || usage; BY=$2; shift 2 ;; -h|--help) usage ;; *) say "unknown argument: $1"; usage ;; esac done if [ "$MODE" = install ]; then { [ "$HAVE_MSG" = 0 ] && [ "$GENESIS" = 0 ]; } || { say "--install-hook takes only --by"; usage; } else [ -z "$BY" ] || { say "--by applies only to --install-hook"; usage; } [ "$HAVE_MSG" = 1 ] && [ -n "$MSG" ] || { say "a commit needs -m MSG"; usage; } fi # --- the repository (8.3): the canonical checkout, located only from this script --- for v in GIT_DIR GIT_WORK_TREE GIT_COMMON_DIR GIT_INDEX_FILE GIT_OBJECT_DIRECTORY GIT_ALTERNATE_OBJECT_DIRECTORIES; do if [ -n "${!v+x}" ]; then die 2 "refused: $v is set; run with the normal git environment"; fi done top=$(g rev-parse --show-toplevel 2>/dev/null) || die 2 "refused: $ROOT is not a git checkout" [ "$(cd "$top" && pwd -P)" = "$ROOT" ] || die 2 "refused: $ROOT is not the top of its checkout" gd=$(g rev-parse --path-format=absolute --git-dir) || die 1 "cannot read the git directory" cd_=$(g rev-parse --path-format=absolute --git-common-dir) || die 1 "cannot read the git common directory" [ "$(cd "$gd" && pwd -P)" = "$ROOT/.git" ] && [ "$(cd "$cd_" && pwd -P)" = "$ROOT/.git" ] \ || die 2 "refused: $ROOT's git directory is not $ROOT/.git (a linked worktree or a separate git dir)" HOOK="$ROOT/.git/hooks/pre-commit" TMPD=$(mktemp -d "${TMPDIR:-/tmp}/queue-commit.XXXXXX") || die 1 "mktemp failed" # --- the queue guard, active and not just present (8.12, G2). $1 is the # commit whose hook blob and tree the checks use. --- guard_check() { local h=$1 when=$2 idx out rc [ -e "$HOOK" ] || [ -L "$HOOK" ] || die 2 "refused ($when): the queue guard is not installed at .git/hooks/pre-commit; run \`scripts/queue-commit.sh --install-hook\`" [ ! -L "$HOOK" ] || die 2 "refused ($when): .git/hooks/pre-commit is a symlink" [ -f "$HOOK" ] || die 2 "refused ($when): .git/hooks/pre-commit is not a regular file" [ -O "$HOOK" ] || die 2 "refused ($when): .git/hooks/pre-commit is not owned by this user" [ -x "$HOOK" ] || die 2 "refused ($when): .git/hooks/pre-commit is not executable, so git would skip it" g cat-file -e "$h:$HOOK_REL" 2>/dev/null || die 2 "refused ($when): $h has no $HOOK_REL" g cat-file blob "$h:$HOOK_REL" | cmp -s - "$HOOK" \ || die 2 "refused ($when): .git/hooks/pre-commit differs from $HOOK_REL at $h" out=$(g config --show-scope --get-all core.hooksPath 2>/dev/null) [ -z "$out" ] || die 2 "refused ($when): core.hooksPath is set ($(printf '%s' "$out" | tr '\n\t' '; ')), so git would not run the queue guard" # The canary: git itself runs the hook it would run for a commit. idx="$TMPD/canary-$when/index" mkdir -p "$(dirname "$idx")" GIT_INDEX_FILE=$idx g read-tree "$h" || die 1 "canary ($when): read-tree failed" if ! out=$(cd "$ROOT" && GIT_INDEX_FILE=$idx git hook run pre-commit 2>&1); then die 2 "refused ($when): the canary's clean run failed, so git is not running the queue guard as installed: $out" fi GIT_INDEX_FILE=$idx g update-index --add --cacheinfo "100644,$(g rev-parse "$h:$HOOK_REL"),$QMD" \ || die 1 "canary ($when): update-index failed" out=$(cd "$ROOT" && GIT_INDEX_FILE=$idx git hook run pre-commit 2>&1); rc=$? [ "$rc" -ne 0 ] && [[ "$out" == *"mosaic queue guard: refused"* ]] \ || die 2 "refused ($when): the canary's changed run was not refused by the queue guard (exit $rc)" rm -rf -- "$(dirname "$idx")" } # --- --install-hook --- if [ "$MODE" = install ]; then [ -n "$BY" ] || BY=${MOSAIC_AGENT_NAME:-} [ -n "$BY" ] || die 2 "refused: no actor; pass --by NAME or set MOSAIC_AGENT_NAME" case "$BY" in jason|sage) ;; *) die 2 "refused: installing the queue guard is privileged (jason or sage), not $BY" ;; esac H=$(g rev-parse --verify -q HEAD) || die 2 "refused: HEAD has no commit" out=$(g config --show-scope --get-all core.hooksPath 2>/dev/null) [ -z "$out" ] || die 2 "refused: core.hooksPath is set ($(printf '%s' "$out" | tr '\n\t' '; ')); unset it first" g cat-file -e "$H:$HOOK_REL" 2>/dev/null || die 2 "refused: HEAD has no $HOOK_REL" if [ -L "$HOOK" ]; then die 2 "refused: .git/hooks/pre-commit is a symlink; remove it by hand if it is not wanted"; fi if [ -e "$HOOK" ]; then [ -f "$HOOK" ] || die 2 "refused: .git/hooks/pre-commit is not a regular file" g cat-file blob "$H:$HOOK_REL" | cmp -s - "$HOOK" || die 2 "refused: a different pre-commit hook exists at .git/hooks/pre-commit" chmod 0755 "$HOOK" || die 1 "chmod failed" note="the same bytes were already there" else mkdir -p "$ROOT/.git/hooks" || die 1 "cannot create .git/hooks" tmp="$ROOT/.git/hooks/.pre-commit.queue-commit.$$" g cat-file blob "$H:$HOOK_REL" > "$tmp" && chmod 0755 "$tmp" || { rm -f -- "$tmp"; die 1 "cannot write .git/hooks/pre-commit"; } # link() never replaces, so a hook that appeared meanwhile is kept. if ! ln -- "$tmp" "$HOOK" 2>/dev/null; then rm -f -- "$tmp"; die 2 "refused: a pre-commit hook appeared while installing"; fi rm -f -- "$tmp" note="copied from HEAD" fi guard_check "$H" install printf 'ok installed the queue guard at .git/hooks/pre-commit (%s, blob %s, mode 0755); canary passed\n' \ "$note" "$(g rev-parse "$H:$HOOK_REL")" exit 0 fi # --- 1. guard. H is recorded first, before the canary, so a branch that moves # at any later point makes update-ref in step 7 fail. --- H=$(g rev-parse --verify -q HEAD) || die 2 "refused: HEAD has no commit" BRANCH=$(g symbolic-ref -q --short HEAD) || die 2 "refused: HEAD is detached" guard_check "$H" step1 g diff-index --cached --quiet "$H" -- "$QJSON" "$QMD" \ || die 2 "refused: the shared index has staged changes to $QJSON or $QMD; seats never stage them; run: $FIX" if g cat-file -e "$H:$QJSON" 2>/dev/null; then [ "$GENESIS" = 0 ] || die 2 "refused: --genesis, but HEAD already has $QJSON" else [ "$GENESIS" = 1 ] || die 2 "refused: HEAD has no $QJSON; the first queue commit needs --genesis" fi # --- 2. snapshot, under the queue lock, with this checkout's code --- SNAP="$TMPD/snapshot" mkdir "$SNAP" || die 1 "mkdir failed" snapline=$(cd "$ROOT" && node "$ROOT/packages/queue/src/cli.mjs" snapshot --out "$SNAP") || die 2 "refused: queue snapshot failed (see above)" say "$snapline" # shellcheck disable=SC2016 info=$(node -e ' const d = JSON.parse(require("fs").readFileSync(process.argv[1], "utf8")); const g = d.log[0]; process.stdout.write([g.args.branch, g.args.root, g.args.map, g.result.mapBlob].join("\n")); ' "$SNAP/queue.json") || die 2 "refused: cannot read the snapshot's genesis entry" { read -r gbranch; read -r groot; read -r gmap; read -r gblob; } <<<"$info" [ "$gbranch" = "$BRANCH" ] || die 2 "refused: HEAD is on $BRANCH, but the queue's genesis branch is $gbranch" [ "$groot" = "$ROOT" ] || die 2 "refused: the queue's canonical root is $groot, not $ROOT" if [ "$GENESIS" = 1 ]; then hblob=$(g rev-parse -q --verify "$H:$gmap" 2>/dev/null) || die 2 "refused: the migration map $gmap is not in HEAD" [ "$hblob" = "$gblob" ] || die 2 "refused: genesis read map blob $gblob, but HEAD's $gmap is $hblob" fi # --- 3. base, from the object database --- TREE=$(g rev-parse "$H^{tree}") || die 1 "cannot read HEAD's tree" printf '%s %s\n' "$H" "$TREE" > "$SNAP/base.id" if [ "$GENESIS" = 1 ]; then BASEARG=(--base-absent) else g cat-file blob "$H:$QJSON" > "$SNAP/base.json" || die 1 "cannot read the base $QJSON" BASEARG=(--base-file "$SNAP/base.json") fi # --- 4. verify with HEAD's code, outside any repository --- ARCH="$TMPD/archive/tree" mkdir -p "$ARCH" || die 1 "mkdir failed" g archive --format=tar "$H" | tar -x -C "$ARCH" || die 1 "cannot unpack HEAD's archive" [ -f "$ARCH/packages/queue/src/cli.mjs" ] || die 2 "refused: HEAD has no packages/queue" # NODE_TEST_CONTEXT is cleared: under a parent test runner, a nested # `node --test` reports to that runner and exits 0 whatever its tests do. if ! (cd "$ARCH" && GIT_CEILING_DIRECTORIES="$TMPD/archive" env -u NODE_TEST_CONTEXT node --test packages/queue/tests/ >"$TMPD/archive-tests.log" 2>&1); then tail -n 40 "$TMPD/archive-tests.log" >&2 die 2 "refused: HEAD's queue tests failed in the archive" fi (cd "$ARCH" && GIT_CEILING_DIRECTORIES="$TMPD/archive" node packages/queue/src/cli.mjs verify --snapshot "$SNAP" "${BASEARG[@]}") >&2 \ || die 2 "refused: HEAD's validator rejected the snapshot" # --- 5. blobs --- B1=$(g hash-object -w --no-filters "$SNAP/queue.json") || die 1 "hash-object failed" B2=$(g hash-object -w --no-filters "$SNAP/QUEUE.md") || die 1 "hash-object failed" # --- 6. tree, in a temporary index at a path that doesn't exist yet --- TIDX="$TMPD/tree/index" mkdir -p "$TMPD/tree" GIT_INDEX_FILE=$TIDX g read-tree "$H" || die 1 "read-tree failed" GIT_INDEX_FILE=$TIDX g update-index --add --cacheinfo "100644,$B1,$QJSON" --cacheinfo "100644,$B2,$QMD" \ || die 1 "update-index failed" T=$(GIT_INDEX_FILE=$TIDX g write-tree) || die 1 "write-tree failed" changed=$(g diff-tree -r --name-only "$H" "$T") || die 1 "diff-tree failed" [ -n "$changed" ] || die 2 "refused: the snapshot equals HEAD's queue files; nothing to commit" while IFS= read -r p; do [ "$p" = "$QJSON" ] || [ "$p" = "$QMD" ] || die 1 "the new tree changes $p, not only the queue files" done <<<"$changed" # --- 7. commit, recheck the guard, publish --- printf '%s\n' "$MSG" > "$TMPD/msg" C=$(g commit-tree "$T" -p "$H" -F "$TMPD/msg") || die 1 "commit-tree failed" guard_check "$H" step7 if ! g update-ref -m queue-commit "refs/heads/$BRANCH" "$C" "$H"; then die 1 "refs/heads/$BRANCH moved since $H; nothing published; start again" fi say "committed $C on $BRANCH (parent $H)" # --- 8. reconcile the shared index --- unreconciled() { say "committed $C, but the shared index was not reconciled: $*"; say "the queue guard refuses ordinary commits until this runs: $FIX"; exit 3; } head_now=$(g rev-parse HEAD) [ "$head_now" = "$C" ] || unreconciled "HEAD is $head_now, not $C; the index was not touched" want=$(g ls-tree --format='%(objectmode) %(objectname) 0 %(path)' "$H" -- "$QJSON" "$QMD") || unreconciled "cannot read HEAD's entries" have=$(g ls-files --format='%(objectmode) %(objectname) %(stage) %(path)' -- "$QJSON" "$QMD") || unreconciled "cannot read the index" [ "$have" = "$want" ] || unreconciled "the index's queue entries differ from $H's, so someone staged a queue path; the index was not touched" [ ! -e "$ROOT/.git/index.lock" ] || unreconciled "another git process holds .git/index.lock" g reset -q -- "$QJSON" "$QMD" || unreconciled "git reset failed" printf 'ok committed %s: %s\n' "$C" "$snapline" exit 0