15 Commits
Author SHA1 Message Date
claude 680ef8049c Merge pull request 'docs: clear PR #28's docs-drift catalog entries' (#31) from chore/docs-catalog-2026-09-23 into main
CI / shellcheck + selftests (push) Successful in 1m29s
CI / move v1 to main (push) Successful in 4s
2026-09-23 03:02:39 +00:00
claudeandClaude Opus 5.5 22dafe45e2 docs: clear PR #28's docs-drift catalog entries
CI / shellcheck + selftests (pull_request) Successful in 1m23s
CI / move v1 to main (pull_request) Skipped
Marks the token grant as verified now that #28's own merge ran
release-tag and moved v1 to that commit (confirmed against the CI
status API and the v1 tag on origin), and rewords the scenario-9
stranding framing to "a run that deferred and left no newer run
behind it" rather than a concurrency-group cancellation ci.yaml no
longer allows.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UkSjXXtU6JYcN2vPntWhfb
2026-09-22 21:53:32 -05:00
claude 0284fcd54b Merge pull request 'ci: release v1 automatically on a green merge to main' (#28) from chore/v1-release-gate into main
CI / shellcheck + selftests (push) Successful in 1m45s
CI / move v1 to main (push) Successful in 4s
2026-09-22 23:12:59 +00:00
claudeandClaude Opus 5.5 77cc5917b6 fix(release): refuse to overwrite an unrelated hand-placed v1
CI / shellcheck + selftests (pull_request) Successful in 1m47s
CI / move v1 to main (pull_request) Skipped
release-v1.sh's push_leased() only detected a lost lease after a push
was *rejected* -- but force-with-lease compares the remote ref's raw
value against the caller's expected value, not ancestry. If v1 already
sat on a hand-placed, unrelated commit when push_leased() was first
called (no race, nobody moves it mid-call), the very first push found
the ref exactly where it expected, succeeded outright, and silently
overwrote the unrelated v1 with <sha> -- skipping every ancestry check
the function has, since those only run after a rejection.

Fix: before the first push attempt, check whether the caller's
`expect` is neither an ancestor of `sha` (the ordinary stale-v1 case)
nor already covering it (nothing to do) -- and go red naming both SHAs
if so. `expect` is always a peeled commit (fetch_v1() reads
`refs/tags/v1^{commit}`), so this doesn't add a second failure mode
for an annotated v1; that tag form's existing "not a lost lease"
behavior on the first rejected push is untouched.

Surfaced by PR #28's final review. New selftest scenario 11 in
release-v1-selftest.sh, red-proven against the unfixed script (v1 was
silently moved off the stray commit); green after the fix, with the
full 7-suite gate (shellcheck + selftest.sh) passing.

Ride-alongs from the same review:
- ci.yaml:120-123 claimed the README's Versioning section documented
  what's verified about the release token's write access; it said
  nothing. Added an accurate sentence there (the grant is unobserved
  until the first merge, capped by repo/owner token-permission maxima
  and unreadable tag protections) and pointed the comment at it.
- Deleted two comment-as-decision-history paragraphs per
  comments-are-not-exposition: ci.yaml's "no job-level concurrency"
  rationale (kept one line of intent) and
  prune-cache-selftest.sh scenario 9's account of how an
  existence-only assertion used to pass with the pass-2 guard removed
  (kept a one-line statement of what it checks).
- README's release-v1-selftest.sh table row now names the new
  hand-placed-v1 scenario.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UkSjXXtU6JYcN2vPntWhfb
2026-09-22 16:14:41 -05:00
claudeandClaude Opus 5.5 ca0ee132d9 fix(ci): add a scheduled v1 sweep and lease every v1 push
CI / shellcheck + selftests (pull_request) Successful in 1m46s
CI / move v1 to main (pull_request) Skipped
The release guard in 17d87b0 was safe but not live. Gitea 1.27.2 calls
CancelPreviousJobsByJobConcurrency whenever a job's `needs` resolve
(services/actions/clear_tasks.go:91, models/actions/run_job.go:641), so
a job's place in the `release-tag-v1` group followed when its own
selftest finished, not merge order. A newer merge C2 finishing selftest
first queued behind the older C1, C1 cancelled it, saw tip = C2, and
deferred: nobody pushed, and if merges then stopped v1 stayed stale
indefinitely behind a Skipped and a Cancelled job. The "always catches
up once merges pause" claim in ci.yaml and README was false.

What now holds:

- release-sweep.yaml runs on `schedule` every 15 minutes, in its own
  workflow and concurrency group, so nothing in ci.yaml can cancel it.
  When v1 already covers main's tip it stops after a checkout and one
  merge-base. Otherwise it checks out the tip, runs the same shellcheck
  and selftest.sh as ci.yaml's selftest job, and tags the tip only if
  they pass; a failing main therefore turns the sweep red on every tick
  while v1 lags, which is #27's AC1 loud-failure half. It reads the tip
  itself because a scheduled run's github.sha is the CommitSHA recorded
  when the schedule was registered on the last push to main
  (services/actions/notifier_helper.go:569-580,
  services/actions/schedule_tasks.go:126-141), and ref is the default
  branch: schedules are registered only from it
  (notifier_helper.go:120, :531, :603-604). event_name is "schedule"
  (context.go:71 reads TriggerEvent, set at schedule_tasks.go:136).
  Cron is 5-field robfig in UTC (models/actions/schedule_spec.go:38-41).

- 15 minutes, not 10: the sweep is the fallback, not the release path,
  and every tick is a run on gitdan-ci's shared slots and a row in the
  Actions list. 96 no-op runs a day of a few seconds each is the cost;
  the lag bound it buys is one interval plus one selftest run.

- Both writers go through scripts/release-v1.sh and push with
  --force-with-lease=refs/tags/v1:<v1 as read>, so v1 cannot move
  backwards when the sweep and a merge job race. A lost lease re-reads
  v1: at or ahead of this run's gated commit is a clean skip (the other
  writer released something at least as new); still behind it is a
  retry leased on the new value, up to three attempts, since the other
  writer may have tagged an older commit and giving up there would leave
  v1 short of a commit this run did gate; anything else goes red. A
  rejection with v1 unmoved is diagnosed as a non-lease failure and goes
  red at once.

- release-tag loses its job-level concurrency group. The lease already
  gives the ordering the group was there for, and the group was what
  cancelled the one job that could have released the newest merge.
  Without it each merge's job runs, and the one whose commit is still
  the tip when it checks releases it.

The shell moves out of ci.yaml into scripts/release-v1.sh so shellcheck
and selftest.sh cover it. release-v1-selftest.sh runs it against a
scratch bare origin: sweep no-op at and ahead of the tip, tag on a
green gate, no tag and a failing sweep on a red one, the stranded trace
above followed by a catching-up sweep, and each lost-lease outcome, with
a control showing an unleased push does step v1 back. Red-proved by
seven mutations of release-v1.sh, each failing a named assertion: plain
--force, accepting any lost lease, a merge job that never defers,
ancestry reduced to equality, no non-lease diagnosis, a sweep that never
needs to run, and a retry that does not re-lease.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UkSjXXtU6JYcN2vPntWhfb
2026-09-22 15:01:05 -05:00
claudeandClaude Sonnet 5 17d87b0647 fix(ci): only ever push a commit this run actually gated; fix vacuous scenario-9 guard
## v1 could advance onto an ungated commit

`needs: selftest` gates this run's own commit, but the push targeted
origin/main's freshly-fetched tip with nothing comparing the two.
Trace: M1 merges green; M2 merges while M1's selftest is still
running; M1's release-tag job fetches tip = M2 and pushes v1 = M2,
whose own selftest may be queued, running, or red. If M2 is red, its
own job is skipped, so v1 sits on a red commit across every consuming
project until the next green merge -- with nothing red pointing at
the release itself. ci.yaml:107-109 and README.md:640-641 both
asserted this couldn't happen; ea48c03's own diff established the
precondition (tip "can be minutes stale... behind its own selftest
job") without closing it.

Fix: skip the push unless origin/main's tip IS this run's own
github.sha, checked before the existing v1-monotonicity check
(dc1e631) rather than replacing it -- the two compose (tip-mismatch
first, since it's the coarser reason to defer; ancestor-check second,
for a duplicate run whose own commit is still current). Reverted the
push target from the fetched tip back to `${{ github.sha }}`, now
that the guard makes them provably equal whenever the push fires.

## Convergence trace: does the newest commit's job still run?

Read gitea's source further at the pinned v1.27.2 tag:
PrepareToStartJobWithConcurrency (services/actions/clear_tasks.go)
calls CancelPreviousJobsByJobConcurrency on every job entering the
group, unconditionally cancelling whatever was previously
Waiting/Blocked there -- so at most one job sits queued in the group
at a time; each new arrival supersedes it. Because job-level
concurrency is only evaluated once `needs: selftest` is satisfied
(job_emitter.go re-evaluates readiness there), "arrival order" tracks
each commit's own selftest-completion time, not raw merge order -- an
older commit with a slower selftest can enter the group after a
newer one and cancel its queued slot.

That cancelled job is gone for good; it will never push. But the
commit that's genuinely current at the moment merges stop arriving is
always the one still queued when the running job finishes, because
every subsequent arrival (from every subsequent merge, not just the
"newest" one at any single instant) keeps re-superseding the queue.
So v1 always eventually catches up -- "one merge later" in the common
case, "at the next merge, whenever that happens" in the adversarial
case where a stale survivor runs, finds itself no longer current,
defers, and nothing is left queued. It cannot get stuck forever short
of the repository never receiving another merge, because every future
push re-attempts the same check against whatever's current by then.
Stated this plainly in the comment and README rather than repeating
the false "never" guarantee in softer words.

Re-derived the truth table against the new guard in a scratch
origin+clone, six cases: own commit == tip, no v1 (push); v1 already
== own commit (skip, duplicate run); tip moved past own gated commit
because a newer merge landed (skip, defers); the newer commit's own
run once nothing further has landed (push); v1 already ahead of a
now-stale gated commit (skip, tip-mismatch catches it first); tip ==
own commit but v1 independently ahead via a local-only descendant,
isolating the second (ancestor) check on its own (skip). All six
resolved as intended.

## Scenario 9's pass-3 guard was vacuous

scripts/prune-cache-selftest.sh:230 grepped 'self-clear' against
$scratch/log. summary_line() (cache-lib.sh) writes only to
$GITHUB_STEP_SUMMARY; the self-clear branch (prune-cache.sh:632)
writes 'self-clear' there and 'clearing own' to stdout (:631) --
'self-clear' never appears in $scratch/log at all, so the branch was
unreachable and the `ok` unconditional. Scenario 8 already greps the
right string against the right file (assert_log "clearing own" ...);
scenario 9 now does the same, staying on $scratch/log where it
already was -- the string was wrong, not the file.

Red-proved by capturing a real prune-cache.sh log where self-clear
genuinely fired (scenario 9's own fixture with MIN_FREE_PCT
temporarily raised to 100, in a scratch copy, reverted after) and
running both patterns against it: `grep -q 'self-clear'` -> no match
(the old check's vacuous pass, confirmed); `grep -q 'clearing own'`
-> match (the fix's correct fail). Matches the reviewer's own
measurement exactly. The real prune-cache-selftest.sh was untouched
during this experiment; only the grep string changed in the actual
commit.

bash scripts/selftest.sh: all 6 suites green (67 prune-cache
assertions, unchanged in count -- the fix corrects what scenario 9's
existing check compares, not what it asserts). shellcheck -x
--source-path=scripts scripts/*.sh: clean, as before not covering the
inline ci.yaml shell.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UkSjXXtU6JYcN2vPntWhfb
2026-09-22 14:01:28 -05:00
claudeandClaude Sonnet 5 ea48c03aeb fix(ci): target origin/main's live tip, not the run's own trigger commit
CI / shellcheck + selftests (pull_request) Successful in 1m35s
CI / move v1 to main (pull_request) Skipped
The wake-one-cancel-the-rest mechanism behind the concurrency group
(CancelPreviousJobsByJobConcurrency, models/actions/run_job.go, at
the v1.27.2 tag this instance runs) picks its survivor from
models/actions/run_job_list.go's query, which carries no `ORDER BY`
-- so under three-way contention on a shared 2-slot runner, the
*newest* commit's job can be the one cancelled while an older sibling
survives and, correctly from its own vantage, advances v1 forward
from a stale view. No push regresses v1 (the ancestor check from
dc1e631 already prevented that), but the newest merge goes silently
unreleased behind a cancelled job that reads as benign, not red --
exactly the failure #27 exists to end.

Every job now resolves `origin/main`'s tip fresh, right before the
push, instead of using `${{ github.sha }}`. Re-fetched explicitly
rather than trusted from the checkout step, which can be minutes
stale by this point behind its own selftest job. Every execution that
reaches the push step now converges on the same target regardless of
which job the concurrency group lets through, so which one wins the
wake no longer matters -- the survivor pushes where any of them
would have.

That doesn't make the push safe on its own: two jobs can still read
main at genuinely different moments if it advances between their two
fetches, so whichever read the tip earlier must not overwrite the
other's already-pushed, newer one. The ancestor check from dc1e631 is
kept for exactly this -- its target changed (origin/main's live tip,
not this job's own trigger commit) but its job didn't.

What each guard now protects against, after this change:
- concurrency group: stops two jobs from pushing at the same time --
  wasted work now that a cancelled job costs nothing, not a
  correctness backstop by itself.
- ancestor check: stops a job whose own fetch of the tip is stale
  relative to another job's already-pushed, fresher one from
  regressing v1.

Rewrote both the job-level comments and README's Versioning section,
which described "force-moves v1 to that commit" and said nothing
about the concurrency group or the skip-as-success case.

Re-derived the truth table against the new target (a live origin/main
tip, not a fixed commit) in a scratch origin+clone: no v1 yet (push),
v1 exactly at the tip (skip), the tip moved past v1 because a newer
merge landed (push, to the new tip -- not stuck on any prior commit),
v1 already ahead of the tip (skip, defensive), unrelated histories
(push, defensive). All five resolved as intended; ran the exact
condition and fetch sequence the workflow step uses, not a
simulation of it.

bash scripts/selftest.sh: all 6 suites green (67 prune-cache
assertions, unchanged). shellcheck -x --source-path=scripts
scripts/*.sh: clean -- as before, this does not cover the inline
`run:` shell in ci.yaml.

What remains unverifiable short of a real merge is unchanged from
dc1e631: the workflow step's actual execution inside a real Actions
run, and whether the built-in token has write access at all. Neither
this commit nor the one before it can exercise those.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UkSjXXtU6JYcN2vPntWhfb
2026-09-22 12:49:05 -05:00
claudeandClaude Sonnet 5 dc1e6317c6 fix(ci): make the v1 push monotonic, not just mutually exclusive
The concurrency group added in af1233f only excludes two release-tag
jobs that are simultaneously Running/Waiting/Blocked
(services/actions/clear_tasks.go:64-80,
models/actions/run_job.go:641-660 at the v1.27.2 tag this instance
runs) -- it has no notion of commit order between jobs that never
overlap. On a 2-slot runner shared across 4 repos, with a
multi-minute selftest gating each release-tag job, two merges landing
close together routinely finish in the opposite order from the pushes
that triggered them: if the newer commit's job completes and exits
first, the older commit's job later finds no live holder in the
group, is not blocked, and force-pushes v1 backward to itself. The
concurrency comment's "never regress it to an older one" and af1233f's
commit message both asserted the opposite -- true of the simultaneous
case the guard covers, false of the staggered one it doesn't, so
authored-false rather than drift.

Added a merge-base check before the push: skip if v1 already points
at this commit or a descendant of it (`--is-ancestor` treats a commit
as its own ancestor, so "at" and "ahead" are the same branch). A v1
that doesn't exist yet, or shares no history with this commit, falls
through to the push -- the guard only ever skips, never fails. Needs
`fetch-depth: 0` on the checkout: actions/checkout's own description
for that value is "all history for all branches and tags", and its
source (dist/index.js: fetchDepth <= 0 selects
getRefSpecForAllHistory, which includes the tags refspec) confirms
tags are fetched as part of that, not gated behind the separate
fetch-tags input -- so refs/tags/v1 and the history behind it are both
guaranteed present locally without a second fetch step.

Rewrote both false claims: the concurrency comment now says what the
group actually bounds (simultaneous competing pushes, not completion
order), and states plainly that the guard below is what makes the
outcome order-independent.

Verified the check's five cases (no tag yet, tag ahead of this
commit, tag behind this commit, tag equal to this commit, unrelated
history) against a real scratch git repo, extracting the exact
condition used in the workflow step -- all five resolved as intended
(skip only when v1 is already at or ahead). The workflow step itself
cannot be exercised outside a real Actions run.

bash scripts/selftest.sh: all 6 suites green (67 prune-cache
assertions, unchanged). shellcheck -x --source-path=scripts
scripts/*.sh: clean -- note this does not cover the inline `run:`
shell in ci.yaml, which shellcheck was never wired to check in this
repo (verified against the CI job itself, which shellchecks only
scripts/*.sh).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UkSjXXtU6JYcN2vPntWhfb
2026-09-22 12:24:27 -05:00
claudeandClaude Sonnet 5 af1233f14a fix(ci): serialise release-tag across concurrent merges
The workflow-level concurrency group is keyed per-commit (github.sha,
ci.yaml:19-28) so unrelated pushes never block each other -- but that
also means two merges landing close together run two concurrent
release-tag jobs, each force-pushing its own commit to v1. If the
older commit's job finishes last, v1 regresses to a stale-but-green
commit and stays there until the next merge corrects it forward.
Bounded blast radius (never a red commit, self-heals on the next
merge), but a silently wrong v1 is the exact failure #27 exists to
end.

Added a job-level `concurrency:` on release-tag with a fixed group
name and `cancel-in-progress: false`. Confirmed this is additive to
the workflow-level group, not a replacement, by reading gitea's source
at the v1.27.2 tag this instance runs (`tea api version`): run-level
and job-level concurrency are separate model fields
(ActionRunAttempt.ConcurrencyGroup vs ActionRunJob.ConcurrencyGroup),
evaluated by separate functions (EvaluateRunConcurrencyFillModel vs
EvaluateJobConcurrencyFillModel, services/actions/concurrency.go) and
enforced by separate cancellation paths (CancelPreviousJobsByRunConcurrency
in models/actions/run.go:364 vs CancelPreviousJobsByJobConcurrency in
models/actions/run_job.go:641) -- job_emitter.go's checkRunConcurrency
checks both groups independently (services/actions/job_emitter.go:210-236).
A fixed, non-sha group name is what serialises release-tag across
commits without touching the per-sha grouping every other job still
relies on. `cancel-in-progress: false` matters because Gitea's default
queue behaviour (documented same as GitHub's: a new queued job
supersedes an older *queued* one in the same group, but not a
*running* one) already gives the newest commit's push priority once
the running job clears; cancelling the running job too would abandon
whichever merge is currently pushing mid-flight, the same defect
wearing different clothes.

Re-verified: YAML parses, shellcheck clean, `bash scripts/selftest.sh`
all 6 suites green (67 prune-cache assertions, unchanged by this
commit).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UkSjXXtU6JYcN2vPntWhfb
2026-09-22 12:07:01 -05:00
claudeandClaude Sonnet 5 21b444121d test(prune): red-prove $OWN_DIR protection under genuine disk pressure
Scenario 9 asserted only that target-$OWN existed, checked right after
a no-pressure run (3b) where nothing was ever a candidate for
eviction, and prune-cache.sh's self-clear step unconditionally
recreates an empty $OWN_DIR whenever the run ends under the percentage
floor regardless of what pass 2 did to it. Either way the existence
check passed whether or not the pass-2 guard (protected_reason,
prune-cache.sh:221) actually protected the directory. Moving that
guard's $OWN_DIR check into the pass-1-only predicate — the exact
mutation gitdan-actions#26 describes — left all 64 assertions green,
confirmed here before the fix.

Rewritten to run under a real, shrinking `df` (the scenario-17
pattern: a fake df that re-measures the fixture with `du` on every
call, so eviction genuinely lowers the reported pressure), with
MIN_FREE_PCT=0 and a clone-headroom floor sized so self-clear's
percentage check can never fire — only pass 2's guard decides the
outcome. The fixture gives $OWN_DIR real content and an older
timestamp than a sibling target dir, sized so the requirement is met
by evicting exactly one of them. With the guard removed, $OWN_DIR is
the one evicted (LRU-oldest, and self-clear is structurally disabled
by MIN_FREE_PCT=0 so there is nothing left to recreate it) — the
scenario now fails loudly on the same mutation that left it green
before. Restored and reverified green (67 assertions, up from 64) with
the guard intact.

bash scripts/selftest.sh: all 6 suites green. shellcheck -x
--source-path=scripts scripts/*.sh: clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UkSjXXtU6JYcN2vPntWhfb
2026-09-22 11:52:01 -05:00
claudeandClaude Sonnet 5 7f18cb2436 feat(ci): advance v1 automatically once the gate is green on main
Nothing moved v1 when main advanced, so a merged change was inert
until someone remembered to retag by hand — it happened on 2026-09-22
(PR #25 merged, v1 stayed on the previous release) and was only caught
because a person asked whether the tag had moved.

Option 1 from gitdan-actions#27 (automate it) over option 2 (fail loud
while it lags): a `release-tag` job, gated with `needs: selftest` so a
broken build never reaches it, force-moves v1 to the pushed commit
using the run's built-in GITHUB_TOKEN. If that token turns out not to
have write access, the push fails and the job goes red in the Actions
UI — a loud failure either way, not the silent one this replaces.
Whether the token actually has write access here is unverified short
of a real merge; that merge is the next step for this branch.

README's Versioning section documented the old manual step as a
deliberate decision "never something a merge does by itself" — that
claim is now false, so it's rewritten to describe the automated job
and keeps the manual command as the recovery path for when the job
can't push.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UkSjXXtU6JYcN2vPntWhfb
2026-09-22 11:51:51 -05:00
claude 21dffdb725 Merge pull request 'fix(prune): stop protecting publisher target dirs under disk pressure' (#25) from fix/prune-unprotect-target into main
CI / shellcheck + selftests (push) Successful in 1m49s
2026-09-22 16:07:52 +00:00
claudeandClaude Sonnet 5 dc473f0d0c fix(prune): stop protecting publisher target dirs under disk pressure
CI / shellcheck + selftests (pull_request) Successful in 1m48s
A publisher branch's target-<ref> was protected identically to its
snapshot-<ref>, so it was never a pressure-pass candidate however old
and however tight the disk. With one branch's target dir permanently
resident alongside its snapshot, a second branch had no room to seed,
and every PR that night needed a hand eviction between runs
(daniel/gitdan-actions#24).

A publisher's target dir is a convenience cache its own next run
reseeds from the snapshot, so losing it under pressure is cheap;
nothing downstream depends on it surviving. Only the snapshot stays
protected in the pressure and self-clear passes.

Unprotecting the target dir outright surfaced a second bug the fix
would otherwise have shipped: a branch's tip is trivially an ancestor
of itself, so once a protected ref's target dir was no longer skipped
before reaching the merged-branch check, pass 1 read it as "merged
into itself" and deleted it unconditionally on every run, independent
of disk pressure. is_protected_from_liveness keeps a protected ref's
target dir out of pass 1 alone, so it stays an ordinary pressure-pass
candidate without ever reaching that check. Scenario 3b in the
selftest red-proves this against the unprotect-only version of the
fix.

Also corrects the header's inode-sharing claim, measured false on the
live volume by daniel/zemyna#1073: publish-snapshot.sh unshares every
executable after its cp -al, and executables are most of the tree by
bytes, so a snapshot eviction is a real, large disk cost rather than
the near-free one the old text described — the target-before-snapshot
ordering still holds, now for the warm-start reason alone plus that
cost.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UkSjXXtU6JYcN2vPntWhfb
2026-09-22 10:42:08 -05:00
claude 0184df25a2 Merge pull request 'fix(prune): reclaim merged branches and size the volume for the clone' (#21) from fix/prune-merged-live-and-seed-headroom into main
CI / shellcheck + selftests (push) Successful in 1m24s
2026-09-07 05:41:12 +00:00
claudeandClaude Fable 5.1 38a6387936 docs(cache): recommend delete-on-merge, and say what ancestry still covers
CI / shellcheck + selftests (pull_request) Successful in 1m27s
`daniel/zemyna` enabled `default_delete_branch_after_merge` after this
branch was written, so the deleted-branch signal will fire there on
future merges. That makes the setting worth recommending — it is the
cheapest case for this scheme, decidable from `ls-remote` with no
checkout, no objects and no walk — and it does not make the ancestry
signal redundant.

Three things it leaves behind, now enumerated in the README's eviction
section rather than implied: every branch merged before the setting was
turned on, of which zemyna carried 48 and which nothing retroactively
deletes; every merge whose deletion the forge declines or is never asked
to make, since it is best-effort and silent and an API merge without the
flag never asks; and every repo that has not enabled it, which is the
default.

Two present-tense claims about one repo's configuration are reworded
into the conditions they were standing in for, in prune-cache.sh's
header and beside is_merged_dead, plus the two in the selftest that
asserted the forge keeps branches rather than describing the fixture.
No behaviour change; the suite is green and unchanged at 61 assertions.

Refs #20.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JXMQCJ5Eg5f9G9cfYzyh4Z
2026-09-07 00:27:11 -05:00
9 changed files with 620 additions and 68 deletions
+33
View File
@@ -101,3 +101,36 @@ jobs:
# file. # file.
- name: Selftests - name: Selftests
run: bash scripts/selftest.sh run: bash scripts/selftest.sh
release-tag:
name: move v1 to main
# `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 release-sweep.yaml picks up anything this
# job defers or misses.
needs: selftest
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
runs-on: ubuntu-latest
timeout-minutes: 2
# Requests write access from the run's built-in token -- whether that
# grant actually lets it push here is unobserved until the first merge
# (see README's Versioning section). Without this the checkout below
# still succeeds -- it's the push that would be rejected, which is a red
# job, not a silent no-op.
permissions:
contents: write
steps:
# 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
# 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 }}"
+69
View File
@@ -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 }}"
+75 -26
View File
@@ -247,8 +247,8 @@ not collapsed into one:
pass still deciding about one is never mistaken for a pass that died holding pass still deciding about one is never mistaken for a pass that died holding
it. That settle window is a bound rather than a construction, and it is the it. That settle window is a bound rather than a construction, and it is the
only part of this that is. Until the rest of it was structural it was merely only part of this that is. Until the rest of it was structural it was merely
policy — snapshots belong to protected refs, protected refs are never policy — snapshots belong to protected refs, and a protected ref's snapshot
eviction candidates — a property held by vigilance rather than by is never an eviction candidate — a property held by vigilance rather than by
construction. construction.
- **Every other way the source can change mid-clone is detected, not - **Every other way the source can change mid-clone is detected, not
prevented.** A `seed-fallback-dir` pointing at a directory something else prevented.** A `seed-fallback-dir` pointing at a directory something else
@@ -271,22 +271,45 @@ not collapsed into one:
available to the clone that follows. Caches for branches that are DEAD are available to the clone that follows. Caches for branches that are DEAD are
removed unconditionally; then, only if free space is under the requirement, removed unconditionally; then, only if free space is under the requirement,
live caches are evicted oldest-first; then, as a last resort, this run's own live caches are evicted oldest-first; then, as a last resort, this run's own
cache. Protected refs, the source this run is about to clone, and any cache cache. A protected ref's *snapshot*, the source this run is about to clone,
held open by a running job are never candidates. Within the pressure pass, and any cache held open by a running job are never candidates in the pressure
`target-*` directories are evicted before `snapshot-*` ones — the reverse of or self-clear passes. A protected ref's own *target* dir is an ordinary
the obvious order, because a snapshot is hardlinked to everything cloned from pressure-pass candidate, since it is a convenience cache the publisher's next
it, so removing one frees almost no real bytes while costing every future PR run reseeds from the snapshot — but it is excluded from the liveness pass
its warm start. alone, because a branch's tip is trivially an ancestor of itself, and without
that exclusion the merged-branch signal would read a publisher's own target
dir as merged into itself and delete it every run, unconditionally. Within the
pressure pass, `target-*` directories are evicted before `snapshot-*` ones:
losing a target dir is cheap for exactly that reseeding reason, while
evicting a snapshot forces every subsequent PR to start cold and, measured on
the live volume (daniel/zemyna#1073), frees real disk rather than the
near-nothing a shared-inode hardlink clone would suggest — `publish-snapshot.sh`
unshares every executable after its `cp -al`, and executables are most of the
tree by bytes.
**A branch is dead in two ways, and the second is the one that reclaims **A branch is dead in two ways, and neither signal makes the other
anything here.** Gitea keeps a PR's branch after the merge unless the repo redundant.** The first is that the branch is gone from origin. The second is
opts into delete-on-merge, so `git ls-remote` reports merged branches forever
and "gone from origin" fires for almost nothing. The second signal is
ancestry: a branch still on origin whose tip is an ancestor of a protected ancestry: a branch still on origin whose tip is an ancestor of a protected
branch's tip holds no commit that branch does not, so its cache will never be branch's tip holds no commit that branch does not, so its cache will never be
read again and goes in the same pass. On zemyna's volume that is the read again and goes in the same pass.
difference between reclaiming nothing and reclaiming a 40 GB directory per
merged PR (gitdan-actions#20). **Turn delete-on-merge on** (`default_delete_branch_after_merge`, per repo) —
it is the setting this scheme is cheapest under, because a deleted branch is
decidable from `ls-remote` alone, with no checkout, no objects and no walk.
The ancestry signal is what covers the rest, and the rest is not a corner:
- **Every branch merged before the setting was turned on.** They stay on
origin forever; nothing retroactively deletes them. zemyna carried 48 of
them at the time the setting was enabled, and ancestry is the only thing
that reclaims a cache dir belonging to any of them.
- **Every merge the deletion declines or fails.** Gitea's delete is
best-effort and silent: it declines for a protected branch and for one
another open PR still uses, and an API merge that omits the flag — which
`tea pulls merge` does — simply never asks.
- **Repos that have not enabled it**, which is the default.
That is the difference between reclaiming nothing and reclaiming a 40 GB
directory per merged PR on a full volume (issue 20).
Ancestry is answered from the commits in the job's own checkout, so it is only Ancestry is answered from the commits in the job's own checkout, so it is only
answered where they are there to answer it — and "cannot tell" is never folded answered where they are there to answer it — and "cannot tell" is never folded
@@ -460,7 +483,7 @@ directory; the line just doesn't say which.
|---|---|---| |---|---|---|
| `cache-root` | `/cache` | mount point of the persistent volume inside the job container | | `cache-root` | `/cache` | mount point of the persistent volume inside the job container |
| `cache-lineage` | *(empty)* | one directory level under `cache-root`, for a second job building the same ref for a different target or profile — see [Multiple jobs in one workflow](#multiple-jobs-in-one-workflow) | | `cache-lineage` | *(empty)* | one directory level under `cache-root`, for a second job building the same ref for a different target or profile — see [Multiple jobs in one workflow](#multiple-jobs-in-one-workflow) |
| `protected-branches` | `dev main` | refs that publish snapshots and are never evicted | | `protected-branches` | `dev main` | refs that publish snapshots, whose snapshots are never evicted (their target dirs are ordinary pressure-pass candidates) |
| `min-free-percent` | `0` | an ADDITIONAL free-space floor, as a percentage of the volume. The gate is derived per run from what the seed is about to clone; this only ever raises it | | `min-free-percent` | `0` | an ADDITIONAL free-space floor, as a percentage of the volume. The gate is derived per run from what the seed is about to clone; this only ever raises it |
| `restore-mtimes` | `true` | restore tracked-file mtimes from git history | | `restore-mtimes` | `true` | restore tracked-file mtimes from git history |
| `prune` | `true` | run the eviction pass — before the seed, so what it frees is available to the clone | | `prune` | `true` | run the eviction pass — before the seed, so what it frees is available to the clone |
@@ -611,13 +634,39 @@ entry, another permission — is a `v2`, not a `v1` move. Everything else moves
`v1`: correctness fixes, new optional inputs, and anything internal to `v1`: correctness fixes, new optional inputs, and anything internal to
`scripts/`. `scripts/`.
**Moving the tag is a release step, and it is the operator's.** Merging to **Moving the tag is automatic, gated on the same build that gates a PR.** Two
`main` ships nothing to anybody. `v1` is a lightweight tag and does not follow jobs move it, both through `scripts/release-v1.sh`, both with the run's
a branch, so until it is re-pointed every consumer keeps fetching the commit it built-in `GITHUB_TOKEN`:
already named, whatever `main` now says. The gap is deliberate: re-pointing
`v1` changes what another repository's CI executes on its next run, so it is a - **`release-tag`** in `.gitea/workflows/ci.yaml` runs on every push to
decision taken once, knowingly, after the merge — never something a merge does `main`, `needs: selftest`, and moves `v1` to that run's own commit — but only
by itself. 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.
Both jobs request `contents: write` on the run's built-in token, and that
grant is now **verified**: PR #28's own merge ran `release-tag` successfully
and moved `v1` to that merge's commit. The grant is still capped by the
repo's and owner's maximum token permissions, and branch/tag protections on
`v1` can't be read without admin access. A rejected push is a red job, not a
silent no-op.
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 neither job can push:
```bash ```bash
git fetch origin git fetch origin
@@ -628,9 +677,8 @@ git ls-remote --tags origin v1 # must equal git rev-parse origin/main
**Downstream** are emowheel, which pins `cargo-cache@v1` and **Downstream** are emowheel, which pins `cargo-cache@v1` and
`cargo-cache-publish@v1` across its CI workflow, and zemyna, migrating to the `cargo-cache-publish@v1` across its CI workflow, and zemyna, migrating to the
same pin. Both pick a move up on their next run with no change on their side, same pin. Both pick a move up automatically on their next run with no change
which is the whole point of the moving pointer and also the reason the move is on their side, which is the whole point of the moving pointer.
not automatic.
--- ---
@@ -702,6 +750,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 | | `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 | | `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 | | `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, 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 — and a `v1` hand-placed on an unrelated commit before any push is ever attempted, also 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. | | `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 Every suite runs the actual script, not a reimplementation of its logic, and
+3 -1
View File
@@ -24,7 +24,9 @@ inputs:
default: '' default: ''
protected-branches: protected-branches:
description: >- description: >-
Space-separated refs that publish snapshots and are never evicted. Space-separated refs that publish snapshots. A protected ref's
snapshot is never evicted; its own target dir is an ordinary
pressure-pass candidate, reseeded from the snapshot on its next run.
These are the branches PR caches layer over. These are the branches PR caches layer over.
required: false required: false
default: 'dev main' default: 'dev main'
+88 -13
View File
@@ -11,8 +11,13 @@
# Waiting for pressure to notice means paying for dead caches until then. # Waiting for pressure to notice means paying for dead caches until then.
# 2. LIVE BRANCH SURVIVES despite being OLDER than the dead one — liveness, # 2. LIVE BRANCH SURVIVES despite being OLDER than the dead one — liveness,
# not age, is what decides pass 1. # not age, is what decides pass 1.
# 3. PROTECTED REFS NEVER EVICTED under forced disk pressure, even when # 3. A PROTECTED REF'S SNAPSHOT NEVER EVICTED under forced disk pressure,
# their caches are the oldest on disk and would rank first for LRU. # even when it is the oldest on disk and would rank first for LRU — its
# own TARGET dir is an ordinary candidate and goes (gitdan-actions#24).
# 3b. AND A PROTECTED REF'S TARGET DIR SURVIVES PASS 1 ANYWAY: its tip is
# trivially an ancestor of itself, so the merged-branch signal must never
# be allowed to evaluate it, or pass 1 — unconditional, not gated on
# pressure — would delete it every run.
# 4. LOCKED CACHE PROTECTED even when dead, old, and under pressure — and # 4. LOCKED CACHE PROTECTED even when dead, old, and under pressure — and
# the pass's closing summary agrees with the decline it just logged, # the pass's closing summary agrees with the decline it just logged,
# rather than reporting that it found nothing. # rather than reporting that it found nothing.
@@ -28,7 +33,10 @@
# so evicting it frees almost nothing while costing every future PR its # so evicting it frees almost nothing while costing every future PR its
# warm start. # warm start.
# 8. SELF-CLEAR REPORTS LOUDLY to the job summary, not just a log warning. # 8. SELF-CLEAR REPORTS LOUDLY to the job summary, not just a log warning.
# 9. OWN CACHE NEVER EVICTED by a sibling pass. # 9. OWN CACHE NEVER EVICTED by a sibling pass, genuinely under pressure —
# against a real, shrinking `df` (gitdan-actions#26). Checks CONTENTS,
# not just existence, so pass 3's self-clear can't mask a missed
# pass-2 guard.
# 10. SCOPED TO THE CACHE ROOT — a decoy outside it (standing in for another # 10. SCOPED TO THE CACHE ROOT — a decoy outside it (standing in for another
# project's volume) is never touched. # project's volume) is never touched.
# 11. A LIVE READER MARKER PROTECTS A CACHE the same way a lock file does — a # 11. A LIVE READER MARKER PROTECTS A CACHE the same way a lock file does — a
@@ -46,10 +54,10 @@
# empty a tree its owner may still restore under a live cache name. Its # empty a tree its owner may still restore under a live cache name. Its
# fixture is an OLD directory renamed a moment ago — production's shape, # fixture is an OLD directory renamed a moment ago — production's shape,
# and what lets it tell the two timestamps apart. # and what lets it tell the two timestamps apart.
# 15. A MERGED-BUT-UNDELETED BRANCH IS DEAD TOO. This forge keeps branches # 15. A MERGED-BUT-UNDELETED BRANCH IS DEAD TOO. A branch the forge did not
# after merge, so `ls-remote` reports them forever and scenario 1's # delete at merge stays on `ls-remote` forever, so scenario 1's signal
# signal never fires for them — which is how three 40 GB caches sat on a # never fires for it — which is how three 40 GB caches sat on a full
# full volume until somebody removed them by hand (gitdan-actions#20). A # volume until somebody removed them by hand (gitdan-actions#20). A
# branch whose tip is an ancestor of a protected branch's tip is pruned # branch whose tip is an ancestor of a protected branch's tip is pruned
# like a deleted one; an unmerged branch beside it is not. # like a deleted one; an unmerged branch beside it is not.
# 16. AND "CANNOT TELL" IS STILL NOT DEATH, at both granularities: a branch # 16. AND "CANNOT TELL" IS STILL NOT DEATH, at both granularities: a branch
@@ -139,17 +147,84 @@ assert_kept "$root/target-$LIVE" "live branch survives despite an older marker t
assert_log "no matching branch on origin" "eviction reason reported" assert_log "no matching branch on origin" "eviction reason reported"
echo echo
echo "=== 3: protected refs never evicted under forced pressure ===" echo "=== 3: protected refs' SNAPSHOTS never evicted under forced pressure ==="
reset_cache reset_cache
run_prune "1000000 1000" # 0.1% free run_prune "1000000 1000" # 0.1% free
assert_kept "$root/target-$DEV" "dev's target dir survives disk pressure"
assert_kept "$root/snapshot-$DEV" "dev's snapshot survives disk pressure" assert_kept "$root/snapshot-$DEV" "dev's snapshot survives disk pressure"
assert_kept "$root/target-$MAIN" "main's target dir survives disk pressure"
assert_kept "$root/snapshot-$MAIN" "main's snapshot survives disk pressure" assert_kept "$root/snapshot-$MAIN" "main's snapshot survives disk pressure"
# Their TARGET dirs are ordinary candidates and go under the same pressure —
# gitdan-actions#24: protecting them starved every second branch of room to
# seed. Asserted here (gone, not kept) so this scenario still red-proves the
# snapshot half if a future change reintroduces target protection.
assert_gone "$root/target-$DEV" "dev's target dir is an ordinary pressure-pass candidate"
assert_gone "$root/target-$MAIN" "main's target dir is an ordinary pressure-pass candidate"
echo echo
echo "=== 9: own cache never evicted by a sibling pass ===" echo "=== 3b: a protected ref's target dir is NOT pruned by pass 1's liveness ==="
assert_kept "$root/target-$OWN" "this run's own cache survives" # The regression this fix could introduce and the selftest above cannot see:
# a protected branch's tip is trivially an ancestor of itself, so once its
# target dir stopped being excluded from pass 1 altogether, is_merged_dead
# read it as "merged into itself" and pass 1 — unconditional, not gated on
# pressure — deleted it on every single run. Plenty of free space, so only
# pass 1 can be responsible for anything gone here.
reset_cache
run_prune "1000000 900000" # 90% free: no pressure at all
assert_kept "$root/target-$DEV" "dev's target dir survives pass 1 despite being its own ancestor"
assert_kept "$root/target-$MAIN" "and so does main's"
if grep -q "merged into" "$scratch/log"; then
fail "a protected ref's own target dir was evaluated by the merged-branch signal at all"
fi
ok "no protected ref's own target dir reaches the merged-branch check"
echo
echo "=== 9: own cache survives a sibling pass genuinely under pressure ==="
# A real, shrinking `df` (the scenario-17 pattern), not CACHE_DF_OVERRIDE:
# eviction has to actually free space for "pressure eases once enough is
# freed" to mean anything. MIN_FREE_PCT=0 and a clone-headroom floor (not
# the percentage floor) drive the requirement, so the requirement is an
# exact, chosen KB rather than a percentage of a volume size this fixture
# would otherwise have to reverse-engineer.
rm -rf "$root"; mkdir -p "$root"
blob_kb=4096
mkdir -p "$root/target-$OWN"
head -c $((blob_kb * 1024)) /dev/zero > "$root/target-$OWN/blob"
touch -d '2020-01-01' "$root/target-$OWN/.cache-last-used"
mkdir -p "$root/target-$LIVE"
head -c $((blob_kb * 1024)) /dev/zero > "$root/target-$LIVE/blob"
touch -d '2021-01-01' "$root/target-$LIVE/.cache-last-used"
# The clone-headroom lookup's base-snapshot candidate — never read for its
# content (CACHE_CLONE_HEADROOM_PERCENT=0 below), only for existing so the
# floor alone becomes the requirement.
mkdir -p "$root/snapshot-$DEV"
real_du=$(command -v du)
used9=$($real_du -sk "$root" | awk '{print $1}')
cap9=$(( used9 + 2048 )) # 2 MB to spare: under the requirement, over nothing else
mkdir -p "$scratch/bin9"
cat > "$scratch/bin9/df" <<DFEOF
#!/usr/bin/env bash
used=\$($real_du -sk "$root" | awk '{print \$1}')
echo "Filesystem 1024-blocks Used Available Capacity Mounted-on"
echo "fake $cap9 \$used \$(( $cap9 - used )) 50% $root"
DFEOF
chmod +x "$scratch/bin9/df"
# Floor sits strictly between "0 evicted" (2048 KB free) and "1 evicted"
# (2048 + blob_kb free) — satisfiable by evicting exactly one candidate.
PATH="$scratch/bin9:$outer_path" \
CACHE_CLONE_HEADROOM_PERCENT=0 CACHE_CLONE_HEADROOM_FLOOR_KB=$(( 2048 + blob_kb / 2 )) \
GITHUB_STEP_SUMMARY="$scratch/summary" \
bash "$prune" "$root" "$root/target-$OWN" "dev main" 0 \
"$(cache_key unused-clone-probe)" "$DEV" "" \
> "$scratch/log" 2>&1 \
|| { cat "$scratch/log"; fail "prune-cache.sh exited non-zero"; }
assert_kept "$root/target-$OWN" "this run's own cache directory survives a genuinely pressured sibling pass"
assert_kept "$root/target-$OWN/blob" "and its contents survive — not a recreated empty directory"
assert_gone "$root/target-$LIVE" "the sibling is evicted instead, to make the same room"
if grep -q 'clearing own' "$scratch/log"; then
fail "own cache was cleared by pass 3, not genuinely spared by pass 2 — this scenario proves nothing"
fi
ok "the requirement was met by pass 2 alone; pass 3 never ran"
echo echo
echo "=== 7: target dirs evicted before snapshots ===" echo "=== 7: target dirs evicted before snapshots ==="
@@ -314,7 +389,7 @@ assert_log "may still be evicting it" "the deferral gives its actual reason"
echo echo
echo "=== 15: a merged-but-undeleted branch is dead too ===" echo "=== 15: a merged-but-undeleted branch is dead too ==="
# This forge keeps a PR's branch after the merge, so `ls-remote` reports it # A branch the forge did not delete at merge, which `ls-remote` then reports
# forever. Built the way that happens: a branch merged into dev with a merge # forever. Built the way that happens: a branch merged into dev with a merge
# commit, still pushed, beside one branched at the same point and NOT merged. # commit, still pushed, beside one branched at the same point and NOT merged.
git="git -c user.email=t@t -c user.name=t -c commit.gpgsign=false" git="git -c user.email=t@t -c user.name=t -c commit.gpgsign=false"
+62 -27
View File
@@ -28,8 +28,9 @@
# removed UNCONDITIONALLY, not gated on free space. A directory for a # removed UNCONDITIONALLY, not gated on free space. A directory for a
# branch nothing will build again is pure loss; waiting for disk pressure # branch nothing will build again is pure loss; waiting for disk pressure
# to notice means paying for it until then. Two signals make a branch # to notice means paying for it until then. Two signals make a branch
# dead, and the second exists because the first alone is inert on a forge # dead, and the second exists because the first alone is inert wherever
# that keeps branches after merge (gitdan-actions#20): # a merged branch stays on origin — the default, and still the outcome
# whenever delete-on-merge declines or is not asked (gitdan-actions#20):
# #
# DELETED — the branch is no longer on origin at all. # DELETED — the branch is no longer on origin at all.
# MERGED — the branch is still on origin, but its tip is an ancestor # MERGED — the branch is still on origin, but its tip is an ancestor
@@ -66,21 +67,32 @@
# physics, not an arbitrary GB number. # physics, not an arbitrary GB number.
# #
# EVICTION ORDER, and why it is the reverse of the obvious one: within the # EVICTION ORDER, and why it is the reverse of the obvious one: within the
# pressure pass, `target-*` directories are evicted BEFORE `snapshot-*` ones. # pressure pass, `target-*` directories are evicted BEFORE `snapshot-*` ones
# A snapshot is a hardlink clone of a live target dir and of every consumer # (a publisher's own target dir included — see PROTECTED below). A publisher
# cloned from it, so removing it frees almost no real bytes — its inodes stay # branch's target dir is a convenience cache that its own next run reseeds
# alive through those other links — while costing every future PR its warm # from the snapshot, so losing it is cheap; evicting the snapshot instead
# start. Evicting snapshots first would be nearly pure loss. Target # forces every subsequent PR to start cold. Measured on the live volume
# directories are where a branch's own divergent artifacts actually live, so # (daniel/zemyna#1073), that cold start is not the near-free move it looks
# they are what freeing space means. # like either: a snapshot shares almost nothing with the target dirs cloned
# from it, because publish-snapshot.sh unshares every executable after its
# `cp -al` (cargo and the linker rewrite binaries in place, so a shared
# original would corrupt under them), and executables are the great majority
# of the tree by bytes. So a snapshot eviction is a real, large disk cost as
# well as a cold-start one — reserved for last because both costs are larger
# than a target dir's.
# #
# Two exclusions every pass respects: # Two exclusions every pass respects:
# #
# PROTECTED — the publisher branches' target and snapshot directories, and # PROTECTED — a publisher branch's SNAPSHOT directory, and this run's own
# this run's own target dir, are never candidates in any pass. Evicting a # target dir, are never candidates in the pressure or self-clear passes. A
# publisher's snapshot doesn't free real disk (every open PR's clone keeps # publisher branch's own TARGET dir is not protected there: it is an
# the data alive) but does force every subsequent PR to start cold, which is # ordinary pressure-pass candidate, evicted oldest-first like any other,
# the entire benefit this scheme exists to deliver. # because nothing downstream depends on it surviving — the publisher's own
# next run reseeds it from the snapshot. It IS excluded from pass 1 alone
# (is_protected_from_liveness): a branch's tip is trivially an ancestor of
# itself, so without this exclusion the merged-branch signal would read a
# publisher's own target dir as "merged into itself" and pass 1 —
# unconditional, not gated on pressure — would delete it every run.
# #
# LOCKED — a directory carrying a .ci-lock-* marker younger than # LOCKED — a directory carrying a .ci-lock-* marker younger than
# STALE_LOCK_SECONDS is held open by a running job, or named by a live # STALE_LOCK_SECONDS is held open by a running job, or named by a live
@@ -179,32 +191,52 @@ else
fi fi
declare -A protected_ns=() declare -A protected_ns=()
# A protected ref's own target dir is excluded from pass 1 ONLY (see
# is_protected_from_liveness below), never from the pressure pass. It must
# stay out of pass 1 for a reason that has nothing to do with disk: a
# protected ref's tip is trivially an ancestor of itself, so without this
# is_merged_dead would read dev's own target dir as "merged into dev" and
# pass 1 — unconditional, not gated on pressure — would delete it on every
# single run.
declare -A protected_target_ns=()
for ref in $PROTECTED_REFS; do for ref in $PROTECTED_REFS; do
suffix=$(cache_key "$ref") suffix=$(cache_key "$ref")
protected_ns["target-${suffix}"]=1
protected_ns["snapshot-${suffix}"]=1 protected_ns["snapshot-${suffix}"]=1
protected_target_ns["target-${suffix}"]=1
done done
# Prints why <dir> is off limits to every pass, or nothing when it is a # Prints why <dir> is off limits to the pressure/self-clear passes, or
# candidate. The reason is not decoration: it is what the failure report at # nothing when it is a candidate there. The reason is not decoration: it is
# the bottom lists against each directory it kept while running out of space. # what the failure report at the bottom lists against each directory it kept
# while running out of space.
# #
# SEED_SRC is the third exclusion and the one this script did not used to need. # SEED_SRC is the third exclusion and the one this script did not used to
# The prune ran after the seed, so the source had already been cloned and the # need. The prune ran after the seed, so the source had already been cloned
# reader marker over it was gone; running BEFORE the seed puts the directory # and the reader marker over it was gone; running BEFORE the seed puts the
# this run is about to read squarely in the candidate set, and pass 1 would # directory this run is about to read squarely in the candidate set, and
# take it the moment its branch merged. # pass 1 would take it the moment its branch merged.
protected_reason() { protected_reason() {
local dir="$1" name local dir="$1" name
name=$(basename "$dir") name=$(basename "$dir")
[ "$dir" = "$OWN_DIR" ] && { printf 'this run own cache'; return 0; } [ "$dir" = "$OWN_DIR" ] && { printf 'this run own cache'; return 0; }
[ -n "$SEED_SRC" ] && [ "$dir" = "$SEED_SRC" ] && { printf 'the source this run is about to clone'; return 0; } [ -n "$SEED_SRC" ] && [ "$dir" = "$SEED_SRC" ] && { printf 'the source this run is about to clone'; return 0; }
[ -n "${protected_ns[$name]:-}" ] && { printf 'a protected branch cache'; return 0; } [ -n "${protected_ns[$name]:-}" ] && { printf 'a protected branch snapshot'; return 0; }
return 1 return 1
} }
is_protected() { protected_reason "$1" >/dev/null; } is_protected() { protected_reason "$1" >/dev/null; }
# Pass 1 (liveness) only: also excludes a protected ref's own target dir, for
# the self-ancestor reason above protected_target_ns documents. The pressure
# pass does not call this — is_protected is what it uses, via protected_reason
# directly.
is_protected_from_liveness() {
local dir="$1" name
is_protected "$dir" && return 0
name=$(basename "$dir")
[ -n "${protected_target_ns[$name]:-}" ]
}
# is_locked <dir> [name] # is_locked <dir> [name]
# #
# `name` is the directory's own name for reporting and for the reader-marker # `name` is the directory's own name for reporting and for the reader-marker
@@ -434,8 +466,11 @@ fi
# #
# True when the branch this directory belongs to is still on origin but every # True when the branch this directory belongs to is still on origin but every
# commit it holds is already on a protected branch — a merged PR whose branch # commit it holds is already on a protected branch — a merged PR whose branch
# the forge did not delete, which is the case zemyna hits on every merge and # the forge did not delete, which the deleted-branch signal above can never
# which the deleted-branch signal above can never see. # see. Enabling delete-on-merge narrows this to the branches merged before it
# was enabled, the ones its deletion declines (protected, or used by another
# open PR), and the merges that never ask (an API merge without the flag);
# see README's eviction section.
# #
# Memoised per tip because target-<key> and snapshot-<key> share one branch, # Memoised per tip because target-<key> and snapshot-<key> share one branch,
# and because the "cannot tell" warning belongs to the branch rather than to # and because the "cannot tell" warning belongs to the branch rather than to
@@ -476,7 +511,7 @@ if [ "$LIVENESS_AVAILABLE" = "1" ]; then
for dir in "$ROOT"/target-* "$ROOT"/snapshot-*; do for dir in "$ROOT"/target-* "$ROOT"/snapshot-*; do
[ -d "$dir" ] || continue [ -d "$dir" ] || continue
name=$(basename "$dir") name=$(basename "$dir")
is_protected "$dir" && continue is_protected_from_liveness "$dir" && continue
if [ -z "${live_ns[$name]:-}" ]; then if [ -z "${live_ns[$name]:-}" ]; then
why="no matching branch on origin" why="no matching branch on origin"
why_summary="branch no longer exists on origin" why_summary="branch no longer exists on origin"
+180
View File
@@ -0,0 +1,180 @@
#!/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 — a run that
# deferred and left no newer run behind it — 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 "=== 11. a v1 hand-placed on an unrelated commit is never silently overwritten ==="
# Unlike #7, nothing races here -- v1 already sits on the stray commit before
# the very first push attempt, so force-with-lease sees exactly the value it
# expects and would otherwise succeed outright.
fresh
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"
tip=$(commit_to_main)
if in_ci merge "$tip" 2>"$scratch/err"; then fail "an unrelated hand-placed v1 was overwritten"; fi
[ "$(origin_v1)" = "$stray" ] || fail "v1 moved off the hand-placed $stray"
grep -q "$stray" "$scratch/err" || fail "the error did not name the stray v1: $(cat "$scratch/err")"
grep -q "$tip" "$scratch/err" || fail "the error did not name the gated sha: $(cat "$scratch/err")"
ok "hand-placed v1, unrelated to tip: red on the first push, v1 untouched"
echo
echo "release-v1-selftest: all $pass_count assertions passed"
+109
View File
@@ -0,0 +1,109 @@
#!/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 <gated-sha> merge-triggered job: release <gated-sha>
# 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 <gated-sha> <v1-as-read>
# 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 <gated-sha>, 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 <sha>: 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
# force-with-lease only compares the ref's current value, not ancestry, so
# an unrelated v1 -- neither behind <sha> nor covering it -- would
# otherwise be silently overwritten on the very first push.
if [ -n "$expect" ] && ! covers "$sha" "$expect" && ! git merge-base --is-ancestor "$expect" "$sha"; then
echo "ERROR: v1 ($expect) is neither an ancestor of $sha nor at/ahead of it -- refusing to overwrite an unrelated v1" >&2
return 1
fi
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:-<absent>} -> $sha"
return 0
fi
now=$(fetch_v1)
if [ "$now" = "$expect" ]; then
echo "ERROR: push of v1 -> $sha rejected while v1 was still ${expect:-<absent>} -- 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:-<absent>}, 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 <sha> | sweep-check | push <sha> <v1-as-read>}"
shift
case "$cmd" in
merge)
SHA="${1:?usage: release-v1.sh merge <gated-sha>}"
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:-<absent>} 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 <gated-sha> <v1-as-read>}" "${2-}"
;;
*)
echo "release-v1.sh: unknown command '$cmd'" >&2
exit 2
;;
esac
+1 -1
View File
@@ -13,7 +13,7 @@ script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
FAST=0 FAST=0
[ "${1:-}" = "--fast" ] && FAST=1 [ "${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) CARGO_TESTS=(hardlink-clone-selftest.sh restore-mtimes-selftest.sh)
TESTS=("${FIXTURE_TESTS[@]}") TESTS=("${FIXTURE_TESTS[@]}")