diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7fb9bbf..7bc81cf 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -205,31 +205,40 @@ jobs: fi changelog_section CHANGELOG.md "$VER" > "$RUNNER_TEMP/notes.md" cat "$RUNNER_TEMP/notes.md" - - name: nothing may exist yet — no tag, no release (re-runs refuse loudly) + - name: preflight — resume this merge, refuse every other collision + id: preflight if: steps.decide.outputs.ceremony == 'yes' env: GH_TOKEN: ${{ github.token }} VER: ${{ steps.facts.outputs.ver }} - # What makes a re-run of a completed ceremony refuse instead of - # clobber, and what catches a manual tag racing the merge. + MERGE_SHA: ${{ github.sha }} + # The pure table in lib/preflight.sh distinguishes a stranded run of + # this door from a completed release or a tag at another commit (#273). run: | - if git ls-remote --exit-code origin "refs/tags/$VER" >/dev/null 2>&1; then - echo "tag '$VER' already exists — this release already happened, or a manual tag won the race; refusing to re-release, creating nothing." >&2 - exit 1 - fi + tag_read_rc=0 + tag_refs="$(git ls-remote --exit-code origin "refs/tags/$VER" "refs/tags/$VER^{}")" || tag_read_rc=$? + case "$tag_read_rc" in + 0) tag_shas="$(printf '%s\n' "$tag_refs" | awk 'NF { print $1 }')" ;; + 2) tag_shas="" ;; + *) + echo "could not read tag '$VER' from origin (git ls-remote exit $tag_read_rc) — refusing rather than assuming it does not exist." >&2 + exit 1 + ;; + esac # shellcheck source=/dev/null . "$CEREMONY_DIR/lib/forge.sh" forge_select "" - if ! exists="$(forge_release_exists "$VER")"; then + if ! released="$(forge_release_exists "$VER")"; then echo "could not read whether release '$VER' exists — refusing rather than assuming it does not (#191)." >&2 exit 1 fi - if [ "$exists" = yes ]; then - echo "release '$VER' already exists — refusing to re-release, creating nothing." >&2 - exit 1 - fi + # shellcheck source=/dev/null + . "$CEREMONY_DIR/lib/preflight.sh" + out="$(TAG_SHAS="$tag_shas" RELEASED="$released" release_preflight)" + printf '%s\n' "$out" + printf '%s\n' "$out" | grep '^resume=' >> "$GITHUB_OUTPUT" - name: tag the merge commit — same job as the publish, on purpose - if: steps.decide.outputs.ceremony == 'yes' + if: steps.decide.outputs.ceremony == 'yes' && steps.preflight.outputs.resume != 'yes' env: GH_TOKEN: ${{ github.token }} VER: ${{ steps.facts.outputs.ver }} diff --git a/test/forge-backends.test.sh b/test/forge-backends.test.sh index f515a19..ebd75a8 100644 --- a/test/forge-backends.test.sh +++ b/test/forge-backends.test.sh @@ -1185,6 +1185,66 @@ release_read 200 '{"id":41,"tag_name":"1.2.3","draft":false}' forge_release_create 1.2.3 1.2.3 "$TMP/notes.md" >/dev/null 2>&1 check "a published same-tag release is never deleted" 1 "" grep -q '^DELETE ' "$WRITES" +# The merge door's preflight step is extracted and executed. Its network +# edges are stubbed at the boundary, while the real pure decision library +# consumes the gathered facts (#273). +MERGE_PREFLIGHT="$TMP/merge-preflight.sh" +{ + printf '%s\n' '#!/usr/bin/env bash' 'set -e' + yq -r '.jobs.release-on-merge.steps[] | select(.id == "preflight") | .run' \ + "$ROOT/.github/workflows/release.yml" +} >"$MERGE_PREFLIGHT" +chmod +x "$MERGE_PREFLIGHT" + +mkdir -p "$TMP/merge-preflight-lib/lib" "$TMP/merge-preflight-bin" +ln -s "$ROOT/lib/preflight.sh" "$TMP/merge-preflight-lib/lib/preflight.sh" +# shellcheck disable=SC2016 # expanded when the generated helper runs +printf '%s\n' \ + 'forge_select() { :; }' \ + 'forge_release_exists() { case "$RELEASE_EXISTS" in error) return 1 ;; *) echo "$RELEASE_EXISTS" ;; esac; }' \ + >"$TMP/merge-preflight-lib/lib/forge.sh" +# shellcheck disable=SC2016 # expanded when the PATH stub is executed +printf '%s\n' \ + '#!/usr/bin/env bash' \ + 'case "$GIT_LS_REMOTE" in' \ + ' absent) exit 2 ;;' \ + ' error) exit 128 ;;' \ + ' *) printf "%s\n" "$GIT_LS_REMOTE" ;;' \ + 'esac' >"$TMP/merge-preflight-bin/git" +chmod +x "$TMP/merge-preflight-bin/git" + +merge_preflight_extracted() { [ "$(wc -l <"$MERGE_PREFLIGHT")" -ge 8 ]; } +run_merge_preflight() { + : >"$TMP/merge-preflight-output" + CEREMONY_DIR="$TMP/merge-preflight-lib" \ + GITHUB_OUTPUT="$TMP/merge-preflight-output" \ + PATH="$TMP/merge-preflight-bin:$PATH" \ + VER=1.2.3 MERGE_SHA=1111111111111111111111111111111111111111 \ + RELEASE_EXISTS="$1" GIT_LS_REMOTE="$2" "$MERGE_PREFLIGHT" +} +merge_preflight_output_is() { + run_merge_preflight "$1" "$2" >/dev/null && \ + [ "$(cat "$TMP/merge-preflight-output")" = "$3" ] +} + +check "the merge door's preflight is extracted" 0 "" merge_preflight_extracted +check "the merge door refuses an unreadable release state" 1 \ + "refusing rather than assuming" run_merge_preflight error absent +check "the merge door refuses an unreadable tag state" 1 \ + "could not read tag '1.2.3'" run_merge_preflight no error +check "ls-remote exit 2 is tag absence and proceeds" 0 "" \ + merge_preflight_output_is no absent resume=no +check "a matching peeled ref records a resume" 0 "" \ + merge_preflight_output_is no \ + $'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\trefs/tags/1.2.3\n1111111111111111111111111111111111111111\trefs/tags/1.2.3^{}' \ + resume=yes +tag_step_uses_preflight() { + yq -r '.jobs.release-on-merge.steps[] | select(.name | test("tag the merge commit")) | .if' \ + "$ROOT/.github/workflows/release.yml" | grep -q 'steps.preflight.outputs.resume' +} +check "the merge-door tag step is conditioned on the preflight output" 0 "" \ + tag_step_uses_preflight + # The tag door's published-release guard is extracted and executed, like the # other workflow-shell contracts in this repository. The tag itself is this # door's premise, so only a published release refuses; adding the merge door's