From 69b342c12b6ce1c7e43fa5e5b1d6323fbbb52dca Mon Sep 17 00:00:00 2001 From: AlexeyShalaev <75322386+AlexeyShalaev@users.noreply.github.com> Date: Thu, 8 Oct 2026 02:48:51 +0300 Subject: [PATCH 1/2] test: fix the nightly e2e on Gitea 1.26 and GitLab; a 32-bit GraphQL Int in ghfake TestDoctor wanted the writer's scopes ok on any Gitea, but Gitea 1.26 has no GET /api/v1/token, so doctor rightly says unknown there. The test now asks the server's version: ok from Gitea 1.27 on, unknown with the reason before. The GitLab stand's shell runner ran the Ubuntu gitlab-runner image, whose git 2.43 is too old for plan (2.45), so TestSetup's reader job failed on every image. It now runs the Alpine image of the same version, with git 2.47 to 2.54. ghfake took a GraphQL Int as 64 bits. The GraphQL spec makes it a signed 32-bit integer, so a literal or variable out of that range now fails validation or coercion as on GitHub; this also ends four CodeQL integer-conversion alerts. --- docs/project/e2e.md | 4 ++-- internal/e2e/doctor_test.go | 10 ++++++++-- internal/platform/github/ghfake/doc.go | 2 ++ internal/platform/github/ghfake/gqlexec.go | 14 ++++++++++---- internal/platform/github/ghfake/gqlparse.go | 2 +- internal/platform/github/ghfake/graphql_test.go | 11 +++++++++++ scripts/e2e/gitlab.sh | 13 +++++++------ 7 files changed, 41 insertions(+), 15 deletions(-) diff --git a/docs/project/e2e.md b/docs/project/e2e.md index 861777f..a0ff6e5 100644 --- a/docs/project/e2e.md +++ b/docs/project/e2e.md @@ -143,7 +143,7 @@ bash scripts/e2e/gitlab.sh --only-seed gitlab/gitlab-ce:17.11.7-ce.0 # boot, s bash scripts/e2e/gitlab.sh --only-seed --keep gitlab/gitlab-ce:18.11.12-ce.0 ``` -A cold GitLab boots in 3 to 6 minutes on a Linux host with 4 CPUs (expected; not yet measured on a hosted runner). Docker Desktop on Windows took 10 to 12 minutes for 17.11.7 and 19.4.1 on 2026-09-27, and 16 for 18.11.12 with other containers busy: raise the 15-minute wait with `TOUCHMARK_E2E_READY_TIMEOUT` (seconds) on a slow machine. The runner image matches the GitLab minor (`gitlab/gitlab-runner:v17.11.4`, `v18.11.4`, `v19.4.1`); the script pulls it when it is missing. +A cold GitLab boots in 3 to 6 minutes on a Linux host with 4 CPUs (expected; not yet measured on a hosted runner). Docker Desktop on Windows took 10 to 12 minutes for 17.11.7 and 19.4.1 on 2026-09-27, and 16 for 18.11.12 with other containers busy: raise the 15-minute wait with `TOUCHMARK_E2E_READY_TIMEOUT` (seconds) on a slow machine. The runner image matches the GitLab minor (`gitlab/gitlab-runner:alpine-v17.11.4`, `alpine-v18.11.4`, `alpine-v19.4.1`): the Alpine images have git 2.47 to 2.54, and the jobs that run `plan` need 2.45, which the Ubuntu images (git 2.43) lack. The script pulls the image when it is missing. `--keep` leaves GitLab, the runner, the network, the volume with the binary and the env file with the tokens in place, publishes GitLab on a loopback port and prints how to rerun the tests and how to remove everything. `--only-seed` stops after the seeding and its checks; with `--keep` it gives a seeded instance to work against. Without `--keep`, the script removes all it created on exit, also after a failure or Ctrl-C: the containers with their anonymous volumes (the GitLab image declares `/etc/gitlab`, `/var/log/gitlab` and `/var/opt/gitlab`, about 0.5 GB per run; the runner image `/etc/gitlab-runner` and `/home/gitlab-runner`), the network, the volume with the binary and the env file. `gitea.sh` removes the forge's `/data` volume the same way. @@ -162,7 +162,7 @@ A cold GitLab boots in 3 to 6 minutes on a Linux host with 4 CPUs (expected; not | `jdoe` | a person, with a personal access token | Owner | `api, read_user, read_repository, write_repository` | Which path an image takes, checked on 2026-09-27: CE 18.11.12 answers 404 to `GET /service_accounts` (there the API is EE code, `ee/lib/api/service_accounts.rb`, which the `gitlab-ce` image lacks), so its reader and writer are group access token bots (`group__bot_`); CE 19.4.1 has instance service accounts (`touchmark-reader`, `touchmark-writer`). The script logs the path, and the tests get it in `TOUCHMARK_E2E_GITLAB_ACCOUNTS`. The checks: every token acts as its account, only root is an administrator, the scopes by `GET /personal_access_tokens/self`, the roles by the reader's `GET /groups/:id/members/all/:user_id` in `acme` and `acme/sub`, and the reader cannot create a project. -5. Creates an instance runner with `POST /user/runners` (a `glrt-` token), starts `gitlab/gitlab-runner` of the same minor in GitLab's network namespace with the binary at `/opt/touchmark/touchmark`, registers it with the shell executor (the token reaches `gitlab-runner register` through the environment) and waits until GitLab reports it `online`. +5. Creates an instance runner with `POST /user/runners` (a `glrt-` token), starts `gitlab/gitlab-runner` (Alpine) of the same minor in GitLab's network namespace with the binary at `/opt/touchmark/touchmark`, registers it with the shell executor (the token reaches `gitlab-runner register` through the environment) and waits until GitLab reports it `online`. 6. Runs `go test -tags e2e -count=1 -race -v ./internal/e2e/gitlab/...` in `golang:1.27`, in GitLab's network namespace. touchmark sends a credential over plain http only to loopback ([Tokens and git](threat-model.md#tokens-and-git)). So GitLab's `external_url` is `http://localhost`, and the tests and the runner share its network namespace: clone URLs in API answers and `CI_SERVER_URL` in jobs are the URL touchmark uses. GitLab's nginx listens on IPv4 only; Go, git and curl fall back from `::1` to `127.0.0.1`, busybox `wget` does not. diff --git a/internal/e2e/doctor_test.go b/internal/e2e/doctor_test.go index cfd62eb..e59edb8 100644 --- a/internal/e2e/doctor_test.go +++ b/internal/e2e/doctor_test.go @@ -175,9 +175,15 @@ func TestDoctor(t *testing.T) { want("provider", pc, "token-expiry", report.StatusOK, "no expiry date") want("provider", pc, "2fa", report.StatusUnknown, "do not show") want("provider", pc, "hub-hidden", report.StatusWarn, "sees the private hub") + // Gitea shows a token's scopes from 1.27 on; older Gitea and Forgejo + // leave them unknown. scopes := check("provider", pc, "scopes") - if e.Flavor == "gitea" && scopes.Status != report.StatusOK { - t.Errorf("scopes on Gitea: %s %q", scopes.Status, scopes.Detail) + if v := forgeVersion(t, e); !v.Forgejo && v.atLeast(1, 27) { + if scopes.Status != report.StatusOK { + t.Errorf("scopes on %s: %s %q", v, scopes.Status, scopes.Detail) + } + } else if !v.Forgejo && (scopes.Status != report.StatusUnknown || !strings.Contains(scopes.Detail, "1.27 and later")) { + t.Errorf("scopes on %s: %s %q, want unknown", v, scopes.Status, scopes.Detail) } finding(t, "doctor-scopes", "the writer's scopes: %s %q", scopes.Status, scopes.Detail) diff --git a/internal/platform/github/ghfake/doc.go b/internal/platform/github/ghfake/doc.go index 178029f..bafe5cf 100644 --- a/internal/platform/github/ghfake/doc.go +++ b/internal/platform/github/ghfake/doc.go @@ -189,6 +189,8 @@ // name 'o/n'." with HTTP 200 and the other aliases' data // validation errors: path from "query [Name]", extensions observed (undefinedField) // code, no data +// Int is a signed 32-bit integer: a literal or variable docs (GraphQL spec, Int) +// out of range fails validation or coercion // a declared variable not used: variableNotUsed, "Variable observed // $v is declared by anonymous query but not used" // object(expression): Blob for files and symlinks (the link observed (a public repository) diff --git a/internal/platform/github/ghfake/gqlexec.go b/internal/platform/github/ghfake/gqlexec.go index 8264368..a8ab0f8 100644 --- a/internal/platform/github/ghfake/gqlexec.go +++ b/internal/platform/github/ghfake/gqlexec.go @@ -511,7 +511,7 @@ func (e *executor) literalFits(v gqlValue, t *gqlTypeRef) bool { } switch t.name { case "Int": - return v.kind == valInt + return v.kind == valInt && isInt32(v.s) case "Float": return v.kind == valInt || v.kind == valFloat case "Boolean": @@ -702,10 +702,10 @@ func (e *executor) coerce(v any, t *gqlTypeRef) (any, string) { if !ok { return nil, "Could not coerce value to Int" } - i, err := n.Int64() - if err != nil { + if !isInt32(n.String()) { return nil, "Could not coerce value to Int" } + i, _ := strconv.ParseInt(n.String(), 10, 32) return i, "" case "Float": n, ok := v.(json.Number) @@ -732,6 +732,12 @@ func (e *executor) coerce(v any, t *gqlTypeRef) (any, string) { return s, "" } +// isInt32 reports whether s is a GraphQL Int: a signed 32-bit integer. +func isInt32(s string) bool { + _, err := strconv.ParseInt(s, 10, 32) + return err == nil +} + // sortedKeys returns the keys of a set, sorted. func sortedKeys(m map[string]bool) []string { out := make([]string, 0, len(m)) @@ -757,7 +763,7 @@ func (e *executor) literal(v gqlValue, t *gqlTypeRef) any { if t != nil && t.named() == "ID" { return v.s } - n, _ := strconv.ParseInt(v.s, 10, 64) + n, _ := strconv.ParseInt(v.s, 10, 32) return n case valFloat: f, _ := strconv.ParseFloat(v.s, 64) diff --git a/internal/platform/github/ghfake/gqlparse.go b/internal/platform/github/ghfake/gqlparse.go index 49ba4af..97d2c74 100644 --- a/internal/platform/github/ghfake/gqlparse.go +++ b/internal/platform/github/ghfake/gqlparse.go @@ -177,7 +177,7 @@ func (l *lexer) str() (string, error) { if l.pos+6 > len(l.src) { return bad() } - n, err := strconv.ParseUint(l.src[l.pos+2:l.pos+6], 16, 32) + n, err := strconv.ParseUint(l.src[l.pos+2:l.pos+6], 16, 16) if err != nil { return bad() } diff --git a/internal/platform/github/ghfake/graphql_test.go b/internal/platform/github/ghfake/graphql_test.go index 1baf249..02158b5 100644 --- a/internal/platform/github/ghfake/graphql_test.go +++ b/internal/platform/github/ghfake/graphql_test.go @@ -79,6 +79,8 @@ func TestGraphQLValidation(t *testing.T) { "Field 'defaultBranchRef' returns Ref but has no selections. Did you mean 'defaultBranchRef { ... }'?", nil}, {"enum", `{ repository(owner: "acme", name: "api") { pullRequests(first: 1, states: [BOGUS]) { totalCount } } }`, "argumentLiteralsIncompatible", "", nil}, + {"Int out of range", `{ repository(owner: "acme", name: "api") { pullRequests(first: 2147483648) { totalCount } } }`, + "argumentLiteralsIncompatible", "", nil}, {"fragment type", `{ repository(owner: "acme", name: "api") { ... on Nope { id } } }`, "undefinedType", "No such type Nope, so it can't be a fragment condition", nil}, {"undeclared variable", `query V { repository(owner: $o, name: "api") { id } }`, "variableNotDefined", @@ -120,6 +122,15 @@ func TestGraphQLValidation(t *testing.T) { if field(got, "data", "repository", "name") != "api" { t.Errorf("variables: %v", got) } + // Int is a signed 32-bit integer. + q = `query P($n: Int) { repository(owner: "acme", name: "api") { pullRequests(first: $n) { totalCount } } }` + got = w.graphql(tok, q, map[string]any{"n": 2147483648}) + if msg := field(got, "errors", 0, "message"); msg != "Variable $n of type Int was provided invalid value" { + t.Errorf("an Int out of range: %v", got) + } + if got = w.graphql(tok, q, map[string]any{"n": 2147483647}); field(got, "data", "repository") == nil { + t.Errorf("the largest Int: %v", got) + } }) t.Run("anonymous", func(t *testing.T) { r := w.call("POST", "/graphql", "", map[string]any{"query": "{ rateLimit { cost } }"}) diff --git a/scripts/e2e/gitlab.sh b/scripts/e2e/gitlab.sh index 56f659e..b05dbe5 100644 --- a/scripts/e2e/gitlab.sh +++ b/scripts/e2e/gitlab.sh @@ -55,8 +55,8 @@ # has instance service accounts. The script logs which path it took, # and the tests get it in TOUCHMARK_E2E_GITLAB_ACCOUNTS; # - the person jdoe, an owner of acme, with a personal access token; -# - an instance runner (gitlab/gitlab-runner of the same minor, shell -# executor), registered with a runner authentication token from +# - an instance runner (gitlab/gitlab-runner of the same minor, Alpine, +# shell executor), registered with a runner authentication token from # POST /user/runners, with a linux touchmark built from the working tree # at /opt/touchmark/touchmark. # @@ -592,12 +592,13 @@ build_touchmark() { } # runner_image is the runner of the minor of the GitLab image; a runner -# works with GitLab of its own minor and older. +# works with GitLab of its own minor and older. The Alpine flavour: its git +# (2.47 to 2.54) runs plan, which needs 2.45; the Ubuntu one has 2.43. runner_image() { case $image in - *:17.11.*) echo gitlab/gitlab-runner:v17.11.4 ;; - *:18.11.*) echo gitlab/gitlab-runner:v18.11.4 ;; - *) echo gitlab/gitlab-runner:v19.4.1 ;; + *:17.11.*) echo gitlab/gitlab-runner:alpine-v17.11.4 ;; + *:18.11.*) echo gitlab/gitlab-runner:alpine-v18.11.4 ;; + *) echo gitlab/gitlab-runner:alpine-v19.4.1 ;; esac } From 456e21d0ae30b4763b70c5380177dacbd7641723 Mon Sep 17 00:00:00 2001 From: AlexeyShalaev <75322386+AlexeyShalaev@users.noreply.github.com> Date: Thu, 8 Oct 2026 04:43:19 +0300 Subject: [PATCH 2/2] test: Gitea 28 in the e2e matrix; teams by units_map only Gitea 28 refuses a team created with both units and units_map (HTTP 400), so gitea.sh and the e2e fixtures send units_map alone, which Gitea 1.26 to 28 and Forgejo 15 and 16 all take. The full suite passes on Gitea 1.26.4, 1.27.3 and 28.1.0 and Forgejo 15.0.9 and 16.0.5, and the facts the e2e records on 28.1.0 match 1.27.3's. Pull requests now run Gitea 28.1.0, the latest, and the nightly run all five images. --- .github/workflows/ci.yml | 4 ++-- docs/project/e2e.md | 6 +++--- internal/e2e/fixture_test.go | 5 +++-- scripts/e2e/gitea.sh | 11 ++++++----- 4 files changed, 14 insertions(+), 12 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ddb7869..9bcfeb5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -526,7 +526,7 @@ jobs: # the platform conformance suite on the gitea driver, plan and distribute # end to end, and the platform facts the driver relies on. The latest # Gitea and Forgejo on every pull request and push, every supported - # version (Gitea 1.26 and 1.27, Forgejo 15 LTS and 16) nightly and by + # version (Gitea 1.26, 1.27 and 28, Forgejo 15 LTS and 16) nightly and by # hand. docs/project/e2e.md explains how to run them locally. name: e2e (${{ matrix.image }}) runs-on: ubuntu-latest @@ -542,7 +542,7 @@ jobs: strategy: fail-fast: false matrix: - image: ${{ fromJSON((github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') && '["docker.gitea.com/gitea:1.26.4","docker.gitea.com/gitea:1.27.3","codeberg.org/forgejo/forgejo:15.0.9","codeberg.org/forgejo/forgejo:16.0.5"]' || '["docker.gitea.com/gitea:1.27.3","codeberg.org/forgejo/forgejo:16.0.5"]') }} + image: ${{ fromJSON((github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') && '["docker.gitea.com/gitea:1.26.4","docker.gitea.com/gitea:1.27.3","docker.gitea.com/gitea:28.1.0","codeberg.org/forgejo/forgejo:15.0.9","codeberg.org/forgejo/forgejo:16.0.5"]' || '["docker.gitea.com/gitea:28.1.0","codeberg.org/forgejo/forgejo:16.0.5"]') }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: diff --git a/docs/project/e2e.md b/docs/project/e2e.md index a0ff6e5..fab9821 100644 --- a/docs/project/e2e.md +++ b/docs/project/e2e.md @@ -7,9 +7,9 @@ You need Docker and bash (Git Bash on Windows). Nothing else: the tests run in `golang:1.27`. ```sh -bash scripts/e2e/gitea.sh docker.gitea.com/gitea:1.27.3 +bash scripts/e2e/gitea.sh docker.gitea.com/gitea:28.1.0 bash scripts/e2e/gitea.sh codeberg.org/forgejo/forgejo:16.0.5 -bash scripts/e2e/gitea.sh all # Gitea 1.26.4, 1.27.3, Forgejo 15.0.9 (LTS), 16.0.5 +bash scripts/e2e/gitea.sh all # Gitea 1.26.4, 1.27.3, 28.1.0, Forgejo 15.0.9 (LTS), 16.0.5 bash scripts/e2e/gitea.sh --run TestFacts all # one test, every image bash scripts/e2e/gitea.sh --keep docker.gitea.com/gitea:1.26.4 bash scripts/e2e/gitea.sh --require-signin --run TestVisibility all @@ -109,7 +109,7 @@ Each test prints what it learned as `FINDING` lines. The run ends with a summary ## CI -The job `e2e-forge` in `.github/workflows/ci.yml` runs the script once per image: the latest Gitea and Forgejo (1.27.3 and 16.0.5) on every pull request and push, all four images nightly and on `workflow_dispatch`. The lint job of every CI run vets the module with the tag `e2e` (`go vet -tags e2e ./...`) for Linux, macOS and Windows, so the tests always compile. `e2e-forge` itself is outside the required check `All checks passed`: it pulls the forges' images from their own registries, whose outage should not block a merge; read its result before merging. +The job `e2e-forge` in `.github/workflows/ci.yml` runs the script once per image: the latest Gitea and Forgejo (28.1.0 and 16.0.5) on every pull request and push, all five images nightly and on `workflow_dispatch`. The lint job of every CI run vets the module with the tag `e2e` (`go vet -tags e2e ./...`) for Linux, macOS and Windows, so the tests always compile. `e2e-forge` itself is outside the required check `All checks passed`: it pulls the forges' images from their own registries, whose outage should not block a merge; read its result before merging. ## Without the script diff --git a/internal/e2e/fixture_test.go b/internal/e2e/fixture_test.go index 591ac19..72dfecc 100644 --- a/internal/e2e/fixture_test.go +++ b/internal/e2e/fixture_test.go @@ -159,7 +159,9 @@ func (fx *fixture) createOrg(t testing.TB, name string) { } // team creates a team of org with access to the code, issues and pull -// requests of its repositories (all of them with all) and the members. +// requests of its repositories (all of them with all) and the members: +// units_map only, since Gitea 28 refuses a team with both units and +// units_map. func (fx *fixture) team(t testing.TB, org, name, access string, all bool, members ...account) int64 { t.Helper() var team struct { @@ -167,7 +169,6 @@ func (fx *fixture) team(t testing.TB, org, name, access string, all bool, member } fx.admin().ok(t, http.MethodPost, "/orgs/"+org+"/teams", map[string]any{ "name": name, "permission": access, "includes_all_repositories": all, "can_create_org_repo": false, - "units": []string{"repo.code", "repo.issues", "repo.pulls"}, "units_map": map[string]string{"repo.code": access, "repo.issues": access, "repo.pulls": access}, }, &team) for _, m := range members { diff --git a/scripts/e2e/gitea.sh b/scripts/e2e/gitea.sh index 1a98c11..f62c934 100644 --- a/scripts/e2e/gitea.sh +++ b/scripts/e2e/gitea.sh @@ -7,8 +7,8 @@ # [--template DIR [--touchmark-image REF]] IMAGE|all # # IMAGE a Gitea or Forgejo image, told apart by its name: -# docker.gitea.com/gitea:1.27.3, codeberg.org/forgejo/forgejo:16.0.5, ... -# all the four supported images, one after the other +# docker.gitea.com/gitea:28.1.0, codeberg.org/forgejo/forgejo:16.0.5, ... +# all the five supported images, one after the other # --keep keep the forge, its network and an env file with the tokens # for debugging, and publish the forge on a loopback port; the # script prints how to reach and remove them @@ -77,6 +77,7 @@ export MSYS_NO_PATHCONV=1 readonly SUPPORTED_IMAGES=( docker.gitea.com/gitea:1.26.4 docker.gitea.com/gitea:1.27.3 + docker.gitea.com/gitea:28.1.0 codeberg.org/forgejo/forgejo:15.0.9 codeberg.org/forgejo/forgejo:16.0.5 ) @@ -332,12 +333,12 @@ seed() { api "$admin_token" POST /orgs \ "{\"username\":\"$ORG\",\"visibility\":\"private\",\"repo_admin_change_team_access\":false}" >/dev/null - local units='"units":["repo.code","repo.issues","repo.pulls"]' + # units_map only: Gitea 28 refuses a team with both units and units_map. readers_id=$(api "$admin_token" POST "/orgs/$ORG/teams" \ - "{\"name\":\"readers\",\"permission\":\"read\",\"includes_all_repositories\":true,\"can_create_org_repo\":false,$units,\"units_map\":{\"repo.code\":\"read\",\"repo.issues\":\"read\",\"repo.pulls\":\"read\"}}" | + "{\"name\":\"readers\",\"permission\":\"read\",\"includes_all_repositories\":true,\"can_create_org_repo\":false,\"units_map\":{\"repo.code\":\"read\",\"repo.issues\":\"read\",\"repo.pulls\":\"read\"}}" | json_id) writers_id=$(api "$admin_token" POST "/orgs/$ORG/teams" \ - "{\"name\":\"writers\",\"permission\":\"write\",\"includes_all_repositories\":true,\"can_create_org_repo\":false,$units,\"units_map\":{\"repo.code\":\"write\",\"repo.issues\":\"write\",\"repo.pulls\":\"write\"}}" | + "{\"name\":\"writers\",\"permission\":\"write\",\"includes_all_repositories\":true,\"can_create_org_repo\":false,\"units_map\":{\"repo.code\":\"write\",\"repo.issues\":\"write\",\"repo.pulls\":\"write\"}}" | json_id) owners_id=$(api "$admin_token" GET "/orgs/$ORG/teams" | { grep -o '"id":[0-9]*,"name":"Owners"' || true; } | sed 's/[^0-9]//g' | head -n 1) [ -n "$readers_id" ] && [ -n "$writers_id" ] && [ -n "$owners_id" ] || die "cannot read the ids of the teams of $ORG"