Compare commits
5
Commits
21dffdb725
...
ea48c03aeb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ea48c03aeb
|
||
|
|
dc1e6317c6
|
||
|
|
af1233f14a
|
||
|
|
21b444121d
|
||
|
|
7f18cb2436
|
@@ -101,3 +101,101 @@ jobs:
|
||||
# file.
|
||||
- name: Selftests
|
||||
run: bash scripts/selftest.sh
|
||||
|
||||
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
|
||||
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). What it buys is cheaper: every
|
||||
# execution that does reach the push step targets origin/main's live tip
|
||||
# (below), never its own trigger commit, so it makes no difference which
|
||||
# one wins -- the survivor pushes where any of them would have, and a
|
||||
# cancelled job costs nothing. 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
|
||||
# rejected, which is a red job, not a silent no-op.
|
||||
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.
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
fetch-depth: 0
|
||||
|
||||
# This run's own trigger commit (${{ github.sha }}) is deliberately not
|
||||
# what gets pushed: whichever job the concurrency group above lets
|
||||
# through is the one that pushes, and that choice carries no relation
|
||||
# to commit recency, so every execution has to converge on the SAME
|
||||
# target regardless of which job it is. `origin/main`'s live tip,
|
||||
# re-fetched here rather than trusted from the checkout above (which
|
||||
# can be minutes stale by this point, behind its own selftest job), is
|
||||
# that common target -- read fresh, every job that reaches this step
|
||||
# resolves to the same commit whenever main hasn't moved between them,
|
||||
# and to whatever's newest when it has.
|
||||
#
|
||||
# A live target doesn't make the push itself safe on its own: two jobs
|
||||
# can still read main at genuinely different moments if it advances
|
||||
# between their two fetches, so the one with the earlier reading must
|
||||
# not overwrite the other's already-pushed, newer one. That's what the
|
||||
# ancestor check below still guards -- not "this job's stale trigger
|
||||
# commit" any more, but "this job's freshly-read tip, which another
|
||||
# job's fresher read may have already superseded." `--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 that shares no
|
||||
# history with this tip, falls through to the push -- the guard is
|
||||
# only ever a reason to skip, never a reason to fail.
|
||||
- name: Determine origin/main's tip and whether v1 needs to move
|
||||
id: check
|
||||
run: |
|
||||
git fetch origin main
|
||||
TIP=$(git rev-parse origin/main)
|
||||
echo "tip=$TIP" >> "$GITHUB_OUTPUT"
|
||||
if git rev-parse -q --verify refs/tags/v1 >/dev/null \
|
||||
&& git merge-base --is-ancestor "$TIP" refs/tags/v1; then
|
||||
echo "v1 already at or ahead of origin/main's tip ($TIP) -- 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 origin/main's tip
|
||||
if: steps.check.outputs.skip != 'true'
|
||||
run: |
|
||||
git tag -f v1 "${{ steps.check.outputs.tip }}"
|
||||
git push --force origin v1
|
||||
|
||||
@@ -634,13 +634,30 @@ 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 a release step, and it is the operator's.** Merging to
|
||||
`main` ships nothing to anybody. `v1` is a lightweight tag and does not follow
|
||||
a branch, so until it is re-pointed every consumer keeps fetching the commit it
|
||||
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
|
||||
decision taken once, knowingly, after the merge — never something a merge does
|
||||
by itself.
|
||||
**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 `origin/main`'s live tip 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.
|
||||
|
||||
Two merges landing close together can start two `release-tag` jobs at once; a
|
||||
job-level `concurrency` group lets only one push at a time, and every job
|
||||
targets `origin/main`'s current tip rather than its own trigger commit, so it
|
||||
makes no difference which one the group lets through. Before pushing, the job
|
||||
also checks whether `v1` already points at that tip or a descendant of it —
|
||||
covering the case where two jobs read the tip at genuinely different moments
|
||||
— and skips as a normal, successful outcome rather than pushing backward. A
|
||||
run whose log says "nothing to do" did its job correctly; it just found
|
||||
nothing to move.
|
||||
|
||||
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:
|
||||
|
||||
```bash
|
||||
git fetch origin
|
||||
@@ -651,9 +668,8 @@ git ls-remote --tags origin v1 # must equal git rev-parse origin/main
|
||||
|
||||
**Downstream** are emowheel, which pins `cargo-cache@v1` and
|
||||
`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,
|
||||
which is the whole point of the moving pointer and also the reason the move is
|
||||
not automatic.
|
||||
same pin. Both pick a move up automatically on their next run with no change
|
||||
on their side, which is the whole point of the moving pointer.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -33,7 +33,16 @@
|
||||
# so evicting it frees almost nothing while costing every future PR its
|
||||
# warm start.
|
||||
# 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): the static
|
||||
# CACHE_DF_OVERRIDE every other scenario uses never reflects an
|
||||
# eviction, so pass 3's self-clear (`rm -rf "$OWN_DIR"; mkdir -p
|
||||
# "$OWN_DIR"`) fires regardless and recreates an empty OWN_DIR whether
|
||||
# pass 2 touched it or not — existence survives either way, which is
|
||||
# why an existence-only assertion here passed even with the pass-2
|
||||
# guard removed. This one checks CONTENTS, and sizes the requirement
|
||||
# so it is satisfiable without self-clear at all: only pass 2's guard
|
||||
# decides the outcome.
|
||||
# 10. SCOPED TO THE CACHE ROOT — a decoy outside it (standing in for another
|
||||
# project's volume) is never touched.
|
||||
# 11. A LIVE READER MARKER PROTECTS A CACHE the same way a lock file does — a
|
||||
@@ -174,8 +183,54 @@ fi
|
||||
ok "no protected ref's own target dir reaches the merged-branch check"
|
||||
|
||||
echo
|
||||
echo "=== 9: own cache never evicted by a sibling pass ==="
|
||||
assert_kept "$root/target-$OWN" "this run's own cache survives"
|
||||
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 'self-clear' "$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 "=== 7: target dirs evicted before snapshots ==="
|
||||
|
||||
Reference in New Issue
Block a user