diff --git a/.github/workflows/selftest.yml b/.github/workflows/selftest.yml index a74c4e4..d47f771 100644 --- a/.github/workflows/selftest.yml +++ b/.github/workflows/selftest.yml @@ -71,6 +71,8 @@ jobs: run: bash scripts/test_release.sh - name: pr.sh unit tests run: bash scripts/test_pr.sh + - name: publish.sh unit tests + run: bash scripts/test_publish.sh # Public repository: no internal document names or plan IDs. - name: public-repo hygiene run: | diff --git a/Justfile b/Justfile new file mode 100644 index 0000000..3b735e6 --- /dev/null +++ b/Justfile @@ -0,0 +1,32 @@ +# The harness's own pull requests and publishing, with the recipes every +# repository has (README: "Pull requests from a laptop", "Maintaining the +# harness"): +# just merge [TITLE] a pull request from this feature branch, merged once +# the self-test is green; it publishes nothing +# just release-patch from a feature branch: its pull request, then, once +# merged, the merge commit published (on main: the +# newest merge): the next v1.x.y, and v1 moved there +# (-minor; -major starts v2) + +# Show the recipes +default: + @just --list + +# scripts/pr.sh merge, with `-`: the harness has no version file. + +# A pull request into main from this feature branch, merged once the self-test is green +[positional-arguments] +merge TITLE="": + bash scripts/pr.sh merge - "$1" + +# Publish: v1.x.y on the merge commit, v1 moved there (a feature branch: its pull request first) +release-patch: + bash scripts/publish.sh patch + +# Publish as v1.x.0 (new inputs or behaviour callers can use), v1 moved there +release-minor: + bash scripts/publish.sh minor + +# A breaking change: v2.0.0 and a new v2; v1 stays until every caller says @v2 +release-major: + bash scripts/publish.sh major diff --git a/README.md b/README.md index 9a36ec9..53a58e6 100644 --- a/README.md +++ b/README.md @@ -168,6 +168,15 @@ the files a bump changes, and writes the version in `_write-version`; copy top of the branch's work; on an up-to-date `main` it goes on a new branch `release/v`. It then waits for the merge and for the release run, and ends back on `main` (detached on `origin/main` in a worktree). +- Run either again when a run broke off (a dropped connection, Ctrl-C): a + branch whose pull request is merged already is left for `main`; a release + then follows the release run of the merged release pull request instead of + releasing twice, or releases `main` when the branch was merged without a + release. Polls survive short outages. A start that cannot work is refused + with what to do: `gh` not logged in (an exported `GH_TOKEN` or + `GITHUB_TOKEN` overrides its login), `main` behind or ahead of + `origin/main`, a detached `HEAD`, a branch missing commits of + `origin/main`. A repository with several release projects (each its own version file, tag prefix and release workflow, e.g. `db-v*` and `migrator-v*`): each project's @@ -219,11 +228,24 @@ The summary lists exceptions that expire within 30 days. a failing test); the self-test is green only when each one is reported and the clean fixture passes with every `fail-on` category set. Alerts that GitHub raises for the fixtures are expected. -- **Releases.** Callers use the moving tag `v1`. Move it only to the merge - commit of a pull request whose self-test was green (squash merge of an - up-to-date branch: the same tree): - `git tag -f v1 && git push -f origin v1`. Breaking input changes get - `v2`. +- **Releases.** Callers use the moving tag `v1`, so a change reaches every + repository when `v1` moves. The harness has the same recipes as every + repository ([Justfile](Justfile)): `just merge [TITLE]` opens the pull + request from a feature branch (merged once the self-test is green; that + publishes nothing). `just release-patch` (`-minor`) publishes + ([scripts/publish.sh](scripts/publish.sh)), like a release in every + repository: on a feature branch it opens the pull request, waits for the + self-test and the merge, and publishes that merge commit; on an up-to-date + `main` it publishes the newest merge. Only the merge commit of a pull + request whose checks are green, tagged with the next `v1.x.y`, and `v1` + moved there. It asks first, and prints the rollback + (`git tag -f v1 && git push -f origin v1`). A breaking input + change is `just release-major`: `v2.0.0` and a new `v2`, while `v1` stays + until every caller says `@v2`. A new input that callers will pass: publish + first, then change the callers (a caller passing an input its `@v1` does + not know fails to start). +- **Publish script.** `bash scripts/test_publish.sh` tests the refusals, the + versions and the moved tag against a local remote. - **Report script.** `python3 -I scripts/test_sarif_summary.py` tests the categories, `fail-on`, VEX expiry and the code-scanning copy. - **Release script.** `bash scripts/test_release.sh` tests the release intent, diff --git a/scripts/pr.sh b/scripts/pr.sh index b8d6651..5db6a31 100755 --- a/scripts/pr.sh +++ b/scripts/pr.sh @@ -24,6 +24,15 @@ # required checks pass; its merge starts (the image and the # tag), and this waits for that run. # +# Running either again after a run whose wait broke off (a dropped connection, +# Ctrl-C) is safe: a branch whose pull request is merged already (squash) is +# left for main, and a release follows the release run of its merged release +# pull request instead of releasing again, or releases main when the branch +# was merged without a release. Polls survive short outages. Every start that +# cannot work (gh not logged in, main behind or ahead of origin/main, a +# detached HEAD, a branch missing commits of origin/main) is refused, saying +# what to do. +# # Env: PR_WAIT_SECONDS (default 20) between polls. TAG_PREFIX (default v when # unset; e.g. db-v for one of several release projects in a repository, empty # for bare-version tags) and WRITE_VERSION (default _write-version; the @@ -34,6 +43,81 @@ wait_s=${PR_WAIT_SECONDS:-20} die() { echo "$*" >&2; exit 1; } +# gh, retried: from a laptop the connection to GitHub drops now and then +# ("connection refused"), which must not end a run that only waits. Prints the +# output of the first attempt that succeeds; fails after five. +gh_retry() { + local attempt out + for attempt in 1 2 3 4 5; do + if out=$(gh "$@" 2>/dev/null); then + printf '%s\n' "$out" + return 0 + fi + [ "$attempt" = 5 ] || sleep "$wait_s" + done + return 1 +} + +# gh must act as the person running this. An exported GH_TOKEN or GITHUB_TOKEN +# takes precedence over gh's own login, and a stale one fails every call (401). +require_gh() { + local err exported="" + err=$(gh api user --jq .login 2>&1 >/dev/null) && return 0 + [ -z "${GH_TOKEN:-}" ] || exported+=" GH_TOKEN" + [ -z "${GITHUB_TOKEN:-}" ] || exported+=" GITHUB_TOKEN" + if [ -n "$exported" ]; then + die "gh cannot use GitHub: ${err:-no answer} +This shell exports$exported, which gh uses instead of its own login. Run: unset$exported; gh auth status; then this again." + fi + die "gh cannot use GitHub: ${err:-no answer} +Check gh auth status (gh auth login renews an expired login), then run this again." +} + +# A release starts from main only when it is origin/main exactly. +check_main() { # check_main + local behind ahead + read -r behind ahead < <(git rev-list --left-right --count origin/main...main) + if [ "$behind" = 0 ] && [ "$ahead" = 0 ]; then return 0; fi + [ "$ahead" != 0 ] || + die "release: main is $behind commit(s) behind origin/main. Run: git pull --ff-only; then this again." + die "release: main has $ahead commit(s) that origin/main lacks: +$(git log --format=' %h %s' origin/main..main | head -n 5) +main is protected: they reach it only through a pull request. Move them onto a branch, put main back on origin/main, and release from the branch: + git switch -c + git branch -f main origin/main + just release-$1" +} + +# The current branch misses commits of origin/main: which, and how to take them. +lacks_main() { # lacks_main + die "$1: origin/main has $(git rev-list --count HEAD..origin/main) commit(s) that $2 lacks: +$(git log --format=' %h %s' HEAD..origin/main | head -n 5) +Run: git rebase origin/main (or git merge origin/main); then this again." +} + +# merged_pr : " " of the +# branch's merged pull request, if it has one. +merged_pr() { + gh_retry pr list --head "$1" --state merged --limit 1 \ + --json number,title,headRefOid,mergeCommit \ + --jq '.[0] // empty | "\(.number) \(.headRefOid) \(.mergeCommit.oid) \(.title)"' +} + +# The current branch lacks origin/main. Usually its pull request was merged +# (squash) already, by a run whose wait broke off: then this leaves the branch +# for main, when nothing on it is newer than that pull request, and prints +# "<number> <merge commit> <title>". Dies in every other case, saying why. +merged_branch() { # merged_branch <command> <branch> + local info pr head sha title + info=$(merged_pr "$2") || die "$1: cannot ask GitHub for the pull requests of $2 (gh pr list failed); run this again" + [ -n "$info" ] || lacks_main "$1" "$2" + read -r pr head sha title <<<"$info" + [ "$(git rev-parse HEAD)" = "$head" ] || + die "$1: $2 was merged as #$pr ($title) and has commits since. Run: git rebase origin/main; then this again." + leave_branch "$2" >&2 + echo "$pr $sha $title" +} + # version <file> [<rev>]: a package.json's `version`, a pyproject.toml's # `[project] version`, otherwise the content without whitespace (same rule as # release.sh). @@ -90,17 +174,52 @@ open_pr() { # open_pr <branch>: number of the branch's open pull request, if any } await_merge() { # await_merge <branch>: waits up to 40 minutes - local state="" + local state="" misses=0 echo "==> waiting for the required checks and the merge of $1" for _ in $(seq 1 120); do - state=$(gh pr view "$1" --json state --jq .state) + if state=$(gh pr view "$1" --json state --jq .state 2>/dev/null); then + misses=0 + case "$state" in + MERGED) return 0 ;; + CLOSED) die "the pull request of $1 was closed without merging" ;; + esac + else + misses=$((misses + 1)) + [ "$misses" -lt 10 ] || + die "lost the connection to GitHub while waiting for the merge of $1. The pull request still merges by itself, and its merge releases: run this again to follow it." + fi + sleep "$wait_s" + done + die "the pull request of $1 is not merged after 40 minutes: gh pr checks $1" +} + +# Waits for the release run of <tag>, which the merge commit <sha> started, and +# fetches the tag. +await_release() { # await_release <workflow> <sha> <tag> + local workflow=$1 sha=$2 tag=$3 run="" state tries=0 + echo "==> waiting for the release run of $tag ($workflow on $sha)" + for _ in $(seq 1 30); do + run=$(gh_retry run list --workflow "$workflow" --commit "$sha" --json databaseId,event \ + --jq 'map(select(.event == "push")) | .[0].databaseId // empty') || run="" + [ -n "$run" ] && break + sleep 5 + done + [ -n "$run" ] || die "no run of $workflow started for $sha: check GitHub Actions" + # A failed watch is a failed run or a dropped connection: the run says which. + until gh run watch "$run" --exit-status --interval "$wait_s" >/dev/null 2>&1; do + state=$(gh_retry run view "$run" --json status,conclusion --jq '"\(.status) \(.conclusion)"') || + die "lost the connection to GitHub while the release run $run of $tag was running; it carries on in CI: gh run watch $run, or run this again" case "$state" in - MERGED) return 0 ;; - CLOSED) die "the pull request of $1 was closed without merging" ;; + "completed success") break ;; + completed*) die "the release run $run of $tag failed (${state#completed }): gh run view $run --log-failed (a re-run is safe)" ;; esac + tries=$((tries + 1)) + [ "$tries" -lt 10 ] || + die "the connection to GitHub keeps dropping while the release run $run of $tag runs; it carries on in CI: gh run watch $run" sleep "$wait_s" done - die "the pull request of $1 is not merged after 40 minutes: gh pr checks $1" + git fetch --quiet origin tag "$tag" + echo "==> $tag published and tagged by CI (run $run)" } # Back on main after the merge, when the local main can simply follow @@ -121,15 +240,21 @@ leave_branch() { cmd_merge() { [ $# -ge 1 ] || die "usage: pr.sh merge <version-file[,version-file...]|-> [<title>]" local vfs=$1 title=${2:-} branch here there vf + local merged pr sha mtitle branch=$(git rev-parse --abbrev-ref HEAD) if [ "$branch" = main ] || [ "$branch" = HEAD ]; then die "merge: run on a feature branch" fi require_clean + require_gh require_checks git fetch --quiet origin main - git merge-base --is-ancestor origin/main HEAD || - die "merge: $branch does not contain origin/main (git rebase origin/main)" + if ! git merge-base --is-ancestor origin/main HEAD; then + merged=$(merged_branch merge "$branch") + read -r pr sha mtitle <<<"$merged" + echo "==> $branch is merged already, as #$pr ($mtitle): nothing to do; now on $(git rev-parse --abbrev-ref HEAD)" + return 0 + fi if [ "$vfs" != - ]; then for vf in ${vfs//,/ }; do here=$(version "$vf") @@ -156,16 +281,35 @@ cmd_release() { [ $# -ge 5 ] || die "usage: pr.sh release <patch|minor|major> <name> <version-file> <workflow> <file>..." local level=$1 name=$2 vf=$3 workflow=$4 shift 4 - local base next tag title branch pr sha run + local base next tag title branch pr sha merged mtitle require_clean + require_gh require_checks git fetch --quiet origin main branch=$(git rev-parse --abbrev-ref HEAD) + [ "$branch" != HEAD ] || + die "release: HEAD is detached. A finished release leaves it so when the local main holds commits origin/main lacks (git log origin/main..main lists them). Switch to main or to your feature branch; then run this again." if [ "$branch" = main ]; then - [ "$(git rev-parse HEAD)" = "$(git rev-parse origin/main)" ] || - die "release: main is not up to date (git pull --ff-only)" - elif [ "$branch" = HEAD ] || ! git merge-base --is-ancestor origin/main HEAD; then - die "release: run on main or on a branch that contains origin/main (git rebase origin/main)" + check_main "$level" + elif ! git merge-base --is-ancestor origin/main HEAD; then + # Merged already: a run whose wait broke off, or `just merge`. + merged=$(merged_branch release "$branch") + read -r pr sha mtitle <<<"$merged" + if [[ "$mtitle" == "release $name "* ]]; then + tag=${mtitle##* } + if git ls-remote --exit-code --tags origin "refs/tags/$tag" >/dev/null 2>&1; then + git fetch --quiet origin tag "$tag" + echo "==> #$pr ($mtitle) is merged, and $tag published and tagged already: nothing to do" + return 0 + fi + echo "==> #$pr ($mtitle) is merged already: following its release, not releasing again" + await_release "$workflow" "$sha" "$tag" + return 0 + fi + [ "$(git rev-parse --abbrev-ref HEAD)" = main ] || + die "release: $branch was merged as #$pr ($mtitle) without a release, and the local main cannot follow origin/main (it holds commits origin/main lacks, or another worktree has it). Release from an up-to-date main." + echo "==> $branch was merged as #$pr ($mtitle) without a release: releasing main, which holds it" + branch=main fi base=$(version "$vf" origin/main) @@ -201,22 +345,10 @@ cmd_release() { fi gh pr merge "$branch" --squash --auto --delete-branch await_merge "$branch" - sha=$(gh pr view "$branch" --json mergeCommit --jq '.mergeCommit.oid') + sha=$(gh_retry pr view "$branch" --json mergeCommit --jq '.mergeCommit.oid') || + die "lost the connection to GitHub after the merge of $branch; it releases in CI: run this again to follow it" leave_branch "$branch" - - echo "==> waiting for the release run of $tag ($workflow on $sha)" - run="" - for _ in $(seq 1 30); do - run=$(gh run list --workflow "$workflow" --commit "$sha" --json databaseId,event \ - --jq 'map(select(.event == "push")) | .[0].databaseId // empty') - [ -n "$run" ] && break - sleep 5 - done - [ -n "$run" ] || die "no run of $workflow started for $sha: check GitHub Actions" - gh run watch "$run" --exit-status --interval "$wait_s" >/dev/null || - die "the release run $run of $tag failed: gh run view $run --log-failed (a re-run is safe)" - git fetch --quiet origin tag "$tag" - echo "==> $tag published and tagged by CI (run $run)" + await_release "$workflow" "$sha" "$tag" } case "${1:-}" in diff --git a/scripts/publish.sh b/scripts/publish.sh new file mode 100755 index 0000000..e0e8a88 --- /dev/null +++ b/scripts/publish.sh @@ -0,0 +1,141 @@ +#!/usr/bin/env bash +# Publishes the harness: the logic behind `just release-patch|minor|major` in +# this repository. Every repository uses the harness at the moving tag v<major> +# (callers `uses: ...@v1`, Justfiles fetch scripts/pr.sh at v1), so a change +# reaches them when that tag moves. +# +# publish.sh <patch|minor|major> +# On a feature branch: opens its pull request (pr.sh merge; the harness +# has no version file), waits for the self-test and the merge, goes back +# to main and publishes that merge commit. On an up-to-date main: +# publishes HEAD, the merge commit of the newest pull request. +# +# Publishing tags the commit with the next version v<major>.<minor>.<patch> +# (the last one on origin, or v1.0.0 before the first) and moves +# v<major> to it: v1 for patch and minor; for major a new v<major+1>, +# while the old one stays where it is until every caller has moved. Only +# the merge commit of a pull request whose checks are green. Asks before +# it pushes (PUBLISH_YES=1 answers yes); prints how to roll back. +# +# Running it again after a run whose wait broke off is safe: pr.sh leaves the +# merged branch for main, and the newest merge is published from there. +# +# Env: PR_WAIT_SECONDS (default 20) between polls. +# Rollback: git tag -f v<major> <previous commit> && git push -f origin v<major> +set -euo pipefail + +here=$(cd "$(dirname "$0")" && pwd) +wait_s=${PR_WAIT_SECONDS:-20} + +die() { echo "publish: $*" >&2; exit 1; } + +# Waits for the merge of the branch's pull request; prints "<merge commit> <head commit>". +await_merged() { # await_merged <branch> + local out misses=0 + echo "==> waiting for the self-test and the merge of $1" >&2 + for _ in $(seq 1 120); do + if out=$(gh pr view "$1" --json state,mergeCommit,headRefOid \ + --jq '"\(.state) \(.mergeCommit.oid // "") \(.headRefOid)"' 2>/dev/null); then + misses=0 + case "$out" in + "MERGED "[0-9a-f]*) echo "${out#MERGED }"; return 0 ;; + CLOSED*) die "the pull request of $1 was closed without merging: nothing published" ;; + esac + else + misses=$((misses + 1)) + [ "$misses" -lt 10 ] || + die "lost the connection to GitHub while waiting for the merge of $1. The pull request still merges by itself: run this again to publish it." + fi + sleep "$wait_s" + done + die "the pull request of $1 is not merged after 40 minutes: gh pr checks $1" +} + +# Tags <sha> with the next version and moves v<major> there. +publish_commit() { # publish_commit <level> <sha> + local level=$1 sha=$2 released pr last major minor patch next moving old answer + git fetch --quiet --force --tags origin + released=$(git tag --points-at "$sha" -l 'v[0-9]*.[0-9]*.[0-9]*' | sort -V | tail -n1) + [ -z "$released" ] || die "$(git rev-parse --short "$sha") is published already, as $released" + + # Only the merge commit of a pull request whose checks (the self-test) are green. + pr=$(gh api "repos/{owner}/{repo}/commits/$sha/pulls" \ + --jq '[.[] | select(.merged_at != null)][0].number // empty') || + die "cannot ask GitHub which pull request made $(git rev-parse --short "$sha") (gh api failed)" + [ -n "$pr" ] || die "$(git rev-parse --short "$sha") is not the merge commit of a pull request: only those are published" + gh pr checks "$pr" >/dev/null 2>&1 || + die "the checks of #$pr are not all green: gh pr checks $pr" + + last=$(git tag -l 'v[0-9]*.[0-9]*.[0-9]*' | sort -V | tail -n1) + IFS=. read -r major minor patch <<<"${last:-v1.0.0}" + major=${major#v} + case "$level" in + patch) next="v$major.$minor.$((patch + 1))" ;; + minor) next="v$major.$((minor + 1)).0" ;; + major) next="v$((major + 1)).0.0" ;; + esac + moving=${next%%.*} + old=$(git rev-parse -q --verify "refs/tags/$moving^{commit}" || true) + + echo "==> publish $next: #$pr, $(git log -1 --format='%h %s' "$sha")" + if [ -n "$old" ]; then + echo " $moving moves from $(git rev-parse --short "$old") to $(git rev-parse --short "$sha"); every repository's next CI run and just merge|release-* use it" + else + echo " new tag $moving at $(git rev-parse --short "$sha"); repositories use it once their callers say @$moving" + fi + if [ "${PUBLISH_YES:-}" != 1 ]; then + [ -t 0 ] || die "confirm interactively, or set PUBLISH_YES=1" + read -r -p "Publish? [y/N] " answer + case "$answer" in y | Y | yes) ;; *) die "not published" ;; esac + fi + + git tag -a "$next" -m "workflows $next (#$pr)" "$sha" + git push --quiet origin "refs/tags/$next" + git tag -f "$moving" "$sha" >/dev/null + git push --quiet --force origin "refs/tags/$moving" + echo "==> published $next; $moving is at $(git rev-parse --short "$sha")" + if [ -n "$old" ]; then + echo " roll back: git tag -f $moving $old && git push -f origin $moving" + fi +} + +level=${1:-} +case "$level" in + patch | minor | major) ;; + *) die "usage: publish.sh <patch|minor|major>" ;; +esac +[ -z "$(git status --porcelain)" ] || die "uncommitted changes: commit them first, with a message describing the work" + +branch=$(git rev-parse --abbrev-ref HEAD) +[ "$branch" != HEAD ] || die "HEAD is detached: switch to your feature branch, or to main" +if [ "$branch" != main ]; then + # The pull request (or, when it is merged already, back to main), as `just merge`. + bash "$here/pr.sh" merge - + if [ "$(git rev-parse --abbrev-ref HEAD)" = "$branch" ]; then + merged=$(await_merged "$branch") + read -r sha head <<<"$merged" + git fetch --quiet origin main + if git merge-base --is-ancestor main origin/main 2>/dev/null && git switch --quiet main 2>/dev/null; then + git merge --quiet --ff-only origin/main + else + git switch --quiet --detach origin/main + echo "==> continuing detached on origin/main; the local main is left as it is" + fi + # Only a branch the pull request holds entirely. + if [ "$(git rev-parse -q --verify "refs/heads/$branch")" = "$head" ]; then + git branch -D "$branch" >/dev/null + else + echo "==> $branch keeps commits pushed after its pull request: left as it is" + fi + publish_commit "$level" "$sha" + exit 0 + fi +fi + +# On main: its HEAD, the merge commit of the newest pull request. +git fetch --quiet origin main +[ "$(git rev-parse --abbrev-ref HEAD)" = main ] || + die "the local main cannot follow origin/main (it holds commits origin/main lacks): see git log origin/main..main" +[ "$(git rev-parse HEAD)" = "$(git rev-parse origin/main)" ] || + die "main is not origin/main: git pull --ff-only (local commits reach main through just merge)" +publish_commit "$level" "$(git rev-parse HEAD)" diff --git a/scripts/test_pr.sh b/scripts/test_pr.sh index c21d147..c8ff90f 100755 --- a/scripts/test_pr.sh +++ b/scripts/test_pr.sh @@ -13,24 +13,51 @@ export GIT_COMMITTER_NAME=test GIT_COMMITTER_EMAIL=test@example.invalid export GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_NOSYSTEM=1 export PR_WAIT_SECONDS=0 -# gh: `pr list` prints the number in $STUB/pr (if any), `pr create` writes it, -# `pr view` reports MERGED and the merge commit $STUB/sha, `run list` run 42, -# `run watch` exits with $STUB/watch (default 0). Every call goes to $STUB/log. +# gh: `pr list` prints the number in $STUB/pr (if any; with --state merged the +# line in $STUB/merged), `pr create` writes it, `pr view` reports MERGED and the +# merge commit $STUB/sha (after failing as often as $STUB/viewfail says, a +# dropped connection), `run list` run 42, `run watch` exits with $STUB/watch +# (default 0; on success it sets the tag "<repo> <tag>" in $STUB/watchtag, as +# CI would), `run view` reports $STUB/runstate (default: what the watch said), +# `api user` fails while $STUB/noauth exists. Every call goes to $STUB/log. mkdir -p "$work/bin" cat >"$work/bin/gh" <<'EOF' #!/usr/bin/env bash echo "gh $*" >>"$STUB/log" case "$1 $2" in - "pr list") cat "$STUB/pr" 2>/dev/null || true ;; + "pr list") + case "$*" in + *"--state merged"*) cat "$STUB/merged" 2>/dev/null || true ;; + *) cat "$STUB/pr" 2>/dev/null || true ;; + esac ;; "pr create") echo 7 >"$STUB/pr" ;; "pr view") + fails=$(cat "$STUB/viewfail" 2>/dev/null || echo 0) + if [ "$fails" -gt 0 ]; then + echo $((fails - 1)) >"$STUB/viewfail" + echo 'Post "https://api.github.com/graphql": dial tcp: connect: connection refused' >&2 + exit 1 + fi case "$*" in *mergeCommit*) cat "$STUB/sha" ;; *) echo MERGED ;; esac ;; "run list") echo 42 ;; "api repos/{owner}/{repo}/rules/branches/main") cat "$STUB/checks" 2>/dev/null || echo 2 ;; - "run watch") exit "$(cat "$STUB/watch" 2>/dev/null || echo 0)" ;; + "api user") + if [ -f "$STUB/noauth" ]; then echo "HTTP 401: Bad credentials (https://api.github.com/user)" >&2; exit 1; fi + echo tester ;; + "run watch") + status=$(cat "$STUB/watch" 2>/dev/null || echo 0) + if [ "$status" = 0 ] && [ -f "$STUB/watchtag" ]; then + read -r repo tag <"$STUB/watchtag" + git -C "$repo" push -q origin "HEAD:refs/tags/$tag" + fi + exit "$status" ;; + "run view") + if [ -f "$STUB/runstate" ]; then cat "$STUB/runstate" + elif [ "$(cat "$STUB/watch" 2>/dev/null || echo 0)" = 0 ]; then echo "completed success" + else echo "completed failure"; fi ;; esac EOF # just: `_write-version <v>` writes the version file named in $STUB/vf (a @@ -112,7 +139,19 @@ expect() { # expect <name> <command...>: the command succeeds logged() { grep -qF -- "$1" "$STUB/log"; } # shellcheck disable=SC2329 # called through expect not_logged() { ! grep -qF -- "$1" "$STUB/log"; } +# shellcheck disable=SC2329 # called through expect +said() { grep -qF -- "$1" "$work/out"; } subject() { git -C "$repo" log -1 --format=%s "$1"; } +# squash_onto_main <subject>: what GitHub's squash merge of feat leaves on +# origin/main; prints the merge commit. feat stays as it was. +squash_onto_main() { + git -C "$repo" switch -q main + git -C "$repo" checkout -q feat -- . + git -C "$repo" commit -q -m "$1" + git -C "$repo" push -q origin main + git -C "$repo" rev-parse HEAD + git -C "$repo" switch -q feat +} files_of() { git -C "$repo" show --name-only --format= "$1" | tr '\n' ' '; } # ---- merge @@ -273,5 +312,119 @@ check "release: WRITE_VERSION names the project's recipe" 0 release patch x VERS unset WRITE_VERSION expect "release: the project's recipe was called" logged "just _write-db-version 1.2.4" +# ---- wrong starts, said plainly +setup VERSION 1.2.3 +touch "$STUB/noauth" +check "release: refused when gh cannot use GitHub" 1 release patch x VERSION release.yml VERSION +expect "release: says to check the gh login" said "gh auth status" +expect "release: nothing pushed when gh cannot use GitHub" not_logged "gh pr create" +GH_TOKEN=stale check "merge: an exported GH_TOKEN is named" 1 merge VERSION +expect "merge: says to unset it" said "unset GH_TOKEN" + +setup VERSION 1.2.3 +git -C "$repo" switch -q main +git -C "$repo" commit -q --allow-empty -m "on origin only" +git -C "$repo" push -q origin main +git -C "$repo" reset -q --hard HEAD~1 +check "release: refused on a main behind origin" 1 release patch x VERSION release.yml VERSION +expect "release: says to pull" said "1 commit(s) behind origin/main" + +setup VERSION 1.2.3 +git -C "$repo" switch -q main +git -C "$repo" commit -q --allow-empty -m "local only work" +check "release: a main ahead of origin is explained" 1 release patch x VERSION release.yml VERSION +expect "release: lists the local commits" said "local only work" +expect "release: says how to move them onto a branch" said "git branch -f main origin/main" + +setup VERSION 1.2.3 +git -C "$repo" switch -q --detach main +check "release: refused on a detached HEAD" 1 release patch x VERSION release.yml VERSION +expect "release: says HEAD is detached" said "HEAD is detached" + +setup VERSION 1.2.3 +git -C "$repo" switch -q main +echo c >>"$repo/work.txt" +git -C "$repo" commit -q -am "someone else's fix" +git -C "$repo" push -q origin main +git -C "$repo" switch -q feat +check "release: refused when the branch lacks origin/main" 1 release patch x VERSION release.yml VERSION +expect "release: names the missing commits" said "someone else's fix" +expect "release: says to rebase" said "git rebase origin/main" + +# ---- a run whose wait broke off, run again +setup VERSION 1.2.3 +echo 1.2.4 >"$repo/VERSION" +git -C "$repo" commit -q -am "release x v1.2.4" +head=$(git -C "$repo" rev-parse HEAD) +sha=$(squash_onto_main "release x v1.2.4 (#7)") +echo "7 $head $sha release x v1.2.4" >"$STUB/merged" +ci_tag v1.2.4 +check "release again: the merged release is not released twice" 0 release patch x VERSION release.yml VERSION +expect "release again: says it is published already" said "published and tagged already" +expect "release again: no pull request, no bump" not_logged "gh pr create" +expect "release again: no _write-version" not_logged "just _write-version" +expect "release again: back on main" test "$(git -C "$repo" rev-parse --abbrev-ref HEAD)" = main +expect "release again: the merged branch is gone" test -z "$(git -C "$repo" branch --list feat)" + +setup VERSION 1.2.3 +echo 1.2.4 >"$repo/VERSION" +git -C "$repo" commit -q -am "release x v1.2.4" +head=$(git -C "$repo" rev-parse HEAD) +sha=$(squash_onto_main "release x v1.2.4 (#7)") +echo "7 $head $sha release x v1.2.4" >"$STUB/merged" +echo "$repo v1.2.4" >"$STUB/watchtag" +check "release again: follows the release run still going" 0 release patch x VERSION release.yml VERSION +expect "release again: looks the run up on the merge commit" logged "gh run list --workflow release.yml --commit $sha" +expect "release again: watches it" logged "gh run watch 42 --exit-status" +expect "release again: the tag fetched" git -C "$repo" rev-parse --verify refs/tags/v1.2.4 +expect "release again: still no pull request" not_logged "gh pr create" + +setup VERSION 1.2.3 +head=$(git -C "$repo" rev-parse HEAD) +sha=$(squash_onto_main "fix the widget (#39)") +echo "39 $head $sha fix the widget" >"$STUB/merged" +ci_tag v1.2.4 +check "release: a branch merged without a release releases main" 0 release patch x VERSION release.yml VERSION +expect "release: says main is released instead" said "releasing main, which holds it" +expect "release: the release branch comes from main" logged "gh pr create --base main --head release/v1.2.4 --title release x v1.2.4" +expect "release: the merged branch is gone" test -z "$(git -C "$repo" branch --list feat)" + +setup VERSION 1.2.3 +head=$(git -C "$repo" rev-parse HEAD) +sha=$(squash_onto_main "fix the widget (#39)") +echo "39 $head $sha fix the widget" >"$STUB/merged" +echo d >>"$repo/work.txt" +git -C "$repo" commit -q -am "after the merge" +check "release: a merged branch with commits since is refused" 1 release patch x VERSION release.yml VERSION +expect "release: says to rebase them" said "has commits since" +expect "release: the branch stays" test -n "$(git -C "$repo" branch --list feat)" +expect "release: nothing pushed" not_logged "gh pr create" + +setup VERSION 1.2.3 +head=$(git -C "$repo" rev-parse HEAD) +sha=$(squash_onto_main "fix the widget (#39)") +echo "39 $head $sha fix the widget" >"$STUB/merged" +check "merge again: a merged branch has nothing to do" 0 merge VERSION +expect "merge again: says so" said "merged already, as #39" +expect "merge again: back on main" test "$(git -C "$repo" rev-parse --abbrev-ref HEAD)" = main + +# ---- a dropped connection +setup VERSION 1.2.3 +ci_tag v1.2.4 +echo 3 >"$STUB/viewfail" +check "release: a few dropped polls do not end the wait" 0 release patch x VERSION release.yml VERSION +expect "release: still published" said "v1.2.4 published and tagged by CI" + +setup VERSION 1.2.3 +echo 99 >"$STUB/viewfail" +check "release: a lasting outage ends the wait, saying how to go on" 1 release patch x VERSION release.yml VERSION +expect "release: says to run it again" said "run this again to follow it" + +setup VERSION 1.2.3 +ci_tag v1.2.4 +echo 1 >"$STUB/watch" +echo "completed success" >"$STUB/runstate" +check "release: a dropped watch of a run that succeeded is a success" 0 release patch x VERSION release.yml VERSION + [ "$fail" = 0 ] && echo "all pr.sh tests passed" exit "$fail" diff --git a/scripts/test_publish.sh b/scripts/test_publish.sh new file mode 100644 index 0000000..dd31da4 --- /dev/null +++ b/scripts/test_publish.sh @@ -0,0 +1,237 @@ +#!/usr/bin/env bash +# Tests for publish.sh (and the pr.sh merge it starts on a feature branch) +# against a local bare remote, with a stub for gh (it records its calls; +# nothing reaches GitHub). Run: bash scripts/test_publish.sh +set -euo pipefail + +script="$(cd "$(dirname "$0")" && pwd)/publish.sh" +work=$(mktemp -d) +trap 'rm -rf "$work"' EXIT +fail=0 + +export GIT_AUTHOR_NAME=test GIT_AUTHOR_EMAIL=test@example.invalid +export GIT_COMMITTER_NAME=test GIT_COMMITTER_EMAIL=test@example.invalid +export GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_NOSYSTEM=1 +export PR_WAIT_SECONDS=0 + +# gh, run in the clone: `api .../commits/<sha>/pulls` prints the pull request in +# $STUB/pr, `pr checks` exits with $STUB/checks (default 0: all green). For +# pr.sh merge: `pr list` prints the open pull request (with --state merged the +# line in $STUB/merged), `pr create` opens 21, and `pr merge` turning on +# auto-merge is GitHub's squash merge onto origin/main, at once (none while +# $STUB/nomerge exists). `pr view` reports that merge ($STUB/closed: closed; +# $STUB/viewfail: a dropped connection). +mkdir -p "$work/bin" +cat >"$work/bin/gh" <<'EOF' +#!/usr/bin/env bash +echo "gh $*" >>"$STUB/log" +case "$1 $2" in + "api user") echo tester ;; + "api repos/{owner}/{repo}/rules/branches/main") echo 2 ;; + "api repos/{owner}/{repo}/commits/"*) cat "$STUB/pr" 2>/dev/null || true ;; + "pr checks") exit "$(cat "$STUB/checks" 2>/dev/null || echo 0)" ;; + "pr list") + case "$*" in + *"--state merged"*) cat "$STUB/merged" 2>/dev/null || true ;; + *) cat "$STUB/open" 2>/dev/null || true ;; + esac ;; + "pr create") echo 21 >"$STUB/open" ;; + "pr merge") + [ ! -f "$STUB/nomerge" ] || exit 0 + git fetch -q origin main + commit=$(git commit-tree "HEAD^{tree}" -p origin/main -m "$(git log -1 --format=%s) (#21)") + git push -q origin "$commit:refs/heads/main" + echo "$commit" >"$STUB/mergecommit" + git rev-parse HEAD >"$STUB/head" ;; + "pr view") + [ ! -f "$STUB/viewfail" ] || exit 1 + if [ -f "$STUB/closed" ]; then echo "CLOSED $(git rev-parse HEAD)" + elif [ -f "$STUB/mergecommit" ]; then echo "MERGED $(cat "$STUB/mergecommit") $(cat "$STUB/head")" + else echo "OPEN $(git rev-parse HEAD)"; fi ;; +esac +EOF +chmod +x "$work/bin/gh" +export PATH="$work/bin:$PATH" + +n=0 +# setup: a remote whose main has two commits, v1 on the first; a clone in $repo +# on main at the second, the merge commit of pull request 21. +setup() { + n=$((n + 1)) + export STUB="$work/stub-$n" + mkdir -p "$STUB" + : >"$STUB/log" + echo 21 >"$STUB/pr" + origin="$work/origin-$n.git" + repo="$work/repo-$n" + git init -q --bare -b main "$origin" + git init -q -b main "$repo" + echo a >"$repo/pr.sh" + git -C "$repo" add -A && git -C "$repo" commit -q -m "first" + git -C "$repo" remote add origin "$origin" + git -C "$repo" tag v1 + echo b >>"$repo/pr.sh" + git -C "$repo" commit -q -am "guards (#21)" + git -C "$repo" push -q -u origin main + git -C "$repo" push -q origin v1 + first=$(git -C "$repo" rev-parse HEAD~1) + head=$(git -C "$repo" rev-parse HEAD) +} +# feature: a feature branch off main with one commit. +feature() { + git -C "$repo" switch -q -c feature + echo c >>"$repo/pr.sh" + git -C "$repo" commit -q -am "publish recipes" +} + +on_origin() { git -C "$origin" rev-parse -q --verify "refs/tags/$1^{commit}" 2>/dev/null; } + +check() { # check <name> <expected exit> <publish.sh args...> + local name=$1 want=$2 got=0 + shift 2 + (cd "$repo" && "$script" "$@") >"$work/out" 2>&1 </dev/null || got=$? + if [ "$got" = "$want" ]; then + echo "ok $name" + else + echo "FAIL $name: exit $got, expected $want" + sed 's/^/ /' "$work/out" + fail=1 + fi +} + +expect() { # expect <name> <command...>: the command succeeds + local name=$1 + shift + if "$@" >/dev/null 2>&1; then echo "ok $name"; else echo "FAIL $name"; fail=1; fi +} + +# shellcheck disable=SC2329 # called through expect +said() { grep -qF -- "$1" "$work/out"; } +# shellcheck disable=SC2329 # called through expect +logged() { grep -qF -- "$1" "$STUB/log"; } +# shellcheck disable=SC2329 # called through expect +tags_on_origin() { git -C "$origin" tag -l | sort | tr '\n' ' '; } +branch_now() { git -C "$repo" rev-parse --abbrev-ref HEAD; } + +export PUBLISH_YES=1 + +# ---- from a feature branch: the pull request, then publishing its merge +setup +feature +check "branch: opens the pull request, waits, publishes the merge" 0 patch +merge=$(cat "$STUB/mergecommit" 2>/dev/null || true) +expect "branch: the pull request was opened" logged "gh pr create --base main --head feature" +expect "branch: with auto-merge" logged "gh pr merge feature --squash --auto --delete-branch" +expect "branch: v1.0.1 on the merge commit" test "$(on_origin v1.0.1)" = "${merge:-no merge}" +expect "branch: v1 moved to the merge commit" test "$(on_origin v1)" = "${merge:-no merge}" +expect "branch: back on main, at the merge" test "$(branch_now)/$(git -C "$repo" rev-parse HEAD)" = "main/$merge" +expect "branch: the merged branch is gone" test -z "$(git -C "$repo" branch --list feature)" +expect "branch: says how to roll back" said "git tag -f v1 $first && git push -f origin v1" + +setup +feature +touch "$STUB/nomerge" "$STUB/closed" +before=$(tags_on_origin) +check "branch: a closed pull request publishes nothing" 1 patch +expect "closed: says so" said "closed without merging" +expect "closed: no tag" test "$(tags_on_origin)" = "$before" + +setup +feature +touch "$STUB/nomerge" "$STUB/viewfail" +before=$(tags_on_origin) +check "branch: a lasting outage while waiting publishes nothing" 1 patch +expect "outage: says to run it again" said "run this again to publish it" +expect "outage: no tag" test "$(tags_on_origin)" = "$before" + +setup +feature +echo 1 >"$STUB/checks" +before=$(tags_on_origin) +check "branch: red checks after the merge publish nothing" 1 patch +expect "red after merge: back on main all the same" test "$(branch_now)" = main +expect "red after merge: no tag" test "$(tags_on_origin)" = "$before" + +# Run again after the wait broke off: the pull request is merged already. +setup +feature +fhead=$(git -C "$repo" rev-parse HEAD) +git -C "$repo" fetch -q origin main +squash=$(git -C "$repo" commit-tree "HEAD^{tree}" -p origin/main -m "publish recipes (#21)") +git -C "$repo" push -q origin "$squash:refs/heads/main" +echo "21 $fhead $squash publish recipes" >"$STUB/merged" +check "again: a merged branch is published from main" 0 patch +expect "again: v1.0.1 on the merge commit" test "$(on_origin v1.0.1)" = "$squash" +expect "again: no second pull request" test -z "$(grep -F 'gh pr create' "$STUB/log")" +expect "again: back on main, the branch gone" test "$(branch_now)/$(git -C "$repo" branch --list feature)" = "main/" + +# ---- on main: its newest merge +setup +check "main: the first after v1 alone is v1.0.1" 0 patch +expect "main: v1.0.1 on origin at HEAD" test "$(on_origin v1.0.1)" = "$head" +expect "main: v1 moved to HEAD" test "$(on_origin v1)" = "$head" +expect "main: v1.0.1 is annotated" test "$(git -C "$origin" cat-file -t v1.0.1)" = tag +expect "main: asked for #21's checks" logged "gh pr checks 21" + +setup +git -C "$repo" tag -a v1.0.4 -m old "$first" && git -C "$repo" push -q origin v1.0.4 +check "minor: from the last version" 0 minor +expect "minor: v1.1.0" test "$(on_origin v1.1.0)" = "$head" +expect "minor: v1 moved" test "$(on_origin v1)" = "$head" + +setup +check "major: a new major" 0 major +expect "major: v2.0.0 and v2 at HEAD" test "$(on_origin v2.0.0)/$(on_origin v2)" = "$head/$head" +expect "major: v1 stays where it was" test "$(on_origin v1)" = "$first" +expect "major: says callers move to @v2" said "@v2" + +# ---- refusals: nothing pushed +setup +before=$(tags_on_origin) +echo dirty >>"$repo/pr.sh" +check "refused with uncommitted changes" 1 patch +expect "dirty: no tag" test "$(tags_on_origin)" = "$before" + +setup +git -C "$repo" switch -q --detach HEAD +check "refused on a detached HEAD" 1 patch + +setup +git -C "$repo" commit -q --allow-empty -m "local only" +check "refused on a main ahead of origin" 1 patch +expect "ahead: says main is not origin/main" said "main is not origin/main" + +setup +git -C "$repo" commit -q --allow-empty -m "on origin only" && git -C "$repo" push -q origin main +git -C "$repo" reset -q --hard HEAD~1 +check "refused on a main behind origin" 1 patch + +setup +git -C "$repo" tag -a v1.0.1 -m 'published' && git -C "$repo" push -q origin v1.0.1 +check "refused when HEAD is published already" 1 patch +expect "published: names the version" said "published already, as v1.0.1" + +setup +before=$(tags_on_origin) +rm "$STUB/pr" +check "refused when HEAD is no pull request's merge commit" 1 patch +expect "no pull request: says so" said "not the merge commit of a pull request" +expect "no pull request: no tag" test "$(tags_on_origin)" = "$before" + +setup +before=$(tags_on_origin) +echo 1 >"$STUB/checks" +check "refused when the pull request's checks are not green" 1 patch +expect "red checks: names the pull request" said "gh pr checks 21" +expect "red checks: no tag, v1 unmoved" test "$(tags_on_origin)/$(on_origin v1)" = "$before/$first" + +setup +before=$(tags_on_origin) +PUBLISH_YES='' check "refused without a terminal to confirm in" 1 patch +expect "no confirmation: no tag" test "$(tags_on_origin)" = "$before" + +setup +check "refused for an unknown level" 1 huge + +[ "$fail" = 0 ] && echo "all publish.sh tests passed" +exit "$fail" diff --git a/templates/repo/Justfile.release b/templates/repo/Justfile.release index 5c6a3fd..ea89a95 100644 --- a/templates/repo/Justfile.release +++ b/templates/repo/Justfile.release @@ -21,8 +21,11 @@ _pr *args: set -euo pipefail script=$(mktemp) trap 'rm -f "$script"' EXIT - gh api -H 'Accept: application/vnd.github.raw' \ - 'repos/IronFlock/workflows/contents/scripts/pr.sh?ref=v1' > "$script" + if ! gh api -H 'Accept: application/vnd.github.raw' \ + 'repos/IronFlock/workflows/contents/scripts/pr.sh?ref=v1' > "$script"; then + echo "could not fetch pr.sh with gh (see above). An exported GH_TOKEN or GITHUB_TOKEN overrides gh's own login: unset it, check gh auth status, then run this again." >&2 + exit 1 + fi bash "$script" "$@" [positional-arguments]