@astrofoundry/pi-astro 0.27.0 → 0.28.0
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 +1 -0
- package/package.json +1 -1
- package/skills/arcane/SKILL.md +13 -1
- package/specialists/AGENTS.md +1 -0
- package/specialists/README.md +1 -1
- package/specialists/arcane/run.ts +35 -2
- package/specialists/lib/image-updates.test.ts +69 -0
- package/specialists/lib/image-updates.ts +1 -0
- package/specialists/lib/registry.ts +28 -0
- package/specialists/lib/releases.ts +144 -0
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
package/skills/arcane/SKILL.md
CHANGED
|
@@ -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
|
|
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
39
|
- Rows marked private have no version data because the registry needs credentials; their digest result from Arcane still stands.
|
|
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.
|
package/specialists/AGENTS.md
CHANGED
|
@@ -55,6 +55,7 @@ Wrappers ship as TypeScript and run under Node 24 type stripping: erasable synta
|
|
|
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
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.
|
|
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
|
|
package/specialists/README.md
CHANGED
|
@@ -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];
|
|
@@ -142,6 +146,7 @@ describe("buildUpdateReport", () => {
|
|
|
142
146
|
return ["17.1", "18.1"];
|
|
143
147
|
},
|
|
144
148
|
digest: async () => null,
|
|
149
|
+
labels: async () => ({}),
|
|
145
150
|
};
|
|
146
151
|
const registry = cachingRegistry(inner);
|
|
147
152
|
await Promise.all([registry.tags(parseImageRef("postgres:17")), registry.tags(parseImageRef("postgres:18"))]);
|
|
@@ -188,3 +193,67 @@ describe("HttpRegistry", () => {
|
|
|
188
193
|
expect(nextLink(null)).toBeNull();
|
|
189
194
|
});
|
|
190
195
|
});
|
|
196
|
+
|
|
197
|
+
describe("release notes", () => {
|
|
198
|
+
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 });
|
|
199
|
+
|
|
200
|
+
it("reads GitHub repositories from source labels", () => {
|
|
201
|
+
expect(githubRepo("https://github.com/Stirling-Tools/Stirling-PDF")).toBe("Stirling-Tools/Stirling-PDF");
|
|
202
|
+
expect(githubRepo("https://github.com/linuxserver/docker-radarr.git")).toBe("linuxserver/docker-radarr");
|
|
203
|
+
expect(githubRepo("git@github.com:o/r.git")).toBe("o/r");
|
|
204
|
+
expect(githubRepo("https://gitlab.com/o/r")).toBeNull();
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("keeps published releases after the running version up to the latest, oldest first", () => {
|
|
208
|
+
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 })];
|
|
209
|
+
expect(releasesBetween(all, "2.14.3", "3.0.0").map((r) => r.tag)).toEqual(["v2.15.0", "v3.0.0"]);
|
|
210
|
+
expect(releasesBetween(all, null, "3.0.0").map((r) => r.tag)).toEqual(["v2.14.2", "v2.14.3", "v2.15.0", "v3.0.0"]);
|
|
211
|
+
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"]);
|
|
212
|
+
expect(releasesBetween(all, "2.14.3", "latest")).toEqual([]);
|
|
213
|
+
const long = releasesBetween([release("1.1.0", { body: "x".repeat(13_000) })], "1.0.0", "1.1.0")[0];
|
|
214
|
+
expect(long.notes.startsWith("x".repeat(12_000))).toBe(true);
|
|
215
|
+
expect(long.truncated).toBe(true);
|
|
216
|
+
expect(long.omittedSections).toEqual(["rest of introduction (13000 chars)"]);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("keeps upgrade-relevant sections of long notes first", () => {
|
|
220
|
+
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("");
|
|
221
|
+
const { notes, omitted } = condenseNotes(body, 12_000);
|
|
222
|
+
expect(notes).toContain("V3 is live");
|
|
223
|
+
expect(notes).toContain("### Docker Updates");
|
|
224
|
+
expect(notes).toContain("### Bug Fixes");
|
|
225
|
+
expect(notes.indexOf("Bug Fixes")).toBeLessThan(notes.indexOf("Docker Updates"));
|
|
226
|
+
expect(omitted).toEqual(["Enhancements (20018 chars)", "New Contributors (1521 chars)"]);
|
|
227
|
+
expect(condenseNotes("short")).toEqual({ notes: "short", omitted: [] });
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it("drops the oldest notes first when the total is too long", () => {
|
|
231
|
+
const notes = Array.from({ length: 10 }, (_, i) => ({ tag: `1.${i}.0`, name: "", published: "", url: "", notes: "x".repeat(6000), truncated: false, omittedSections: [] }));
|
|
232
|
+
const capped = capTotal(notes);
|
|
233
|
+
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"]);
|
|
234
|
+
expect(capped.omitted).toEqual(["1.0.0", "1.1.0", "1.2.0", "1.3.0"]);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it("finds the source through the image labels and reports why when it cannot", async () => {
|
|
238
|
+
const labelled: Registry = { tags: async () => [], digest: async () => null, labels: async () => ({ "org.opencontainers.image.source": "https://github.com/o/r" }) };
|
|
239
|
+
const fetchImpl = (async () => new Response(JSON.stringify([release("v1.1.0"), release("v1.0.0")]), { status: 200 })) as unknown as typeof fetch;
|
|
240
|
+
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: "" });
|
|
241
|
+
const bare: Registry = { ...labelled, labels: async () => ({}) };
|
|
242
|
+
expect(await releaseNotesFor("o/r:latest", "1.0.0", "1.1.0", "latest", bare, fetchImpl)).toMatchObject({ repository: null, reason: expect.stringContaining("declares no") });
|
|
243
|
+
const elsewhere: Registry = { ...labelled, labels: async () => ({ "org.opencontainers.image.source": "https://gitlab.com/o/r" }) };
|
|
244
|
+
expect(await releaseNotesFor("o/r:latest", "1.0.0", "1.1.0", "latest", elsewhere, fetchImpl)).toMatchObject({ reason: "the source repository is not on GitHub" });
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
describe("HttpRegistry labels", () => {
|
|
249
|
+
it("walks the index to the amd64 image config", async () => {
|
|
250
|
+
const fetchImpl = (async (input: string | URL | Request) => {
|
|
251
|
+
const url = String(input);
|
|
252
|
+
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 });
|
|
253
|
+
if (url.endsWith("/manifests/sha256:amd")) return new Response(JSON.stringify({ config: { digest: "sha256:cfg" } }), { status: 200 });
|
|
254
|
+
if (url.endsWith("/blobs/sha256:cfg")) return new Response(JSON.stringify({ config: { Labels: { "org.opencontainers.image.source": "https://github.com/o/r" } } }), { status: 200 });
|
|
255
|
+
return new Response("", { status: 404 });
|
|
256
|
+
}) as typeof fetch;
|
|
257
|
+
expect(await new HttpRegistry(fetchImpl).labels(parseImageRef("o/r:latest"), "latest")).toEqual({ "org.opencontainers.image.source": "https://github.com/o/r" });
|
|
258
|
+
});
|
|
259
|
+
});
|
|
@@ -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
|
+
}
|