Public: anyone can read this; the crew can change it.
An empty Atom feed says when it was last true, not 1970: /changelog.atom and the events feed carry the running build's time #8583
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/8460-an-empty-atom-feed-says-when-it-was-last → main 2 commits fork
5 files
changed +68
−3
src/web/feeds.ts +3 −2
| @@ -24,6 +24,7 @@ import { shippedAnswer } from "../release/answer.ts"; | ||
| 24 | 24 | import { notePhase, takeSessionTime, timePhase, timePhaseAsync } from "../core/route_timing.ts"; |
| 25 | 25 | import type { Ctx } from "../http/context.ts"; |
| 26 | 26 | import { notFound } from "../http/respond.ts"; |
| 27 | import { STARTED } from "./started.ts"; | |
| 27 | 28 | |
| 28 | 29 | const FEED_KINDS = new Set([ |
| 29 | 30 | "push", |
| @@ -165,7 +166,7 @@ export function activityAtom(ctx: Ctx): Response { | ||
| 165 | 166 | </entry>`; |
| 166 | 167 | }); |
| 167 | 168 | |
| 168 | const updated = events[0]?.createdAt ?? new Date(0).toISOString(); | |
| 169 | const updated = events[0]?.createdAt ?? STARTED; | |
| 169 | 170 | const body = `<?xml version="1.0" encoding="utf-8"?> |
| 170 | 171 | <feed xmlns="http://www.w3.org/2005/Atom"> |
| 171 | 172 | <id>${xml(base)}/activity.atom</id> |
| @@ -214,7 +215,7 @@ export async function changelogAtom(ctx: Ctx): Promise<Response> { | ||
| 214 | 215 | <link rel="alternate" type="text/html" href="${xml(link)}"/> |
| 215 | 216 | </entry>`; |
| 216 | 217 | }); |
| 217 | const updated = live[0]?.at ?? new Date(0).toISOString(); | |
| 218 | const updated = live[0]?.at ?? STARTED; | |
| 218 | 219 | const body = `<?xml version="1.0" encoding="utf-8"?> |
| 219 | 220 | <feed xmlns="http://www.w3.org/2005/Atom"> |
| 220 | 221 | <id>${xml(origin)}/changelog.atom</id> |
src/web/pages/waves.ts +2 −1
| @@ -9,6 +9,7 @@ | ||
| 9 | 9 | import { canAdmin, canRead, getRepo } from "../../core/repos.ts"; |
| 10 | 10 | import { notFound } from "../../http/respond.ts"; |
| 11 | 11 | import { holds, stamp, xml } from "../feeds.ts"; |
| 12 | import { STARTED } from "../started.ts"; | |
| 12 | 13 | import { notePhase, takeSessionTime, timePhase, timePhaseAsync } from "../../core/route_timing.ts"; |
| 13 | 14 | import { repoDir } from "../../git/repos.ts"; |
| 14 | 15 | import type { Ctx } from "../../http/context.ts"; |
| @@ -138,7 +139,7 @@ export async function wavesAtom(ctx: Ctx): Promise<Response> { | ||
| 138 | 139 | <id>${xml(base)}/waves.atom</id> |
| 139 | 140 | <title>${xml(`${repo.ownerName}/${repo.name} waves`)}</title> |
| 140 | 141 | <subtitle>${xml(repo.description === "" ? "Waves" : repo.description)}</subtitle> |
| 141 | <updated>${xml(newest ? updatedAt(newest.date) : new Date(0).toISOString())}</updated> | |
| 142 | <updated>${xml(newest ? updatedAt(newest.date) : STARTED)}</updated> | |
| 142 | 143 | <link rel="self" type="application/atom+xml" href="${xml(base)}/waves.atom"/> |
| 143 | 144 | <link rel="alternate" type="text/html" href="${xml(base)}/waves"/> |
| 144 | 145 | <generator>forge</generator> |
src/web/started.ts added +2
| @@ -0,0 +1,2 @@ | ||
| 1 | /** When this process began serving: taken once, so an empty feed's `<updated>` is stable and its ETag holds. */ | |
| 2 | export const STARTED = new Date().toISOString(); |
test/changelog-atom.test.ts +19
| @@ -75,3 +75,22 @@ describe("GET /changelog.atom", () => { | ||
| 75 | 75 | expect(html).toMatch(/rel="alternate"\s+type="application\/atom\+xml"[^>]*href="\/changelog\.atom"/); |
| 76 | 76 | }); |
| 77 | 77 | }); |
| 78 | ||
| 79 | describe("GET /changelog.atom on an empty forge", () => { | |
| 80 | test("says when the process started, not 1970, and the same twice", async () => { | |
| 81 | const dataDir = await mkdtemp(join(tmpdir(), "forge-changelog-atom-empty-")); | |
| 82 | const app = createApp({ dataDir, port: 0, forgeRepo: "alice/forge" }); | |
| 83 | const servers = await serve(app); | |
| 84 | try { | |
| 85 | const get = async () => (await (await fetch(`${servers.url}/changelog.atom`)).text()).match(/<updated>([^<]*)<\/updated>/)![1]!; | |
| 86 | const first = await get(); | |
| 87 | expect(first).not.toContain("1970-01-01"); | |
| 88 | expect(Date.parse(first)).toBeGreaterThan(Date.parse("2026-01-01")); | |
| 89 | expect(await get()).toBe(first); | |
| 90 | } finally { | |
| 91 | servers.stop(); | |
| 92 | app.close(); | |
| 93 | await rm(dataDir, { recursive: true, force: true }); | |
| 94 | } | |
| 95 | }, 30_000); | |
| 96 | }); |
test/empty-feeds.test.ts added +42
| @@ -0,0 +1,42 @@ | ||
| 1 | /** An empty repository's activity and waves feeds say when the process started, not 1970, and the same twice. */ | |
| 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 { createRepo } from "../src/actions/repos.ts"; | |
| 7 | import { createApp, type App } from "../src/app.ts"; | |
| 8 | import { createUser } from "../src/core/users.ts"; | |
| 9 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 10 | ||
| 11 | describe("empty feeds", () => { | |
| 12 | let dataDir: string; | |
| 13 | let app: App; | |
| 14 | let servers: Servers; | |
| 15 | ||
| 16 | beforeAll(async () => { | |
| 17 | dataDir = await mkdtemp(join(tmpdir(), "forge-empty-feeds-")); | |
| 18 | app = createApp({ dataDir, port: 0 }); | |
| 19 | servers = await serve(app); | |
| 20 | const u = await createUser(app.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true }); | |
| 21 | if (!u.ok) throw new Error(u.error); | |
| 22 | const made = await createRepo(app, u.value, { name: "r", visibility: "public", description: "d" }); | |
| 23 | if (!made.ok) throw new Error(String(made.error)); | |
| 24 | }); | |
| 25 | ||
| 26 | afterAll(async () => { | |
| 27 | servers.stop(); | |
| 28 | app.close(); | |
| 29 | await rm(dataDir, { recursive: true, force: true }); | |
| 30 | }); | |
| 31 | ||
| 32 | for (const feed of ["activity.atom", "waves.atom"]) { | |
| 33 | test(`${feed} is dated by the process start`, async () => { | |
| 34 | const get = async () => | |
| 35 | (await (await fetch(`${servers.url}/alice/r/${feed}`)).text()).match(/<updated>([^<]*)<\/updated>/)![1]!; | |
| 36 | const first = await get(); | |
| 37 | expect(first).not.toContain("1970-01-01"); | |
| 38 | expect(Date.parse(first)).toBeGreaterThan(Date.parse("2026-01-01")); | |
| 39 | expect(await get()).toBe(first); | |
| 40 | }); | |
| 41 | } | |
| 42 | }); |
new above
offline