fix(869,893): re-establish the 1.25.4-dated CI claims on 1.27.1, and settle the page_statuses asymmetry from source (#905)
Build ErsatzTV Image / CI toolchain image resolves (push) Successful in 7s
Build ErsatzTV Image / Delimiter ban (release path) (push) Successful in 23s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 9m5s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 6m24s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 6m10s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Skipped
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 4m17s

Finishes the 1.25.4-dated CI claim sweep #747 deliberately left incomplete (#869), and answers #893 from the Gitea v1.27.1 source instead of inferring it from a header. Docs and comments only - zero non-comment changes in scripts/ and .gitea/.

Population derived with `git ls-files`, not from the issue's item list: 17 files, 43 occurrences of `1.25.4`, against the 4 items #869 named.

Re-established on 1.27.1: the `creator`-attribution claim the H10 allow-list rests on (4 merged heads, both endpoints); the scope enum (no `status` scope); the `reqRepoWriter(unit.TypeCode)` gate; the `write:package` 403 (live probe with a read control 200 and a write control 201, throwaway repo, artifacts deleted); the absence of any REST cancel route (from source, which a 404 alone cannot establish); and `pull_request`/`pull_request_target` definition resolution.

#893: `/statuses/{sha}` does NOT drop rows after pagination. `getCommitStatuses` appends unconditionally and its only filter is a SQL WHERE in the same query as the LIMIT/OFFSET, so an empty page really is the end, `page_statuses` terminating on its first empty page is safe, and the asymmetry with `count_pr_mutations` is correct - recorded with its reason and a date so it is not tidied away.

Corrected rather than re-dated: the `--depth=1` no-merge-base claim was filed against the wrong axis (a git property, re-probed on git 2.55.0), and `enable_bypass_allowlist` postdating 1.25.4 had an issue body as its only provenance.

Five cold review rounds plus a cross-family Codex pass. They caught a wrong MECHANISM for `creator: null` (it is `CreatorID == -2`, not `== 0`), an evidence count that straddled the upgrade, and a reason for not re-probing MCP `cancel_run` that was invented - all fixed, final verdict CLEAN.

fixes #869
fixes #893

Decisions-Edit: yes
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Whe75djeAEuZpdNk6KU7No
Co-authored-by: Timothy <timothy@noreply.gitea.tblindustries.be>
This commit was merged in pull request #905.
This commit is contained in:
2026-09-02 20:20:43 +00:00
committed by timothy
co-authored by Claude Opus 5
parent 8fd9eae0bf
commit fb55929711
11 changed files with 352 additions and 49 deletions
+70 -1
View File
@@ -73,6 +73,40 @@ name: Review verdict
# ignored: the BASE definition ran and posted `h10=pending`, on both `opened` and `synchronize`,
# with `secrets` still available.
#
# THE DEFINITION-RESOLUTION HALF is re-established FROM THE v1.27.1 SOURCE, 2026-09-02
# (ersatztv#869) — the mechanism behind the measurement above. In
# `services/actions/notifier_helper.go` one function resolves the two triggers from two SEPARATELY
# RESOLVED refs: for a PR event `input.Ref` resolves to the PR's own HEAD ref (`WithPullRequest`
# defaults it to `pr.GetGitHeadRefName()` when the caller has not already set one), and workflows
# detected at that commit are kept only if their trigger is NOT `pull_request_target`; the base is
# then resolved separately as `git.BranchPrefix + input.PullRequest.BaseBranch`, and workflows
# detected there are kept only if their trigger IS `pull_request_target`. A head rewrite cannot
# reach this file's definition at all.
#
# WHICH HALF, precisely — the source covers definition resolution and NOTHING ELSE. It does not
# speak to which `types` produce a run, nor to secrets availability.
#
# What IS established on 1.27.1, and stated no wider than it was measured: `pull_request_target`
# fires for THIS workflow continuously — 210 such runs across 189 distinct head shas, counting only
# runs started strictly after the 2026-08-05 upgrade (measured 2026-09-02). The WINDOW is part of
# the claim, and an earlier draft got it wrong by citing the all-time REPO-WIDE figure: 258 runs /
# 233 shas = 44 before the upgrade date + 4 ON it + 210 after. Citing 258 here would have imported
# 1.25.4 evidence into a 1.27.1 sentence. This workflow's own all-time count is 250; the difference
# is 8 runs from three since-deleted probe workflows, all pre-upgrade — which is why every one of
# the 210 post-upgrade runs is this file.
# The 4 same-day runs are excluded rather than assigned, because the upgrade landed mid-session and
# their 17:41-22:36 UTC start times cannot be placed either side of it without the exact upgrade
# time. Read those as UTC deliberately: this endpoint returns `started_at` Z-suffixed, while other
# Gitea timestamps render in the host zone, Europe/Brussels — `+02:00` in August, `+01:00` in
# winter — so a UI comparison shows different clock times.
# What that does NOT establish, deliberately: WHICH `types:` fire (a run count identifies no event
# type), and whether the STORED secret store is reachable under this trigger — this job references
# only the injected `secrets.GITEA_TOKEN`. Both of those stay 1.25.4-dated.
#
# That is also why `branches: [main]` is load-bearing rather than tidy: the base is resolved by
# BRANCH NAME, not by a pinned sha, so the base definition is whatever that branch points at when
# the event fires.
#
# `pull_request_target` is normally the DANGEROUS trigger, and it is worth being explicit about why
# that reputation does not transfer here. Its footgun is running untrusted HEAD code with a
# privileged token. This job never checks out the head and never executes anything the PR supplies:
@@ -1364,12 +1398,47 @@ jobs:
# The guard still exists for its original reason: re-posting `pending` over a real human
# verdict would un-approve a reviewed head and stall the PR. So it discriminates by PROVENANCE.
#
# MEASURED on this instance (Gitea 1.25.4), on the COMBINED endpoint this job reads: a status
# MEASURED on this instance, on the COMBINED endpoint this job reads: a status
# POSTed with a USER credential — how `scripts/post-review-verdict.sh` writes a verdict —
# carries `.creator.login`, while one POSTed by an Actions job with the built-in `GITEA_TOKEN`
# carries `"creator": null`. A real verdict read back `creator=timothy`; this job's own
# exemption read back `creator=null`.
#
# FIRST measured on 1.25.4; RE-MEASURED on 1.27.1, 2026-09-02 (ersatztv#869) — this is the
# claim the H10 allow-list rests on, which is why #869 prioritised it. It is NOT the last
# 1.25.4-dated load-bearing claim: the `pull_request_target` half the retarget fence relies
# on is still 1.25.4-dated too, deliberately (`ci.verdict-write-retarget-fence`).
# Four merged PR heads read back independently (#903, #884, #902,
# #899): on every one exactly ONE status carries `.creator.login` — `review-verdict/h10`,
# `timothy`, the one `post-review-verdict.sh` writes — and EVERY other row on that head
# carries `creator: null`. It holds on BOTH endpoints, combined `/commits/{sha}/status` and
# list `/statuses/{sha}`, so this guard and `page_statuses` see the same attribution. The
# two differ only in HOW MANY rows come back, which is not an attribution difference: the
# combined endpoint is latest-per-context (15 rows on each of the four) and the list
# endpoint returns every row ever posted (43-63 across them).
#
# THE MECHANISM, so this is re-derivable without a live PR. An earlier draft of this comment
# got it wrong in a way worth recording, because the wrong version is the intuitive one:
# it said `creator: null` means `CreatorID == 0`. It does not. At v1.27.1 an Actions job
# authenticates as the SYNTHETIC Actions user, `ActionsUserID = -2`
# (`models/user/user_system.go`), which is never a row in the `user` table, and
# `NewCommitStatus` stores `CreatorID = opts.Creator.ID` — so an Actions-posted status
# stores -2, NOT 0. `services/convert/status.go`'s `ToCommitStatus` therefore TAKES its
# `if status.CreatorID != 0` branch and the null comes from the next line:
# `creator, _ := user_model.GetUserByID(ctx, status.CreatorID)` is a bare lookup with NO
# system-user mapping (contrast `GetPossibleUserByID`, which handles `id < 0` explicitly),
# so -2 misses, THE ERROR IS DISCARDED, and `ToUser(ctx, nil, nil)` returns nil.
#
# It is still a property of the SERIALIZER, which is why it cannot differ between the two
# endpoints — the combined endpoint reaches the same `ToCommitStatus`.
#
# THE CONSEQUENCE FOR THIS GUARD, which the wrong mechanism hid: `creator: null` does not
# mean "a machine wrote this". Any row whose creator account was later DELETED serializes
# null by the same path. That does NOT weaken this guard, and the reason is the direction
# of the test below: it short-circuits only on a POSITIVE allow-list match, so both a
# machine write and a vanished author land on RE-DERIVE, the safe branch. It would break a
# guard written the other way round, which is one more reason not to invert it.
#
# BOTH conditions are required, and the DIRECTION of the test is the point: we short-circuit
# only on something POSITIVELY identified as a human verdict. Anything else, including anything
# we do not recognise, is RE-DERIVED. Written the other way round ("skip if it looks
+22
View File
@@ -1747,6 +1747,16 @@ arranging two real merges, or adding a throwaway trigger; both cost more than th
because nothing branches on it. The `pull_request_target` half the fence actually relies on was **not**
re-measured either and is 1.25.4-dated too.
The two facts this paragraph rests on — that no workflow triggers on a push to a non-`main` branch,
and that `main` refuses direct pushes — were **re-checked on 2026-09-02 (ersatztv#869)** and both still
hold. Note what that does and does not cover: it re-checks the REASON, not the claims. The
`push`-supersession and `pull_request_target` measurements above remain 1.25.4-dated and were not
re-run by #869 either. Re-checking the reason matters on its own, because a reason decays exactly like
the claim it excuses. One limit is worth stating so this is not over-read: the enumeration covers
`push:` triggers only. Four of the six workflows carry `workflow_dispatch:`, so a disposable run can
still be raised on demand — what is gone is the cheap scratch-BRANCH push probe, not every route to a
throwaway run.
Separately, after posting **any** status the job re-reads the per-POST status history and, if a human
`Review-verdict:` row appeared during the write window, overwrites its own status with `pending` and
logs an error. It ran only for a `success` write until ersatztv#849, on the claim that a `pending`
@@ -1818,6 +1828,18 @@ validate at most 950 rows, since the twentieth must be the empty terminator. The
per endpoint and they differ: `/statuses/{sha}` returns `[]`, `/issues/{n}/timeline` a bare `null`,
`/commits/{sha}/status` an object with `statuses: null`.
**The two walks terminate by OPPOSITE rules, and that asymmetry is deliberate** (ersatztv#870, settled
by ersatztv#893 on 2026-09-02). `count_pr_mutations` reads the timeline to its cap and trusts the
counts only when the LAST page came back empty; `page_statuses` still returns on its FIRST empty page.
They differ because the ENDPOINTS differ, established from the v1.27.1 source: `getCommitStatuses`
serializes every row the paged query returns — an unconditional `append` loop, the only filter being a
SQL `WHERE` the database evaluates in the same query as the `LIMIT`/`OFFSET` — so a serialized page is
exactly as long as its database page, and an empty page really is the end. `ListIssueCommentsAndTimeline` instead drops
`CommentTypeCode` rows AFTER paging, which is what makes a fully filtered page byte-identical to the
end of the list. The same source explains the header: `/statuses/{sha}` sets `X-Total-Count` from a SQL
COUNT, so it is a true total (page 1 of 50 on a head reporting 63, measured 2026-09-02), where the
timeline reports its post-filter page length. **Do not "tidy" the two walks into agreement.**
**The page-2 probe is gone.** ersatztv#751 treated "there are rows I did not read" as "assume raced".
That fired on Renovate PR #761: a head that grew past one page over ordinary CI re-runs had its
exemption repaired away, with an `::error::` asserting a human verdict was overwritten when the head
@@ -7,7 +7,7 @@ supersedes: none
superseded-by: none
rule: 'Any credential reachable from an Actions job is scoped to what that job needs. The container-registry secret `REGISTRY_PASSWORD` is a personal access token scoped `write:package` + `read:repository` — never an account PASSWORD. This matters because Gitea has NO `status` token scope: `POST /repos/{o}/{r}/statuses/{sha}` is gated by `reqRepoWriter(unit.TypeCode)`, so ANY credential that can write the repository can forge `review-verdict/h10`, the required context that is supposed to make merge-consent derived rather than assertable. Package-write IS a separate scope, so the registry credential can be made status-incapable at no cost: `scripts/ci-detect-already-validated.sh` only GETs. `permissions:` on a workflow/job DOES bind on this instance — MEASURED 2026-08-27 on 1.27.1 by matched scratch-base probe PRs differing only in one unit, the block carried at JOB level on `set-verdict-status` (`code: write` posted the probe status; `code: read` 403ed the POST, curl exit 22, no status written), so all six workflows here now declare it (#748). Two properties that make it usable: the declaration is EXHAUSTIVE, not additive — a unit omitted is not granted — and it binds while the owner-level default is `permissive`, which is what makes the five `code: read` declarations effective TODAY rather than only after a flip. NOT established: what a declared `code: write` does under a RESTRICTED default. GitHub semantics let `permissions:` only narrow, never widen past the default, and if Gitea copies that, Restricted would cap the gate job at read and `review-verdict/h10` would stop being writable — the exact catastrophic case. The probe ran under `permissive` and CANNOT rule that out. Flip the owner default only behind the scratch-base probe re-run under Restricted (server-management#714, still open for this reason). The earlier form of this rule said the opposite ("do NOT add a `permissions:` key on the assumption that it binds — below Gitea 1.26.0 it is silently a NO-OP"); that was correct at 1.25.4 and is retained here so a reader meeting the old advice recognises it as superseded. There is still no API surface for the owner-level default (`/api/v1/settings/actions` 404s at 1.27.1). The instance default HAS since been probed and is NOT unknown: it was set to Restricted, verified, and reverted to `permissive` on 2026-08-05 (#748), which is where it stands. Probe before relying on it; do not read the upgrade alone as the constraint now working. Scoping is necessary and not sufficient: it bounds what a job may DO, never whether attacker YAML runs at all, so a self-referencing trigger needs its own filter. That landed for `ci-image.yml` in #744 (`ci.toolchain-image-publish-is-a-dispatch`) — deliberately NOT bundled here, because until it also removed the file from `ci-image-pin`''s `expected`, editing it re-pointed that job at the editing commit and reddened a blocking check. A second, separate consequence of the same boundary: `actions/checkout` persists whatever the job token can do into `.git/config` unless `persist-credentials: false` is set — read-only everywhere since #748 declared `permissions:` on all six workflows — and all 16 of this repo''s checkouts now set it — 15 in #746 and `ci-image.yml`''s in #744, once `ci.toolchain-image-publish-is-a-dispatch` removed the two mechanical reasons it was excluded; the convention is held with no exemption list by `scripts/tests/test_workflow_persist_credentials.py` (#835). Ordering is part of the rule: unmask the dependent fetches FIRST, because until then a credential regression presents as an empty changed-file set and a silently skipped check rather than a red job. This record closes ONE route. It does not close the class, and the later sections say exactly what survives — read them before citing this record as a mitigation. The `workflow_dispatch` half of what survives is now settled rather than open: #853 probed Gitea 1.27.1 and ACCEPTED it (`ci.workflow-dispatch-ref-unrestricted`) — there is no ref restriction and no protected-environment concept to gate a secret behind, and restricting dispatch would close nothing anyway, because `docker-build.yml`''s head-resolved `pull_request:` runs attacker-authored YAML, which reaches EVERY secret in the store and not merely the ones the committed workflows name. The PR route and the `v*` tag push, not dispatch, are the live residuals (#885).'
signals: 'admin password in CI secrets, registry credential scope, ETV_STATUS_AUTH can write statuses, forge review-verdict/h10, head-resolved workflow holds credentials, persist-credentials on actions/checkout, credential left in .git/config, masked git fetch yields empty changed set, Gitea token scopes, no status scope, write:package vs write:repository, permissions key no-op, GITEA_TOKEN default read/write, Restricted default token permissions, orphan secret, deploy key in secret store, toolchain image overwrite, prod floating tag write · paths: `.gitea/workflows/docker-build.yml`, `.gitea/workflows/pr-checks.yml`, `.gitea/workflows/dependency-scan.yml`, `.gitea/workflows/ci-image.yml`, `.gitea/workflows/renovate.yml`, `scripts/ci-detect-already-validated.sh` · issues: #697, #672, #698, #742, #743, #746, #748, #835, #420, server-management#714'
mechanics: 'PAT `ci-registry-scoped-697`, scopes `write:package,read:repository`, stored as repo Actions secret `REGISTRY_PASSWORD`; `REGISTRY_USER` remains `timothy`. Verified 2026-08-05 on Gitea 1.25.4: registry push of a probe tag SUCCEEDED; `GET /commits/{sha}/status` 200; `POST /statuses/{sha}` REFUSED HTTP 403 `token does not have at least one of required scope(s), required=[write:repository], token scope=write:package,read:repository`. Probe artifacts deleted, confirmed 404. NOT measured with this token: the `container:` pull, the buildcache write and the base-image pull. Those rest on Gitea''s scope model (write implies read per category, read at tag `v1.25.4`) — INFERRED. Note WHICH run proves which: only the `container:` pull is exercised by a PR. `cache-to`/`cache-from` and the base-image pull are confined to the `build` job, which carries `if: github.event_name != ''pull_request''`, so they are first exercised on the post-merge push to `main` — AFTER the merge gate has passed. A wrong inference there reddens main, not the PR.'
mechanics: 'PAT `ci-registry-scoped-697`, scopes `write:package,read:repository`, stored as repo Actions secret `REGISTRY_PASSWORD`; `REGISTRY_USER` remains `timothy`. Verified 2026-08-05 on Gitea 1.25.4: registry push of a probe tag SUCCEEDED; `GET /commits/{sha}/status` 200; `POST /statuses/{sha}` REFUSED HTTP 403 `token does not have at least one of required scope(s), required=[write:repository], token scope=write:package,read:repository`. Probe artifacts deleted, confirmed 404. RE-PROBED 2026-09-02 on Gitea 1.27.1 (#869) in a throwaway repo with a freshly minted PAT of the same two scopes: same 403 with the same message, plus a read control (same token GET -> 200) and a write control (admin POST -> 201); repo and token deleted, deletion confirmed. NOT COVERED BY EITHER PROBE (2026-08-05 or the #869 re-probe) — note this means not probed, NOT unexercised, and the `container:` pull is in fact exercised by the shipped credential on every PR: the `container:` pull, the buildcache write and the base-image pull. Those rest on Gitea''s scope model (write implies read per category, re-read at tag `v1.27.1` 2026-09-02) — INFERRED. Note WHICH run proves which: only the `container:` pull is exercised by a PR. `cache-to`/`cache-from` and the base-image pull are confined to the `build` job, which carries `if: github.event_name != ''pull_request''`, so they are first exercised on the post-merge push to `main` — AFTER the merge gate has passed. A wrong inference there reddens main, not the PR.'
---
**What was wrong.** `REGISTRY_USER`/`REGISTRY_PASSWORD` were the **admin account's** basic auth, and
@@ -102,16 +102,50 @@ count is now **16 of 16 with no exemption**. That is what let #835 ship the guar
exemption list at all: a one-entry list justified by a temporary condition elsewhere keeps passing
after that condition expires, leaving the guard permanently blind to the file it was written for.
**Measured vs inferred.** Measured here: the `v1.25.4` scope enum (`access_token_scope.go`) has no
**Measured vs inferred.** Measured here: the scope enum (`access_token_scope.go`) has no
`status` entry; the `reqRepoWriter` gate (`routers/api/v1/api.go`); the probes in `mechanics`. Read from
docs, NOT verified (2026-08-05): `permissions:` landed in 1.26.0 (Gitea PR #36173); no `app.ini` lever
at any version; Gitea rejects GitHub's `statuses`/`checks` scopes.
**MEASURED 2026-08-27 on 1.27.1 (#748) — this block is NOT covered by the 1.25.4 caveat below.** It
re-probes exactly ONE of that caveat's items, the `permissions:` question, and supersedes the
"whether `permissions:` is honored here is UNPROBED" claim. It does NOT discharge the caveat: the
scope enum, the `reqRepoWriter` gate and the `write:package` 403 are still 1.25.4-dated and
un-re-run. Method: a matched pair of scratch-base probe PRs, identical except for one permission
**RE-ESTABLISHED ON 1.27.1, 2026-09-02 (#869) — the three items the version caveat below NAMES.**
It discharges those three and only those three. It does NOT discharge every dated statement in this
record: "no container tag immutability in Gitea 1.25" (above) is still INFERRED and untouched, and
the caveat is kept rather than deleted because it names what each item rests on.
- **Scope enum — no `status` scope.** Read at tag `v1.27.1`, `models/auth/access_token_scope.go`
the same path it occupies at `v1.25.4`, checked in both trees rather than assumed, because this
record had only ever named the file and not its directory.
Nine categories — ActivityPub, Admin, Misc, Notification, Organization, Package, Issue,
Repository, User — and the string `status` does not occur in the file at all. `write:package`,
`read:repository` and `write:repository` remain distinct scopes, which is what keeps the registry
credential scopable off status-write at no cost.
- **The `reqRepoWriter` gate.** Read at tag `v1.27.1`, `routers/api/v1/api.go`: the `/statuses`
group registers `m.Combo("/{sha}").Get(repo.GetCommitStatuses).Post(reqToken(),
reqRepoWriter(unit.TypeCode), bind(api.CreateStatusOption{}), repo.NewCommitStatus)`. Unchanged in
substance from the 1.25.4 read: writing a commit status is still gated on repository-code WRITE,
so any credential that can write the repository can still forge `review-verdict/h10`.
- **The `write:package` 403 — re-probed live, with both controls.** A PAT scoped exactly
`write:package,read:repository`, minted against the live 1.27.1 instance, `POST
/repos/{o}/{r}/statuses/{sha}` -> **403**, message byte-for-byte the 1.25.4 one: `token does not
have at least one of required scope(s), required=[write:repository], token
scope=write:package,read:repository`. Two controls, because a bare 403 is equally consistent with a
dead token or a dead route: the SAME token `GET`ting the same path -> **200** (the token is live and
the read scope works), and an admin credential `POST`ing the same path -> **201** (the route accepts
a POST). **Be precise about what the second control licenses** — it changes the credential, so scope
and identity vary together and the PAIR alone cannot separate a SCOPE refusal from a
unit-permission refusal. What actually discriminates is the 403 BODY, which names the scope it
wanted and the scope it got; the admin 201 only rules out "the route rejects every POST". Cite the
message as the evidence and the control as its backstop, not the other way round. Run in a
throwaway private repo created for the probe; repo and token both deleted afterwards and the
deletion confirmed (repo `GET` -> 404, token `DELETE` -> 204), so nothing was written to a real
head.
**MEASURED 2026-08-27 on 1.27.1 (#748).** It re-probed exactly ONE of the old 1.25.4 caveat's items,
the `permissions:` question, and supersedes the "whether `permissions:` is honored here is UNPROBED"
claim. It did **not** by itself discharge that caveat — the scope enum, the `reqRepoWriter` gate and
the `write:package` 403 were still 1.25.4-dated and un-re-run when this paragraph was written; #869
re-established all three on 2026-09-02, which is what finally discharged it (see the block above and
"Version history" below). Method: a matched pair of scratch-base probe PRs, identical except for one permission
unit, the block carried **at job level on `set-verdict-status`** — the same level and job as the
shipped `review-verdict.yml`, so the gate's own shape is what was measured.
@@ -137,24 +171,32 @@ controls ran under `permissive`); and the TOP-LEVEL declaration form the other f
which is inferred rather than measured. That inference fails safe (a top-level key Gitea ignored is a
security no-op, never a brick). Probe branches and PRs (#851, #852) deleted after reading.
**Version caveat — this record's measurements are pinned to 1.25.4, the instance is now 1.27.1.**
The instance was upgraded mid-session on 2026-08-05 (#743). Everything above measured on 1.25.4 is
therefore a *dated* claim, not a current one: the scope enum, the `reqRepoWriter` gate and the 403
probe were all taken pre-upgrade and have NOT been re-run. They are recorded honestly as of their
date and are the best evidence available, but do not cite them as current behaviour without
re-probing. #747 re-probed SOME of the 1.25.4-pinned CI claims on 2026-08-28 —
`ci.docs-only-skip-steps`, `ci.shared-pr-file-enumeration`, the REST-route half of
`ci.batch-pushes-no-cancel-route`, the `compare` half of `ci.exemption-provenance` and the
`branches: [main]` item in `ci.gate-trigger-base-resolved` — and it was **not** a sweep of the
corpus. Other 1.25.4-dated CI claims remain un-re-run, the `creator`-attribution measurement the H10
allow-list rests on (`review-verdict.yml`) among them. It **did not** discharge this record's own
remaining items either: the `v1.25.4` scope enum, the `reqRepoWriter` gate and the `write:package` 403
all stay 1.25.4-dated. Only the 403 needs a scoped PAT minted against the live instance; the scope enum
and the `reqRepoWriter` gate were established by reading Gitea's source at the version tag (see
"Measured vs inferred" above), so re-running those needs only a source read at `v1.27.1`. #747 did
neither. What #747 did settle elsewhere is the merge-path half — see `release.main-direct-push-disabled`
for the measured
`block_admin_merge_override` semantics and for why `enable_bypass_allowlist` is not a substitute.
**Version history — the 1.25.4 caveat, and what discharged it.** The instance was upgraded mid-session
on 2026-08-05 (#743), which left this record's measurements dated against a version it no longer ran.
#747 re-probed SOME of the 1.25.4-pinned CI claims on 2026-08-28 — `ci.docs-only-skip-steps`,
`ci.shared-pr-file-enumeration`, the REST-route half of `ci.batch-pushes-no-cancel-route`, the
`compare` half of `ci.exemption-provenance` and the `branches: [main]` item in
`ci.gate-trigger-base-resolved` — and it was **not** a sweep of the corpus. #869 then swept the corpus
by deriving the population with `git ls-files` rather than from an issue's item list, and re-established
most of it — but "finished" would be too strong. Several findings remain 1.25.4-dated by decision, and
**nothing here tracks which ones, deliberately.** Two attempts at that tracking both failed inside the
commit that wrote them: an enumerated list named three residuals and omitted three the same commit
created, and its replacement — a `git grep` pointer — missed the residual that same commit created,
because the commit had introduced a second spelling the grep did not match. A second copy of a set,
whether written out or derived by a pattern, is one more thing to keep in step with the set. Each
residual states its own reason where it lives, which is the copy that cannot drift from its subject.
This record's own
three items are re-established on 1.27.1 above (2026-09-02, #869), so they may now be cited as current. What #747 settled elsewhere is the merge-path half — see
`release.main-direct-push-disabled` for the measured `block_admin_merge_override` semantics and for why
`enable_bypass_allowlist` is not a substitute.
**Read the two source items as source-attested, not instance-attested, and know the difference.** The
scope enum and the `reqRepoWriter` gate are established by reading Gitea's source at tag `v1.27.1`,
which is the version this instance reports (`GET /api/v1/version` -> `1.27.1`, 2026-09-02). That is a
strong claim about the code and a weaker one about the deployment: a patched or vendored binary
reporting `1.27.1` would satisfy the version read and not the source read. Nothing suggests one here,
and the third item — the live 403, whose error string enumerates the required scope — is what ties the
source to this instance. Cite the trio together rather than the source pair alone.
**Surviving routes — this record is not a mitigation for any of them.** `RENOVATE_TOKEN` is a
`write:repository` bot PAT in the same store and still cannot be scoped down, because Renovate needs
@@ -1,12 +1,12 @@
---
key: ci.batch-pushes-no-cancel-route
title: '2026-07-21 — Batch your pushes: there is no agent-side cancel route on Gitea 1.25.4; REST route re-probed on 1.27.1 (#542)'
title: '2026-07-21 — Batch your pushes: no agent-side cancel route; REST absence confirmed in the v1.27.1 router source, web UI still 1.25.4-dated (#542, #869)'
status: active
since: '2026-07-21'
supersedes: none
superseded-by: none
rule: Hold review fixes, doc corrections and format fixes locally and push **once** — a superseded run cannot be cancelled from the agent side and holds a runner slot until it finishes.
signals: 'cancel run 404 · Gitea 1.25.4 · Gitea 1.27.1 · `POST /api/v1/.../actions/runs/{id}/cancel` · MCP `actions_run_write` `cancel_run` · session+CSRF · `dispatch_workflow` · orphaned run · 4-slot runner · paths: n/a · issues: #542'
signals: 'cancel run 404 · Gitea 1.25.4 · Gitea 1.27.1 · `POST /api/v1/.../actions/runs/{id}/cancel` · MCP `actions_run_write` `cancel_run` · session+CSRF · `routers/api/v1/api.go` · `dispatch_workflow` · orphaned run · 4-slot runner · paths: n/a · issues: #542, #869'
mechanics: Gitea Actions REST + MCP `actions_run_write`; operator-only cancel in the browser UI.
---
@@ -18,7 +18,9 @@ session cookie).
**Re-probed on 1.27.1, 2026-08-28 (#747) — the REST route only.** `POST .../cancel` still returns
**404**, and the live swagger shows the absence is structural rather than one 404: the only POST routes
under `actions/runs/{run}` are `rerun`, `rerun-failed-jobs` and `jobs/{job_id}/rerun`. The MCP
`cancel_run` and web-UI/CSRF findings above were **not** re-run and stay 1.25.4-dated. Swagger also
`cancel_run` and web-UI findings above were **not** re-run and stay 1.25.4-dated. What is said about
each at the end of this record differs, deliberately: the MCP one has a stated but SESSION-LOCAL
reason (#869), and the web-UI one has none beyond being unchanged in kind. Swagger also
lists `DELETE /actions/runs/{run}` (delete a run record); it was not probed — do not assume it cancels
a run or frees a runner slot. **Only the operator can cancel, in the browser** — so if you must supersede a live
run, say so explicitly instead of leaving it burning. (`dispatch_workflow` is a different route and
@@ -26,3 +28,34 @@ still works for re-triggering a **main** run.)
This corrects the older "superseded runs drain on their own" framing: they do finish, but they hold
one of the 4 runner slots while doing it, which is a real cost with several parallel sessions.
**Confirmed from SOURCE at tag `v1.27.1`, 2026-09-02 (#869), which upgrades what the claim rests on.**
`routers/api/v1/api.go` registers the entire `/repos/{o}/{r}/actions/runs/{run}` group and it holds no
cancel route in any method: `GET ""`, `GET /attempts/{attempt}`, `GET /attempts/{attempt}/jobs`,
`DELETE ""` (`repo.DeleteActionRun`), `POST /rerun`, `POST /rerun-failed-jobs`, `GET /jobs`,
`POST /jobs/{job_id}/rerun`, `GET /artifacts`. The only occurrence of `cancel` in that entire router is
`repo.CancelScheduledAutoMerge`, an auto-merge route and unrelated. Reading the source is what draws a
distinction a status code cannot: a 404 is equally consistent with an absent route, a mistyped path and
a permission refusal, and only the first justifies "there is no route".
**Scope that read honestly: it covers the REST API surface and nothing else.** `routers/api/v1/api.go`
cannot speak for the web UI's own routes, for the runner protocol, or for anything an agent driving an
authenticated browser session could reach. "Operator-only in the browser" therefore still rests on the
1.25.4 web-UI finding, not on this source read.
**Why the other two stay dated (#869, 2026-09-02) — and the honest reason, which is not the tidy one.**
The MCP `cancel_run` tool was NOT re-invoked this session because the agent harness refused the call.
That is a session-local constraint, not a property of this repo, and it is recorded as such rather
than dressed up as an impossibility.
An earlier draft of this paragraph did dress it up — it claimed there was "no disposable run to spend"
because nothing triggers on a non-`main` push. **That reason is false and cold review caught it.**
Four of the six workflows carry `workflow_dispatch:`, and `dependency-scan.yml` is read-only while
`renovate.yml` defaults its dispatch to `dryRun: full` (log only, no PRs), so either raises a
disposable run on demand. A scratch PR raises runs too, which is how #747 and #748 probed. And the
premise does not even need a disposable run: invoking `cancel_run` against an already-FINISHED run id
costs nothing, and that is the right re-probe whenever a session can make the call.
What IS established: the REST surface carries no cancel route at v1.27.1 (above), which explains the
tool's 404 mechanistically — but that is an inference about a wrapper whose own source was not read
here, so it is a strong explanation and not a measurement. The web-UI finding is unchanged in kind.
@@ -23,9 +23,14 @@ behind a green required check. Closed unmerged, branches deleted, no forged `h10
**Why a base BINDING and not a pinned diff.** Diffing two immutable shas would close it outright;
Gitea cannot serve that: `compare/{base}...{head}` returns an object whose only keys are `commits`
and `total_commits`, no `files` — measured on 1.25.4 and **re-confirmed on 1.27.1, 2026-08-28 (#747)**,
probed on `b16ec15d6c1e...9b4844c1a8a9`, 5 commits. Separately, and **not re-probed since 1.25.4**, a
`--depth=1` fetch of the two shas has no merge base, so three-dot is impossible and two-dot
over-reports everything `main` gained since the branch point. So the base is read before the first page
probed on `b16ec15d6c1e...9b4844c1a8a9`, 5 commits. Separately, a `--depth=1` fetch of the two shas has
no merge base, so three-dot is impossible and two-dot over-reports everything `main` gained since the
branch point. **Re-probed 2026-09-02 (#869), and the re-probe corrected how it is FILED: this is a GIT
property, not a Gitea one, so it was never 1.25.4-dated in the first place.** Carrying a Gitea version
stamp on it invited a re-run against the wrong axis — a Gitea upgrade cannot change it, and a git
upgrade can. Measured on git 2.55.0 in a disposable local repo: after `git fetch --depth=1 origin
<base> <head>`, `.git/shallow` is present, `git merge-base` exits **1**, and `git diff <base>...<head>`
exits **128** with `fatal: ...: no merge base`. Re-probe it against a GIT version. So the base is read before the first page
and after the last, and **the gap is stated plainly**: a retarget opening *and* closing strictly
between the files call and the re-read stays invisible from inside the enumeration.
@@ -28,6 +28,58 @@ and the base definition posted `h10=pending` on `opened` and `synchronize` alike
establishing the residual below — is counted because omitting it turns an honest partial into an
overclaim. Probes posted only probe-named contexts, never a forged `h10`. Full results in #699.
**The DEFINITION-RESOLUTION half re-established FROM THE v1.27.1 SOURCE, 2026-09-02 (#869)** — the
mechanism behind the measurement, without spending four more scratch PRs. **Be precise about which
half**, because an earlier draft of this block overclaimed and cold review caught it: `notify()`
establishes only WHICH COMMIT each trigger reads its definition from. It says nothing about which
`types` produce a run, and nothing about secrets availability — the file's only other
`pull_request_target` reference is `ifNeedApprovalWith`, an APPROVAL gate, not a secrets gate.
**What IS established on 1.27.1, stated no wider than it was measured:** `pull_request_target` fires
for `review-verdict.yml` continuously — **210 such runs across 189 distinct head shas**, counting only
runs started strictly after the 2026-08-05 upgrade (measured 2026-09-02). All 210 belong to this
workflow. The WINDOW is part of the claim: the all-time REPO-WIDE figure is 258 runs / 233 shas (this
workflow's own all-time count is 250 — the difference is **8 runs** from three since-deleted probe
workflows, all pre-upgrade), which splits 44 before the upgrade date + 4 ON it + 210 after, so citing
258 here would import 1.25.4 evidence into a 1.27.1 sentence. The 4 same-day runs are excluded rather
than assigned — the upgrade landed mid-session and their 17:41-22:36 **UTC** start times cannot be
placed either side of it without the exact upgrade time, so they go to the side that weakens the
claim. Read those times as UTC deliberately: this endpoint returns `started_at` Z-suffixed, while
other Gitea timestamps render in the host zone, Europe/Brussels — `+02:00` in August, `+01:00` in
winter — so a UI comparison shows different clock times. **What that
deliberately does NOT establish:** WHICH `types:` fire, since a run count identifies no event type;
and whether the STORED secret store is reachable under this trigger, since the job references only the
injected `secrets.GITEA_TOKEN`. Both stay 1.25.4-dated.
Two earlier attempts at this paragraph are worth not repeating. The first re-dated BOTH halves —
over-retreating past what production already shows. The second over-corrected into a per-PR
completeness claim ("every PR merged since the upgrade carries this job's context"), which is false
and refuted by this record's own PR #863 observation — that PR merged carrying "no review-verdict
context of any kind", because a PR into a scratch base does not match `branches: [main]`, the very
filter this record exists to explain. (Cited by content rather than by position: a "two paragraphs
down" pointer was wrong the one time it was written, and a positional reference breaks whenever
anything is inserted above it.) A blunt run count needs no per-PR census and cannot rot that way.
Now the mechanism itself. In `services/actions/notifier_helper.go`, one function resolves both. For a
PR event `input.Ref` resolves
to the PR's own HEAD ref — `WithPullRequest` sets `input.Ref = git.RefName(pr.GetGitHeadRefName())`
when the caller has not already supplied one; that ref's commit is passed to
`DetectWorkflows`, and every workflow found there is kept **only if** its trigger is NOT
`pull_request_target` (`if wf.TriggerEvent.Name != actions_module.GithubEventPullRequestTarget`).
Then, separately and only when `input.PullRequest != nil`, it resolves `baseRef := git.BranchPrefix +
input.PullRequest.BaseBranch`, calls `DetectWorkflows` again on that commit, and from THAT set keeps
only workflows whose trigger **is** `pull_request_target`. So the two triggers read their definitions
from two SEPARATELY RESOLVED refs — head and base — and a head rewrite cannot reach a
`pull_request_target` definition at all. (Separately resolved, not necessarily different: nothing
forbids the two refs resolving to the same commit, which is simply the uninteresting case where the
rewrite is not a rewrite.)
The same lines make the `branches: [main]` filter's necessity concrete rather than cautionary: the
base is resolved by BRANCH NAME (`BranchPrefix + BaseBranch`), not by a pinned sha, so "the base
definition" is whatever that branch points at when the event fires. That is why a PR opened into an
attacker-pushed base branch would run that branch's gate, and it is the same re-resolution the
retarget fence exists to fence (`ci.verdict-write-retarget-fence`).
That third item was **re-confirmed at the STATUS level on 1.27.1, 2026-08-28 (#747)**, as a by-product
of an unrelated probe (PR #863): a PR from a scratch head into a scratch base carried thirteen `pull_request`
contexts and no review-verdict context of any kind. The run list itself was not re-enumerated, so the
@@ -21,6 +21,13 @@ closes the permanent case is the post-POST re-count recorded at `ci.verdict-unve
**The `push`-supersession half stays 1.25.4-dated (#747, 2026-08-28), and the cheap probe route is
gone.** No workflow triggers on a push to a non-`main` branch, and `main` refuses direct pushes, so the
only remaining route is two `push`es to `main` — i.e. two PR merges — landing close together.
Both halves of that reason were RE-CHECKED on 2026-09-02 (#869) rather than carried forward, because a
reason a claim stays dated goes stale exactly like the claim does. All six workflows were enumerated
from the tracked tree and the trigger half still holds; the protection half was re-read live the same
day. Note the scope: that re-check covers the REASON, not the two measurements themselves, which were
not re-run. And it licenses only the "no PUSH-triggered scratch run" half — four of the six workflows
carry `workflow_dispatch:`, so a disposable run IS raisable on demand; what is gone is the cheap
scratch-BRANCH push probe these particular measurements used.
Supersession is per-ref, so a `v*` tag push is not one: `docker-build.yml` triggers on it, but each
release tag is a ref pushed once, with nothing to supersede. That route is real and arrangeable, but it
is not the disposable scratch-branch probe this record's other push measurements used, and the
@@ -272,16 +279,58 @@ here rewritten because what it leaves behind still bounds the fence.
**The two walks do NOT now agree, and an earlier draft of this record said they did.** What they
share is only the cap treatment — "the 20th request has to be the EMPTY terminator". Their
TERMINATION rules are now opposite: `page_statuses` still returns on the FIRST empty page, and
this walk changed away from that. Whether it should follow is not settled here, because the
defect that forced this change has not been shown to exist on `/statuses/{sha}`: measured
2026-08-30, that endpoint returns a TRUE `X-Total-Count` (105 on a 105-row head, at both
`?limit=1` and `?limit=50`), where `/issues/{n}/timeline` returns the post-filter page length.
A true total is consistent with counting before filtering and so with a terminator that means
what it says — evidence, not proof, and the reason it is only evidence is that no filtering
predicate on that endpoint has been exhibited either way. Tracked rather than asserted:
ersatztv#893. "Require K consecutive empty pages" was rejected: K is arbitrary and only moves the
bar to 50K filtered rows.
TERMINATION rules are opposite: `page_statuses` returns on the FIRST empty page, and this walk
changed away from that.
**The asymmetry is CORRECT, and it is correct because the two ENDPOINTS differ — established from
the v1.27.1 source on 2026-09-02 (ersatztv#893), which closes the question this record previously
only tracked.** `GET /repos/{o}/{r}/statuses/{sha}` is `repo.GetCommitStatuses` ->
`getCommitStatuses` in `routers/api/v1/repo/status.go`. It calls
`db.FindAndCount[git_model.CommitStatus]` with `CommitStatusOptions{ListOptions, RepoID, SHA,
SortType, State}`, then builds the response with an UNCONDITIONAL loop —
`for _, status := range statuses { apiStatuses = append(apiStatuses, convert.ToCommitStatus(ctx,
status)) }`. There is no `continue`, no predicate and no nil-drop: `convert.ToCommitStatus` returns
a struct pointer for every row it is handed. `CommitStatusOptions.ToConds()` builds the ONLY filter
`repo_id`, `sha`, and an optional `state` — and it is a SQL `WHERE`, evaluated by the database in
the SAME query that carries the `LIMIT`/`OFFSET`, never as a pass over the rows after they come
back. So the serialized page length equals the database page length, and an empty page really does
mean "past the end".
That is the precise property `/issues/{n}/timeline` lacks. `ListIssueCommentsAndTimeline` calls
`FindComments` with the `ListOptions` (so LIMIT/OFFSET at the database level) and then appends
CONDITIONALLY — `if comment.Type != issues_model.CommentTypeCode &&
isXRefCommentAccessible(ctx, ctx.Doer, comment, issue.RepoID)`. Note that is TWO predicates, not
one: the row type AND a per-viewer accessibility check, so the number of rows dropped is not even a
property of the issue alone. That is what makes a full page of filtered rows byte-identical to the
end of the list. The defect #870 fixed therefore does not exist on `/statuses/{sha}`, and adopting
#870's treatment here would buy nothing for 20 requests per walk on the post-write path, where
latency is least welcome.
**The same two handlers explain the terminator SHAPES this record measured per endpoint, which
until now were an empirical table.** `getCommitStatuses` builds its slice with
`make([]*api.CommitStatus, 0, len(statuses))` — a non-nil empty slice, which serializes as `[]`.
`ListIssueCommentsAndTimeline` declares `var apiComments []*api.TimelineComment` — a nil slice when
nothing appends, which serializes as bare `null`. So "`/statuses/{sha}` returns `[]`,
`/issues/{n}/timeline` returns `null`" is a source-level property of how each handler initialises
its slice, not a coincidence to be re-measured after every upgrade. It still has to be re-read if
either handler is rewritten — but a reader can now check it in the source instead of constructing a
past-the-end request.
**The header measurement is now explained rather than merely suggestive.** Measured 2026-08-30,
`/statuses/{sha}` returns a TRUE `X-Total-Count` (105 on a 105-row head, at both `?limit=1` and
`?limit=50`) where `/issues/{n}/timeline` returns the post-filter page length. The source says why,
on both sides: `getCommitStatuses` calls `ctx.SetTotalCountHeader(maxResults)`, where `maxResults`
is `FindAndCount`'s SQL COUNT over the same conditions — computed by the database, never from the
serialized page — while the timeline calls
`ctx.SetTotalCountHeader(int64(len(apiComments)))`, literally the length of the filtered page it
just built, which is why that header cannot derive a page count there. Re-confirmed live on
2026-09-02 on a head where the two necessarily differ: page 1 returned 50 rows with
`X-Total-Count: 63`. Previously this was recorded as "evidence, not proof" because no filtering
predicate had been exhibited either way; the absence is now read off the handler directly, so it is
settled. **Do not "tidy" the two walks together** — they differ because their endpoints differ.
"Require K consecutive empty pages" was rejected: K is arbitrary and only moves the bar to 50K
filtered rows.
Three measurements at Gitea 1.27.1 (2026-08-30) rule out the cheaper fixes, and are recorded
because each is the kind of thing a later reader would otherwise re-propose. `X-Total-Count` on
@@ -63,8 +63,21 @@ forced one. `main` carries the field `true`, which is what closes the force path
differs from row A's only in `enable_bypass_allowlist`, and its PR merged just the same — so enabling it did not substitute
for `block_admin_merge_override`. A NON-empty username list was not tested, and this cannot distinguish
"the allowlist does not govern admins" from "an empty list is treated as unconfigured". Enough to
refuse the swap; not enough to describe the field's semantics. The claim that it postdates 1.25.4 comes
from #747's issue body, not from a probe here.
refuse the swap; not enough to describe the field's semantics.
**That `enable_bypass_allowlist` postdates 1.25.4 was an INHERITED claim and is now a checked one
(#869, 2026-09-02).** It came from #747's issue body rather than from any probe, which is the shape
this repo keeps getting burned by — a claim whose only provenance is another artifact's prose. Read
off `modules/structs/repo_branch.go` at both tags: `EnableBypassAllowlist` does not occur anywhere in
the file at `v1.25.4` and is present at `v1.27.1`, where this instance also returns
`enable_bypass_allowlist` in a live branch-protection read. So the claim holds, and the reason it is
believed is now the source rather than a sentence someone typed into an issue.
Note the neighbouring field does NOT share that history, which is worth stating because the paragraph
above could invite the inference: `BlockAdminMergeOverride` / `block_admin_merge_override` is present
at `v1.25.4` too — in all three structs (`BranchProtection`, `CreateBranchProtectionOption`,
`EditBranchProtectionOption`). It is not a 1.27 addition, and nothing here should be read as saying it
is. What was new in this repo was our USE of it, not the field.
**Trap: `mergeable` does not answer the gate question.** On PR #863 — row 1, the ordinary-merge arm —
the API reported `mergeable: true` at the moment that merge was being refused 405, so there it did not
+1 -1
View File
@@ -96,7 +96,7 @@ another doc or an old issue comment should land here and then follow the link.
- 2026-07-21 — An infra-shaped red under host load is not a code failure (#542) — [`ci.infra-shaped-red-under-load`](records/ci/infra-shaped-red-under-load.md)
- 2026-07-21 — Arm the CI monitor at PR-open, via the commit-status endpoint (#542) — [`ci.monitor-armed-at-pr-open`](records/ci/monitor-armed-at-pr-open.md)
- 2026-07-21 — BOM/format pre-push detection: use the `xxd` loop, and run `dotnet format --include` under `bash -c` (#542) — [`process.bom-format-detection-recipe`](records/process/bom-format-detection-recipe.md)
- 2026-07-21 — Batch your pushes: there is no agent-side cancel route on Gitea 1.25.4; REST route re-probed on 1.27.1 (#542) — [`ci.batch-pushes-no-cancel-route`](records/ci/batch-pushes-no-cancel-route.md)
- 2026-07-21 — Batch your pushes: no agent-side cancel route; REST absence confirmed in the v1.27.1 router source, web UI still 1.25.4-dated (#542, #869) — [`ci.batch-pushes-no-cancel-route`](records/ci/batch-pushes-no-cancel-route.md)
- 2026-07-21 — Before deleting a "workaround", enumerate every behavior it provided (#542) — [`process.enumerate-workaround-behaviors-before-deleting`](records/process/enumerate-workaround-behaviors-before-deleting.md)
- 2026-07-21 — Bound parallel builds by FREE RAM, never regenerate goldens (#542) — [`process.build-concurrency-limits`](records/process/build-concurrency-limits.md)
- 2026-07-21 — Build and verify locally, then trust it; CI confirms (#542) — [`ci.verify-locally-ci-confirms`](records/ci/verify-locally-ci-confirms.md)
+5 -3
View File
@@ -60,9 +60,11 @@
# that opens and closes strictly between the files call and the re-read is not observable from here.
# Pinning the diff to two shas would close it, and Gitea cannot serve that: `compare/{base}...
# {head}` returns `total_commits`/`commits` and NO `files` (measured on 1.25.4, re-confirmed on
# 1.27.1 2026-08-28 ersatztv#747). Separately, and NOT re-probed since 1.25.4: a `--depth=1` fetch of
# the two shas has no merge base, so a three-dot diff is impossible while a two-dot one over-reports
# every commit `main` gained since the branch point (measured, #698). Independently re-probed again on
# 1.27.1 2026-08-28 ersatztv#747). Separately: a `--depth=1` fetch of the two shas has no merge base,
# so a three-dot diff is impossible while a two-dot one over-reports every commit `main` gained since
# the branch point (measured, #698). That one is a GIT property, not a Gitea one — it carried a Gitea
# version stamp until ersatztv#869 re-probed it on 2026-09-02 (git 2.55.0: `merge-base` exits 1, the
# three-dot diff exits 128 `no merge base`) and re-filed it against the right axis. Independently re-probed again on
# 1.27.1, 2026-08-28 (ersatztv#803): still `total_commits`/`commits` only, over a 12-commit range.
#
# The remainder is covered one level up instead, and since ersatztv#803 that cover is explicit on BOTH
+17 -1
View File
@@ -2316,7 +2316,15 @@ def test_a_BOT_pr_touching_a_protected_path_is_still_NOT_exempt(tmp_path):
# accepted unchanged on every later run, because the guard exited before looking at the PR, the base,
# the author or the files.
#
# MEASURED on Gitea 1.25.4, on the COMBINED endpoint this job reads: a status POSTed with a user
# MEASURED first on Gitea 1.25.4 and RE-MEASURED on 1.27.1, 2026-09-02 (ersatztv#869) over four
# merged PR heads, on the list endpoint as well as the combined one. It comes from the v1.27.1
# response builder: an Actions job's creator id is the synthetic -2, which no `user` row matches, so
# the lookup fails, the error is discarded and the field serializes null. Same builder on both
# endpoints, so they cannot disagree. Note null therefore means "no user row resolved", NOT "a
# machine wrote it" — a deleted author looks identical, which is safe here only because the test
# below fires on a POSITIVE match. The workflow comment this duplicates carries the full
# measurement and the full mechanism; keep the two in step.
# On the COMBINED endpoint this job reads: a status POSTed with a user
# credential carries `.creator.login` (`timothy`), one POSTed by an Actions job carries
# `"creator": null`. Both halves are required, and the test is written in the POSITIVE direction —
# short-circuit only on something identified as a human verdict — so an unrecognised shape is
@@ -3141,6 +3149,14 @@ def test_a_branch_name_containing_a_PAREN_cannot_truncate_into_the_current_base(
# --- ersatztv#706: the retarget fence (race 1) and the post-write repair (race 2) -----------------
#
# These three stay 1.25.4-dated on purpose, but NOT all for the same reason — an earlier version of
# this note gave one blanket reason and cold review showed it fits only the third. The first two were
# measured on probe PR #722, and a scratch PR is still raisable; they were simply not re-run
# (ersatztv#747 did not re-measure the `pull_request_target` half either). Only the third — Gitea
# auto-cancelling superseded `push` runs — used the disposable scratch-BRANCH push route, and that
# route is genuinely gone. See `ci.verdict-write-retarget-fence`, re-checked 2026-09-02
# (ersatztv#869).
#
# Race 1 was reproduced LIVE before any of this was written, because the fix turns on which Gitea
# behaviours are real rather than on what the docs imply. Measured on Gitea 1.25.4:
# * `pull_request_target` runs for one PR are NOT auto-cancelled — probe PR #722, run 7520