Repository navigation
feat(forge): P38 F0 contract recorded from Gitea, spec, ADR-0058 (Proposed) #294
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
24 commits
Select commit
Hold shift + click to select a range
8f81c1f
docs: P38 sc-forge design spec and ADR-0058 (Proposed)
tonibergholm-codento a4517dd
docs: P38 spec revision 2 (design review: crypto quarantine, http-bac…
tonibergholm-codento b32f031
test(forge): Gitea conformance runner and fixtures recorded from 1.22…
tonibergholm-codento 7190641
docs(forge): record Gitea findings, reconcile spec with fixtures
tonibergholm-codento 0cfd3a9
docs(forge): resolve review findings in spec and ADR-0058
tonibergholm-codento c2a713e
test(forge): conformance checker compares error text, git refusals an…
tonibergholm-codento 9d2a7f5
docs(forge): record follow-up scenarios, admin token-minting rule
tonibergholm-codento 0bbfa1b
Merge branch 'main' into feat/forge-p38
tonibergholm-codento 762769a
docs(forge): fix status/contents/token-rule contradictions found in s…
tonibergholm-codento 851e430
test(forge): stricter departures, identity-aware error text, settle f…
tonibergholm-codento 976577f
docs(forge): address bot review findings (CGI env, export, admin boot…
tonibergholm-codento d19200f
test(forge): per-principal clones, check-mode expect, team and passwo…
tonibergholm-codento d9cf6f1
docs(forge): define recovery for interrupted repository deletion
tonibergholm-codento ad2d9cf
test(forge): departures keep comparing headers and body; compare url …
tonibergholm-codento d316294
docs(forge): hide refs/forge from both services with transfer.hideRefs
tonibergholm-codento 513b059
docs(forge): ordered retention-ref freeze, creating state, commit ide…
tonibergholm-codento ac4b235
docs(forge): protection linearization point, issue-comment POST, comm…
tonibergholm-codento d9feee8
docs(forge): user/repos routes, final_head_sha, trusted proxy peers, …
tonibergholm-codento 7b1501e
docs(forge): route inventory appendix, dumb HTTP via http-backend, re…
tonibergholm-codento dd92ace
test(forge): web tag, unpaired exchanges, per-kind departures, ref ad…
tonibergholm-codento 7e6cf84
docs(forge): record ref advertisement and web probe findings
tonibergholm-codento e31d8c9
docs(forge): allowAnySHA1InWant (recorded deleted-branch fetch; teste…
tonibergholm-codento b7f1148
docs(forge): status empty-answer rule, repo vs owner name rules, exac…
tonibergholm-codento f57cbc4
docs(forge): reserved names apply to owners only
tonibergholm-codento File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,204 @@ | ||
| # Findings from the Gitea recordings | ||
|
|
||
| Recorded 2026-10-22 against `gitea/gitea:1.22.6-rootless` and `1.26.2-rootless` with | ||
| `REQUIRE_SIGNIN_VIEW=true`, `FORCE_PRIVATE=true`, Actions, SSH and registration off. Scenario | ||
| letters match `fixtures/<version>/<letter>-*.json`. Every statement below was observed, not read | ||
| from source. "Both" means both versions answered the same. | ||
|
|
||
| Each version passes its own fixtures (22/22). Checking the 1.22.6 fixtures against 1.26.2 passes 3/22 | ||
| (I, Q, V); every failure is a difference listed in the last table. | ||
|
|
||
| ## Headline answers | ||
|
|
||
| - **Merge of a conflicting PR:** `405 {"message":"Please try again later"}`. Gitea sends the same text | ||
| while a PR is still being checked, so status and body cannot tell the two apart. | ||
| - **Wrong `head_commit_id`:** `409 {"message":"head out of date"}`. | ||
| - **Required status check not met:** merge is `405`; 1.22.6 says `not allowed to merge [reason: Not all | ||
| required status checks successful]`, 1.26.2 says `Not all required status checks successful`. Both | ||
| contain "required status checks". A push to a protected branch is refused by the pre-receive hook | ||
| (`Not allowed to push to protected branch main`, 1.22.6 prefixes `Gitea:`), for the admin too. | ||
| - **"already exists" texts:** repo `409 "The repository with the same name already exists."`; user and | ||
| org `422 "user already exists [name: x]"`; PR `409 "pull request already exists for these targets | ||
| [...]"`; branch `422 "branch already exists [name: ..]"`; file `422 "repository file already exists | ||
| [path: ..]"`; branch protection `403 "Branch protection already exist"`; token name `400 "access | ||
| token name has been used already"`; repo rename `422 "repository already exists [uname: acme, name: | ||
| mixed]"`. | ||
| - **Foreign token (valid, no access):** API `404`, identical to a missing repo (1.22.6 body | ||
| `{"errors":null,"message":"The target couldn't be found."}`, 1.26.2 `{"message":"not found"}`); git | ||
| `404 "Repository not found"`. One leak: `git-receive-pack` on a missing org repo answers `403 "Push to | ||
| create is not enabled for organizations."`. A bad or unknown token is `401` (1.22.6 `user does not | ||
| exist [uid: 0, name: ]`, 1.26.2 `invalid username, password or token`). Anonymous: API `403 "Only | ||
| signed in user is allowed to call APIs."`; git `401` with `WWW-Authenticate: Basic realm="Gitea"`. | ||
| - **Status de-duplication:** `commits/{sha}/status` keeps only the latest status per context; | ||
| `commits/{sha}/statuses` and `statuses/{sha}` return the full history. | ||
| - **Merged immediately?** Yes. A GET right after `POST merge` shows `merged:true` with | ||
| `merge_commit_sha`, and the merge commit has 2 parents. | ||
| - **`--force-with-lease`:** the check is client-side. With `refs/heads/b:` the push succeeds when `b` | ||
| is absent and is refused locally (`stale info`) when it exists. | ||
|
|
||
| ## By scenario | ||
|
|
||
| **A to D (meta, users, repos, empty repo)** | ||
| - No `WWW-Authenticate` on API 401s. `PATCH /admin/users/{u}` is 200. Token create returns | ||
| `{id,name,sha1,token_last_eight,scopes}` (1.26.2 adds `created_at` and `last_used_at`). | ||
| - Scope enforcement is 403: a token with `read:user,read:organization,write:repository` cannot | ||
| `POST /user/repos` (needs `write:user`) and cannot touch issue comments (needs `read:issue` or | ||
| `write:issue`). | ||
| - The repo object has 58 keys; `permissions` is `{admin,push,pull}`. | ||
| - Empty repo: `commits` is `409 "Git Repository is empty."`; `GET branches` is 200 with the literal | ||
| body `null`; `GET contents` is `200 []` on 1.22.6 and 404 on 1.26.2. | ||
|
|
||
| **E (history reads)** | ||
| - `git/refs/heads/{x}` is always an array. Contents `POST` is 201; `PUT` without `sha` is `422 | ||
| "[SHA]: Required"` on 1.22.6. Archive `.zip` starts `504b0304`, `.tar.gz` starts `1f8b0800`. | ||
|
|
||
| **F (pull requests)** | ||
| - `PATCH pulls/{n}` returns **201**. The `head=` filter on the PR list is ignored. `head.sha` follows a | ||
| push immediately. A review with an unknown `event` is accepted as `PENDING`. A read-only | ||
| collaborator's `APPROVED` is accepted with `official:false`. Approving your own PR is `422 "approve | ||
| your own pull is not allowed"`. Issue comments need an all-scope token. | ||
|
|
||
| **G and T (merge)** | ||
| - Clean merge: 200, empty body. Retry after success: 405 with an empty message (1.22.6) or `"The PR is | ||
| already merged"` (1.26.2). Closed PR: 404. `squash` and `rebase`: 1 parent; `rebase-merge`: 2. | ||
| `fast-forward-only` when base moved: **500 with `{"message":""}`**. Head already contained in base: | ||
| PR create accepted (201), merge 405. Unrelated histories: 409 with a `{Style,StdOut,StdErr,Err}` body | ||
| (truncated, not valid JSON, on 1.26.2). | ||
| - After any base change Gitea re-checks open PRs and answers 405 "try again later" for a few seconds. | ||
|
|
||
| **H (statuses)** | ||
| - The POST response key is `status`, not `state`. Any state string is accepted and stored (`bogus`, | ||
| empty). A status POST to an unknown sha is 500 on both versions (the combined-status GET of an | ||
| unknown sha is 200 empty on 1.22.6 and 404 on 1.26.2). Combined state: success plus failure is | ||
| `failure`; success plus pending is `pending`. No statuses: `state:""`, `sha:""` (1.22.6) or | ||
| `state:"pending"` with the real `sha` (1.26.2). | ||
|
|
||
| **I and Q (protection)** | ||
| - Contents `POST`/`PUT` to a protected `main` is 403, even for the admin; with `new_branch` it is | ||
| allowed. A merge through a PR is allowed. A forced push to `main` is rejected even with | ||
| `enable_push:true`; deleting `main` by push is rejected. A force-push to an unprotected branch is | ||
| allowed. | ||
|
|
||
| **J and V (git over HTTP)** | ||
| - Username is not checked: any username works with a valid token, as does the token as username with an | ||
| empty password, and `-c http.extraHeader` with `Basic base64(TOKEN:)`, `token TOKEN` or `Bearer | ||
| TOKEN`. A bad token gives `remote: Verify` (1.22.6) or `remote: Failed to authenticate user` | ||
| (1.26.2). `Git-Protocol: version=2` returns a v2 advert. Fetch by sha works for the tip, an old | ||
| commit and a deleted-branch tip over v0 and v2. `--filter=blob:none`, `--depth` and `--unshallow` | ||
| work. gzip request bodies behave like plain ones. `info/refs` headers: `Cache-Control: no-cache, | ||
| max-age=0, must-revalidate`, `Expires: Fri, 01 Jan 1980 00:00:00 GMT`, `Pragma: no-cache`. | ||
|
|
||
| **K and L (visibility)** | ||
| - Anonymous and bad-token probing cannot tell a missing repo from a private one, except the | ||
| `receive-pack` case above. `PATCH private:false` is **accepted and stored** despite `FORCE_PRIVATE`; | ||
| only creation is forced to private. | ||
|
|
||
| **M and N (names, default branch)** | ||
| - Duplicate repo names are case-insensitive (409). Rename leaves a redirect (307 on 1.22.6, 301 on | ||
| 1.26.2); the old name still works for git. `PATCH default_branch:"nope"` returns 200 and keeps `main`. | ||
| An empty repo whose first push is a branch other than the default still reports `empty:true`. | ||
|
|
||
| **O and P (PR lifecycle, pagination)** | ||
| - Force-pushing the head moves `head.sha`. Deleting the head branch leaves the PR open; | ||
| `refs/pull/N/head` stays visible and merge is 404. A merged PR's `head.sha` is **not** frozen: it | ||
| follows a recreated branch. Pushing the head commit straight into base does **not** mark the PR | ||
| merged (still open after 15 s). Default page size is 30, max 50; `limit=0`, `-1` and `abc` fall back | ||
| to the default. PR lists are newest first. | ||
|
|
||
| **R and S (token scopes, special refs)** | ||
| - A `public-only` token on a private repo is `403 "token scope is limited to public repos"` (1.22.6) or | ||
| `404` (1.26.2). Basic auth with a **token** as the password works on `/users/{u}/tokens`. Alice's | ||
| password on `/users/bot/tokens` is 403. A `write:organization` token cannot create a repo in an org | ||
| the user is not in. A write collaborator may push `refs/replace`, `notes`, `changes`, `keep-around`, | ||
| `custom` and tags. `refs/pull/*` and `refs/for/*` are rejected; a branch literally named | ||
| `pull/1/head` is accepted. | ||
|
|
||
| **U (status-check edges)** | ||
| - A success on a different sha is refused. `warning`, `error`, `failure`, `pending` and a success on | ||
| another context are refused. **`skipped` on the required context allows the merge.** After the head | ||
| moves, the old status no longer counts. | ||
|
|
||
| ## Differences between the versions | ||
|
|
||
| | Area | 1.22.6 | 1.26.2 | | ||
| | --- | --- | --- | | ||
| | Not-found API body | `{"errors":null,"message":"The target couldn't be found."}` | `{"message":"not found"}` | | ||
| | Bad-token API message | `user does not exist [uid: 0, name: ]` | `invalid username, password or token` | | ||
| | Scope-error text | `...scope(s): [write:repository]` | `...scope(s), required=[..], token scope=..` | | ||
| | Repo object | has `parent` and `repo_transfer` (null), `default_allow_maintainer_edit:false` | omits both, `default_allow_maintainer_edit:true` | | ||
| | PR object | `assignees`, `requested_reviewers` null; list items include `additions/deletions/changed_files` | arrays; list items omit those three | | ||
| | Email in comments and statuses | `bot@noreply.localhost` | `3+bot@noreply.localhost` | | ||
| | Token objects | `id,name,scopes,sha1,token_last_eight` | adds `created_at`, `last_used_at` | | ||
| | Branch-protection objects | already has `created_at`, `updated_at`, `rule_name` | adds `priority`, `enable_force_push`, `enable_force_push_allowlist`, `force_push_allowlist_{usernames,teams,deploy_keys}`, `block_admin_merge_override` | | ||
| | Empty repo `GET contents` | `200 []` | 404 | | ||
| | `GET commits?sha=nope` | 500 | 404 with body `null` | | ||
| | `PUT contents` without sha | `422 [SHA]: Required` | `422 repository file already exists` | | ||
| | Merge retry after success | 405, empty message | 405 `The PR is already merged` | | ||
| | Combined status, no statuses | `state:""`, `sha:""` | `state:"pending"`, real `sha` | | ||
| | Combined status, unknown sha | 200 empty | 404 | | ||
| | Non-member's permissions in a private org | 404 | 200, all false | | ||
| | Read team member's `can_read` | true | false | | ||
| | Rename redirect | 307 | 301 | | ||
| | Web page for a user without access | 303 to login | 404 | | ||
| | Org name `admin` | 422 reserved | 201 | | ||
| | Repo names `..` and `a..b` | 422, 201 | 500 both | | ||
| | Delete default branch | `can not delete default branch` | `can not delete default or pull request target branch` | | ||
| | Unrelated-histories merge body | valid JSON | truncated or empty | | ||
| | Merge when head not yet checked | sometimes 405 | 200 in this run | | ||
|
|
||
| ## Not recorded or partial | ||
|
|
||
| - SSH, LFS, Actions, webhooks, releases, labels, milestones and issues other than comments. | ||
| - `mergeable` is compared by type only, since it flips while a PR is checked; the conflict state is | ||
| kept as the note `conflicting_pr_mergeable_after_8s`. | ||
| - A non-fast-forward push to a protected branch is refused by the git client before it reaches the | ||
| server, so only the forced variants exercise the server. | ||
| - Which of two same-second rows lists first (tokens, statuses, branches) is arbitrary on some versions; | ||
| scenarios sleep 1.1 s between creations where order is compared. | ||
|
|
||
| ## Operational notes | ||
|
|
||
| - A full record run takes about 2.5 to 3 minutes per version and needs a fresh server (the merge | ||
| scenario waits for Gitea's async PR checks and is the slowest, about 45 s). | ||
| - Fixtures are 3.5 MB across the two versions; compact JSON would roughly halve that. | ||
|
|
||
| ## Additional recordings (review follow-up) | ||
|
|
||
| Scenarios W (protected contents) and X (auth forms, token minting), plus additions to H and T. | ||
|
|
||
| - **Contents API on a protected `main` with `enable_push:true`:** allowed, with and without | ||
| `enable_status_check`, even with the required context unmet and nothing posted (`POST contents` 201, | ||
| `PUT contents` 200, for the write collaborator and the admin). Only `enable_push:false` blocks it | ||
| (403, scenario Q). | ||
| - **Direct fast-forward push with `enable_push:true`, status check on, context unmet:** accepted | ||
| (W#8; I#12 confirms). Required status checks gate merges, not pushes. | ||
| - **Auth forms on API routes** (`GET /user`, a private repo read, `POST statuses`): `Authorization: | ||
| token T`, `Bearer T`, Basic `bot:T`, `oauth2:T`, `anyname:T`, Basic `T:`, and Basic with the real | ||
| password all work. A bad token is 401 in every form. | ||
| - **Token minting:** the site admin using Basic with the admin's **password** on `POST | ||
| /users/bot/tokens` is allowed (201) and may list (200). The admin's **token** on that route is `401 | ||
| "auth required"`. A read-only token used as the Basic password on `POST /users/alice/tokens` is | ||
| refused with 403 (`required scope write:user`) and mints nothing; listing with it is 403 | ||
| (`read:user`). | ||
| - **Combined status:** unknown sha is `200` with empty fields on 1.22.6 and 404 on 1.26.2. A lone | ||
| `bogus` status reads `state:"bogus"` on 1.22.6 and `"pending"` on 1.26.2, both with `total_count:1`. | ||
| (An earlier note here said an unknown state counts as failing; that holds only in a mix.) | ||
| - **Unrelated-histories merge:** 409. The 1.22.6 body is `{"Style":"merge","StdOut":"","StdErr":"fatal: | ||
| refusing to merge unrelated histories\n","Err":{"Stderr":null}}` (no `message` key); the 1.26.2 body | ||
| is truncated or empty. | ||
| - **Reopening a closed PR whose head branch was deleted** is racy on 1.26.2: the PATCH answers `open`, | ||
| then the PR sometimes reverts to `closed` (3 of 6 trials). That state is volatile and excluded from | ||
| comparison. | ||
| - **Harness notes:** `--only` reruns can differ from the full ordered run because later scenarios read | ||
| state from earlier ones (scenario B makes `alice` private); the full run is the reference. Cross-version | ||
| `check` now fails about 310 exchanges because error text is compared. | ||
|
|
||
| ## Ref advertisement and web probes (round 6) | ||
|
|
||
| - Real Gitea advertises `refs/pull/*` on both `git-upload-pack` and `git-receive-pack`. In scenario S | ||
| it also advertises `refs/changes`, `refs/custom`, `refs/keep-around`, `refs/notes` and `refs/replace`, | ||
| which a write collaborator can push. `sc-forge` advertises only `HEAD`, `refs/heads/*` and | ||
| `refs/tags/*` (departures pin the filtered list). | ||
| - Four exchanges in scenario K are Gitea web-UI routes (`GET /acme/{vis,ghost}` as anonymous and as a | ||
| user). They are tagged `web` and skipped by default; `check --include-web` runs them for comparison | ||
| against real Gitea. | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.