@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 +1 -0
- package/package.json +1 -1
- package/skills/arcane/SKILL.md +14 -2
- package/specialists/AGENTS.md +2 -1
- package/specialists/README.md +1 -1
- package/specialists/arcane/run.ts +35 -2
- package/specialists/lib/image-updates.test.ts +75 -2
- package/specialists/lib/image-updates.ts +14 -5
- 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
|
|
39
|
-
-
|
|
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.
|
package/specialists/AGENTS.md
CHANGED
|
@@ -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
|
|
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
|
|
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];
|
|
@@ -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",
|
|
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
|
|
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
|
-
|
|
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:
|
|
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
|
|
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}: ${
|
|
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
|
+
}
|