Public: anyone can read this; the crew can change it.
GET /api/v1/releases answers the kept releases, and ?shipped=true what went live, as MCP `releases` does #8026
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/8020-get-api-v1-releases-answers-the-kept-rel → main 2 commits fork
7 files
changed +186
−26
src/contract/openapi/routes/getApiV1Releases.ts added +13
| @@ -0,0 +1,13 @@ | ||
| 1 | import type { Route } from "../shared.ts"; | |
| 2 | ||
| 3 | export const route: Route = { | |
| 4 | method: "get", | |
| 5 | path: "/api/v1/releases", | |
| 6 | summary: "The kept releases newest first, for the forge's administrators and its repository's readers; ?shipped=true is what went live and what is on its way, for anyone", | |
| 7 | auth: false, | |
| 8 | query: { shipped: "boolean" }, | |
| 9 | refusals: { | |
| 10 | forbidden: "the release history is for the forge's administrators and its repository's readers", | |
| 11 | invalid_shipped: "shipped is true or absent (422)", | |
| 12 | }, | |
| 13 | }; |
src/contract/openapi/routes/index.ts +1
| @@ -71,6 +71,7 @@ export { route as getApiV1OrgsOrg } from "./getApiV1OrgsOrg.ts"; | ||
| 71 | 71 | export { route as getApiV1OrgsOrgTeams } from "./getApiV1OrgsOrgTeams.ts"; |
| 72 | 72 | export { route as getApiV1OrgsOrgTeamsTeamMembers } from "./getApiV1OrgsOrgTeamsTeamMembers.ts"; |
| 73 | 73 | export { route as getApiV1PassportHealth } from "./getApiV1PassportHealth.ts"; |
| 74 | export { route as getApiV1Releases } from "./getApiV1Releases.ts"; | |
| 74 | 75 | export { route as getApiV1Repos } from "./getApiV1Repos.ts"; |
| 75 | 76 | export { route as getApiV1ReposOwnerName } from "./getApiV1ReposOwnerName.ts"; |
| 76 | 77 | export { route as getApiV1ReposOwnerNameApp } from "./getApiV1ReposOwnerNameApp.ts"; |
src/http/api.ts +2
| @@ -311,6 +311,7 @@ import { COPIES_PER_DAY, makeItYours } from "../actions/make_it_yours.ts"; | ||
| 311 | 311 | import { passportRoutes } from "./passport_edge.ts"; |
| 312 | 312 | import { egressGrantRoutes } from "./api/egress_grants.ts"; |
| 313 | 313 | import { forgeRoutes } from "./api/forges.ts"; |
| 314 | import { releasesRoutes } from "./api/releases.ts"; | |
| 314 | 315 | import { hookRoutes } from "./api/hooks.ts"; |
| 315 | 316 | import { agentRoutes } from "./api/agents.ts"; |
| 316 | 317 | import { boardRoutes } from "./api/boards.ts"; |
| @@ -3169,6 +3170,7 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 3169 | 3170 | }); |
| 3170 | 3171 | |
| 3171 | 3172 | forgeRoutes(router); |
| 3173 | releasesRoutes(router); | |
| 3172 | 3174 | |
| 3173 | 3175 | router.add("GET", "/api/v1/apps", (ctx) => { |
| 3174 | 3176 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); |
src/http/api/releases.ts added +23
| @@ -0,0 +1,23 @@ | ||
| 1 | /** | |
| 2 | * `GET /api/v1/releases`: the kept releases for whoever may read /releases, | |
| 3 | * and `?shipped=true` what went live, for anyone — MCP `releases` over HTTP. | |
| 4 | * The answers are `release/answer.ts`'s. | |
| 5 | */ | |
| 6 | ||
| 7 | import { releasesAnswer, shippedAnswer } from "../../release/answer.ts"; | |
| 8 | import { notePhase, takeSessionTime } from "../../core/route_timing.ts"; | |
| 9 | import type { Handler } from "../context.ts"; | |
| 10 | import { apiError, json } from "../respond.ts"; | |
| 11 | import type { Router } from "../router.ts"; | |
| 12 | ||
| 13 | export function releasesRoutes(router: Router<Handler>): void { | |
| 14 | router.add("GET", "/api/v1/releases", async (ctx) => { | |
| 15 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); | |
| 16 | const shipped = ctx.url.searchParams.get("shipped"); | |
| 17 | if (shipped !== null && shipped !== "true") | |
| 18 | return apiError("invalid_shipped", 422, "shipped is true (what went live) or absent (the kept releases)"); | |
| 19 | if (shipped === "true") return json(await shippedAnswer(ctx.app)); | |
| 20 | const answer = releasesAnswer(ctx.app, ctx.auth.user); | |
| 21 | return answer.ok ? json(answer.value) : apiError(answer.error, 403, answer.message); | |
| 22 | }); | |
| 23 | } |
src/http/mcp/tools/releases.ts +4 −26
| @@ -1,8 +1,4 @@ | ||
| 1 | import { getRepoById } from "../../../core/repos.ts"; | |
| 2 | import { listReleases, releaseName } from "../../../release/record.ts"; | |
| 3 | import { BOOTED, mayRead } from "../../../release/gate.ts"; | |
| 4 | import { readLandings } from "../../../release/landings.ts"; | |
| 5 | import { running } from "../../../kernel/version.ts"; | |
| 1 | import { releasesAnswer, shippedAnswer } from "../../../release/answer.ts"; | |
| 6 | 2 | import { bad, good, type Tool } from "../shared.ts"; |
| 7 | 3 | |
| 8 | 4 | export const tool: Tool = { |
| @@ -12,27 +8,9 @@ export const tool: Tool = { | ||
| 12 | 8 | inputSchema: { type: "object", properties: { shipped: { type: "boolean" } } }, |
| 13 | 9 | writes: false, |
| 14 | 10 | async run(ctx, user, args) { |
| 15 | if (args["shipped"] === true) { | |
| 16 | const { version, commit, live, onItsWay } = await readLandings(ctx.app); | |
| 17 | return good({ version, commit, live, onItsWay }); | |
| 18 | } | |
| 11 | if (args["shipped"] === true) return good(await shippedAnswer(ctx.app)); | |
| 19 | 12 | // The page's own gate: its refusal, in words. |
| 20 | if (!mayRead(ctx.app, user)) | |
| 21 | return bad("forbidden", "the release history is for the forge's administrators and its repository's readers"); | |
| 22 | const live = running(); | |
| 23 | return good({ | |
| 24 | items: listReleases(ctx.app.db).map((r) => { | |
| 25 | const isLive = live.commit !== null && r.sha === live.commit; | |
| 26 | const repo = getRepoById(ctx.app.db, r.repoId); | |
| 27 | return { | |
| 28 | sha: releaseName(r), | |
| 29 | version: isLive ? live.version : null, | |
| 30 | pull: r.pullNumber === null ? null : { repo: repo === undefined ? null : `${repo.ownerName}/${repo.name}`, number: r.pullNumber }, | |
| 31 | landed: r.createdAt, | |
| 32 | ok: r.ok, | |
| 33 | ...(isLive ? { live: true, since: BOOTED } : {}), | |
| 34 | }; | |
| 35 | }), | |
| 36 | }); | |
| 13 | const answer = releasesAnswer(ctx.app, user); | |
| 14 | return answer.ok ? good(answer.value) : bad(answer.error, answer.message); | |
| 37 | 15 | }, |
| 38 | 16 | }; |
src/release/answer.ts added +48
| @@ -0,0 +1,48 @@ | ||
| 1 | /** | |
| 2 | * What the forge says about its releases, as plain data: what went live and | |
| 3 | * what is on its way, and the kept list. MCP `releases` and | |
| 4 | * `GET /api/v1/releases` both answer from here. | |
| 5 | */ | |
| 6 | ||
| 7 | import type { App } from "../app.ts"; | |
| 8 | import { getRepoById } from "../core/repos.ts"; | |
| 9 | import type { User } from "../core/users.ts"; | |
| 10 | import { running } from "../kernel/version.ts"; | |
| 11 | import { BOOTED, mayRead } from "./gate.ts"; | |
| 12 | import { readLandings } from "./landings.ts"; | |
| 13 | import { listReleases, releaseName } from "./record.ts"; | |
| 14 | ||
| 15 | export const RELEASES_REFUSAL = "the release history is for the forge's administrators and its repository's readers"; | |
| 16 | ||
| 17 | export type ReleasesAnswer = | |
| 18 | | { ok: true; value: unknown } | |
| 19 | | { ok: false; error: "forbidden"; message: string }; | |
| 20 | ||
| 21 | /** What went live, for anyone: the list /changelog shows. */ | |
| 22 | export async function shippedAnswer(app: App) { | |
| 23 | const { version, commit, live, onItsWay } = await readLandings(app); | |
| 24 | return { version, commit, live, onItsWay }; | |
| 25 | } | |
| 26 | ||
| 27 | /** The kept releases newest first, for whoever may read /releases; others get its refusal. */ | |
| 28 | export function releasesAnswer(app: App, user: User | undefined): ReleasesAnswer { | |
| 29 | if (!mayRead(app, user)) return { ok: false, error: "forbidden", message: RELEASES_REFUSAL }; | |
| 30 | const live = running(); | |
| 31 | return { | |
| 32 | ok: true, | |
| 33 | value: { | |
| 34 | items: listReleases(app.db).map((r) => { | |
| 35 | const isLive = live.commit !== null && r.sha === live.commit; | |
| 36 | const repo = getRepoById(app.db, r.repoId); | |
| 37 | return { | |
| 38 | sha: releaseName(r), | |
| 39 | version: isLive ? live.version : null, | |
| 40 | pull: r.pullNumber === null ? null : { repo: repo === undefined ? null : `${repo.ownerName}/${repo.name}`, number: r.pullNumber }, | |
| 41 | landed: r.createdAt, | |
| 42 | ok: r.ok, | |
| 43 | ...(isLive ? { live: true, since: BOOTED } : {}), | |
| 44 | }; | |
| 45 | }), | |
| 46 | }, | |
| 47 | }; | |
| 48 | } |
test/api-releases.test.ts added +95
| @@ -0,0 +1,95 @@ | ||
| 1 | /** `GET /api/v1/releases` is MCP `releases` over HTTP: the same rows, the same gate, `?shipped=true` open to anyone. */ | |
| 2 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 3 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 4 | import { tmpdir } from "node:os"; | |
| 5 | import { join } from "node:path"; | |
| 6 | import { createApp, type App } from "../src/app.ts"; | |
| 7 | import { createRepo } from "../src/actions/repos.ts"; | |
| 8 | import { createToken } from "../src/core/tokens.ts"; | |
| 9 | import { createUser } from "../src/core/users.ts"; | |
| 10 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 11 | import { recordRelease } from "../src/release/record.ts"; | |
| 12 | ||
| 13 | const LIVE = "a".repeat(40); | |
| 14 | ||
| 15 | describe("GET /api/v1/releases", () => { | |
| 16 | let dataDir: string; | |
| 17 | let app: App; | |
| 18 | let servers: Servers; | |
| 19 | let admin: string; | |
| 20 | let bob: string; | |
| 21 | const saved = process.env["FORGE_COMMIT"]; | |
| 22 | const get = (query: string, token?: string) => | |
| 23 | fetch(`${servers.url}/api/v1/releases${query}`, token ? { headers: { authorization: `Bearer ${token}` } } : {}); | |
| 24 | ||
| 25 | beforeAll(async () => { | |
| 26 | process.env["FORGE_COMMIT"] = LIVE; | |
| 27 | dataDir = await mkdtemp(join(tmpdir(), "forge-api-releases-")); | |
| 28 | app = createApp({ dataDir, port: 0, forgeRepo: "alice/forge" }); | |
| 29 | servers = await serve(app); | |
| 30 | const mk = async (username: string, first = false) => { | |
| 31 | const u = await createUser(app.db, { | |
| 32 | origin: "seed", trusted: true, username, | |
| 33 | email: `${username}@forge.local`, password: "a-good-passphrase", adminIfFirst: first, | |
| 34 | }); | |
| 35 | if (!u.ok) throw new Error(u.error); | |
| 36 | return u.value; | |
| 37 | }; | |
| 38 | const alice = await mk("alice", true); | |
| 39 | const b = await mk("bob"); | |
| 40 | admin = createToken(app.db, alice.id, "a", "admin").token; | |
| 41 | bob = createToken(app.db, b.id, "b", "admin").token; | |
| 42 | const made = await createRepo(app, alice, { name: "forge", visibility: "public" }); | |
| 43 | if (!made.ok) throw new Error(String(made.error)); | |
| 44 | recordRelease(app.db, { sha: LIVE, repoId: made.value.id, pullNumber: 2, ok: true, line: "ok" }); | |
| 45 | }, 30_000); | |
| 46 | ||
| 47 | afterAll(async () => { | |
| 48 | if (saved === undefined) delete process.env["FORGE_COMMIT"]; | |
| 49 | else process.env["FORGE_COMMIT"] = saved; | |
| 50 | servers.stop(); | |
| 51 | app.close(); | |
| 52 | await rm(dataDir, { recursive: true, force: true }); | |
| 53 | }); | |
| 54 | ||
| 55 | test("?shipped=true answers anyone, no token", async () => { | |
| 56 | const res = await get("?shipped=true"); | |
| 57 | expect(res.status).toBe(200); | |
| 58 | const body: any = await res.json(); | |
| 59 | expect(Array.isArray(body.live)).toBe(true); | |
| 60 | expect(Array.isArray(body.onItsWay)).toBe(true); | |
| 61 | expect(body.commit).toBe(LIVE); | |
| 62 | }); | |
| 63 | ||
| 64 | test("an administrator gets the kept releases, the live one marked", async () => { | |
| 65 | const res = await get("", admin); | |
| 66 | expect(res.status).toBe(200); | |
| 67 | const { items }: any = await res.json(); | |
| 68 | expect(items.map((i: any) => i.sha)).toEqual(["a".repeat(12)]); | |
| 69 | expect(items[0].live).toBe(true); | |
| 70 | expect(items[0].pull).toEqual({ repo: "alice/forge", number: 2 }); | |
| 71 | }); | |
| 72 | ||
| 73 | test("a stranger and an anonymous caller are refused in words", async () => { | |
| 74 | for (const token of [bob, undefined]) { | |
| 75 | const res = await get("", token); | |
| 76 | expect(res.status).toBe(403); | |
| 77 | const body: any = await res.json(); | |
| 78 | expect(body.error).toBe("forbidden"); | |
| 79 | expect(body.message).toContain("release history"); | |
| 80 | } | |
| 81 | }); | |
| 82 | ||
| 83 | test("any other shipped value is 422 invalid_shipped", async () => { | |
| 84 | for (const v of ["maybe", "false", ""]) { | |
| 85 | const res = await get(`?shipped=${v}`, admin); | |
| 86 | expect(res.status).toBe(422); | |
| 87 | expect(((await res.json()) as any).error).toBe("invalid_shipped"); | |
| 88 | } | |
| 89 | }); | |
| 90 | ||
| 91 | test("the OpenAPI document lists the route", async () => { | |
| 92 | const doc = (await (await fetch(`${servers.url}/api/v1/openapi.json`)).json()) as any; | |
| 93 | expect(doc.paths["/api/v1/releases"].get).toBeDefined(); | |
| 94 | }); | |
| 95 | }); |
new above
offline