ci: release v1 automatically on a green merge to main #28
+39
-35
@@ -128,13 +128,12 @@ jobs:
|
|||||||
# Gitea wakes exactly one Blocked job in the group and cancels the rest
|
# 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
|
# outright, with no ordering on which one it picks (no `ORDER BY` in the
|
||||||
# query behind CancelPreviousJobsByJobConcurrency,
|
# query behind CancelPreviousJobsByJobConcurrency,
|
||||||
# models/actions/run_job_list.go). What it buys is cheaper: every
|
# models/actions/run_job_list.go). Every execution still only ever pushes
|
||||||
# execution that does reach the push step targets origin/main's live tip
|
# its OWN gated commit (below), never another job's, so a job that gets
|
||||||
# (below), never its own trigger commit, so it makes no difference which
|
# cancelled here costs nothing beyond its own wasted run -- it was never
|
||||||
# one wins -- the survivor pushes where any of them would have, and a
|
# going to push anyone else's commit either. This group's only job is to
|
||||||
# cancelled job costs nothing. This group's only job is to stop more than
|
# stop more than one job from pushing AT THE SAME TIME, which is wasted
|
||||||
# one job from pushing AT THE SAME TIME, which is wasted work, not a
|
# work, not a correctness risk on its own.
|
||||||
# correctness risk on its own.
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: release-tag-v1
|
group: release-tag-v1
|
||||||
cancel-in-progress: false
|
cancel-in-progress: false
|
||||||
@@ -155,37 +154,42 @@ jobs:
|
|||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
# This run's own trigger commit (${{ github.sha }}) is deliberately not
|
# `needs: selftest` only gates THIS run's own commit -- it says nothing
|
||||||
# what gets pushed: whichever job the concurrency group above lets
|
# about whether `origin/main` has since moved on to a merge whose own
|
||||||
# through is the one that pushes, and that choice carries no relation
|
# selftest hasn't finished, is still queued, or is red. Two things
|
||||||
# to commit recency, so every execution has to converge on the SAME
|
# follow, checked in this order:
|
||||||
# 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
|
# 1. If `origin/main`'s live tip (re-fetched here, not trusted from the
|
||||||
# can still read main at genuinely different moments if it advances
|
# checkout above, which can be minutes stale behind this job's own
|
||||||
# between their two fetches, so the one with the earlier reading must
|
# selftest) is no longer THIS run's own `github.sha`, some other
|
||||||
# not overwrite the other's already-pushed, newer one. That's what the
|
# merge has landed since. Pushing it would release a commit this run
|
||||||
# ancestor check below still guards -- not "this job's stale trigger
|
# never gated -- so this run defers instead, unconditionally. The
|
||||||
# commit" any more, but "this job's freshly-read tip, which another
|
# commit that IS the live tip has its own run, and that run's own
|
||||||
# job's fresher read may have already superseded." `--is-ancestor`
|
# guard is what releases it once ITS turn to push comes -- possibly
|
||||||
# treats a commit as its own ancestor, so "already at" and "already
|
# only once merges pause for a moment, so v1 can land one merge
|
||||||
# ahead" are one case. A v1 that doesn't exist yet, or that shares no
|
# later than the newest one in a busy stretch. It never lands on a
|
||||||
# history with this tip, falls through to the push -- the guard is
|
# commit that wasn't gated, and it always catches up once things go
|
||||||
# only ever a reason to skip, never a reason to fail.
|
# quiet, because every future merge re-attempts the same check
|
||||||
- name: Determine origin/main's tip and whether v1 needs to move
|
# 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
|
id: check
|
||||||
run: |
|
run: |
|
||||||
git fetch origin main
|
git fetch origin main
|
||||||
TIP=$(git rev-parse origin/main)
|
TIP=$(git rev-parse origin/main)
|
||||||
echo "tip=$TIP" >> "$GITHUB_OUTPUT"
|
SHA="${{ github.sha }}"
|
||||||
if git rev-parse -q --verify refs/tags/v1 >/dev/null \
|
if [ "$TIP" != "$SHA" ]; then
|
||||||
&& git merge-base --is-ancestor "$TIP" refs/tags/v1; 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 "v1 already at or ahead of origin/main's tip ($TIP) -- nothing to do"
|
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"
|
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||||
else
|
else
|
||||||
echo "skip=false" >> "$GITHUB_OUTPUT"
|
echo "skip=false" >> "$GITHUB_OUTPUT"
|
||||||
@@ -194,8 +198,8 @@ jobs:
|
|||||||
# Lightweight tag, matching what v1 already is (`git cat-file -t v1`
|
# Lightweight tag, matching what v1 already is (`git cat-file -t v1`
|
||||||
# reports `commit`, not `tag`) -- no identity needed to move it, only
|
# reports `commit`, not `tag`) -- no identity needed to move it, only
|
||||||
# to push it.
|
# to push it.
|
||||||
- name: Force v1 to origin/main's tip
|
- name: Force v1 to this commit
|
||||||
if: steps.check.outputs.skip != 'true'
|
if: steps.check.outputs.skip != 'true'
|
||||||
run: |
|
run: |
|
||||||
git tag -f v1 "${{ steps.check.outputs.tip }}"
|
git tag -f v1 "${{ github.sha }}"
|
||||||
git push --force origin v1
|
git push --force origin v1
|
||||||
|
|||||||
@@ -636,22 +636,26 @@ entry, another permission — is a `v2`, not a `v1` move. Everything else moves
|
|||||||
|
|
||||||
**Moving the tag is automatic, gated on the same build that gates a PR.** A
|
**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`,
|
`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
|
`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
|
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
|
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
|
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:
|
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.
|
`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
|
Before pushing, the job checks two things and pushes only if both hold: that
|
||||||
job-level `concurrency` group lets only one push at a time, and every job
|
`origin/main`'s live tip is still this run's own commit (not some later merge
|
||||||
targets `origin/main`'s current tip rather than its own trigger commit, so it
|
that landed while this job was queued behind its own selftest), and that `v1`
|
||||||
makes no difference which one the group lets through. Before pushing, the job
|
doesn't already point at that commit or a descendant of it. Either check can
|
||||||
also checks whether `v1` already points at that tip or a descendant of it —
|
skip the push, as a normal, successful outcome — a run whose log says
|
||||||
covering the case where two jobs read the tip at genuinely different moments
|
"nothing to do" did its job correctly. The first check is what a job-level
|
||||||
— and skips as a normal, successful outcome rather than pushing backward. A
|
`concurrency` group alone can't guarantee: Gitea's wake-one/cancel-rest
|
||||||
run whose log says "nothing to do" did its job correctly; it just found
|
handling applies no ordering by commit recency, so an older merge's job can
|
||||||
nothing to move.
|
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.
|
||||||
|
|
||||||
This used to be a manual step, treated as a deliberate release decision taken
|
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
|
once, knowingly, after the merge — in practice it was still forgotten
|
||||||
|
|||||||
@@ -227,7 +227,7 @@ PATH="$scratch/bin9:$outer_path" \
|
|||||||
assert_kept "$root/target-$OWN" "this run's own cache directory survives a genuinely pressured sibling pass"
|
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_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"
|
assert_gone "$root/target-$LIVE" "the sibling is evicted instead, to make the same room"
|
||||||
if grep -q 'self-clear' "$scratch/log"; then
|
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"
|
fail "own cache was cleared by pass 3, not genuinely spared by pass 2 — this scenario proves nothing"
|
||||||
fi
|
fi
|
||||||
ok "the requirement was met by pass 2 alone; pass 3 never ran"
|
ok "the requirement was met by pass 2 alone; pass 3 never ran"
|
||||||
|
|||||||
Reference in New Issue
Block a user