From 311f7f1fdb61020feb484decdd2c58464b30b149 Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 25 Sep 2026 21:59:09 +0200 Subject: [PATCH] gitea: the package team may read code, and its units are reconciled MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The builder's first credentialed clone of a private repository answered 'not found': the packages team named only repo.packages in its units_map, which is exhaustive — so members had no code unit at all, and gitea hides what a user cannot read. One credential answering npm and git alike was the whole design of the builder's grant; the team now says so. And found teams are patched, not just returned: a team is configuration the reconcile loop owns, the same as a user's password, so a unit this code gains reaches the team that already exists rather than only the next mesh raised from scratch. --- modules/gitea/client.ts | 38 ++++++++++++++++++++++++-------------- 1 file changed, 24 insertions(+), 14 deletions(-) diff --git a/modules/gitea/client.ts b/modules/gitea/client.ts index 62ddb74..0fcc47e 100644 --- a/modules/gitea/client.ts +++ b/modules/gitea/client.ts @@ -352,24 +352,34 @@ export class GiteaAdmin { GiteaAdmin.fail("/orgs", res); } - /** Ensure the org's package team exists, granting read+write on packages, and return its id. The - * team is found by name if it is already there, created otherwise; a lost create race is resolved - * by re-listing. */ + /** Ensure the org's package team exists with exactly these units, and return its id. Found or + * created, the units are applied either way — a team is configuration the reconcile loop owns, + * the same as a user's password, so a unit this code gains reaches a team that already exists + * rather than only the next mesh raised from scratch. A lost create race is resolved by + * re-listing. */ async ensureTeam(org: string, team: string, packageWrite: boolean): Promise { + // The units a consumer needs, and no more. `units_map` is exhaustive — a unit not named is a + // unit the team does not have — so code read must be said here: without it gitea answers a + // member's clone of a private repository with "not found", which is how the builder's first + // credentialed clone failed against a team that named only packages. + const units = { + permission: "read", + units_map: { "repo.code": "read", "repo.packages": packageWrite ? "write" : "read" }, + includes_all_repositories: true, + can_create_org_repo: false, + }; const found = await this.findTeam(org, team); - if (found !== null) return found; + if (found !== null) { + const patch = await this.request(`/teams/${found}`, { + method: "PATCH", + body: JSON.stringify({ name: team, ...units }), + }); + if (patch.status === 200) return found; + GiteaAdmin.fail(`/teams/${found}`, patch); + } const res = await this.request(`/orgs/${encodeURIComponent(org)}/teams`, { method: "POST", - body: JSON.stringify({ - name: team, - permission: "read", - // Package access is a per-unit grant; the team needs write on the packages unit and nothing - // else. includes_all_repositories keeps the team's repo view whole without widening its - // repo permission beyond read. - units_map: { "repo.packages": packageWrite ? "write" : "read" }, - includes_all_repositories: true, - can_create_org_repo: false, - }), + body: JSON.stringify({ name: team, ...units }), }); if (res.status === 201) return Number(res.body?.id); if (res.status === 422 || res.status === 409) {