From 5daf3a333ae3c8c538f99522d7aeadb710a35f0d Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 11 Nov 2026 11:37:36 +0100 Subject: [PATCH] GIT11: the seat git's tag writes go/vMAJOR.MINOR.PATCH at the commit a released version was built from MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A tag at that commit is answered performed; at another commit, refused (a tag is never moved). The builder alone calls it: the bus refuses any other account from the definition's callers. Builds: design 47 G §370 Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ --- modules/gitea/client.ts | 18 ++++++++++++++++++ modules/gitea/module.json | 5 +++-- modules/gitea/seat.ts | 16 +++++++++++++++- modules/gitea/test/guard.test.ts | 5 +++++ 4 files changed, 41 insertions(+), 3 deletions(-) diff --git a/modules/gitea/client.ts b/modules/gitea/client.ts index 42898d71c..78648b3fb 100644 --- a/modules/gitea/client.ts +++ b/modules/gitea/client.ts @@ -496,6 +496,24 @@ export class GiteaClient { return list.map((t) => String(t?.name ?? "")).filter((n) => n !== ""); } + /** The commit a tag names, or null when the repository has no such tag (the forge's 404 only). */ + async tagCommit(owner: string, repo: string, tag: string): Promise { + try { + const t = await this.request(`${repoPath(owner, repo)}/tags/${pathSegments("tag", tag)}`); + const sha = String(t?.commit?.sha ?? t?.id ?? ""); + if (!/^[0-9a-f]{40}$/.test(sha)) throw new Error(`the forge named no commit for ${owner}/${repo}'s tag ${tag}`); + return sha; + } catch (err) { + if (/: 404 /.test(err instanceof Error ? err.message : "")) return null; + throw err; + } + } + + /** Write a lightweight tag at a commit (design 47 §370): what the seat git's `tag` does. */ + async createTag(owner: string, repo: string, tag: string, commit: string): Promise { + await this.request(`${repoPath(owner, repo)}/tags`, { method: "POST", body: JSON.stringify({ tag_name: tag, target: commit }) }); + } + /** A commit's parents, by sha, in order: what a merge commit says it joined (novox/hq issue 381). An answer * that names no parents is no answer, never an empty list read as a root commit. */ async commitParents(owner: string, repo: string, sha: string): Promise { diff --git a/modules/gitea/module.json b/modules/gitea/module.json index d6bdf1e8a..dd462ecbe 100644 --- a/modules/gitea/module.json +++ b/modules/gitea/module.json @@ -181,7 +181,8 @@ "comment", "view", "note", - "merge-pull" + "merge-pull", + "tag" ] } ], @@ -252,7 +253,7 @@ "type": "string", "trusted": true, "runtime": true, - "default": "{\"publish\":[\"mesh-build-go\",\"mesh-build-node\"],\"publish-part\":[\"mesh-build-go\",\"mesh-build-node\"],\"remove\":[\"mesh-delivery-builder\"],\"versions\":[\"*\"],\"pull\":[\"mesh-delivery-forge-bridge\",\"mesh-delivery-gate\"],\"branch\":[\"mesh-delivery-forge-bridge\",\"mesh-delivery-indexer\"],\"open-pulls\":[\"mesh-delivery-forge-bridge\",\"mesh-delivery-indexer\",\"mesh-delivery-gate\"],\"tags\":[\"mesh-delivery-forge-bridge\"],\"trees\":[\"mesh-delivery-forge-bridge\",\"mesh-delivery-checker\",\"mesh-delivery-indexer\"],\"file\":[\"mesh-delivery-forge-bridge\",\"mesh-delivery-checker\",\"mesh-delivery-indexer\"],\"contains\":[\"mesh-delivery-indexer\",\"mesh-delivery-gate\"],\"statuses\":[\"mesh-delivery-gate\"],\"status\":[\"mesh-delivery-forge-bridge\"],\"comment\":[\"mesh-delivery-forge-bridge\"],\"view\":[\"mesh-delivery-forge-bridge\"],\"note\":[\"mesh-delivery-forge-bridge\"],\"merge-pull\":[\"mesh-delivery-forge-bridge\"]}", + "default": "{\"publish\":[\"mesh-build-go\",\"mesh-build-node\"],\"publish-part\":[\"mesh-build-go\",\"mesh-build-node\"],\"remove\":[\"mesh-delivery-builder\"],\"versions\":[\"*\"],\"pull\":[\"mesh-delivery-forge-bridge\",\"mesh-delivery-gate\"],\"branch\":[\"mesh-delivery-forge-bridge\",\"mesh-delivery-indexer\"],\"open-pulls\":[\"mesh-delivery-forge-bridge\",\"mesh-delivery-indexer\",\"mesh-delivery-gate\"],\"tags\":[\"mesh-delivery-forge-bridge\"],\"trees\":[\"mesh-delivery-forge-bridge\",\"mesh-delivery-checker\",\"mesh-delivery-indexer\"],\"file\":[\"mesh-delivery-forge-bridge\",\"mesh-delivery-checker\",\"mesh-delivery-indexer\"],\"contains\":[\"mesh-delivery-indexer\",\"mesh-delivery-gate\"],\"statuses\":[\"mesh-delivery-gate\"],\"status\":[\"mesh-delivery-forge-bridge\"],\"comment\":[\"mesh-delivery-forge-bridge\"],\"view\":[\"mesh-delivery-forge-bridge\"],\"note\":[\"mesh-delivery-forge-bridge\"],\"merge-pull\":[\"mesh-delivery-forge-bridge\"],\"tag\":[\"mesh-delivery-builder\"]}", "description": "per verb of the seats the module holds, the callers that may call it (JSON object of verb to callers; * every proved caller, operator the operator's session); each holder reads the lines of its own verbs. The package seats' lines are design 51's (empty is that list for them); the seat git's are design 47's: its reads the parts that declare them in invokes, its acts (status, comment, view, note, merge-pull) the forge bridge alone, and a verb of git with no line is called by no one" } }, diff --git a/modules/gitea/seat.ts b/modules/gitea/seat.ts index 33c56dbeb..5715098d3 100644 --- a/modules/gitea/seat.ts +++ b/modules/gitea/seat.ts @@ -22,7 +22,7 @@ import { seat as GIT, verbs as GIT_VERBS, type BranchAnswer, type ContainsAnswer, type CommentAnswer, type ErrorKind, type FileAnswer, type MergePullAnswer, type Merging, type NoteAnswer, type OpenPull, type OpenPullsAnswer, type Pull, type PullAnswer, type Status, type StatusAnswer, type StatusesAnswer, - type TagsAnswer, type TreesAnswer, type ViewAnswer, + type TagAnswer, type TagsAnswer, type TreesAnswer, type ViewAnswer, } from "@novox/mesh-delivery-contracts-git"; import type { CommitStatus, GiteaClient, GiteaComment, GiteaPull } from "./client.js"; import { MAX_FILE_BYTES } from "./client.js"; @@ -95,6 +95,8 @@ export function repositoryOf(raw: unknown): { owner: string; repo: string; full: } const SHA = /^[0-9a-f]{40}$/; +/** The git tag of a released Go version (design 51; design 47 §370). */ +const GO_TAG = /^go\/v\d+\.\d+\.\d+$/; const STATES = new Set(["pending", "success", "failure", "error", "warning"]); function commitOf(name: string, raw: unknown): string { const sha = String(raw ?? "").trim(); @@ -280,6 +282,18 @@ export function seatTools(gitea: GiteaClient, deps: SeatDeps): ToolDefinition[] }), verb(GIT_VERBS.mergePull, "The seat git's merge-pull: an act of the forge bridge, a pull request merged at the head the caller names; it judges no check.", async (args): Promise => mergePull(gitea, args, deps.registered)), + verb(GIT_VERBS.tag, "The seat git's tag: the git tag go/vMAJOR.MINOR.PATCH of a released version, written at the commit its build was made from; a tag that is at that commit is performed.", + async (args): Promise => { + const { owner, repo } = repositoryOf(args.repository); + const tag = String(args.tag ?? ""); + if (!GO_TAG.test(tag)) throw new Refused(`${JSON.stringify(tag)} is not go/vMAJOR.MINOR.PATCH`); + const commit = commitOf("commit", args.commit); + const at = await gitea.tagCommit(owner, repo, tag); + if (at === commit) return { tag, performed: true }; + if (at !== null) throw new Refused(`${tag} is at ${at.slice(0, 12)} already, not at ${commit.slice(0, 12)}; a tag is never moved`); + await gitea.createTag(owner, repo, tag, commit); + return { tag, performed: true }; + }), ]; } diff --git a/modules/gitea/test/guard.test.ts b/modules/gitea/test/guard.test.ts index a066ee62d..1474a1aec 100644 --- a/modules/gitea/test/guard.test.ts +++ b/modules/gitea/test/guard.test.ts @@ -536,6 +536,7 @@ test("every read and act of the seat git answers its contract, and an error is a const gone = () => { throw new Error("Gitea API GET /repos/novox/app/pulls/9: 404 Not Found"); }; const down = () => { throw new Error("Gitea API GET /repos/novox/app/x: 502 Bad Gateway"); }; const pr = { number: 9, title: "t", state: "open", merged: false, head: "feat/x", head_sha: SHA, base: "main", user: "jochen", html_url: "http://f/9", mergeable: true }; + const tagged: string[] = []; let comments: { id: number; body: string }[] = [], noteShown = "", added = 0, noteWritten = 0; const forge: Record = { getPullRequest: async (_o: string, _r: string, n: number) => (n === 9 ? pr : gone()), @@ -583,6 +584,10 @@ test("every read and act of the seat git answers its contract, and an error is a ["contains", "forge down", { repository: "novox/app", commit: SHA, in: OTHER }, { compare: down }, (a) => [a.contains, a["error-kind"]], [undefined, "failed"]], ["statuses", "newest of each", { repository: "novox/app", commit: SHA }, {}, (a) => [a.combined, a.statuses.map((s: any) => s.context)], ["success", ["mesh/check"]]], ["statuses", "forge down", { repository: "novox/app", commit: SHA }, { combinedStatus: down }, (a) => [a.statuses, a["error-kind"]], [undefined, "failed"]], + ["tag", "written", { repository: "novox/app", tag: "go/v1.2.3", commit: SHA }, { tagCommit: async () => null, createTag: async (_o: string, _r: string, tag: string, c: string) => { tagged.push(`${tag}@${c}`); } }, (a) => [a.performed, tagged.splice(0)], [true, [`go/v1.2.3@${SHA}`]]], + ["tag", "already at that commit", { repository: "novox/app", tag: "go/v1.2.3", commit: SHA }, { tagCommit: async () => SHA, createTag: async () => { throw new Error("written again"); } }, (a) => [a.performed, a.error], [true, undefined]], + ["tag", "at another commit is never moved", { repository: "novox/app", tag: "go/v1.2.3", commit: SHA }, { tagCommit: async () => OTHER }, (a) => [a.performed, a["error-kind"]], [undefined, "refused"]], + ["tag", "no Go version", { repository: "novox/app", tag: "v1", commit: SHA }, {}, (a) => [a.performed, a["error-kind"]], [undefined, "refused"]], ["status", "refused state", { id: "s1", repository: "novox/app", commit: SHA, context: "c", state: "bogus" }, {}, (a) => [a.performed, a["error-kind"]], [undefined, "refused"]], ["comment", "first time", { id: "c1", repository: "novox/app", number: 9, text: "hi" }, {}, (a) => [a.performed, a.held, added], [true, undefined, 1]], ["comment", "forge down", { id: "c2", repository: "novox/app", number: 9, text: "hi" }, { listComments: down }, (a) => [a.performed, a["error-kind"]], [undefined, "failed"]],