@astrofoundry/pi-astro 0.27.0 → 0.28.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/agents/arcane.md CHANGED
@@ -20,5 +20,6 @@ Rules:
20
20
  - Confirm destructive operations (`down`, `delete`, `prune`) are explicitly requested in the task before running them.
21
21
  - Updating Arcane itself is a hand-over: do the repository change and the sync, then end with the exact hand-over block from the skill (numbered host steps, digests, rollback). Never run `system upgrade` or `projects upgrade arcane`.
22
22
  - For update questions run `update-report` and answer with its table verbatim in a code block; never answer from `projects updates`, which is stored and paginated.
23
+ - Before a major version update, read its notes with `release-notes`, summarise breaking changes and manual steps, then ask for one go. Never ask the caller to read release notes for you.
23
24
  - Report facts from the JSON output. Sensitive fields are already removed; never guess at what was removed.
24
25
  - Delegate through `subagent` only to another specialist (`astro.identity`, `astro.network`, `astro.dns`, `astro.edge`, `astro.security`, `astro.backup`, `astro.proxmox`, `astro.inference`) and only for that specialist's own area.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astrofoundry/pi-astro",
3
- "version": "0.27.0",
3
+ "version": "0.28.1",
4
4
  "description": "Personal pi customizations (extensions, subagents, skills, prompts, themes) for the pi coding agent.",
5
5
  "keywords": [
6
6
  "pi-package"
@@ -35,11 +35,23 @@ The output is JSON with `counts`, `rows`, and `table`. Answer with the `table` f
35
35
 
36
36
  - `Running` and `Latest` are release tags. `older build`: the running build carries no tag any more. `new build`: no release tag names the newer build. `17 (older build)`: the running build of that major series is no longer tagged.
37
37
  - `Update`: `pull` means redeploying the stack with an image pull applies it; `edit tag` means changing the tag in the GitOps repository; `edit digest` means changing the pinned digest in the repository (`latestDigest` in the row holds it); `unknown` has its reason in the notes under the table.
38
- - A note "major version change" or "newer major ... available" means the update crosses a major version: say so, and apply it only on the caller's explicit go after they have read the release notes.
39
- - Rows marked private have no version data because the registry needs credentials; their digest result from Arcane still stands.
38
+ - A note "major version change" or "newer major ... available" means the update crosses a major version. Read its release notes yourself before asking anything (next section).
39
+ - Private images (the registry refuses anonymous access, such as our own Grimoire builds) are left out of the table and the counts; the summary line counts them and `privateImages` lists them. Mention them only when the caller asks.
40
40
 
41
41
  Apply updates only when asked, one stack at a time, through the operations or GitOps procedure above, and run `update-report` again afterwards to show the result.
42
42
 
43
+ ## Release notes
44
+
45
+ `["release-notes", "<stack>/<service>"]` (the service may be left out when the stack has one) finds the image's source repository through its `org.opencontainers.image.source` label and returns the GitHub releases after the running version up to the latest: `repository`, `releases` (`tag`, `name`, `published`, `url`, `notes`, `omittedSections`), `omitted` (older releases dropped for length), `reason` (why there are none, or that the running version was unknown). Long notes are condensed by section: the introduction and upgrade-relevant sections (breaking changes, migration, Docker, configuration) are kept first; `omittedSections` names what was left out, and the full text is at `url`. For linuxserver images the repository is their packaging repository, whose releases carry the upstream changes.
46
+
47
+ For every update that crosses a major version, and whenever the caller asks what changed:
48
+
49
+ 1. Run `release-notes` for that service and read every release it returns.
50
+ 2. Summarise in a few lines: breaking changes, manual steps (database or data migrations, new, renamed, or removed settings and environment variables, changed volumes or ports), and what the Docker or upgrade sections require. Say plainly when the notes show none, and name the sections that were omitted.
51
+ 3. Ask the caller for one go for that stack, with the summary. Do not apply a major update without it.
52
+
53
+ Minor and patch updates need no notes unless the caller asks. When `release-notes` reports no source, a source outside GitHub, or no releases, say so and ask for the go with that caveat; never guess the changes.
54
+
43
55
  ## Updating Arcane itself
44
56
 
45
57
  The `arcane` project is GitOps-managed and pinned by digest (`ghcr.io/getarcaneapp/manager@sha256:...`). The manager cannot recreate its own container, so the update is a hand-over: you do the repository and sync work, then give the caller exact steps for VM 100 and verify afterwards. Never use `["system", "upgrade"]` or `["projects", "upgrade", "arcane"]` for the manager; both bypass the pinned digest in the repository.
@@ -54,7 +54,8 @@ Wrappers ship as TypeScript and run under Node 24 type stripping: erasable synta
54
54
  - Every passthrough call writes a private per-call copy of `arcanecli.yml` with `jwt_token` set, because some `arcane-cli` commands validate the config file before reading `ARCANE_TOKEN`. The copy is removed after the call.
55
55
  - Every wrapper answers `--help` without reading config, so agents can discover subcommands before anything else is set up.
56
56
  - `main()` waits for stdout and stderr to flush before `process.exit`: on a pipe, exiting right after a write drops everything past the 64 KiB buffer, which cut large answers such as `projects list --all` until 2026-09-25 (`lib/flush.test.ts`).
57
- - `arcane update-report [--all]` is `lib/image-updates.ts` over `lib/registry.ts`: a fresh `images updates check --all`, then `projects list --all` for the stack and service of each image reference, then the registries' tag lists and manifest digests (anonymous bearer tokens from each registry's challenge; digests by HEAD, which Docker Hub does not count as a pull; lscr.io is read from ghcr.io). A floating or major-series tag is resolved to the release tag whose digest matches the running and the latest build, searching the newest tags of the three commonest release shapes; a fixed tag is compared with the newest tag of the same shape; a digest pin with the newest release. Checks older than one minute before the run count as stale and show as unknown. Private images keep Arcane's digest result without versions. The table is fixed-width text for a code block, because Discord renders no Markdown tables.
57
+ - `arcane update-report [--all]` is `lib/image-updates.ts` over `lib/registry.ts`: a fresh `images updates check --all`, then `projects list --all` for the stack and service of each image reference, then the registries' tag lists and manifest digests (anonymous bearer tokens from each registry's challenge; digests by HEAD, which Docker Hub does not count as a pull; lscr.io is read from ghcr.io). A floating or major-series tag is resolved to the release tag whose digest matches the running and the latest build, searching the newest tags of the three commonest release shapes; a fixed tag is compared with the newest tag of the same shape; a digest pin with the newest release. Checks older than one minute before the run count as stale and show as unknown. Private images (anonymous access refused with 401 or 403) are excluded from rows, counts, and table, listed in `privateImages`, and counted in the summary line. The table is fixed-width text for a code block, because Discord renders no Markdown tables.
58
+ - `arcane release-notes <stack>[/<service>]` is `lib/releases.ts`: a single-image check and `resolveRef` give the running and latest versions, `HttpRegistry.labels` reads `org.opencontainers.image.source` from the latest image (index to linux/amd64 manifest to config blob), and the anonymous GitHub releases API (60 requests an hour, up to 3 pages of 100) gives the releases in between, oldest first. `condenseNotes` keeps each release under 12,000 characters by section priority; `capTotal` drops the oldest releases past 40,000 characters in total.
58
59
 
59
60
  ## Gate
60
61
 
@@ -4,7 +4,7 @@ A specialist is a Pi subagent that is the only way to operate one area of the ho
4
4
 
5
5
  | Agent | Area | What it can do |
6
6
  |---|---|---|
7
- | `astro.arcane` | Arcane container platform (VM 100) and its GitOps repository | Every `arcane-cli` operation except its own `config`, `auth`, `self-update`; `update-report` (fresh check of every stack with running and latest release versions); Compose changes of GitOps-managed projects through `git show|write|commit|push`, then `arcane-cli gitops` sync |
7
+ | `astro.arcane` | Arcane container platform (VM 100) and its GitOps repository | Every `arcane-cli` operation except its own `config`, `auth`, `self-update`; `update-report` (fresh check of every stack with running and latest release versions); `release-notes <stack>/<service>` (GitHub notes between the two versions); Compose changes of GitOps-managed projects through `git show|write|commit|push`, then `arcane-cli gitops` sync |
8
8
  | `astro.identity` | Zitadel; Pomerium, nginx, lego on VM 104; `02-pulsar-proxmox/dmz/` in the homelab repository | Any Zitadel API call (v2, management, admin, auth) with responses redacted; `dmz status|journal|config-template|render|pomerium-restart|nginx-reload|lego-renew|deploy-config`; `git` on the homelab checkout, writes limited to `02-pulsar-proxmox/dmz/` |
9
9
  | `astro.network` | UniFi, FreeRADIUS (LXC 108), Tailscale (LXC 105) | Read UniFi sites, devices, clients, networks, zones, firewall policies, WANs; create, update, delete firewall policies; RADIUS and Tailscale checks on Pulsar |
10
10
  | `astro.dns` | Technitium primary (Synapse) and secondary (LXC 107); Cloudflare zones `37pla.net`, `monadeo.com` | Read zones and records on both resolvers, resolve, stats; create and delete zones, add, update, delete records on the primary; Cloudflare zones and record add, update, delete |
@@ -4,10 +4,11 @@ import { dirname, join } from "node:path";
4
4
  import { readConfig } from "../lib/config.ts";
5
5
  import { ServiceError, UsageError } from "../lib/errors.ts";
6
6
  import { main } from "../lib/main.ts";
7
- import { buildUpdateReport } from "../lib/image-updates.ts";
7
+ import { buildUpdateReport, parseTag, resolveRef, stackServices, type CheckEntry } from "../lib/image-updates.ts";
8
8
  import { printJson, printRaw } from "../lib/output.ts";
9
9
  import { run } from "../lib/proc.ts";
10
- import { HttpRegistry } from "../lib/registry.ts";
10
+ import { HttpRegistry, parseImageRef } from "../lib/registry.ts";
11
+ import { releaseNotesFor } from "../lib/releases.ts";
11
12
  import { REPO_HELP, repoCommand, type RepoSpec } from "../lib/repo.ts";
12
13
  import { ARCANE_DENIED_KEYS, filterJsonText, stripKeys } from "../lib/sanitize.ts";
13
14
  import { readSecret } from "../lib/secrets.ts";
@@ -71,6 +72,9 @@ const HELP = `arcane specialist
71
72
  update-report [--all] fresh image update check for every stack, with running and latest release versions
72
73
  from the registries; JSON with a ready table (--all also lists up-to-date services).
73
74
  Checks only: never pulls images or redeploys.
75
+ release-notes <stack>[/<service>]
76
+ GitHub release notes between the running and the latest version of one service's image,
77
+ found through the image's org.opencontainers.image.source label
74
78
  ${REPO_HELP}
75
79
  Refused: ${REFUSED_PREFIXES.join(", ")}. GitOps-managed projects (gitOpsManagedBy set) change in the repository, then sync.`;
76
80
 
@@ -177,6 +181,34 @@ async function updateReport(config: ArcaneConfig, args: string[]): Promise<numbe
177
181
  });
178
182
  }
179
183
 
184
+ async function releaseNotes(config: ArcaneConfig, args: string[]): Promise<number> {
185
+ if (args.length !== 1 || !/^[A-Za-z0-9_.-]+(\/[A-Za-z0-9_.-]+)?$/.test(args[0])) throw new UsageError("release-notes <stack>[/<service>]");
186
+ const [stack, service] = args[0].split("/");
187
+ const startedAt = Date.now();
188
+ return withCli(config, async (cli) => {
189
+ const all = stackServices(await cliJson(cli, ["projects", "list", "--all"]));
190
+ const matches = all.filter((s) => s.stack === stack && (service === undefined || s.service === service));
191
+ if (matches.length === 0) throw new UsageError(`no service ${args[0]}; stacks: ${[...new Set(all.map((s) => s.stack))].join(", ")}`);
192
+ if (matches.length > 1) throw new UsageError(`${stack} has several services (${matches.map((s) => s.service).join(", ")}); name one as ${stack}/<service>`);
193
+ const target = matches[0];
194
+ if (target.ref === null) throw new ServiceError(`Arcane lists no image for ${args[0]}`);
195
+ const ref = parseImageRef(target.ref);
196
+ const entry = ref.digest ? undefined : ((await cliJson(cli, ["images", "updates", "check", target.ref])) as CheckEntry);
197
+ const registry = new HttpRegistry();
198
+ const resolved = await resolveRef(target.ref, entry, registry, startedAt);
199
+ const latest = parseTag(resolved.latest) ? resolved.latest : null;
200
+ const running = parseTag(resolved.running) ? resolved.running : null;
201
+ const base = { stack: target.stack, service: target.service, ref: target.ref, running: resolved.running, latest: resolved.latest, status: resolved.status };
202
+ if (latest === null) {
203
+ printJson({ ...base, releases: [], reason: `no release version names the latest build (${resolved.latest}); nothing to compare` });
204
+ return 0;
205
+ }
206
+ const notes = await releaseNotesFor(target.ref, running, latest, ref.tag ?? latest, registry);
207
+ printJson({ ...base, ...notes });
208
+ return 0;
209
+ });
210
+ }
211
+
180
212
  export async function command(args: string[]): Promise<number> {
181
213
  if (args.length === 0 || args[0] === "--help" || args[0] === "help") {
182
214
  printRaw(HELP);
@@ -185,6 +217,7 @@ export async function command(args: string[]): Promise<number> {
185
217
  const config = readConfig<ArcaneConfig>(SERVICE, SHAPE);
186
218
  if (args[0] === "git") return repoCommand(repoSpec(config), args.slice(1));
187
219
  if (args[0] === "update-report") return updateReport(config, args.slice(1));
220
+ if (args[0] === "release-notes") return releaseNotes(config, args.slice(1));
188
221
  return passthrough(config, args);
189
222
  }
190
223
 
@@ -1,6 +1,7 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
  import { buildUpdateReport, cachingRegistry, checkEntries, classify, compareVersions, parseTag, releaseCandidates, resolveRef, stackServices, type CheckEntry } from "./image-updates.ts";
3
3
  import { HttpRegistry, nextLink, parseChallenge, parseImageRef, RegistryError, type ImageRef, type Registry } from "./registry.ts";
4
+ import { capTotal, condenseNotes, githubRepo, releaseNotesFor, releasesBetween, type GithubRelease } from "./releases.ts";
4
5
 
5
6
  const NOW = Date.parse("2026-09-25T18:30:00Z");
6
7
  const FRESH = "2026-09-25T18:30:05Z";
@@ -17,6 +18,9 @@ class FakeRegistry implements Registry {
17
18
  if (repo === undefined || repo === "private") throw new RegistryError(`${ref.repository}: private or not found`, 401);
18
19
  return repo.tags;
19
20
  }
21
+ async labels(): Promise<Record<string, string>> {
22
+ return {};
23
+ }
20
24
  async digest(ref: ImageRef, tag: string): Promise<string | null> {
21
25
  this.heads++;
22
26
  const repo = this.repos[ref.repository];
@@ -96,7 +100,7 @@ describe("resolveRef", () => {
96
100
  });
97
101
 
98
102
  it("falls back to the digest result for private images and marks bad checks unknown", async () => {
99
- expect(await resolveRef("ghcr.io/astrofoundry/app:main", entry("A1", "A2"), registry, NOW)).toMatchObject({ status: "update", latest: "new build", note: "versions unavailable: private image, the registry needs credentials" });
103
+ expect(await resolveRef("ghcr.io/astrofoundry/app:main", entry("A1", "A2"), registry, NOW)).toMatchObject({ status: "update", latest: "new build", private: true });
100
104
  expect(await resolveRef("nginx:1.31.4-alpine", undefined, registry, NOW)).toMatchObject({ status: "unknown", note: "not in the fresh check" });
101
105
  expect(await resolveRef("nginx:1.31.4-alpine", entry("N", "N", { checkTime: "2026-09-24T00:00:00Z" }), registry, NOW)).toMatchObject({ status: "unknown", note: expect.stringContaining("stale") });
102
106
  expect(await resolveRef("nginx:1.31.4-alpine", entry("N", "N", { error: "timeout" }), registry, NOW)).toMatchObject({ status: "unknown", note: "check failed: timeout" });
@@ -116,17 +120,21 @@ describe("buildUpdateReport", () => {
116
120
  { name: "profiles", status: "running", updateInfo: { serviceUpdates: { profiles: { imageRef: "nginx:1.31.4-alpine" } } } },
117
121
  { name: "canary", status: "stopped", updateInfo: { serviceUpdates: { canary: { imageRef: "busybox:latest" } } } },
118
122
  { name: "empty", status: "running" },
123
+ { name: "ours", status: "running", updateInfo: { serviceUpdates: { api: { imageRef: "ghcr.io/astrofoundry/app:main" }, worker: { imageRef: "ghcr.io/astrofoundry/app:main" } } } },
119
124
  ],
120
125
  };
121
126
  const check = {
122
127
  "lscr.io/linuxserver/prowlarr:latest": entry("D1", "D2"),
123
128
  "nginx:1.31.4-alpine": entry("N", "N"),
124
129
  "busybox:latest": entry("B", "B"),
130
+ "ghcr.io/astrofoundry/app:main": entry("A1", "A2"),
125
131
  };
126
132
  const report = await buildUpdateReport({ check, projects, registry, startedAt: NOW, all: false });
127
133
  expect(report.counts).toEqual({ update: 1, release: 0, rebuild: 0, unknown: 1, "up-to-date": 2 });
128
134
  expect(report.rows.map((r) => `${r.stack}/${r.service}:${r.status}`)).toEqual(["media/prowlarr:update", "empty/empty:unknown"]);
129
- expect(report.table).toMatch(/^Fresh check 2026-09-25T18:30:00Z: 4 services in 4 stacks, 3 images\./);
135
+ expect(report.table).toMatch(/^Fresh check 2026-09-25T18:30:00Z: 4 services in 5 stacks, 3 images\. 1 private images \(2 services\) excluded\./);
136
+ expect(report.privateImages).toEqual(["ghcr.io/astrofoundry/app:main"]);
137
+ expect(report.rows.some((r) => r.stack === "ours")).toBe(false);
130
138
  expect(report.table).toContain("media/prowlarr prowlarr 2.0.5.5160-ls123 2.1.0.5190-ls124 pull");
131
139
  expect(report.table).toContain("* empty: Arcane lists no images for this stack");
132
140
  const all = await buildUpdateReport({ check, projects, registry, startedAt: NOW, all: true });
@@ -142,6 +150,7 @@ describe("buildUpdateReport", () => {
142
150
  return ["17.1", "18.1"];
143
151
  },
144
152
  digest: async () => null,
153
+ labels: async () => ({}),
145
154
  };
146
155
  const registry = cachingRegistry(inner);
147
156
  await Promise.all([registry.tags(parseImageRef("postgres:17")), registry.tags(parseImageRef("postgres:18"))]);
@@ -188,3 +197,67 @@ describe("HttpRegistry", () => {
188
197
  expect(nextLink(null)).toBeNull();
189
198
  });
190
199
  });
200
+
201
+ describe("release notes", () => {
202
+ const release = (tag: string, extra: Partial<GithubRelease> = {}): GithubRelease => ({ tag_name: tag, name: `Release ${tag}`, body: `notes ${tag}`, published_at: "2026-09-01T00:00:00Z", html_url: `https://github.com/o/r/releases/tag/${tag}`, draft: false, prerelease: false, ...extra });
203
+
204
+ it("reads GitHub repositories from source labels", () => {
205
+ expect(githubRepo("https://github.com/Stirling-Tools/Stirling-PDF")).toBe("Stirling-Tools/Stirling-PDF");
206
+ expect(githubRepo("https://github.com/linuxserver/docker-radarr.git")).toBe("linuxserver/docker-radarr");
207
+ expect(githubRepo("git@github.com:o/r.git")).toBe("o/r");
208
+ expect(githubRepo("https://gitlab.com/o/r")).toBeNull();
209
+ });
210
+
211
+ it("keeps published releases after the running version up to the latest, oldest first", () => {
212
+ const all = [release("v3.1.0"), release("v3.0.0"), release("v3.0.0-rc1", { prerelease: true }), release("v2.15.0"), release("v2.14.3"), release("v2.14.2"), release("draft", { draft: true })];
213
+ expect(releasesBetween(all, "2.14.3", "3.0.0").map((r) => r.tag)).toEqual(["v2.15.0", "v3.0.0"]);
214
+ expect(releasesBetween(all, null, "3.0.0").map((r) => r.tag)).toEqual(["v2.14.2", "v2.14.3", "v2.15.0", "v3.0.0"]);
215
+ expect(releasesBetween([release("2.6.5.5623-ls162"), release("2.6.5.5623-ls161")], "2.6.5.5623-ls161", "2.6.5.5623-ls162").map((r) => r.tag)).toEqual(["2.6.5.5623-ls162"]);
216
+ expect(releasesBetween(all, "2.14.3", "latest")).toEqual([]);
217
+ const long = releasesBetween([release("1.1.0", { body: "x".repeat(13_000) })], "1.0.0", "1.1.0")[0];
218
+ expect(long.notes.startsWith("x".repeat(12_000))).toBe(true);
219
+ expect(long.truncated).toBe(true);
220
+ expect(long.omittedSections).toEqual(["rest of introduction (13000 chars)"]);
221
+ });
222
+
223
+ it("keeps upgrade-relevant sections of long notes first", () => {
224
+ const body = ["V3 is live, read the upgrade guide.\n", "## What's Changed\n", `### Bug Fixes\n${"b".repeat(8000)}\n`, `### Enhancements\n${"e".repeat(20_000)}\n`, `### Docker Updates\n${"d".repeat(3000)}\n`, `## New Contributors\n${"c".repeat(1500)}\n`].join("");
225
+ const { notes, omitted } = condenseNotes(body, 12_000);
226
+ expect(notes).toContain("V3 is live");
227
+ expect(notes).toContain("### Docker Updates");
228
+ expect(notes).toContain("### Bug Fixes");
229
+ expect(notes.indexOf("Bug Fixes")).toBeLessThan(notes.indexOf("Docker Updates"));
230
+ expect(omitted).toEqual(["Enhancements (20018 chars)", "New Contributors (1521 chars)"]);
231
+ expect(condenseNotes("short")).toEqual({ notes: "short", omitted: [] });
232
+ });
233
+
234
+ it("drops the oldest notes first when the total is too long", () => {
235
+ const notes = Array.from({ length: 10 }, (_, i) => ({ tag: `1.${i}.0`, name: "", published: "", url: "", notes: "x".repeat(6000), truncated: false, omittedSections: [] }));
236
+ const capped = capTotal(notes);
237
+ expect(capped.notes.map((n) => n.tag)).toEqual(["1.4.0", "1.5.0", "1.6.0", "1.7.0", "1.8.0", "1.9.0"]);
238
+ expect(capped.omitted).toEqual(["1.0.0", "1.1.0", "1.2.0", "1.3.0"]);
239
+ });
240
+
241
+ it("finds the source through the image labels and reports why when it cannot", async () => {
242
+ const labelled: Registry = { tags: async () => [], digest: async () => null, labels: async () => ({ "org.opencontainers.image.source": "https://github.com/o/r" }) };
243
+ const fetchImpl = (async () => new Response(JSON.stringify([release("v1.1.0"), release("v1.0.0")]), { status: 200 })) as unknown as typeof fetch;
244
+ expect(await releaseNotesFor("o/r:latest", "1.0.0", "1.1.0", "latest", labelled, fetchImpl)).toMatchObject({ repository: "o/r", releases: [{ tag: "v1.1.0" }], reason: "" });
245
+ const bare: Registry = { ...labelled, labels: async () => ({}) };
246
+ expect(await releaseNotesFor("o/r:latest", "1.0.0", "1.1.0", "latest", bare, fetchImpl)).toMatchObject({ repository: null, reason: expect.stringContaining("declares no") });
247
+ const elsewhere: Registry = { ...labelled, labels: async () => ({ "org.opencontainers.image.source": "https://gitlab.com/o/r" }) };
248
+ expect(await releaseNotesFor("o/r:latest", "1.0.0", "1.1.0", "latest", elsewhere, fetchImpl)).toMatchObject({ reason: "the source repository is not on GitHub" });
249
+ });
250
+ });
251
+
252
+ describe("HttpRegistry labels", () => {
253
+ it("walks the index to the amd64 image config", async () => {
254
+ const fetchImpl = (async (input: string | URL | Request) => {
255
+ const url = String(input);
256
+ if (url.endsWith("/manifests/latest")) return new Response(JSON.stringify({ manifests: [{ digest: "sha256:arm", platform: { os: "linux", architecture: "arm64" } }, { digest: "sha256:amd", platform: { os: "linux", architecture: "amd64" } }] }), { status: 200 });
257
+ if (url.endsWith("/manifests/sha256:amd")) return new Response(JSON.stringify({ config: { digest: "sha256:cfg" } }), { status: 200 });
258
+ if (url.endsWith("/blobs/sha256:cfg")) return new Response(JSON.stringify({ config: { Labels: { "org.opencontainers.image.source": "https://github.com/o/r" } } }), { status: 200 });
259
+ return new Response("", { status: 404 });
260
+ }) as typeof fetch;
261
+ expect(await new HttpRegistry(fetchImpl).labels(parseImageRef("o/r:latest"), "latest")).toEqual({ "org.opencontainers.image.source": "https://github.com/o/r" });
262
+ });
263
+ });
@@ -34,6 +34,8 @@ export interface UpdateReport {
34
34
  checkedAt: string;
35
35
  stacks: number;
36
36
  images: number;
37
+ /** Private image references, excluded from `rows`, `counts`, and the table. */
38
+ privateImages: string[];
37
39
  counts: Record<UpdateStatus, number>;
38
40
  rows: UpdateRow[];
39
41
  table: string;
@@ -150,6 +152,8 @@ interface Resolution {
150
152
  action: string;
151
153
  note: string;
152
154
  latestDigest?: string;
155
+ /** The registry refused anonymous access: an image of our own, left out of the report. */
156
+ private?: boolean;
153
157
  }
154
158
 
155
159
  /** Resolves one image reference to running and latest versions from the fresh check and the registry's tags. */
@@ -175,7 +179,8 @@ export async function resolveRef(refText: string, entry: CheckEntry | undefined,
175
179
  tags = (await registry.tags(ref)).map(parseTag).filter((t): t is TagVersion => t !== null);
176
180
  } catch (err) {
177
181
  const status = err instanceof RegistryError ? err.status : 0;
178
- return digestOnly(status === 401 || status === 403 ? "private image, the registry needs credentials" : err instanceof Error ? err.message : String(err));
182
+ if (status === 401 || status === 403) return { ...digestOnly("private image, the registry needs credentials"), private: true };
183
+ return digestOnly(err instanceof Error ? err.message : String(err));
179
184
  }
180
185
 
181
186
  const digests = new Map<string, string | null>();
@@ -243,7 +248,7 @@ export async function resolveRef(refText: string, entry: CheckEntry | undefined,
243
248
  const sameShape = tags.filter((t) => shapeKey(t) === shapeKey(r)).sort(byVersionDesc).slice(0, PER_SHAPE);
244
249
  latest = (await findTag(entry.latestDigest, sameShape)) ?? latest;
245
250
  }
246
- return { kind, running, latest, status: "update", action: "pull", note: majorChange(running, latest) ? "major version change: check the release notes before pulling" : "" };
251
+ return { kind, running, latest, status: "update", action: "pull", note: majorChange(running, latest) ? "major version change: read its release notes before pulling" : "" };
247
252
  }
248
253
 
249
254
  async function mapLimit<T, R>(items: readonly T[], limit: number, fn: (item: T) => Promise<R>): Promise<R[]> {
@@ -330,6 +335,7 @@ export function cachingRegistry(inner: Registry): Registry {
330
335
  if (!digests.has(k)) digests.set(k, inner.digest(ref, tag));
331
336
  return digests.get(k) as Promise<string | null>;
332
337
  },
338
+ labels: (ref, tag) => inner.labels(ref, tag),
333
339
  };
334
340
  }
335
341
 
@@ -346,7 +352,9 @@ export async function buildUpdateReport(input: { check: unknown; projects: unkno
346
352
  await mapLimit(refs, CONCURRENCY, async (ref) => {
347
353
  resolved.set(ref, await resolveRef(ref, entries[ref], registry, input.startedAt));
348
354
  });
349
- const rows: UpdateRow[] = services.map((s) => {
355
+ const privateImages = refs.filter((ref) => resolved.get(ref)?.private === true);
356
+ const listed = services.filter((s) => s.ref === null || !privateImages.includes(s.ref));
357
+ const rows: UpdateRow[] = listed.map((s) => {
350
358
  if (s.ref === null) return { stack: s.stack, service: s.service, ref: "", image: "?", kind: "floating", running: "?", latest: "?", status: "unknown", action: "", note: "Arcane lists no images for this stack" };
351
359
  const r = resolved.get(s.ref) as Resolution & { kind: RefKind };
352
360
  const note = [r.note, s.stopped ? "stack is stopped" : ""].filter(Boolean).join("; ");
@@ -358,9 +366,10 @@ export async function buildUpdateReport(input: { check: unknown; projects: unkno
358
366
  const shown = input.all ? rows : rows.filter((r) => r.status !== "up-to-date" || r.note.includes("newer major"));
359
367
  const checkedAt = new Date(input.startedAt).toISOString().replace(/\.\d+Z$/, "Z");
360
368
  const stacks = new Set(services.map((s) => s.stack)).size;
369
+ const excluded = privateImages.length === 0 ? "" : ` ${privateImages.length} private images (${services.length - listed.length} services) excluded.`;
361
370
  const summary =
362
- `Fresh check ${checkedAt}: ${services.length} services in ${stacks} stacks, ${refs.length} images. ` +
371
+ `Fresh check ${checkedAt}: ${listed.length} services in ${stacks} stacks, ${refs.length - privateImages.length} images.${excluded} ` +
363
372
  `${counts.update} new builds, ${counts.release} new releases, ${counts.rebuild} rebuilds, ${counts.unknown} unknown, ${counts["up-to-date"]} up to date` +
364
373
  (input.all ? "." : " (not listed unless a newer major exists).");
365
- return { checkedAt, stacks, images: refs.length, counts, rows: shown, table: renderTable(shown, summary) };
374
+ return { checkedAt, stacks, images: refs.length - privateImages.length, privateImages, counts, rows: shown, table: renderTable(shown, summary) };
366
375
  }
@@ -46,6 +46,14 @@ export interface Registry {
46
46
  tags(ref: ImageRef): Promise<string[]>;
47
47
  /** Manifest digest of one tag, or null when the tag does not exist. */
48
48
  digest(ref: ImageRef, tag: string): Promise<string | null>;
49
+ /** Image labels and index annotations of one tag (linux/amd64 when the tag is multi-platform). */
50
+ labels(ref: ImageRef, tag: string): Promise<Record<string, string>>;
51
+ }
52
+
53
+ interface ManifestJson {
54
+ manifests?: { digest: string; platform?: { os?: string; architecture?: string } }[];
55
+ config?: { digest?: string };
56
+ annotations?: Record<string, string>;
49
57
  }
50
58
 
51
59
  export class RegistryError extends ServiceError {
@@ -117,6 +125,26 @@ export class HttpRegistry implements Registry {
117
125
  return res.headers.get("docker-content-digest");
118
126
  }
119
127
 
128
+ async labels(ref: ImageRef, tag: string): Promise<Record<string, string>> {
129
+ const top = await this.getJson<ManifestJson>(ref, `/v2/${ref.repository}/manifests/${encodeURIComponent(tag)}`, MANIFEST_ACCEPT);
130
+ let manifest = top;
131
+ if (Array.isArray(top.manifests)) {
132
+ const pick = top.manifests.find((m) => m.platform?.os === "linux" && m.platform.architecture === "amd64") ?? top.manifests.find((m) => m.platform?.os !== "unknown");
133
+ if (!pick) return { ...top.annotations };
134
+ manifest = await this.getJson<ManifestJson>(ref, `/v2/${ref.repository}/manifests/${pick.digest}`, MANIFEST_ACCEPT);
135
+ }
136
+ const configDigest = manifest.config?.digest;
137
+ // Blobs redirect to a signed CDN URL; fetch drops the bearer token on that cross-origin hop.
138
+ const config = configDigest ? await this.getJson<{ config?: { Labels?: Record<string, string> | null } }>(ref, `/v2/${ref.repository}/blobs/${configDigest}`, "application/json") : {};
139
+ return { ...top.annotations, ...manifest.annotations, ...(config.config?.Labels ?? {}) };
140
+ }
141
+
142
+ private async getJson<T>(ref: ImageRef, path: string, accept: string): Promise<T> {
143
+ const res = await this.request(ref, path, "GET", accept);
144
+ if (!res.ok) throw new RegistryError(`${apiHost(ref.registry)}/${ref.repository}: ${path.split("/").slice(-2).join("/")} returned ${res.status}`, res.status);
145
+ return (await res.json()) as T;
146
+ }
147
+
120
148
  private async request(ref: ImageRef, path: string, method: string, accept: string): Promise<Response> {
121
149
  const host = apiHost(ref.registry);
122
150
  const key = `${host}/${ref.repository}`;
@@ -0,0 +1,144 @@
1
+ import { compareVersions, parseTag, type TagVersion } from "./image-updates.ts";
2
+ import { parseImageRef, type Registry } from "./registry.ts";
3
+
4
+ /** OCI label that names the repository an image was built from. */
5
+ export const SOURCE_LABEL = "org.opencontainers.image.source";
6
+ const NOTES_LIMIT = 12_000;
7
+ /** Section headings that matter for running an upgrade; they are kept before anything else. */
8
+ const PRIORITY = /breaking|upgrad|migrat|docker|deploy|config|important|notice|deprecat|remov|security|action required|environment|database/i;
9
+ /** Sections that never matter for an upgrade decision; they go last. */
10
+ const LOW = /new contributors|development tools|dependenc|chore|full changelog|contributors/i;
11
+ const TOTAL_LIMIT = 40_000;
12
+ const PAGES = 3;
13
+ /** Releases shown when the running version is unknown. */
14
+ const RECENT = 5;
15
+
16
+ export interface GithubRelease {
17
+ tag_name: string;
18
+ name: string | null;
19
+ body: string | null;
20
+ published_at: string | null;
21
+ html_url: string;
22
+ draft: boolean;
23
+ prerelease: boolean;
24
+ }
25
+
26
+ export interface ReleaseNote {
27
+ tag: string;
28
+ name: string;
29
+ published: string;
30
+ url: string;
31
+ notes: string;
32
+ truncated: boolean;
33
+ /** Sections left out of `notes`, with their size; the full text is at `url`. */
34
+ omittedSections: string[];
35
+ }
36
+
37
+ /**
38
+ * Shortens long release notes by section: the introduction and upgrade-relevant
39
+ * sections (breaking changes, migration, Docker, configuration) first, the rest
40
+ * in their original order while space remains, contributor lists last.
41
+ */
42
+ export function condenseNotes(body: string, limit = NOTES_LIMIT): { notes: string; omitted: string[] } {
43
+ if (body.length <= limit) return { notes: body, omitted: [] };
44
+ const sections = body.split(/^(?=#{1,4} )/m).filter((s) => s.trim().length > 0);
45
+ const title = (s: string) => (s.startsWith("#") ? s.split("\n")[0].replace(/^#+\s*/, "").trim() : "introduction");
46
+ const rank = (s: string) => (!s.startsWith("#") ? 0 : PRIORITY.test(title(s)) ? 1 : LOW.test(title(s)) ? 3 : 2);
47
+ const order = sections.map((s, i) => ({ s, i, r: rank(s) })).sort((a, b) => a.r - b.r || a.i - b.i);
48
+ const kept = new Map<number, string>();
49
+ const omitted: string[] = [];
50
+ let used = 0;
51
+ for (const { s, i, r } of order) {
52
+ if (used + s.length <= limit) {
53
+ kept.set(i, s);
54
+ used += s.length;
55
+ } else if (r <= 1 && limit - used > 200) {
56
+ kept.set(i, `${s.slice(0, limit - used)}\n[... section cut]\n`);
57
+ used = limit;
58
+ omitted.push(`rest of ${title(s)} (${s.length} chars)`);
59
+ } else {
60
+ omitted.push(`${title(s)} (${s.length} chars)`);
61
+ }
62
+ }
63
+ return { notes: [...kept.entries()].sort((a, b) => a[0] - b[0]).map(([, s]) => s).join(""), omitted };
64
+ }
65
+
66
+ /** `owner/name` of a GitHub source URL such as `https://github.com/Stirling-Tools/Stirling-PDF.git`, or null for other hosts. */
67
+ export function githubRepo(source: string): string | null {
68
+ const m = /^(?:https?:\/\/|git@)github\.com[/:]([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+?)(?:\.git)?(?:[/#?].*)?$/.exec(source.trim());
69
+ return m ? `${m[1]}/${m[2]}` : null;
70
+ }
71
+
72
+ /** A release tag as a version, with a leading `v` or `release-` ignored. */
73
+ function releaseVersion(tag: string): TagVersion | null {
74
+ return parseTag(tag.replace(/^(release-|version-)/i, ""));
75
+ }
76
+
77
+ /**
78
+ * Published releases after `running` up to and including `latest`, oldest
79
+ * first. When the running version is unknown, the last few up to `latest`.
80
+ */
81
+ export function releasesBetween(releases: GithubRelease[], running: string | null, latest: string): ReleaseNote[] {
82
+ const to = releaseVersion(latest);
83
+ const from = running === null ? null : releaseVersion(running);
84
+ if (!to) return [];
85
+ const picked = releases
86
+ .filter((r) => !r.draft && !r.prerelease)
87
+ .map((r) => ({ r, v: releaseVersion(r.tag_name) }))
88
+ .filter((x): x is { r: GithubRelease; v: TagVersion } => x.v !== null && compareVersions(x.v, to) <= 0 && (from === null || compareVersions(x.v, from) > 0))
89
+ .sort((a, b) => compareVersions(a.v, b.v));
90
+ const chosen = from === null ? picked.slice(-RECENT) : picked;
91
+ return chosen.map(({ r }) => {
92
+ const { notes, omitted } = condenseNotes(r.body ?? "");
93
+ return { tag: r.tag_name, name: r.name ?? r.tag_name, published: r.published_at ?? "", url: r.html_url, notes, truncated: omitted.length > 0, omittedSections: omitted };
94
+ });
95
+ }
96
+
97
+ /** Keeps the newest notes whole and drops the oldest once the total passes the limit. */
98
+ export function capTotal(notes: ReleaseNote[]): { notes: ReleaseNote[]; omitted: string[] } {
99
+ let total = 0;
100
+ const kept: ReleaseNote[] = [];
101
+ for (const n of [...notes].reverse()) {
102
+ if (total + n.notes.length > TOTAL_LIMIT && kept.length > 0) break;
103
+ total += n.notes.length;
104
+ kept.unshift(n);
105
+ }
106
+ return { notes: kept, omitted: notes.slice(0, notes.length - kept.length).map((n) => n.tag) };
107
+ }
108
+
109
+ export async function fetchReleases(repo: string, fetchImpl: typeof fetch = fetch): Promise<GithubRelease[]> {
110
+ const all: GithubRelease[] = [];
111
+ for (let page = 1; page <= PAGES; page++) {
112
+ const res = await fetchImpl(`https://api.github.com/repos/${repo}/releases?per_page=100&page=${page}`, {
113
+ headers: { accept: "application/vnd.github+json", "user-agent": "pi-astro-arcane-release-notes" },
114
+ signal: AbortSignal.timeout(30_000),
115
+ });
116
+ if (!res.ok) throw new Error(`GitHub answered ${res.status} for ${repo} releases`);
117
+ const batch = (await res.json()) as GithubRelease[];
118
+ all.push(...batch);
119
+ if (batch.length < 100) break;
120
+ }
121
+ return all;
122
+ }
123
+
124
+ export interface ReleaseNotesResult {
125
+ source: string | null;
126
+ repository: string | null;
127
+ releases: ReleaseNote[];
128
+ omitted: string[];
129
+ reason: string;
130
+ }
131
+
132
+ /** Finds the image's source repository from its labels, then the GitHub releases between the two versions. */
133
+ export async function releaseNotesFor(refText: string, running: string | null, latest: string, labelTag: string, registry: Registry, fetchImpl: typeof fetch = fetch): Promise<ReleaseNotesResult> {
134
+ const ref = parseImageRef(refText);
135
+ const labels = await registry.labels(ref, labelTag);
136
+ const source = labels[SOURCE_LABEL] ?? null;
137
+ if (!source) return { source: null, repository: null, releases: [], omitted: [], reason: `the image declares no ${SOURCE_LABEL} label` };
138
+ const repository = githubRepo(source);
139
+ if (!repository) return { source, repository: null, releases: [], omitted: [], reason: "the source repository is not on GitHub" };
140
+ const between = releasesBetween(await fetchReleases(repository, fetchImpl), running, latest);
141
+ const { notes, omitted } = capTotal(between);
142
+ const reason = between.length === 0 ? "GitHub lists no published release between the two versions" : running === null ? `running version unknown: the last ${notes.length} releases up to ${latest}` : "";
143
+ return { source, repository, releases: notes, omitted, reason };
144
+ }