diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index 5d01627..886e515 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -104,39 +104,19 @@ jobs: release-tag: name: move v1 to main - # `needs:` is what makes this "after the gate is green" rather than - # merely "after a push": a failed selftest skips this job outright, so - # v1 can never advance onto a broken build. The `if:` restricts it to an - # actual push to main -- a pull_request run targeting main shares this - # workflow but has no ref worth tagging. + # `needs: selftest` is what makes this "after the gate is green": a failed + # selftest skips this job, so v1 never advances onto a broken build. The + # `if:` restricts it to an actual push to main. + # + # No job-level `concurrency:`. The lease in release-v1.sh already keeps v1 + # from moving backwards, and a group here only cancelled queued jobs in + # whatever order their selftests finished -- which could leave no job to + # release the newest merge. Anything this job defers or misses, + # release-sweep.yaml picks up. needs: selftest if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }} runs-on: ubuntu-latest timeout-minutes: 2 - # The workflow-level group above is keyed per-commit (github.sha) so - # unrelated commits' CI never blocks each other -- which also means two - # merges landing close together can run two concurrent release-tag jobs. - # A job-level `concurrency:` is a second, independent group scoped to - # this job alone -- it does not replace the workflow-level one, it adds - # to it (confirmed by reading gitea's source at the v1.27.2 tag this - # instance runs: run-level and job-level concurrency are separate model - # fields, evaluated and enforced by separate functions -- - # CancelPreviousJobsByRunConcurrency vs CancelPreviousJobsByJobConcurrency - # in models/actions/{run,run_job}.go -- not one overriding the other). - # - # This does NOT decide which of several contending jobs gets to push -- - # Gitea wakes exactly one Blocked job in the group and cancels the rest - # outright, with no ordering on which one it picks (no `ORDER BY` in the - # query behind CancelPreviousJobsByJobConcurrency, - # models/actions/run_job_list.go). Every execution still only ever pushes - # its OWN gated commit (below), never another job's, so a job that gets - # cancelled here costs nothing beyond its own wasted run -- it was never - # going to push anyone else's commit either. This group's only job is to - # stop more than one job from pushing AT THE SAME TIME, which is wasted - # work, not a correctness risk on its own. - concurrency: - group: release-tag-v1 - cancel-in-progress: false # Requests write access from the run's built-in token (see README's # Versioning section for what's actually verified about it). Without # this the checkout below still succeeds -- it's the push that would be @@ -144,62 +124,14 @@ jobs: permissions: contents: write steps: - # fetch-depth: 0 fetches full history AND all tags (actions/checkout's - # own description: "0 indicates all history for all branches and - # tags") -- REQUIRED so refs/tags/v1 and the ancestry behind it are - # both present locally for the merge-base check below, regardless of - # which commit this run happens to be built from. + # Full history, so the ancestry checks in release-v1.sh can see how + # this commit relates to v1. - uses: actions/checkout@v4 with: token: ${{ secrets.GITHUB_TOKEN }} fetch-depth: 0 - # `needs: selftest` only gates THIS run's own commit -- it says nothing - # about whether `origin/main` has since moved on to a merge whose own - # selftest hasn't finished, is still queued, or is red. Two things - # follow, checked in this order: - # - # 1. If `origin/main`'s live tip (re-fetched here, not trusted from the - # checkout above, which can be minutes stale behind this job's own - # selftest) is no longer THIS run's own `github.sha`, some other - # merge has landed since. Pushing it would release a commit this run - # never gated -- so this run defers instead, unconditionally. The - # commit that IS the live tip has its own run, and that run's own - # guard is what releases it once ITS turn to push comes -- possibly - # only once merges pause for a moment, so v1 can land one merge - # later than the newest one in a busy stretch. It never lands on a - # commit that wasn't gated, and it always catches up once things go - # quiet, because every future merge re-attempts the same check - # against whatever is current by then. - # 2. Only once (1) confirms this IS the live tip does it matter whether - # v1 already covers it -- a duplicate run, or a manual push already - # having done this. `--is-ancestor` treats a commit as its own - # ancestor, so "already at" and "already ahead" are one case. A v1 - # that doesn't exist yet, or shares no history with this commit, - # falls through to the push -- both checks are only ever a reason to - # skip, never a reason to fail. - - name: Determine whether this commit is still current and needs releasing - id: check - run: | - git fetch origin main - TIP=$(git rev-parse origin/main) - SHA="${{ github.sha }}" - if [ "$TIP" != "$SHA" ]; then - echo "origin/main's tip ($TIP) has moved past this run's own gated commit ($SHA) -- deferring to whichever run's own commit is now the live tip" - echo "skip=true" >> "$GITHUB_OUTPUT" - elif git rev-parse -q --verify refs/tags/v1 >/dev/null \ - && git merge-base --is-ancestor "$SHA" refs/tags/v1; then - echo "v1 already at or ahead of $SHA -- nothing to do" - echo "skip=true" >> "$GITHUB_OUTPUT" - else - echo "skip=false" >> "$GITHUB_OUTPUT" - fi - - # Lightweight tag, matching what v1 already is (`git cat-file -t v1` - # reports `commit`, not `tag`) -- no identity needed to move it, only - # to push it. - - name: Force v1 to this commit - if: steps.check.outputs.skip != 'true' - run: | - git tag -f v1 "${{ github.sha }}" - git push --force origin v1 + # Releases this run's own commit only while it is still main's tip, and + # only forward -- see release-v1.sh. + - name: Move v1 to this commit if it is still main's tip + run: bash scripts/release-v1.sh merge "${{ github.sha }}" diff --git a/.gitea/workflows/release-sweep.yaml b/.gitea/workflows/release-sweep.yaml new file mode 100644 index 0000000..589222e --- /dev/null +++ b/.gitea/workflows/release-sweep.yaml @@ -0,0 +1,69 @@ +name: Release sweep + +# Keeps v1 from lagging main when ci.yaml's release-tag job defers or never +# runs. Each tick either finds v1 already covering main's tip and exits, or +# gates the tip exactly as ci.yaml's selftest job does and moves v1 to it. A +# red run here means v1 is behind a main that fails its gate. +# +# Gitea registers schedules from the default branch only, so this fires once +# it is on main. +on: + schedule: + - cron: '*/15 * * * *' + +# A tick that arrives while another is still gating waits behind it rather +# than gating the same tip twice. +concurrency: + group: release-sweep + cancel-in-progress: false + +jobs: + sweep: + name: move v1 to main if it lags + runs-on: ubuntu-latest + timeout-minutes: 25 + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + with: + token: ${{ secrets.GITHUB_TOKEN }} + fetch-depth: 0 + + # A scheduled run's github.sha is main as of the last push, not + # necessarily its tip, so the tip is read here instead. + - name: Check whether v1 lags main + id: check + run: bash scripts/release-v1.sh sweep-check + + # Everything below runs only when v1 lags, and gates the tip itself, + # not the commit this run was created from. + - name: Check out main's tip + if: steps.check.outputs.needed == 'true' + run: git checkout -q --detach "${{ steps.check.outputs.tip }}" + + - name: Install shellcheck + if: steps.check.outputs.needed == 'true' + uses: taiki-e/install-action@v2 + with: + tool: shellcheck + + # Same toolchains, same order, as ci.yaml's selftest job. + - name: Install Rust nightly + if: steps.check.outputs.needed == 'true' + uses: dtolnay/rust-toolchain@nightly + - name: Install Rust toolchain + if: steps.check.outputs.needed == 'true' + uses: dtolnay/rust-toolchain@stable + + - name: shellcheck + if: steps.check.outputs.needed == 'true' + run: shellcheck -x --source-path=scripts scripts/*.sh + + - name: Selftests + if: steps.check.outputs.needed == 'true' + run: bash scripts/selftest.sh + + - name: Move v1 to the gated tip + if: steps.check.outputs.needed == 'true' + run: bash scripts/release-v1.sh push "${{ steps.check.outputs.tip }}" "${{ steps.check.outputs.v1 }}" diff --git a/README.md b/README.md index c9f646f..b21aa46 100644 --- a/README.md +++ b/README.md @@ -634,34 +634,32 @@ entry, another permission — is a `v2`, not a `v1` move. Everything else moves `v1`: correctness fixes, new optional inputs, and anything internal to `scripts/`. -**Moving the tag is automatic, gated on the same build that gates a PR.** A -`release-tag` job in `.gitea/workflows/ci.yaml` runs on every push to `main`, -`needs: selftest`, and force-moves `v1` to *that run's own commit* once -selftest succeeds — a broken build never reaches it, so `v1` can't advance -onto one. It pushes with the run's built-in `GITHUB_TOKEN`; if that token -turns out not to have write access, the push step fails and the job goes red -in the Actions UI. That's a loud failure, not the silent one this replaced: -`v1` stays put, and nobody has to notice on their own that it lagged. +**Moving the tag is automatic, gated on the same build that gates a PR.** Two +jobs move it, both through `scripts/release-v1.sh`, both with the run's +built-in `GITHUB_TOKEN`: -Before pushing, the job checks two things and pushes only if both hold: that -`origin/main`'s live tip is still this run's own commit (not some later merge -that landed while this job was queued behind its own selftest), and that `v1` -doesn't already point at that commit or a descendant of it. Either check can -skip the push, as a normal, successful outcome — a run whose log says -"nothing to do" did its job correctly. The first check is what a job-level -`concurrency` group alone can't guarantee: Gitea's wake-one/cancel-rest -handling applies no ordering by commit recency, so an older merge's job can -be the one that survives to run — that job now defers instead of releasing a -commit it never gated. The newer merge's own job releases it once its own -turn comes, which can land `v1` one merge behind the newest during a busy -stretch; it always catches up once merges pause, because every later run -re-checks against whatever is current by then. +- **`release-tag`** in `.gitea/workflows/ci.yaml` runs on every push to + `main`, `needs: selftest`, and moves `v1` to that run's own commit — but only + while that commit is still `main`'s tip. A run whose merge has already been + overtaken defers, as a successful no-op, rather than release a commit it + never gated. +- **`release-sweep.yaml`** runs every 15 minutes. When `v1` already points at + `main`'s tip or a descendant of it, it exits after a checkout and one + comparison. Otherwise it runs the same shellcheck and selftests against the + tip and moves `v1` there only if they pass. + +So `v1` trails a green `main` by at most about one sweep interval plus one +selftest run, and **a `main` that fails its gate shows up as a red sweep on every +tick until it is fixed** — as does a push the token is not allowed to +make. Both jobs push with `--force-with-lease` on the `v1` they read, so +neither can move `v1` backwards over the other; a job that loses the lease to +a newer `v1` finishes green. This used to be a manual step, treated as a deliberate release decision taken once, knowingly, after the merge — in practice it was still forgotten (gitdan-actions#27): PR #25 merged to `main` and `v1` stayed on the previous release until someone asked whether it had moved. The manual form below is -still the recovery path, for when the automated job can't push: +still the recovery path, for when neither job can push: ```bash git fetch origin @@ -745,6 +743,7 @@ change here reaches all of them at once. That is what the gate is for. | `seed-target-dir-selftest.sh` | seed-source preference, lock-file stripping, two jobs racing on one cache key, **and one scenario per check a hardlink clone is validated against**: a source rotated wholesale, a subtree silently lost from the walk, a copy that reports failure over a tree both other checks read as whole, and a source identity that resolved at neither end — plus a staging tree that could not be privately owned being discarded rather than published, and the publisher's log showing it waited on the consumer's own reader-lock marker before reclaiming a rotated snapshot | | `publish-snapshot-selftest.sh` | the atomic swap, that a live consumer survives a republish, and the publisher's side of the rotation race: deferred reclamation under a live reader, and its sweep once the reader is gone | | `prune-cache-selftest.sh` | liveness in both its forms — a branch deleted from origin, and one still on it whose tip is already merged — plus protection, locking, eviction order, self-clear, **that a cache a job claims *inside* the check-to-unlink window survives it**, and that a requirement derived from the clone's mutable set evicts exactly enough and then fails rather than under-delivering. Against a real scratch `origin`, including a genuinely shallow clone of it and a `df` that answers from the fixture's own size, since a fixed one cannot show a pass stopping | +| `release-v1-selftest.sh` | that `v1` reaches `main`'s tip only through a gate and never moves backwards: the sweep's no-op, tag and red-gate cases, the stranded-defer trace the sweep exists to recover, and each lost-lease outcome — a newer `v1` skipped cleanly (with a control showing an unleased push steps it back), an older one retried, an unrelated one and a server rejection red. Against a real scratch `origin`; the other writer is sequenced between check and push, not raced | | `restore-mtimes-selftest.sh` | the merge hazard and the watermark that closes it, including the two-jobs-one-namespace case. Needs a real compiler. | Every suite runs the actual script, not a reimplementation of its logic, and diff --git a/scripts/release-v1-selftest.sh b/scripts/release-v1-selftest.sh new file mode 100644 index 0000000..5e85721 --- /dev/null +++ b/scripts/release-v1-selftest.sh @@ -0,0 +1,165 @@ +#!/usr/bin/env bash +# Regression test for release-v1.sh against a scratch bare origin: v1 reaches +# main's tip once it has been gated, never lands on an ungated commit, and +# never moves backwards when two writers race (gitdan-actions#27). +# +# run_sweep mirrors release-sweep.yaml's step order -- check, gate only when +# needed, push the gated tip leased on the v1 the check read -- with the gate +# stood in for by a command, so a failing gate is a failing sweep. +set -euo pipefail +script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +release="$script_dir/release-v1.sh" + +scratch=$(mktemp -d) +trap 'rm -rf "$scratch"' EXIT +pass_count=0 +fail() { echo "ASSERTION FAILED: $*" >&2; exit 1; } +ok() { pass_count=$((pass_count + 1)); echo "PASS: $*"; } + +export GIT_AUTHOR_NAME=t GIT_AUTHOR_EMAIL=t@t GIT_COMMITTER_NAME=t GIT_COMMITTER_EMAIL=t@t +unset GITHUB_OUTPUT + +# A fresh origin with main at one commit and v1 on it; dev pushes to main, +# ci and ci2 are the runners' clones. +fresh() { + rm -rf "$scratch/w"; mkdir -p "$scratch/w" + git init -q --bare "$scratch/w/origin.git" + git clone -q "$scratch/w/origin.git" "$scratch/w/dev" 2>/dev/null + commit_to_main >/dev/null + git -C "$scratch/w/dev" push -q origin HEAD:refs/tags/v1 + git clone -q "$scratch/w/origin.git" "$scratch/w/ci" + git clone -q "$scratch/w/origin.git" "$scratch/w/ci2" +} +commit_to_main() { + git -C "$scratch/w/dev" commit -q --allow-empty -m "c$RANDOM" + git -C "$scratch/w/dev" push -q origin HEAD:refs/heads/main + git -C "$scratch/w/dev" rev-parse HEAD +} +origin_v1() { git -C "$scratch/w/origin.git" rev-parse -q --verify 'refs/tags/v1^{commit}' || true; } +origin_tip() { git -C "$scratch/w/origin.git" rev-parse refs/heads/main; } +in_ci() { (cd "$scratch/w/${CLONE:-ci}" && bash "$release" "$@"); } +field() { sed -n "s/^$1=//p"; } + +run_sweep() { + local gate="$1" out tip v1 + out=$(in_ci sweep-check) + [ "$(field needed <<<"$out")" = true ] || return 0 + tip=$(field tip <<<"$out"); v1=$(field v1 <<<"$out") + "$gate" || return 1 + in_ci push "$tip" "$v1" +} + +echo "=== 1. sweep with v1 at the tip is a no-op ===" +fresh +before=$(origin_v1) +out=$(in_ci sweep-check) +[ "$(field needed <<<"$out")" = false ] || fail "a current v1 was reported as lagging" +run_sweep false || fail "a current v1 ran the gate" +[ "$(origin_v1)" = "$before" ] || fail "a no-op sweep moved v1" +ok "v1 == tip: needed=false, gate not run, v1 unchanged" + +echo +echo "=== 2. sweep with v1 ahead of the tip is a no-op ===" +fresh +ahead=$(git -C "$scratch/w/dev" commit-tree -p HEAD -m ahead 'HEAD^{tree}') +git -C "$scratch/w/dev" push -q -f origin "$ahead:refs/tags/v1" +out=$(in_ci sweep-check) +[ "$(field needed <<<"$out")" = false ] || fail "a v1 descending from the tip was reported as lagging" +ok "v1 descends from tip: needed=false" + +echo +echo "=== 3. sweep with v1 behind and a passing gate tags the tip ===" +fresh +tip=$(commit_to_main) +run_sweep true || fail "a passing sweep failed" +[ "$(origin_v1)" = "$tip" ] || fail "v1 is $(origin_v1), not the gated tip $tip" +ok "v1 behind, gate green: v1 -> tip" + +echo +echo "=== 4. sweep with v1 behind and a failing gate goes red and tags nothing ===" +fresh +before=$(origin_v1) +commit_to_main >/dev/null +if run_sweep false; then fail "a sweep over a failing gate succeeded"; fi +[ "$(origin_v1)" = "$before" ] || fail "a failing gate still moved v1" +ok "v1 behind, gate red: sweep red, v1 unchanged" + +echo +echo "=== 5. a lost lease to a newer writer is a clean skip, never a step back ===" +fresh +t1=$(commit_to_main) +out=$(in_ci sweep-check); v1_read=$(field v1 <<<"$out") +t2=$(commit_to_main) +CLONE=ci2 in_ci merge "$t2" >/dev/null +[ "$(origin_v1)" = "$t2" ] || fail "the merge job did not release its own tip" +in_ci push "$t1" "$v1_read" || fail "a lease lost to a newer v1 went red" +[ "$(origin_v1)" = "$t2" ] || fail "v1 went backwards from $t2 to $(origin_v1)" +ok "older writer lost the lease: exit 0, v1 stays at the newer $t2" +git -C "$scratch/w/ci" push -q -f origin "$t1:refs/tags/v1" +[ "$(origin_v1)" = "$t1" ] || fail "control: an unleased push did not step v1 back" +ok "control: the same push without the lease steps v1 back to $t1" + +echo +echo "=== 6. a lease lost to an older writer retries and lands the newer commit ===" +fresh +t1=$(commit_to_main) +t2=$(commit_to_main) +out=$(in_ci sweep-check); v1_read=$(field v1 <<<"$out") +git -C "$scratch/w/dev" push -q -f origin "$t1:refs/tags/v1" +in_ci push "$t2" "$v1_read" || fail "a lease lost to an older v1 went red" +[ "$(origin_v1)" = "$t2" ] || fail "v1 is $(origin_v1), not $t2" +ok "v1 moved to an ancestor under us: retried, v1 -> $t2" + +echo +echo "=== 7. a lease lost to an unrelated commit goes red ===" +fresh +tip=$(commit_to_main) +out=$(in_ci sweep-check); v1_read=$(field v1 <<<"$out") +stray=$(git -C "$scratch/w/dev" commit-tree -m stray 'HEAD^{tree}') +git -C "$scratch/w/dev" push -q -f origin "$stray:refs/tags/v1" +if in_ci push "$tip" "$v1_read" 2>/dev/null; then fail "a v1 moved sideways was accepted"; fi +[ "$(origin_v1)" = "$stray" ] || fail "the stray v1 was overwritten" +ok "v1 moved to a commit neither ahead nor behind: red, v1 untouched" + +echo +echo "=== 8. a push rejected for another reason goes red ===" +fresh +tip=$(commit_to_main) +mkdir -p "$scratch/w/origin.git/hooks" +printf '#!/bin/sh\nexit 1\n' > "$scratch/w/origin.git/hooks/pre-receive" +chmod +x "$scratch/w/origin.git/hooks/pre-receive" +before=$(origin_v1) +if in_ci merge "$tip" 2>"$scratch/err"; then fail "a rejected push reported success"; fi +[ "$(origin_v1)" = "$before" ] || fail "v1 moved despite the rejection" +grep -q 'not a lost lease' "$scratch/err" || fail "the rejection was not diagnosed as one: $(cat "$scratch/err")" +ok "server rejection with v1 unmoved: red, not a lost lease" + +echo +echo "=== 9. the merge job defers on a moved tip; the next sweep catches up ===" +# The stranded trace: C1's job runs after C2 merged and defers, C2's job was +# cancelled in the concurrency group, and merges stop. +fresh +before=$(origin_v1) +c1=$(commit_to_main) +c2=$(commit_to_main) +in_ci merge "$c1" >/dev/null || fail "the deferring merge job went red" +[ "$(origin_v1)" = "$before" ] || fail "the merge job released a commit that was not the tip" +run_sweep true || fail "the catch-up sweep failed" +[ "$(origin_v1)" = "$c2" ] || fail "v1 is $(origin_v1), not the tip $c2" +ok "C1 deferred, C2 never ran: the sweep moved v1 to $c2" + +echo +echo "=== 10. the merge job releases its own tip, and creates a missing v1 ===" +fresh +tip=$(commit_to_main) +in_ci merge "$tip" >/dev/null +[ "$(origin_v1)" = "$tip" ] || fail "the merge job did not release the tip" +git -C "$scratch/w/dev" push -q origin :refs/tags/v1 +tip=$(commit_to_main) +in_ci merge "$tip" >/dev/null +[ "$(origin_v1)" = "$tip" ] || fail "the merge job did not create an absent v1" +[ "$(origin_tip)" = "$tip" ] || fail "main moved" +ok "tip == gated sha: released, including onto an absent v1" + +echo +echo "release-v1-selftest: all $pass_count assertions passed" diff --git a/scripts/release-v1.sh b/scripts/release-v1.sh new file mode 100644 index 0000000..131edfb --- /dev/null +++ b/scripts/release-v1.sh @@ -0,0 +1,100 @@ +#!/usr/bin/env bash +# Moves the floating `v1` tag forward to a gated commit on `main`, and never +# backwards. Run from a clone whose `origin` is this repository. +# +# release-v1.sh merge merge-triggered job: release +# if it is still main's tip +# release-v1.sh sweep-check scheduled sweep: report whether v1 lags +# main (tip=, v1=, needed= to +# $GITHUB_OUTPUT, or stdout without one) +# release-v1.sh push +# scheduled sweep, after gating the tip +# +# Every push is leased on the v1 value the caller reasoned about. A lost lease +# means another writer moved v1 first: that is a clean skip once v1 is at or +# ahead of , a retry against the new value while v1 is still behind +# it, and a failure otherwise. +set -euo pipefail + +MAX_ATTEMPTS=3 + +fetch_main() { + git fetch -q origin +refs/heads/main:refs/remotes/origin/main + git rev-parse refs/remotes/origin/main +} + +# Prints origin's v1 commit, or nothing when origin has no v1. +fetch_v1() { + if [ -z "$(git ls-remote origin refs/tags/v1)" ]; then + git update-ref -d refs/release-v1/seen 2>/dev/null || true + return 0 + fi + git fetch -q origin +refs/tags/v1:refs/release-v1/seen + git rev-parse 'refs/release-v1/seen^{commit}' +} + +# True when v1 already covers : at it, or a descendant of it. +covers() { + local sha="$1" v1="$2" + [ -n "$v1" ] && git merge-base --is-ancestor "$sha" "$v1" +} + +push_leased() { + local sha="$1" expect="$2" now attempt + for ((attempt = 1; attempt <= MAX_ATTEMPTS; attempt++)); do + if git push -q --force-with-lease="refs/tags/v1:$expect" origin "$sha:refs/tags/v1"; then + echo "v1 moved ${expect:-} -> $sha" + return 0 + fi + now=$(fetch_v1) + if [ "$now" = "$expect" ]; then + echo "ERROR: push of v1 -> $sha rejected while v1 was still ${expect:-} -- not a lost lease" >&2 + return 1 + fi + if covers "$sha" "$now"; then + echo "lost the lease: another writer moved v1 to $now, at or ahead of $sha -- nothing to do" + return 0 + fi + if [ -n "$now" ] && ! git merge-base --is-ancestor "$now" "$sha"; then + echo "ERROR: v1 moved to $now, which is neither behind nor ahead of $sha" >&2 + return 1 + fi + echo "lost the lease: v1 moved to ${now:-}, still behind $sha -- retrying" + expect="$now" + done + echo "ERROR: lost the lease on v1 $MAX_ATTEMPTS times running" >&2 + return 1 +} + +cmd="${1:?usage: release-v1.sh merge | sweep-check | push }" +shift +case "$cmd" in + merge) + SHA="${1:?usage: release-v1.sh merge }" + TIP=$(fetch_main) + if [ "$TIP" != "$SHA" ]; then + echo "main's tip ($TIP) is past this run's gated commit ($SHA) -- deferring; the sweep releases the tip" + exit 0 + fi + V1=$(fetch_v1) + if covers "$SHA" "$V1"; then + echo "v1 ($V1) already at or ahead of $SHA -- nothing to do" + exit 0 + fi + push_leased "$SHA" "$V1" + ;; + sweep-check) + TIP=$(fetch_main) + V1=$(fetch_v1) + if covers "$TIP" "$V1"; then NEEDED=false; else NEEDED=true; fi + echo "main=$TIP v1=${V1:-} release-needed=$NEEDED" + printf 'tip=%s\nv1=%s\nneeded=%s\n' "$TIP" "$V1" "$NEEDED" >> "${GITHUB_OUTPUT:-/dev/stdout}" + ;; + push) + push_leased "${1:?usage: release-v1.sh push }" "${2-}" + ;; + *) + echo "release-v1.sh: unknown command '$cmd'" >&2 + exit 2 + ;; +esac diff --git a/scripts/selftest.sh b/scripts/selftest.sh index 0c7168a..a215799 100755 --- a/scripts/selftest.sh +++ b/scripts/selftest.sh @@ -13,7 +13,7 @@ script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) FAST=0 [ "${1:-}" = "--fast" ] && FAST=1 -FIXTURE_TESTS=(cache-root-selftest.sh seed-target-dir-selftest.sh publish-snapshot-selftest.sh prune-cache-selftest.sh) +FIXTURE_TESTS=(cache-root-selftest.sh seed-target-dir-selftest.sh publish-snapshot-selftest.sh prune-cache-selftest.sh release-v1-selftest.sh) CARGO_TESTS=(hardlink-clone-selftest.sh restore-mtimes-selftest.sh) TESTS=("${FIXTURE_TESTS[@]}")