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
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user