diff --git a/CHANGELOG.md b/CHANGELOG.md index d193d3aac..16d5a625c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.6.0-rc.13] — 2026-08-08 + +### Security + +- Pinned `js-yaml` to 3.15.1 in the e2e workspace (override; transitive dependency) for [GHSA-5p4m-2wfm-xmqj](https://github.com/advisories/GHSA-5p4m-2wfm-xmqj) (CVE-2026-59870 backport gap: quadratic CPU consumption in `!!omap` resolution). +- Pinned `nanoid` to 3.3.18 (override; transitive dependency of `postcss` in the root, app, apps/demo, apps/web, and ui workspaces, and of `artillery` in the e2e workspace) for [GHSA-2v37-7h3g-55p8](https://github.com/advisories/GHSA-2v37-7h3g-55p8) (CVE-2026-67213) and, in the e2e workspace which was still on 3.3.12, also [GHSA-28wg-ghj8-5hjv](https://github.com/advisories/GHSA-28wg-ghj8-5hjv) (CVE-2026-67214). +- **`node:24-alpine` base image bumped to Node 24.19.0**, replacing the Node 24.18.0 image shipped in rc.12. Picks up Node's July 29 2026 security release, fixing 3 HIGH (CVE-2026-56846, CVE-2026-56848, CVE-2026-58043) + 5 MEDIUM CVEs that landed in 24.18.1 ([#682](https://github.com/CodesWhat/drydock/pull/682)). +- **Vendored `aquasec/trivy` build-stage pin bumped from 0.72.0 to 0.73.0**, resolving 4 HIGH / 6 MEDIUM CVEs in its vendored Go dependencies: go-git ([CVE-2026-71556](https://github.com/advisories/CVE-2026-71556)), `x/text` ([CVE-2026-56852](https://github.com/advisories/CVE-2026-56852)), grpc ([GHSA-hrxh-6v49-42gf](https://github.com/advisories/GHSA-hrxh-6v49-42gf)), oras-go ([CVE-2026-50151](https://github.com/advisories/CVE-2026-50151), [CVE-2026-50163](https://github.com/advisories/CVE-2026-50163)), and the Go stdlib ([CVE-2026-39822](https://github.com/advisories/CVE-2026-39822)) ([#682](https://github.com/CodesWhat/drydock/pull/682)). + +### Fixed + +- **Icon bundle no longer silently drops referenced icons at image build time** ([#683](https://github.com/CodesWhat/drydock/pull/683)). The bundle is regenerated from the locked `@iconify-json` packages during every Docker image build, but the extractor only looked up plain icon entries — `lucide:history` (the Audit navigation icon in the Lucide icon theme) became an alias in lucide 1.2.121 and vanished from shipped images, rendering blank. The extractor now resolves alias chains, and references that never existed in the locked collections are fixed: `iconoir:history` → `iconoir:clock-rotate-right`, `iconoir:gitlab` → `iconoir:gitlab-full`, `iconoir:stack` → `iconoir:multiple-pages`, and the Font Awesome brand glyphs (GitHub/GitLab/Google/Microsoft registry icons) gained the previously missing `@iconify-json/fa6-brands` package. A new test asserts every icon referenced in `icons.ts` exists in the committed bundle. +- **Star History chart is now self-hosted** ([#671](https://github.com/CodesWhat/drydock/issues/671)). The homepage card and README embed rendered a broken image after api.star-history.com's global outage (their GitHub tokens rate-limited; starchart.cc also failing). A new `/api/star-history` route on the website fetches stargazer timestamps from the GitHub API server-side (optional `GITHUB_TOKEN`, edge-cached six hours with stale-while-revalidate, short-lived fallback SVG on fetch failure) and renders the chart in the site's own palette for both themes; the README uses a `` element with theme-matched variants. No third-party chart service remains in the path, and `api.star-history.com` is dropped from the site's CSP `img-src`. +- **Digest-update comparison no longer anchors on an arbitrary `RepoDigests[0]` entry** ([#669](https://github.com/CodesWhat/drydock/issues/669)). A local Docker image can carry multiple `repo@digest` entries for one Image ID (pull/retag accumulation, no ordering guarantee); `getRepoDigest` blindly took index 0, so a stale or foreign-repo entry landing first anchored the whole digest-update pipeline to the wrong manifest and produced a persistent digest-update false positive that survived applying the update. `getOrderedRepoDigests` (`app/watchers/providers/docker/docker-helpers.ts`) now returns every RepoDigests entry whose repo component matches the container's own image reference, ordered, falling back to the full list only when nothing matches; the container model gained an optional `image.digest.repoDigests` field carrying that ordered list, re-derived from the live Docker image inspect on every discovery/refresh cycle. `handleDigestWatch` (`app/watchers/providers/docker/image-comparison.ts`) now walks that candidate list — a cheap raw-value check first, then a normalize-and-compare registry call per remaining candidate, skipping anchors whose manifest lookup fails — and re-anchors `digest.repo` to whichever candidate actually matched, so a store already poisoned with a stale `digest.repo` self-heals on its own. A genuine same-tag republish (no candidate matches) still flags an update exactly as before; if every candidate fails to normalize, the failure now propagates instead of being silently coerced into a false "no update". + ## [1.6.0-rc.12] — 2026-08-04 ### Changed @@ -2326,7 +2341,8 @@ Remaining upstream-only changes (not ported — not applicable to drydock): | Fix codeberg tests | Covered by drydock's own tests | | Update changelog | Upstream-specific | -[Unreleased]: https://github.com/CodesWhat/drydock/compare/v1.6.0-rc.12...HEAD +[Unreleased]: https://github.com/CodesWhat/drydock/compare/v1.6.0-rc.13...HEAD +[1.6.0-rc.13]: https://github.com/CodesWhat/drydock/compare/v1.6.0-rc.12...v1.6.0-rc.13 [1.6.0-rc.12]: https://github.com/CodesWhat/drydock/compare/v1.6.0-rc.11...v1.6.0-rc.12 [1.6.0-rc.11]: https://github.com/CodesWhat/drydock/compare/v1.6.0-rc.10...v1.6.0-rc.11 [1.6.0-rc.10]: https://github.com/CodesWhat/drydock/compare/v1.6.0-rc.9...v1.6.0-rc.10 diff --git a/Dockerfile b/Dockerfile index 2c76e9db5..3db6c0724 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,10 +1,10 @@ # checkov:skip=CKV_DOCKER_3: entrypoint uses su-exec for runtime privilege drop # Trivy publishes a multi-arch image and installs its binary at this path. # Pin the image index so every target architecture resolves reproducibly. -FROM aquasec/trivy@sha256:cffe3f5161a47a6823fbd23d985795b3ed72a4c806da4c4df16266c02accdd6f AS trivy-bin +FROM aquasec/trivy@sha256:7cced7cae583819fc7806d4cbc0dbbc7cad18b99f7d3e235192e6da8c091045c AS trivy-bin # Common Stage -FROM node:24-alpine@sha256:a0b9bf06e4e6193cf7a0f58816cc935ff8c2a908f81e6f1a95432d679c54fbfd AS base +FROM node:24-alpine@sha256:d32cdf619f63fe0471182d08996dd516c6275bb5fd31ae06e55a570bd9e1ad43 AS base WORKDIR /home/node/app LABEL maintainer="CodesWhat" diff --git a/README.md b/README.md index 30153d62e..6f22080bb 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@

- Version + Version Multi-arch License AGPL-3.0
@@ -178,6 +178,17 @@ See the [Quick Start guide](https://getdrydock.com/docs/quickstart) for Docker C

🆕 Recent Updates

+v1.6.0-rc.13 highlights + +- **Digest comparison anchors on repo-matched candidates** — `getOrderedRepoDigests` filters a container's `RepoDigests` to entries whose repo component matches its own image reference before comparing, instead of trusting an arbitrary index-0 entry; a store already poisoned with a stale anchor self-heals. ([#670](https://github.com/CodesWhat/drydock/pull/670)) +- **`nanoid` pinned to 3.3.18** across the root, app, apps/demo, apps/web, ui, and e2e workspaces (transitive override) for CVE-2026-67213 and, in e2e, CVE-2026-67214. ([#673](https://github.com/CodesWhat/drydock/pull/673)) +- **Star History chart is self-hosted** — a new same-origin `/api/star-history` route replaces the third-party embed that went down in a global outage, edge-cached with a fallback SVG on fetch failure. ([#672](https://github.com/CodesWhat/drydock/pull/672)) +- **Base-image CVE sweep** — `node:24-alpine` bumped to Node 24.19.0 and the vendored `aquasec/trivy` build-stage pin bumped to 0.73.0, clearing HIGH/MEDIUM CVEs in both. ([#682](https://github.com/CodesWhat/drydock/pull/682)) +- **Icon bundle alias resolution** — the build-time icon extractor follows iconify alias chains and gains the missing Font Awesome brands collection, so renamed icons (like the Lucide-theme Audit icon) no longer ship as blank glyphs; a guard test pins every referenced icon into the bundle. ([#683](https://github.com/CodesWhat/drydock/pull/683)) + +
+ +
v1.6.0-rc.12 highlights - **Security dependency refresh** — `brace-expansion` 5.0.9 (app/UI/e2e, CVE-2026-69152), `ip-address` 10.3.1 (app runtime, CVE-2026-54272/-69192/-69198), and `fast-uri` 4.1.2 (app/UI, CVE-2026-18446). ([#659](https://github.com/CodesWhat/drydock/pull/659)) @@ -409,8 +420,11 @@ High-level themes only — see [CHANGELOG.md](CHANGELOG.md) for per-release deta
- - Star History Chart + + + + Star History Chart +
diff --git a/app/configuration/dockerfile-defaults.test.ts b/app/configuration/dockerfile-defaults.test.ts index 0f34c1d47..fcd4861e1 100644 --- a/app/configuration/dockerfile-defaults.test.ts +++ b/app/configuration/dockerfile-defaults.test.ts @@ -7,11 +7,19 @@ describe('Dockerfile release defaults', () => { expect(dockerfile).toMatch(/FROM base AS release\s+ENV DD_LOG_FORMAT=text/u); }); + test('release image builds from the digest-pinned Node base image', () => { + const dockerfile = fs.readFileSync(new URL('../../Dockerfile', import.meta.url), 'utf8'); + + expect(dockerfile).toContain( + 'FROM node:24-alpine@sha256:d32cdf619f63fe0471182d08996dd516c6275bb5fd31ae06e55a570bd9e1ad43 AS base', + ); + }); + test('release image copies Trivy from the digest-pinned multi-arch image', () => { const dockerfile = fs.readFileSync(new URL('../../Dockerfile', import.meta.url), 'utf8'); expect(dockerfile).toContain( - 'FROM aquasec/trivy@sha256:cffe3f5161a47a6823fbd23d985795b3ed72a4c806da4c4df16266c02accdd6f AS trivy-bin', + 'FROM aquasec/trivy@sha256:7cced7cae583819fc7806d4cbc0dbbc7cad18b99f7d3e235192e6da8c091045c AS trivy-bin', ); expect(dockerfile).toContain('COPY --from=trivy-bin /usr/local/bin/trivy /usr/local/bin/trivy'); expect(dockerfile).not.toContain('alpine/edge/testing'); diff --git a/app/model/container.ts b/app/model/container.ts index 0c8586371..b7b102db8 100644 --- a/app/model/container.ts +++ b/app/model/container.ts @@ -66,6 +66,13 @@ export interface ContainerImage { watch: boolean; value?: string; repo?: string; + // Ordered candidate digests whose repo component matches this container's + // own image reference, re-derived from the live Docker image inspect on + // every discovery/refresh cycle (#669). Optional/additive — old stored + // containers lack it and fall back to single-anchor comparison using + // `repo` alone. See `getOrderedRepoDigests` in docker-helpers.ts and + // `handleDigestWatch` in image-comparison.ts. + repoDigests?: string[]; }; // True when the live Docker image inspect had no RepoDigests (built locally // or `docker load`ed) — derived once at discovery/refresh time, independent @@ -437,6 +444,7 @@ const schema = joi.object({ watch: joi.boolean().default(false), value: joi.string(), repo: joi.string(), + repoDigests: joi.array().items(joi.string()), }) .required(), isLocalImage: joi.boolean(), diff --git a/app/package-lock.json b/app/package-lock.json index 4f2261c23..8d97b898e 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -6397,9 +6397,9 @@ "optional": true }, "node_modules/nanoid": { - "version": "3.3.16", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", - "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", "dev": true, "funding": [ { diff --git a/app/package.json b/app/package.json index fb5cee5e8..c1bb7eaa7 100644 --- a/app/package.json +++ b/app/package.json @@ -87,7 +87,8 @@ "@babel/core": "7.29.7", "form-data": "4.0.6", "protobufjs": "7.6.5", - "undici": "8.9.0" + "undici": "8.9.0", + "nanoid": "3.3.18" }, "devDependencies": { "@fast-check/vitest": "0.4.1", diff --git a/app/watchers/providers/docker/docker-helpers.test.ts b/app/watchers/providers/docker/docker-helpers.test.ts index 515d45e92..487084109 100644 --- a/app/watchers/providers/docker/docker-helpers.test.ts +++ b/app/watchers/providers/docker/docker-helpers.test.ts @@ -12,10 +12,12 @@ import { getFirstConfigNumber, getFirstConfigString, getImageForRegistryLookup, + getImageReferenceCandidates, getImageReferenceCandidatesFromPattern, getImgsetSpecificity, getInspectValueByPath, getOldContainers, + getOrderedRepoDigests, getRawContainerName, getRepoDigest, getResolvedImgsetConfiguration, @@ -298,6 +300,79 @@ describe('docker helper extraction module', () => { expect(isContainerToWatch('', true)).toBe(true); }); + describe('getOrderedRepoDigests (#669)', () => { + test('returns undefined when RepoDigests is empty or undefined', () => { + expect(getOrderedRepoDigests({ RepoDigests: [] })).toBeUndefined(); + expect(getOrderedRepoDigests({} as any)).toBeUndefined(); + }); + + test('returns the full list, in order, when no reference candidates are supplied', () => { + expect( + getOrderedRepoDigests({ + RepoDigests: ['acme/service@sha256:one', 'other/service@sha256:two'], + }), + ).toEqual(['sha256:one', 'sha256:two']); + }); + + test('prefers the entry matching the container repo when a foreign-repo entry comes first (#669)', () => { + const referenceCandidates = getImageReferenceCandidates('acme/service', 'ghcr.io'); + + const result = getOrderedRepoDigests( + { + RepoDigests: ['unrelated/other-image@sha256:foreign', 'ghcr.io/acme/service@sha256:mine'], + }, + referenceCandidates, + ); + + expect(result).toEqual(['sha256:mine']); + }); + + test('returns every matching entry in original order when several entries match', () => { + const referenceCandidates = getImageReferenceCandidates('acme/service', 'ghcr.io'); + + const result = getOrderedRepoDigests( + { + RepoDigests: [ + 'ghcr.io/acme/service@sha256:first', + 'unrelated/other-image@sha256:foreign', + 'ghcr.io/acme/service@sha256:second', + ], + }, + referenceCandidates, + ); + + expect(result).toEqual(['sha256:first', 'sha256:second']); + }); + + test('falls back to the full list, in original order, when no entry matches any candidate', () => { + const referenceCandidates = getImageReferenceCandidates('acme/service', 'ghcr.io'); + + const result = getOrderedRepoDigests( + { + RepoDigests: ['unrelated/other-image@sha256:foreign', 'another/one@sha256:also-foreign'], + }, + referenceCandidates, + ); + + expect(result).toEqual(['sha256:foreign', 'sha256:also-foreign']); + }); + + test('ignores malformed RepoDigests entries lacking an "@" separator', () => { + expect(getOrderedRepoDigests({ RepoDigests: ['malformed-entry'] })).toBeUndefined(); + }); + + test('ignores entries with an empty repo or digest component', () => { + expect(getOrderedRepoDigests({ RepoDigests: ['acme/service@'] })).toBeUndefined(); + expect(getOrderedRepoDigests({ RepoDigests: ['@sha256:orphan'] })).toBeUndefined(); + // A well-formed entry still wins over malformed siblings. + expect( + getOrderedRepoDigests({ + RepoDigests: ['acme/service@', '@sha256:orphan', 'acme/service@sha256:good'], + }), + ).toEqual(['sha256:good']); + }); + }); + test('digest watch defaults require a meaningful current tag', () => { expect( isDigestToWatch( diff --git a/app/watchers/providers/docker/docker-helpers.ts b/app/watchers/providers/docker/docker-helpers.ts index 178ed73be..0926f21b1 100644 --- a/app/watchers/providers/docker/docker-helpers.ts +++ b/app/watchers/providers/docker/docker-helpers.ts @@ -245,7 +245,7 @@ export function getContainerConfigBooleanValue(...values: unknown[]) { return undefined; } -function getImageReferenceCandidates(path?: string, domain?: string) { +export function getImageReferenceCandidates(path?: string, domain?: string) { const pathNormalized = normalizeConfigStringValue(path)?.toLowerCase(); if (!pathNormalized) { return []; @@ -286,7 +286,7 @@ export function getImageReferenceCandidatesFromPattern(pattern: string) { } } -function getImageReferenceCandidatesFromParsedImage(parsedImage: ParsedImageLike) { +export function getImageReferenceCandidatesFromParsedImage(parsedImage: ParsedImageLike) { return getImageReferenceCandidates(parsedImage?.path, parsedImage?.domain); } @@ -396,17 +396,77 @@ export function getFirstConfigNumber(value: unknown, paths: string[]) { } /** - * Get image repo digest. + * Return the ordered list of digest values from a Docker image's RepoDigests, + * preferring entries whose repo component matches the container's own image + * reference over entries pulled/retagged from a foreign repo (#669). + * + * A local Docker image object can carry multiple `repo@digest` entries for a + * single Image ID with no ordering guarantee (pull/retag accumulation), so + * blindly trusting index 0 can anchor digest-update detection to the wrong + * manifest. `referenceCandidates` should be built via + * `getImageReferenceCandidates`/`getImageReferenceCandidatesFromParsedImage` + * so Docker Hub aliasing (`docker.io`/`registry-1.docker.io`/`library/` + * prefix) is handled the same way as imgset matching. + * + * When one or more entries match a candidate, only the matching entries are + * returned (in their original RepoDigests order). When none match — or no + * candidates were supplied — every entry is returned, in original order, so + * callers never regress to `undefined` just because identity was unknown or + * unmatched. Only an empty/undefined RepoDigests list returns `undefined`. * @param containerImage - * @returns {*} digest + * @param referenceCandidates + * @returns {string[]|undefined} ordered digest values */ -export function getRepoDigest(containerImage: ImageWithRepoDigests) { - if (!containerImage.RepoDigests || containerImage.RepoDigests.length === 0) { +export function getOrderedRepoDigests( + containerImage: ImageWithRepoDigests, + referenceCandidates: string[] = [], +): string[] | undefined { + const repoDigests = containerImage.RepoDigests; + if (!repoDigests || repoDigests.length === 0) { + return undefined; + } + + const entries = repoDigests + .map((fullDigest) => { + const separatorIndex = fullDigest.indexOf('@'); + // Reject malformed entries with a missing separator or an empty + // repo/digest component: an empty digest would pass the model's + // `!== undefined` guards and compare as a phantom "change". + if (separatorIndex <= 0 || separatorIndex === fullDigest.length - 1) { + return undefined; + } + return { + repo: fullDigest.substring(0, separatorIndex).toLowerCase(), + digest: fullDigest.substring(separatorIndex + 1), + }; + }) + .filter((entry): entry is { repo: string; digest: string } => entry !== undefined); + + if (entries.length === 0) { return undefined; } - const fullDigest = containerImage.RepoDigests[0]; - const digestSplit = fullDigest.split('@'); - return digestSplit[1]; + + if (referenceCandidates.length > 0) { + const candidateSet = new Set(referenceCandidates); + const matchingDigests = entries + .filter((entry) => candidateSet.has(entry.repo)) + .map((entry) => entry.digest); + if (matchingDigests.length > 0) { + return matchingDigests; + } + } + + return entries.map((entry) => entry.digest); +} + +/** + * Get image repo digest (first candidate from `getOrderedRepoDigests`). + * Kept for callers with no container identity to match against. + * @param containerImage + * @returns {*} digest + */ +export function getRepoDigest(containerImage: ImageWithRepoDigests) { + return getOrderedRepoDigests(containerImage)?.[0]; } /** diff --git a/app/watchers/providers/docker/docker-image-details-orchestration.test.ts b/app/watchers/providers/docker/docker-image-details-orchestration.test.ts index 9c4e926c8..ee747650b 100644 --- a/app/watchers/providers/docker/docker-image-details-orchestration.test.ts +++ b/app/watchers/providers/docker/docker-image-details-orchestration.test.ts @@ -613,6 +613,7 @@ describe('docker image details orchestration module', () => { expect(containerInStore.image.id).toBe('image-new'); expect(containerInStore.image.digest).toEqual({ repo: 'sha256:new', + repoDigests: ['sha256:new'], value: 'sha256:new', }); expect(containerInStore.image.created).toBe('2026-03-01T00:00:00.000Z'); @@ -797,6 +798,7 @@ describe('docker image details orchestration module', () => { expect(containerInStore.image.tag.value).toBe('latest'); expect(containerInStore.image.digest).toEqual({ repo: 'sha256:new', + repoDigests: ['sha256:new'], value: 'sha256:new', watch: true, }); @@ -1119,6 +1121,7 @@ describe('docker image details orchestration module', () => { expect(containerInStore.image.tag.value).toBe('latest'); expect(containerInStore.image.digest).toEqual({ repo: 'sha256:new', + repoDigests: ['sha256:new'], value: 'sha256:new', watch: true, }); @@ -1185,6 +1188,7 @@ describe('docker image details orchestration module', () => { expect(containerInStore.image.id).toBe('image-new'); expect(containerInStore.image.digest).toEqual({ repo: 'sha256:new', + repoDigests: ['sha256:new'], value: 'sha256:new', watch: false, }); @@ -1517,6 +1521,202 @@ describe('docker image details orchestration module', () => { }); }); + describe('repo-aware digest anchor selection (#669)', () => { + test('discovery: selects the matching-repo entry as digest anchor and populates repoDigests when a foreign-repo entry comes first', async () => { + vi.spyOn(storeContainer, 'getContainer').mockReturnValue(undefined); + + const { watcher, inspectImage } = createWatcher(); + inspectImage.mockResolvedValue({ + Id: 'image-new', + RepoDigests: ['unrelated/other-image@sha256:foreign', 'ghcr.io/acme/service@sha256:mine'], + Architecture: 'amd64', + Os: 'linux', + Created: '2026-02-01T00:00:00.000Z', + }); + + const result = await addImageDetailsToContainerOrchestration( + watcher as any, + createDockerSummaryContainer(), + {}, + createHelpers() as any, + ); + + expect(result?.image.digest).toMatchObject({ + repo: 'sha256:mine', + repoDigests: ['sha256:mine'], + value: 'sha256:mine', + }); + }); + + test('refresh (no-repair path): re-anchors digest.repo away from a foreign entry and refreshes repoDigests every cycle even when unchanged', async () => { + const containerInStore = { + id: 'container-1', + name: 'service', + error: undefined, + details: { ports: [], volumes: [], env: [] }, + image: { + id: 'image-old', + name: 'acme/service', + registry: { name: 'ghcr', url: 'https://ghcr.io/v2' }, + tag: { value: '1.0.0', semver: true }, + digest: { repo: 'sha256:stale-foreign', value: 'sha256:stale-foreign', watch: true }, + created: '2025-01-01T00:00:00.000Z', + }, + }; + vi.spyOn(storeContainer, 'getContainer').mockReturnValue(containerInStore as any); + + const { watcher, inspectImage } = createWatcher(); + inspectImage.mockResolvedValue({ + Id: 'image-old', + RepoDigests: ['unrelated/other-image@sha256:foreign', 'ghcr.io/acme/service@sha256:mine'], + Created: '2026-02-01T00:00:00.000Z', + }); + + await addImageDetailsToContainerOrchestration( + watcher as any, + createDockerSummaryContainer(), + {}, + createHelpers() as any, + ); + + // Image id is unchanged, but the ordered candidate list is still + // re-derived (and the stale repo pointer corrected) every cycle rather + // than only when the digest.repo/image id merge check fires. + expect(containerInStore.image.digest.repoDigests).toEqual(['sha256:mine']); + expect(containerInStore.image.digest.repo).toBe('sha256:mine'); + }); + + test('refresh: keeps a stored anchor that is not first but still among the fresh candidates (no ping-pong with handleDigestWatch re-anchoring)', async () => { + const containerInStore = { + id: 'container-1', + name: 'service', + error: undefined, + details: { ports: [], volumes: [], env: [] }, + image: { + id: 'image-old', + name: 'acme/service', + registry: { name: 'ghcr', url: 'https://ghcr.io/v2' }, + tag: { value: '1.0.0', semver: true }, + // handleDigestWatch re-anchored to the second candidate last cycle. + digest: { repo: 'sha256:second', value: 'sha256:remote', watch: true }, + created: '2025-01-01T00:00:00.000Z', + }, + }; + vi.spyOn(storeContainer, 'getContainer').mockReturnValue(containerInStore as any); + + const { watcher, inspectImage } = createWatcher(); + inspectImage.mockResolvedValue({ + Id: 'image-old', + RepoDigests: ['ghcr.io/acme/service@sha256:first', 'ghcr.io/acme/service@sha256:second'], + Created: '2026-02-01T00:00:00.000Z', + }); + + await addImageDetailsToContainerOrchestration( + watcher as any, + createDockerSummaryContainer(), + {}, + createHelpers() as any, + ); + + // digest.repo must NOT snap back to index 0: the stored anchor is still + // a valid candidate, and re-deriving it here would undo the + // registry-verified re-anchor every cycle. + expect(containerInStore.image.digest.repo).toBe('sha256:second'); + expect(containerInStore.image.digest.value).toBe('sha256:remote'); + expect(containerInStore.image.digest.repoDigests).toEqual(['sha256:first', 'sha256:second']); + }); + + test('refresh: a malformed http(s) registry.url degrades to unmatched candidates instead of throwing', async () => { + const containerInStore = { + id: 'container-1', + name: 'service', + error: undefined, + details: { ports: [], volumes: [], env: [] }, + image: { + id: 'image-old', + name: 'acme/service', + // No hostname — new URL() throws, exercising the catch fallback in + // extractRegistryDomainForMatching. + registry: { name: 'ghcr', url: 'https://' }, + tag: { value: '1.0.0', semver: true }, + digest: { repo: 'sha256:old', value: 'sha256:old', watch: true }, + created: '2025-01-01T00:00:00.000Z', + }, + }; + vi.spyOn(storeContainer, 'getContainer').mockReturnValue(containerInStore as any); + + const { watcher, inspectImage } = createWatcher(); + inspectImage.mockResolvedValue({ + Id: 'image-old', + RepoDigests: ['unrelated/other-image@sha256:foreign', 'ghcr.io/acme/service@sha256:mine'], + Created: '2026-02-01T00:00:00.000Z', + }); + + await addImageDetailsToContainerOrchestration( + watcher as any, + createDockerSummaryContainer(), + {}, + createHelpers() as any, + ); + + // No candidate matched (domain extraction failed), so the fallback is + // the full RepoDigests list in original order — index 0 wins, same as + // pre-#669 behavior when identity can't be determined. + expect(containerInStore.image.digest.repoDigests).toEqual(['sha256:foreign', 'sha256:mine']); + expect(containerInStore.image.digest.repo).toBe('sha256:foreign'); + }); + + test('refresh (repair path): the reparsed reference selects the matching-repo entry, not index 0', async () => { + const containerInStore = { + id: 'container-1', + name: 'service', + displayName: 'service', + status: 'running', + error: undefined, + details: { ports: [], volumes: [], env: [] }, + image: { + id: 'image-old', + name: 'acme/service', + registry: { name: 'unknown', url: '' }, + tag: { value: 'unknown', semver: false }, + digest: { repo: 'sha256:old', value: 'sha256:old', watch: false }, + architecture: 'amd64', + os: 'linux', + created: '2025-01-01T00:00:00.000Z', + }, + }; + vi.spyOn(storeContainer, 'getContainer').mockReturnValue(containerInStore as any); + + const { watcher, inspectContainer, inspectImage } = createWatcher(); + inspectContainer.mockResolvedValue({ + Config: { Image: 'ghcr.io/acme/service:latest' }, + }); + inspectImage.mockResolvedValue({ + Id: 'image-new', + RepoDigests: ['unrelated/other-image@sha256:foreign', 'ghcr.io/acme/service@sha256:mine'], + Architecture: 'amd64', + Os: 'linux', + Created: '2026-03-01T00:00:00.000Z', + }); + const helpers = createHelpers({ + resolveImageName: vi.fn().mockReturnValue({ domain: 'ghcr.io', path: 'acme/service' }), + resolveTagName: vi.fn().mockReturnValue('latest'), + }); + + await addImageDetailsToContainerOrchestration( + watcher as any, + createDockerSummaryContainer(), + {}, + helpers as any, + ); + + expect(containerInStore.image.digest).toMatchObject({ + repo: 'sha256:mine', + repoDigests: ['sha256:mine'], + }); + }); + }); + test('detects sourceRepo from manual label override and OCI source labels', async () => { vi.spyOn(storeContainer, 'getContainer').mockReturnValue(undefined); diff --git a/app/watchers/providers/docker/docker-image-details-orchestration.ts b/app/watchers/providers/docker/docker-image-details-orchestration.ts index 266355ee2..f2809369b 100644 --- a/app/watchers/providers/docker/docker-image-details-orchestration.ts +++ b/app/watchers/providers/docker/docker-image-details-orchestration.ts @@ -19,8 +19,10 @@ import { canonicalizeContainerName, getContainerDisplayName, getContainerName, + getImageReferenceCandidates, + getImageReferenceCandidatesFromParsedImage, getInspectValueByPath, - getRepoDigest, + getOrderedRepoDigests, getSemverTagFromInspectPath, isDigestToWatch, type ResolvedImgset, @@ -150,6 +152,7 @@ interface ResolvedContainerImageState { tagPrecision: TagPrecision; watchDigest: boolean; repoDigest: string | undefined; + repoDigests: string[] | undefined; } interface DockerImageDetailsWatcher { @@ -322,12 +325,23 @@ async function refreshStoredContainerImageFields( try { const currentImage = await watcher.dockerApi.getImage(container.Image).inspect(); - const freshDigestRepo = getRepoDigest(currentImage); + const referenceCandidates = getImageReferenceCandidates( + containerInStore.image?.name, + extractRegistryDomainForMatching(containerInStore.image?.registry?.url), + ); + const freshOrderedRepoDigests = getOrderedRepoDigests(currentImage, referenceCandidates); + const freshDigestRepo = freshOrderedRepoDigests?.[0]; const freshImageId = currentImage.Id; // Keep isLocalImage in sync with the live Docker image inspect every // cycle — independent of shouldRepairStoredImageReference below, and // spread forward by the repair branch's `...containerInStore.image`. containerInStore.image.isLocalImage = freshDigestRepo === undefined; + // Re-derive the full ordered candidate list every cycle (not just when + // digest.repo/image id changed) so a store poisoned with a stale/wrong + // digest.repo self-heals: handleDigestWatch (image-comparison.ts) anchors + // on repoDigests when present, so a fresh list here lets it re-anchor even + // when the old digest.repo isn't among the freshly derived entries (#669). + containerInStore.image.digest.repoDigests = freshOrderedRepoDigests; if (shouldRepairStoredImageReference(containerInStore)) { const resolvedImageState = resolveContainerImageState({ @@ -364,6 +378,7 @@ async function refreshStoredContainerImageFields( ...containerInStore.image.digest, watch: resolvedImageState.watchDigest, repo: resolvedImageState.repoDigest, + repoDigests: resolvedImageState.repoDigests, value: resolvedImageState.repoDigest !== undefined && resolvedImageState.repoDigest !== containerInStore.image.digest.repo @@ -394,8 +409,16 @@ async function refreshStoredContainerImageFields( if (freshDigestRepo !== undefined && containerInStore.image.digest.value === undefined) { containerInStore.image.digest.value = freshDigestRepo; } + // A stored anchor that is still among the fresh candidates stays put even + // when it isn't first: handleDigestWatch re-anchors digest.repo to the + // candidate that actually matches the registry, and re-deriving it from + // index 0 here every cycle would undo that and ping-pong the anchor (#669). + const storedRepoStillValid = + containerInStore.image.digest.repo !== undefined && + freshOrderedRepoDigests !== undefined && + freshOrderedRepoDigests.includes(containerInStore.image.digest.repo); if ( - freshDigestRepo !== containerInStore.image.digest.repo || + (freshDigestRepo !== containerInStore.image.digest.repo && !storedRepoStillValid) || freshImageId !== containerInStore.image.id ) { containerInStore.image.digest.repo = freshDigestRepo; @@ -595,6 +618,10 @@ function resolveContainerImageState( const parsedTag = parseSemver(transformedTag); const isSemver = parsedTag != null; const tagPrecision = classifyTagPrecision(tagName, resolvedConfig.transformTags, parsedTag); + const repoDigests = getOrderedRepoDigests( + image, + getImageReferenceCandidatesFromParsedImage(parsedImage), + ); return { parsedImage, @@ -610,10 +637,31 @@ function resolveContainerImageState( tagName, container.Image, ), - repoDigest: getRepoDigest(image), + repoDigest: repoDigests?.[0], + repoDigests, }; } +/** + * Extract a bare domain from a stored `registry.url`, which may already be a + * canonicalized URL (e.g. `https://registry-1.docker.io/v2`, produced by + * `normalizeContainer`/`BaseRegistry.normalizeImageUrl`) rather than a plain + * domain. Falls back to treating the value as an already-bare domain. + */ +function extractRegistryDomainForMatching(url: string | undefined): string | undefined { + if (typeof url !== 'string' || url.trim() === '') { + return undefined; + } + if (/^https?:\/\//i.test(url)) { + try { + return new URL(url).hostname; + } catch { + return undefined; + } + } + return url; +} + function shouldRepairStoredImageReference(containerInStore: Container) { const currentTag = containerInStore.image?.tag?.value; return ( @@ -774,8 +822,16 @@ export async function addImageDetailsToContainerOrchestration( return undefined; } - const { parsedImage, resolvedConfig, tagName, isSemver, tagPrecision, watchDigest, repoDigest } = - resolvedImageState; + const { + parsedImage, + resolvedConfig, + tagName, + isSemver, + tagPrecision, + watchDigest, + repoDigest, + repoDigests, + } = resolvedImageState; const runtimeDetails = await resolveRuntimeDetailsForDiscoveredContainer( runtimeDetailsFromSummary, containerInspect, @@ -827,6 +883,7 @@ export async function addImageDetailsToContainerOrchestration( digest: { watch: watchDigest, repo: repoDigest, + repoDigests, value: repoDigest, }, // True when the live image inspect had no RepoDigests (built locally or diff --git a/app/watchers/providers/docker/image-comparison.test.ts b/app/watchers/providers/docker/image-comparison.test.ts index 90edf9c50..a297a4c30 100644 --- a/app/watchers/providers/docker/image-comparison.test.ts +++ b/app/watchers/providers/docker/image-comparison.test.ts @@ -532,6 +532,212 @@ describe('image-comparison', () => { expect(result.digest).toBe('sha256:def456'); }); + describe('multi-anchor digest comparison (#669)', () => { + function createMultiAnchorContainer(overrides: Record = {}) { + return { + image: { + id: 'image-1', + registry: { name: 'hub' }, + name: 'library/postgres', + tag: { value: '16-alpine', semver: true, tagPrecision: 'floating' }, + digest: { + watch: true, + repo: 'sha256:anchor-a', + repoDigests: ['sha256:anchor-a', 'sha256:anchor-b'], + }, + }, + tagFamily: 'strict', + ...overrides, + }; + } + + function mockRegistry(getImageManifestDigest: ReturnType) { + mockGetState.mockReturnValue({ + registry: { + hub: { + getTags: vi.fn().mockResolvedValue(['16-alpine']), + getImageManifestDigest, + normalizeImage: identityNormalizeImage, + }, + }, + }); + } + + const log = { error: vi.fn(), warn: vi.fn(), debug: vi.fn() }; + + test('(b) raw match on a non-first anchor short-circuits with no normalization call and re-anchors digest.repo', async () => { + const getImageManifestDigest = vi.fn().mockResolvedValueOnce({ + digest: 'sha256:anchor-b', + created: '2026-04-01T00:00:00.000Z', + version: 2, + }); + mockRegistry(getImageManifestDigest); + const container = createMultiAnchorContainer(); + + await findNewVersion(container as never, log); + + // Only the initial remote-digest lookup happened — the raw match at + // step 2 skips the normalize-and-compare registry call entirely. + expect(getImageManifestDigest).toHaveBeenCalledTimes(1); + expect(container.image.digest.value).toBe('sha256:anchor-b'); + expect(container.image.digest.repo).toBe('sha256:anchor-b'); + }); + + test('(c) match only after normalizing a non-first anchor (e.g. manifest-list digest) re-anchors digest.repo', async () => { + const getImageManifestDigest = vi + .fn() + .mockResolvedValueOnce({ + digest: 'sha256:platform-digest', + created: '2026-04-01T00:00:00.000Z', + version: 2, + }) + .mockResolvedValueOnce({ digest: 'sha256:normalized-a' }) + .mockResolvedValueOnce({ digest: 'sha256:platform-digest' }); + mockRegistry(getImageManifestDigest); + const container = createMultiAnchorContainer(); + + await findNewVersion(container as never, log); + + expect(getImageManifestDigest).toHaveBeenCalledTimes(3); + expect(getImageManifestDigest.mock.calls[1][1]).toBe('sha256:anchor-a'); + expect(getImageManifestDigest.mock.calls[2][1]).toBe('sha256:anchor-b'); + expect(container.image.digest.value).toBe('sha256:platform-digest'); + expect(container.image.digest.repo).toBe('sha256:anchor-b'); + }); + + test('(d) genuine republish: no anchor matches, update still flagged using the first anchor normalized value', async () => { + const getImageManifestDigest = vi + .fn() + .mockResolvedValueOnce({ + digest: 'sha256:new-republish', + created: '2026-04-01T00:00:00.000Z', + version: 2, + }) + .mockResolvedValueOnce({ digest: 'sha256:normalized-a' }) + .mockResolvedValueOnce({ digest: 'sha256:normalized-b' }); + mockRegistry(getImageManifestDigest); + const container = createMultiAnchorContainer(); + + await findNewVersion(container as never, log); + + expect(getImageManifestDigest).toHaveBeenCalledTimes(3); + expect(container.image.digest.value).toBe('sha256:normalized-a'); + // digest.repo is left untouched — no anchor matched, so no re-anchoring. + expect(container.image.digest.repo).toBe('sha256:anchor-a'); + }); + + test('(e) one anchor fails to normalize, a later anchor matches — comparison still succeeds', async () => { + const getImageManifestDigest = vi + .fn() + .mockResolvedValueOnce({ + digest: 'sha256:target', + created: '2026-04-01T00:00:00.000Z', + version: 2, + }) + .mockRejectedValueOnce(new Error('manifest unknown (404)')) + .mockResolvedValueOnce({ digest: 'sha256:target' }); + mockRegistry(getImageManifestDigest); + const container = createMultiAnchorContainer(); + + const result = await findNewVersion(container as never, log); + + expect(getImageManifestDigest).toHaveBeenCalledTimes(3); + expect(result.digest).toBe('sha256:target'); + expect(container.image.digest.value).toBe('sha256:target'); + expect(container.image.digest.repo).toBe('sha256:anchor-b'); + }); + + test('(f) every anchor fails to normalize — findNewVersion rejects instead of reporting no update', async () => { + const getImageManifestDigest = vi + .fn() + .mockResolvedValueOnce({ + digest: 'sha256:target', + created: '2026-04-01T00:00:00.000Z', + version: 2, + }) + .mockRejectedValueOnce(new Error('anchor-a manifest unknown')) + .mockRejectedValueOnce(new Error('anchor-b manifest unknown')); + mockRegistry(getImageManifestDigest); + const container = createMultiAnchorContainer(); + + await expect(findNewVersion(container as never, log)).rejects.toThrow( + 'anchor-b manifest unknown', + ); + }); + + test('(g) legacy container with no repoDigests field falls back to single-anchor comparison unchanged', async () => { + const getImageManifestDigest = vi + .fn() + .mockResolvedValueOnce({ + digest: 'sha256:new-manifest', + created: '2026-04-01T00:00:00.000Z', + version: 2, + }) + .mockResolvedValueOnce({ digest: 'sha256:normalized-legacy' }); + mockRegistry(getImageManifestDigest); + const container = createMultiAnchorContainer({ + image: { + id: 'image-1', + registry: { name: 'hub' }, + name: 'library/postgres', + tag: { value: '16-alpine', semver: true, tagPrecision: 'floating' }, + digest: { watch: true, repo: 'sha256:legacy-anchor' }, + }, + }); + + await findNewVersion(container as never, log); + + expect(getImageManifestDigest).toHaveBeenCalledTimes(2); + expect(getImageManifestDigest.mock.calls[1][1]).toBe('sha256:legacy-anchor'); + expect(container.image.digest.value).toBe('sha256:normalized-legacy'); + expect(container.image.digest.repo).toBe('sha256:legacy-anchor'); + }); + + test('(i) self-heal: a stale digest.repo absent from the fresh repoDigests list is replaced by the matching entry', async () => { + const getImageManifestDigest = vi.fn().mockResolvedValueOnce({ + digest: 'sha256:anchor-b', + created: '2026-04-01T00:00:00.000Z', + version: 2, + }); + mockRegistry(getImageManifestDigest); + const container = createMultiAnchorContainer({ + image: { + id: 'image-1', + registry: { name: 'hub' }, + name: 'library/postgres', + tag: { value: '16-alpine', semver: true, tagPrecision: 'floating' }, + digest: { + watch: true, + // Poisoned: not present anywhere in the freshly derived list below. + repo: 'sha256:very-stale-and-foreign', + repoDigests: ['sha256:anchor-a', 'sha256:anchor-b'], + }, + }, + }); + + await findNewVersion(container as never, log); + + expect(container.image.digest.repo).toBe('sha256:anchor-b'); + expect(container.image.digest.value).toBe('sha256:anchor-b'); + }); + + test('(j) version !== 2 fallback is unchanged: digest.value takes digest.repo verbatim, repoDigests is not consulted', async () => { + const getImageManifestDigest = vi.fn().mockResolvedValueOnce({ + digest: 'sha256:anchor-b', + created: '2026-04-01T00:00:00.000Z', + version: 1, + }); + mockRegistry(getImageManifestDigest); + const container = createMultiAnchorContainer(); + + await findNewVersion(container as never, log); + + expect(getImageManifestDigest).toHaveBeenCalledTimes(1); + expect(container.image.digest.value).toBe('sha256:anchor-a'); + expect(container.image.digest.repo).toBe('sha256:anchor-a'); + }); + }); + test('sets noUpdateReason from getTagCandidates when tag is pinned-specific and digest watch is off', async () => { mockGetState.mockReturnValue({ registry: { diff --git a/app/watchers/providers/docker/image-comparison.ts b/app/watchers/providers/docker/image-comparison.ts index 7db9f23c8..15b3a4f03 100644 --- a/app/watchers/providers/docker/image-comparison.ts +++ b/app/watchers/providers/docker/image-comparison.ts @@ -238,6 +238,18 @@ function getRegistry(registryName: string): Registry { /** * Resolve remote digest information when digest watching is enabled. * Updates `container.image.digest.value` and populates digest/created on `result`. + * + * When the remote manifest is a v2 (manifest-list-capable) reference, a + * single local anchor (`container.image.digest.repo`) is not always the + * right one to normalize against: a local Docker image can carry several + * `repo@digest` RepoDigests entries for one Image ID (pull/retag + * accumulation), and `container.image.digest.repoDigests` — populated in + * `docker-image-details-orchestration.ts` via `getOrderedRepoDigests` — holds + * the full ordered candidate list (#669). This compares every candidate in + * order (cheap raw match first, then a normalize-and-compare registry call + * per remaining candidate) instead of trusting a single anchor, and + * re-anchors `container.image.digest.repo` to whichever candidate actually + * matched so a store poisoned with a stale/wrong anchor self-heals. */ async function handleDigestWatch( container: Container, @@ -264,16 +276,68 @@ async function handleDigestWatch( result.digest = remoteDigest.digest; result.created = remoteDigest.created; - if (remoteDigest.version === 2) { - const digestV2 = await registryProvider.getImageManifestDigest( - queryImage, - container.image.digest.repo, - lookupOptions, - ); - container.image.digest.value = digestV2.digest; - } else { + if (remoteDigest.version !== 2) { container.image.digest.value = container.image.digest.repo; + return; + } + + const { repoDigests } = container.image.digest; + const anchors: string[] = + repoDigests && repoDigests.length > 0 + ? repoDigests + : // findNewVersion only calls handleDigestWatch when digest.repo is + // truthy (`container.image.digest.watch && container.image.digest.repo`), + // so this legacy fallback always has exactly one entry. + [container.image.digest.repo as string]; + + // Cheap path: a raw anchor already equal to the remote digest needs no + // registry call at all — this also covers today's single-anchor case with + // no behavior change beyond skipping one now-redundant lookup. + const rawMatch = anchors.find((anchor) => anchor === result.digest); + if (rawMatch !== undefined) { + container.image.digest.value = result.digest; + container.image.digest.repo = rawMatch; + return; + } + + let firstNormalizedValue: string | undefined; + let hasNormalizedValue = false; + let lastError: unknown; + + for (const anchor of anchors) { + try { + const normalized = await registryProvider.getImageManifestDigest( + queryImage, + anchor, + lookupOptions, + ); + if (!hasNormalizedValue) { + firstNormalizedValue = normalized.digest; + hasNormalizedValue = true; + } + if (normalized.digest === result.digest) { + container.image.digest.value = result.digest; + container.image.digest.repo = anchor; + return; + } + } catch (error: unknown) { + // A GC'd/unreachable manifest for this anchor is itself evidence the + // anchor is stale — skip it and keep trying the remaining candidates. + lastError = error; + } + } + + if (!hasNormalizedValue) { + // Every candidate failed to normalize — propagate the failure so + // findNewVersion rejects and watchContainer keeps the previous cycle's + // verdict, rather than silently coercing a total failure into "no update". + throw lastError; } + + // No anchor matched: preserve today's semantics for a genuine same-tag + // republish by flagging an update against the first anchor's normalized + // value. + container.image.digest.value = firstNormalizedValue; } export interface FindNewVersionOptions { diff --git a/apps/demo/package-lock.json b/apps/demo/package-lock.json index 510e47273..5faf8501c 100644 --- a/apps/demo/package-lock.json +++ b/apps/demo/package-lock.json @@ -2755,9 +2755,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.16", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", - "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", "funding": [ { "type": "github", diff --git a/apps/demo/package.json b/apps/demo/package.json index 9cc4040f3..52c5bbc62 100644 --- a/apps/demo/package.json +++ b/apps/demo/package.json @@ -40,7 +40,8 @@ "overrides": { "postcss": "8.5.25", "yaml": "2.9.0", - "esbuild": "0.28.1" + "esbuild": "0.28.1", + "nanoid": "3.3.18" }, "msw": { "workerDirectory": [ diff --git a/apps/demo/src/mocks/data/agents.ts b/apps/demo/src/mocks/data/agents.ts index 5e30b6425..a9c3d39b4 100644 --- a/apps/demo/src/mocks/data/agents.ts +++ b/apps/demo/src/mocks/data/agents.ts @@ -4,7 +4,7 @@ export const agents = [ host: '192.168.1.50', port: 3001, connected: true, - version: '1.6.0-rc.12', + version: '1.6.0-rc.13', os: 'linux', arch: 'amd64', cpus: 4, diff --git a/apps/demo/src/mocks/data/audit.ts b/apps/demo/src/mocks/data/audit.ts index dc58412f4..52c2e33cd 100644 --- a/apps/demo/src/mocks/data/audit.ts +++ b/apps/demo/src/mocks/data/audit.ts @@ -3,7 +3,7 @@ export const auditEntries = [ id: 'aud-001', timestamp: '2026-03-10T08:00:00.000Z', action: 'system:start', - details: 'Drydock v1.6.0-rc.12 started', + details: 'Drydock v1.6.0-rc.13 started', }, { id: 'aud-002', @@ -207,6 +207,6 @@ export const auditEntries = [ timestamp: '2026-03-03T18:00:00.000Z', action: 'container:watch', container: 'drydock', - details: 'Started watching ghcr.io/codeswhat/drydock:1.6.0-rc.12', + details: 'Started watching ghcr.io/codeswhat/drydock:1.6.0-rc.13', }, ]; diff --git a/apps/demo/src/mocks/data/containers.ts b/apps/demo/src/mocks/data/containers.ts index 500f3ff39..4aca6a2e3 100644 --- a/apps/demo/src/mocks/data/containers.ts +++ b/apps/demo/src/mocks/data/containers.ts @@ -330,7 +330,7 @@ export const containers = [ displayName: 'Drydock', displayIcon: 'sh-drydock', image: 'codeswhat/drydock', - tag: '1.6.0-rc.12', + tag: '1.6.0-rc.13', registryType: 'ghcr', registryUrl: 'https://ghcr.io', scanStatus: 'scanned', diff --git a/apps/demo/src/mocks/data/server.ts b/apps/demo/src/mocks/data/server.ts index c0154f8ae..7cf9d4292 100644 --- a/apps/demo/src/mocks/data/server.ts +++ b/apps/demo/src/mocks/data/server.ts @@ -1,5 +1,5 @@ export const serverInfo = { - version: '1.6.0-rc.12', + version: '1.6.0-rc.13', uptime: 864000, hostname: 'drydock-demo', platform: 'linux', diff --git a/apps/demo/src/mocks/handlers/app.ts b/apps/demo/src/mocks/handlers/app.ts index 1d6f781ff..474760271 100644 --- a/apps/demo/src/mocks/handlers/app.ts +++ b/apps/demo/src/mocks/handlers/app.ts @@ -4,7 +4,7 @@ export const appHandlers = [ http.get('/api/v1/app', () => HttpResponse.json({ name: 'Drydock', - version: '1.6.0-rc.12', + version: '1.6.0-rc.13', description: 'Docker container update manager', repository: 'https://github.com/CodesWhat/drydock', documentation: 'https://getdrydock.com/docs', @@ -16,7 +16,7 @@ export const appHandlers = [ return HttpResponse.json( { generatedAt: new Date().toISOString(), - server: { version: '1.6.0-rc.12', mode: 'demo' }, + server: { version: '1.6.0-rc.13', mode: 'demo' }, summary: { containers: 25, watchers: 2, diff --git a/apps/web/package-lock.json b/apps/web/package-lock.json index 3abde9a11..ab0864c42 100644 --- a/apps/web/package-lock.json +++ b/apps/web/package-lock.json @@ -5680,9 +5680,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.16", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", - "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", "funding": [ { "type": "github", diff --git a/apps/web/package.json b/apps/web/package.json index c8fbdc3af..fb2126e7b 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -55,6 +55,7 @@ "js-yaml": "4.3.1", "sharp": "0.35.3", "@types/react": "19.2.18", - "@types/react-dom": "19.2.4" + "@types/react-dom": "19.2.4", + "nanoid": "3.3.18" } } diff --git a/apps/web/scripts/marketing-performance.test.mjs b/apps/web/scripts/marketing-performance.test.mjs index 0527604cd..610a02170 100644 --- a/apps/web/scripts/marketing-performance.test.mjs +++ b/apps/web/scripts/marketing-performance.test.mjs @@ -11,6 +11,10 @@ const starHistoryChartSource = readFileSync( new URL("../src/components/star-history-chart.tsx", import.meta.url), "utf8", ); +const starHistoryRouteSource = readFileSync( + new URL("../src/app/api/star-history/route.ts", import.meta.url), + "utf8", +); test("aurora drift is finite and does not retain a permanent compositor hint", () => { const motionRule = globalsSource.match( @@ -38,3 +42,24 @@ test("star history lazily loads only the active theme chart", () => { assert.match(starHistoryChartSource, /decoding="async"/u); assert.match(starHistoryChartSource, /fetchPriority="low"/u); }); + +test("star history chart is self-hosted, with no third-party chart service left", () => { + assert.match(starHistoryChartSource, /\/api\/star-history\?theme=dark/u); + assert.match(starHistoryChartSource, /\/api\/star-history\?theme=light/u); + assert.equal(starHistoryChartSource.indexOf("star-history.com"), -1); + assert.equal(starHistoryChartSource.indexOf("starchart.cc"), -1); +}); + +test("star history route never renders partial stargazer data and bounds its fetches", () => { + // Every incomplete outcome (failed page, non-array body, MAX_PAGES exhausted) + // must fall back, not render a truncated series as the repo total. + assert.doesNotMatch(starHistoryRouteSource, /page === 1 \? undefined : starredAt/u); + const undefinedReturns = starHistoryRouteSource.match(/return undefined;/gu) ?? []; + assert.ok(undefinedReturns.length >= 4, "expected each incomplete outcome to return undefined"); + // The series is only returned from the short-page branch — the one complete outcome. + assert.equal((starHistoryRouteSource.match(/return starredAt;/gu) ?? []).length, 1); + assert.match(starHistoryRouteSource, /batch\.length < PER_PAGE\) \{\n[^}]*return starredAt;/u); + // One shared deadline across the whole pagination run. + assert.match(starHistoryRouteSource, /AbortSignal\.timeout\(FETCH_DEADLINE_MS\)/u); + assert.match(starHistoryRouteSource, /\{ headers, signal, next:/u); +}); diff --git a/apps/web/scripts/star-history-svg.test.mjs b/apps/web/scripts/star-history-svg.test.mjs new file mode 100644 index 000000000..c0f87b3bf --- /dev/null +++ b/apps/web/scripts/star-history-svg.test.mjs @@ -0,0 +1,129 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { + buildCumulativeSeries, + renderStarHistoryFallbackSvg, + renderStarHistorySvg, + resolveTheme, +} from "../src/lib/star-history-svg.mjs"; + +const NOW = Date.parse("2026-08-07T12:00:00Z"); + +test("resolveTheme only ever yields light or dark", () => { + assert.equal(resolveTheme("dark"), "dark"); + assert.equal(resolveTheme("light"), "light"); + assert.equal(resolveTheme("neon"), "light"); + assert.equal(resolveTheme(null), "light"); +}); + +test("buildCumulativeSeries sorts, accumulates, and appends a now-point", () => { + const series = buildCumulativeSeries( + ["2026-03-02T00:00:00Z", "2026-01-01T00:00:00Z", "2026-02-01T00:00:00Z"], + NOW, + ); + assert.equal(series.length, 4); + assert.deepEqual( + series.map((point) => point.count), + [1, 2, 3, 3], + ); + assert.equal(series[0].time, Date.parse("2026-01-01T00:00:00Z")); + assert.equal(series[3].time, NOW); + // Monotonic time after sorting unordered input. + for (let i = 1; i < series.length; i += 1) { + assert.ok(series[i].time >= series[i - 1].time); + } +}); + +test("buildCumulativeSeries drops invalid timestamps and handles empty input", () => { + const series = buildCumulativeSeries(["not-a-date", "2026-01-01T00:00:00Z"], NOW); + assert.deepEqual( + series.map((point) => point.count), + [1, 1], + ); + + const empty = buildCumulativeSeries([], NOW); + assert.deepEqual(empty, [{ time: NOW, count: 0 }]); + + assert.deepEqual(buildCumulativeSeries(undefined, "not-a-date"), []); +}); + +test("buildCumulativeSeries does not append when now precedes the last star", () => { + const series = buildCumulativeSeries( + ["2026-01-01T00:00:00Z"], + Date.parse("2025-01-01T00:00:00Z"), + ); + assert.equal(series.length, 1); + assert.equal(series[0].count, 1); +}); + +test("renderStarHistorySvg draws the series with the accent line and ink-token text", () => { + const series = buildCumulativeSeries( + ["2026-01-01T00:00:00Z", "2026-02-01T00:00:00Z", "2026-03-01T00:00:00Z"], + NOW, + ); + const svg = renderStarHistorySvg({ series, theme: "light", repoSlug: "CodesWhat/drydock" }); + + assert.match(svg, /^3<\/text>/u); + assert.match(svg, /aria-label="GitHub star history for CodesWhat\/drydock: 3 stars"/u); + assert.match(svg, /Jan 2026/u); + assert.match(svg, /Aug 2026/u); + // No legend for a single series; the header names it instead. + assert.match(svg, />GitHub stars<\/text>/u); +}); + +test("renderStarHistorySvg selects dark ink tokens for the dark theme", () => { + const series = buildCumulativeSeries(["2026-01-01T00:00:00Z"], NOW); + const dark = renderStarHistorySvg({ series, theme: "dark", repoSlug: "CodesWhat/drydock" }); + const light = renderStarHistorySvg({ series, theme: "light", repoSlug: "CodesWhat/drydock" }); + + assert.match(dark, /fill="#f5f5f5"/u); + assert.match(light, /fill="#171717"/u); + // Same validated accent in both modes. + assert.match(dark, /stroke="#0284c7"/u); + assert.match(light, /stroke="#0284c7"/u); +}); + +test("renderStarHistorySvg escapes the repo slug in text and aria-label", () => { + const svg = renderStarHistorySvg({ + series: buildCumulativeSeries([], NOW), + theme: "light", + repoSlug: "a&b\"d'", + }); + assert.match(svg, /a&b<c>"d'/u); + assert.doesNotMatch(svg, /a&b/u); +}); + +test("renderStarHistorySvg with an empty series renders a flat zero chart without paths", () => { + const svg = renderStarHistorySvg({ series: [], theme: "light", repoSlug: "CodesWhat/drydock" }); + assert.match(svg, /aria-label="GitHub star history for CodesWhat\/drydock: 0 stars"/u); + assert.doesNotMatch(svg, / { + const stars = Array.from({ length: 215 }, (_, i) => + new Date(Date.UTC(2026, 0, 1 + (i % 200))).toISOString(), + ); + const svg = renderStarHistorySvg({ + series: buildCumulativeSeries(stars, NOW), + theme: "light", + repoSlug: "CodesWhat/drydock", + }); + // 215 stars → tightest nice ceiling is 250 (step 50 × 5 ticks), so the + // line fills the plot instead of floating under a 400 ceiling. + assert.match(svg, />250<\/text>/u); + assert.match(svg, />0<\/text>/u); + assert.doesNotMatch(svg, />400<\/text>/u); + assert.equal((svg.match(/ { + const svg = renderStarHistoryFallbackSvg({ theme: "dark", repoSlug: "CodesWhat/drydock" }); + assert.match(svg, /temporarily unavailable/u); + assert.match(svg, /fill="#a3a3a3"/u); + assert.match(svg, /Star history is loading/u); +}); diff --git a/apps/web/src/app/api/star-history/route.ts b/apps/web/src/app/api/star-history/route.ts new file mode 100644 index 000000000..c1686f916 --- /dev/null +++ b/apps/web/src/app/api/star-history/route.ts @@ -0,0 +1,93 @@ +import { REPO_SLUG } from "@/lib/site-config"; +import { + buildCumulativeSeries, + renderStarHistoryFallbackSvg, + renderStarHistorySvg, + resolveTheme, +} from "@/lib/star-history-svg.mjs"; + +// Self-hosted replacement for the api.star-history.com embed (#671): fetches +// stargazer timestamps server-side and renders the SVG from our own origin, +// so the homepage card and the README no longer depend on a third party. + +export const runtime = "nodejs"; + +const PER_PAGE = 100; +// 3,000 stars of headroom; a run that would need more pages is treated as +// incomplete and falls back rather than rendering a truncated total. +const MAX_PAGES = 30; +// One deadline for the whole pagination run, so a stalled GitHub request +// can't hold the SVG response until the platform timeout. +const FETCH_DEADLINE_MS = 10_000; +const SUCCESS_CACHE = "public, s-maxage=21600, stale-while-revalidate=604800"; +const FAILURE_CACHE = "public, s-maxage=300"; + +async function fetchStarredTimestamps(): Promise { + const headers: Record = { + Accept: "application/vnd.github.star+json", + "User-Agent": "drydock-website-star-history", + "X-GitHub-Api-Version": "2022-11-28", + }; + const token = process.env.GITHUB_TOKEN; + if (token) { + headers.Authorization = `Bearer ${token}`; + } + + const signal = AbortSignal.timeout(FETCH_DEADLINE_MS); + const starredAt: string[] = []; + for (let page = 1; page <= MAX_PAGES; page += 1) { + let batch: unknown; + try { + const response = await fetch( + `https://api.github.com/repos/${REPO_SLUG}/stargazers?per_page=${PER_PAGE}&page=${page}`, + { headers, signal, next: { revalidate: 21600 } }, + ); + if (!response.ok) { + return undefined; + } + batch = await response.json(); + } catch { + return undefined; + } + if (!Array.isArray(batch)) { + return undefined; + } + for (const entry of batch) { + const value = (entry as { starred_at?: unknown })?.starred_at; + if (typeof value === "string") { + starredAt.push(value); + } + } + if (batch.length < PER_PAGE) { + // A short page is the end of the history — the only complete outcome. + return starredAt; + } + } + // MAX_PAGES exhausted with a full final page: history may continue, so the + // series is incomplete. Fall back instead of caching a truncated total. + return undefined; +} + +export async function GET(request: Request) { + const theme = resolveTheme(new URL(request.url).searchParams.get("theme")); + const starredAt = await fetchStarredTimestamps(); + + if (starredAt === undefined) { + return new Response(renderStarHistoryFallbackSvg({ theme, repoSlug: REPO_SLUG }), { + status: 200, + headers: { + "Content-Type": "image/svg+xml; charset=utf-8", + "Cache-Control": FAILURE_CACHE, + }, + }); + } + + const series = buildCumulativeSeries(starredAt, Date.now()); + return new Response(renderStarHistorySvg({ series, theme, repoSlug: REPO_SLUG }), { + status: 200, + headers: { + "Content-Type": "image/svg+xml; charset=utf-8", + "Cache-Control": SUCCESS_CACHE, + }, + }); +} diff --git a/apps/web/src/components/star-history-chart.tsx b/apps/web/src/components/star-history-chart.tsx index 02f8dab49..88da7c66b 100644 --- a/apps/web/src/components/star-history-chart.tsx +++ b/apps/web/src/components/star-history-chart.tsx @@ -4,9 +4,11 @@ import { useTheme } from "next-themes"; import { useEffect, useState } from "react"; import { REPO_SLUG } from "@/lib/site-config"; -const DARK_SRC = `https://api.star-history.com/svg?repos=${REPO_SLUG}&type=timeline&theme=dark&legend=top-left`; -const LIGHT_SRC = `https://api.star-history.com/svg?repos=${REPO_SLUG}&type=timeline&legend=top-left`; -const CHART_HREF = `https://www.star-history.com/#${REPO_SLUG}&type=timeline&legend=top-left`; +// Self-hosted chart (#671) — rendered by /api/star-history from our own +// origin after the upstream chart service's outage broke the embed. +const DARK_SRC = "/api/star-history?theme=dark"; +const LIGHT_SRC = "/api/star-history?theme=light"; +const CHART_HREF = `https://github.com/${REPO_SLUG}/stargazers`; export function StarHistoryChart({ className }: { className?: string }) { const { resolvedTheme } = useTheme(); diff --git a/apps/web/src/lib/content-security-policy.mjs b/apps/web/src/lib/content-security-policy.mjs index a02d8584c..022f7737b 100644 --- a/apps/web/src/lib/content-security-policy.mjs +++ b/apps/web/src/lib/content-security-policy.mjs @@ -17,7 +17,7 @@ export function buildContentSecurityPolicy(nonce, isDevelopment) { "default-src 'self'", `script-src ${scriptSources.join(" ")}`, "style-src 'self' 'unsafe-inline'", - "img-src 'self' data: https://img.shields.io https://github.com https://qlty.sh https://api.star-history.com", + "img-src 'self' data: https://img.shields.io https://github.com https://qlty.sh", "font-src 'self' data:", "connect-src 'self' https://va.vercel-scripts.com", "frame-src https://demo.getdrydock.com", diff --git a/apps/web/src/lib/site-config.ts b/apps/web/src/lib/site-config.ts index 416395f6b..f10c8c58d 100644 --- a/apps/web/src/lib/site-config.ts +++ b/apps/web/src/lib/site-config.ts @@ -15,7 +15,7 @@ export const SITE_CONFIG = { /** Brand name shown in the header, footer, and metadata. */ name: "Drydock", /** Current release version shown in the hero badge. */ - version: "1.6.0-rc.12", + version: "1.6.0-rc.13", /** Short product tagline used in page titles and OG metadata. */ tagline: "Container Update Monitoring", /** Default meta / OpenGraph / Twitter description. */ diff --git a/apps/web/src/lib/site-content.ts b/apps/web/src/lib/site-content.ts index 31b54387c..90731abc8 100644 --- a/apps/web/src/lib/site-content.ts +++ b/apps/web/src/lib/site-content.ts @@ -281,7 +281,7 @@ export const roadmap: Milestone[] = [ ], }, { - version: "v1.6.0-rc.12", + version: "v1.6.0-rc.13", title: "Notifications, Policy & Release Intel", emoji: "\u{1F4E8}", status: "next", diff --git a/apps/web/src/lib/star-history-svg.mjs b/apps/web/src/lib/star-history-svg.mjs new file mode 100644 index 000000000..d0f49e0e0 --- /dev/null +++ b/apps/web/src/lib/star-history-svg.mjs @@ -0,0 +1,212 @@ +/** + * Self-hosted Star History chart rendering (#671). + * + * Pure functions only — consumed by the /api/star-history route handler and + * unit-tested directly from scripts/star-history-svg.test.mjs. No fetch, no + * environment access: the route fetches stargazer timestamps and passes them + * in, so everything here is deterministic. + * + * Replaces the api.star-history.com embed, which broke when that service's + * GitHub tokens were rate-limited (their issue #548): the site and README now + * render this SVG from our own origin instead of a third party. + */ + +// Sky-600 — the site's "ocean" aurora family. Validated against both the +// light and dark card surfaces (lightness band + >= 3:1 contrast), so one +// accent serves both themes; text wears neutral ink tokens, never the accent. +const ACCENT = "#0284c7"; + +const THEMES = { + light: { + ink: "#171717", + muted: "#525252", + grid: "rgba(23, 23, 23, 0.08)", + axis: "rgba(23, 23, 23, 0.16)", + area: "rgba(2, 132, 199, 0.10)", + }, + dark: { + ink: "#f5f5f5", + muted: "#a3a3a3", + grid: "rgba(245, 245, 245, 0.08)", + axis: "rgba(245, 245, 245, 0.18)", + area: "rgba(2, 132, 199, 0.18)", + }, +}; + +const WIDTH = 720; +const HEIGHT = 480; +const MARGIN = { top: 48, right: 40, bottom: 44, left: 56 }; + +export function resolveTheme(value) { + return value === "dark" ? "dark" : "light"; +} + +/** + * Turn raw starred_at timestamps into a cumulative [{time, count}] series, + * sorted and terminated with a "now" point so the line reaches the right edge. + * Invalid timestamps are dropped rather than poisoning the sort. + */ +export function buildCumulativeSeries(starredAt, now) { + const times = (Array.isArray(starredAt) ? starredAt : []) + .map((value) => new Date(value).getTime()) + .filter((time) => Number.isFinite(time)) + .sort((a, b) => a - b); + + const nowTime = new Date(now).getTime(); + const series = times.map((time, index) => ({ time, count: index + 1 })); + const last = series[series.length - 1]; + if (Number.isFinite(nowTime) && (!last || nowTime > last.time)) { + series.push({ time: nowTime, count: last ? last.count : 0 }); + } + return series; +} + +/** + * Pick the y-axis {step, ticks} whose ceiling (step × ticks) is the tightest + * "nice" bound over max, so the line fills the plot instead of floating in + * the bottom half. Steps are 1/2/2.5/5 × 10^n with 3–5 ticks; 2.5 only at + * n ≥ 1 so labels stay integers. + */ +function niceScale(max) { + const target = Math.max(1, max); + let best; + for (let power = 1; power <= 10 ** 9; power *= 10) { + for (const base of [1, 2, 2.5, 5]) { + const step = base * power; + if (!Number.isInteger(step)) { + continue; + } + for (let ticks = 3; ticks <= 5; ticks += 1) { + const ceiling = step * ticks; + if (ceiling >= target && (!best || ceiling < best.ceiling)) { + best = { step, ticks, ceiling }; + } + } + } + if (best) { + return best; + } + } + return { step: target, ticks: 1, ceiling: target }; +} + +function escapeXml(value) { + return String(value).replace(/[&<>"']/gu, (char) => { + switch (char) { + case "&": + return "&"; + case "<": + return "<"; + case ">": + return ">"; + case '"': + return """; + default: + return "'"; + } + }); +} + +const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; + +function formatMonthYear(time) { + const date = new Date(time); + return `${MONTHS[date.getUTCMonth()]} ${date.getUTCFullYear()}`; +} + +function toFixed1(value) { + return Number(value.toFixed(1)); +} + +/** + * Render the cumulative-stars SVG. Transparent background — the homepage card + * and GitHub's README canvas both supply their own surface, and the README + * element selects the matching theme variant. + */ +export function renderStarHistorySvg({ series, theme, repoSlug }) { + const palette = THEMES[resolveTheme(theme)]; + const plotWidth = WIDTH - MARGIN.left - MARGIN.right; + const plotHeight = HEIGHT - MARGIN.top - MARGIN.bottom; + + const total = series.length ? series[series.length - 1].count : 0; + const { step, ticks } = niceScale(total); + const yMax = step * ticks; + const minTime = series.length ? series[0].time : 0; + const maxTime = series.length ? series[series.length - 1].time : 1; + const timeSpan = Math.max(1, maxTime - minTime); + + const xFor = (time) => MARGIN.left + ((time - minTime) / timeSpan) * plotWidth; + const yFor = (count) => MARGIN.top + plotHeight - (count / yMax) * plotHeight; + + const points = series.map( + ({ time, count }) => `${toFixed1(xFor(time))},${toFixed1(yFor(count))}`, + ); + const baselineY = toFixed1(yFor(0)); + const linePath = points.length ? `M${points.join(" L")}` : ""; + const areaPath = points.length + ? `${linePath} L${toFixed1(xFor(maxTime))},${baselineY} L${toFixed1(xFor(minTime))},${baselineY} Z` + : ""; + + const gridLines = []; + const yLabels = []; + for (let tick = 0; tick <= ticks; tick += 1) { + const value = tick * step; + const y = toFixed1(yFor(value)); + if (tick > 0) { + gridLines.push( + ``, + ); + } + yLabels.push( + `${value}`, + ); + } + + const xLabels = []; + if (series.length) { + const midTime = minTime + timeSpan / 2; + const labelY = HEIGHT - MARGIN.bottom + 24; + xLabels.push( + `${formatMonthYear(minTime)}`, + `${formatMonthYear(midTime)}`, + `${formatMonthYear(maxTime)}`, + ); + } + + const endX = points.length ? toFixed1(xFor(maxTime)) : 0; + const endY = points.length ? toFixed1(yFor(total)) : 0; + // Keep the end label inside the plot even when the last point hugs the top. + const endLabelY = Math.max(endY - 12, MARGIN.top + 12); + const endMarker = points.length + ? ` + ${total}` + : ""; + + const fontFamily = + "ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', sans-serif"; + + return ` + + GitHub stars + ${escapeXml(repoSlug)} + ${gridLines.join("\n ")} + + ${areaPath ? `` : ""} + ${linePath ? `` : ""} + ${endMarker} + ${yLabels.join("\n ")} + ${xLabels.join("\n ")} + +`; +} + +/** + * Served with a short cache lifetime when the stargazer fetch fails outright, + * so the image element still renders something sane and retries soon. + */ +export function renderStarHistoryFallbackSvg({ theme, repoSlug }) { + const palette = THEMES[resolveTheme(theme)]; + return ` + Star history is loading — check back shortly. +`; +} diff --git a/content/docs/current/api/agent.mdx b/content/docs/current/api/agent.mdx index 7faf7d032..6bcc65f74 100644 --- a/content/docs/current/api/agent.mdx +++ b/content/docs/current/api/agent.mdx @@ -23,7 +23,7 @@ curl http://drydock:3000/api/v1/agents "host": "192.168.1.50", "port": 3000, "connected": true, - "version": "1.6.0-rc.12", + "version": "1.6.0-rc.13", "os": "linux", "arch": "amd64", "cpus": 4, @@ -153,7 +153,7 @@ Sent immediately upon connection to confirm the handshake. { "type": "dd:ack", "data": { - "version": "1.6.0-rc.12", + "version": "1.6.0-rc.13", "os": "linux", "arch": "amd64", "cpus": 4, diff --git a/content/docs/current/api/app.mdx b/content/docs/current/api/app.mdx index adb8430d1..629a6a2b7 100644 --- a/content/docs/current/api/app.mdx +++ b/content/docs/current/api/app.mdx @@ -12,7 +12,7 @@ curl http://drydock:3000/api/v1/app { "name":"drydock", - "version":"1.6.0-rc.12" + "version":"1.6.0-rc.13" } ``` diff --git a/content/docs/current/api/portwing.mdx b/content/docs/current/api/portwing.mdx index 36b692e5a..978642099 100644 --- a/content/docs/current/api/portwing.mdx +++ b/content/docs/current/api/portwing.mdx @@ -197,7 +197,7 @@ A versioned alias `/api/v1/portwing/ws` is also accepted and is signature-equiva "agentId": "edge-host-01", "agentName": "edge-host-01", "protocol": "portwing/1.0", - "version": "1.6.0-rc.12", + "version": "1.6.0-rc.13", "pubKeyId": "3f8a1c2e9b047d56", "timestamp": 1780329600, "nonce": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4", @@ -220,7 +220,7 @@ A name collision with an already-connected agent under the same key (or the in-f "data": { "pollInterval": 300, "config": { - "drydockVersion": "1.6.0-rc.12", + "drydockVersion": "1.6.0-rc.13", "supportedProtocols": "portwing/1.0", "serverCompatLevel": "1.4.0" } diff --git a/content/docs/current/quickstart/index.mdx b/content/docs/current/quickstart/index.mdx index f0c50bbd4..6c98bf775 100644 --- a/content/docs/current/quickstart/index.mdx +++ b/content/docs/current/quickstart/index.mdx @@ -108,7 +108,7 @@ Release tags use the same channel names in every registry: | Tag | Behavior | | --- | --- | -| `1.6.0-rc.12` | Immutable release candidate; best for reproducible testing | +| `1.6.0-rc.13` | Immutable release candidate; best for reproducible testing | | `1.6-rc` | Rolling release-candidate channel; moves to the newest `1.6.0-rc.N` | | `1.6` | Rolling stable minor channel; published only for GA releases | | `1` | Rolling stable major channel; published only for GA releases | diff --git a/content/docs/current/updates/index.mdx b/content/docs/current/updates/index.mdx index e32a8c3f3..9ee941dd9 100644 --- a/content/docs/current/updates/index.mdx +++ b/content/docs/current/updates/index.mdx @@ -5,6 +5,13 @@ description: "Release update notes and feature highlights, with direct links to ## Unreleased +## v1.6.0-rc.13 Highlights — August 8, 2026 + +- **Digest comparison anchors on repo-matched candidates** — `getOrderedRepoDigests` (`app/watchers/providers/docker/docker-helpers.ts`) filters a container's `RepoDigests` entries to the ones whose repo component matches its own image reference before comparing, instead of trusting an arbitrary `RepoDigests[0]`; a store already poisoned with a stale digest anchor self-heals on the next cycle ([#670](https://github.com/CodesWhat/drydock/pull/670)). +- **`nanoid` pinned to 3.3.18** across the root, app, apps/demo, apps/web, ui, and e2e workspaces (transitive override of `postcss`/`artillery`) for [GHSA-2v37-7h3g-55p8](https://github.com/advisories/GHSA-2v37-7h3g-55p8) (CVE-2026-67213) and, in e2e, [GHSA-28wg-ghj8-5hjv](https://github.com/advisories/GHSA-28wg-ghj8-5hjv) (CVE-2026-67214) ([#673](https://github.com/CodesWhat/drydock/pull/673)). +- **Star History chart is now self-hosted** — a new same-origin `GET /api/star-history` route on the website replaces the third-party embed that went down in a global outage, edge-cached six hours with stale-while-revalidate and a short-lived fallback SVG on fetch failure ([#672](https://github.com/CodesWhat/drydock/pull/672)). +- **Base-image CVE sweep** — `node:24-alpine` bumped to Node 24.19.0 (clearing 3 HIGH + 5 MEDIUM CVEs from Node's July 29 2026 security release) and the vendored `aquasec/trivy` build-stage pin bumped from 0.72.0 to 0.73.0 (clearing 4 HIGH / 6 MEDIUM CVEs in its vendored Go dependencies) ([#682](https://github.com/CodesWhat/drydock/pull/682)). + ## v1.6.0-rc.12 Highlights — August 4, 2026 - **Security dependency refresh** — `brace-expansion` advanced to 5.0.9 across the app, UI, and e2e workspaces (CVE-2026-69152), `ip-address` to 10.3.1 in the app runtime (CVE-2026-54272, CVE-2026-69192, CVE-2026-69198), and `fast-uri` to 4.1.2 in app and UI (backslash-authority host confusion, CVE-2026-18446) ([#659](https://github.com/CodesWhat/drydock/pull/659)). diff --git a/e2e/package-lock.json b/e2e/package-lock.json index 990ec8519..ae4034263 100644 --- a/e2e/package-lock.json +++ b/e2e/package-lock.json @@ -5679,9 +5679,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "3.15.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.0.tgz", - "integrity": "sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==", + "version": "3.15.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.1.tgz", + "integrity": "sha512-S99WuO3HlhO3XN41EtYUNl9zzXjoJx7QvmipxsJVxtCBT0YHEFy+iOJhjSvrmV12nYhWpZaM8lPHkJm0yUMbag==", "dev": true, "license": "MIT", "dependencies": { @@ -6251,9 +6251,9 @@ "optional": true }, "node_modules/nanoid": { - "version": "3.3.12", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", - "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", "dev": true, "funding": [ { diff --git a/e2e/package.json b/e2e/package.json index f590bb54b..650fff84e 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -38,6 +38,7 @@ }, "overrides": { "brace-expansion": "5.0.9", + "js-yaml": "3.15.1", "fast-xml-parser": "5.10.1", "joi": "18.2.3", "minimatch": "10.2.5", @@ -49,6 +50,7 @@ "form-data": "4.0.6", "protobufjs": "7.6.5", "@opentelemetry/core": "2.10.0", - "undici": "7.29.0" + "undici": "7.29.0", + "nanoid": "3.3.18" } } diff --git a/package-lock.json b/package-lock.json index c21983ba1..0a06cfb35 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,5 +1,5 @@ { - "name": "drydock", + "name": "dd-nanoid", "version": "1.6.0", "lockfileVersion": 3, "requires": true, @@ -1254,9 +1254,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.16", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", - "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", "dev": true, "funding": [ { diff --git a/package.json b/package.json index da3d64fba..a3053c74e 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ }, "overrides": { "vite": "8.2.0", - "postcss": "8.5.25" + "postcss": "8.5.25", + "nanoid": "3.3.18" } } diff --git a/scripts/changelog-links.test.mjs b/scripts/changelog-links.test.mjs index 86e816bd9..36a63c82f 100644 --- a/scripts/changelog-links.test.mjs +++ b/scripts/changelog-links.test.mjs @@ -56,7 +56,8 @@ test('every linked changelog heading has exactly one link definition', () => { test('v1.6 RC and v1.5.2 GA have a complete chronological comparison-link chain', () => { const definitions = new Map(getLinkDefinitions(changelog).map(({ label, url }) => [label, url])); const expected = new Map([ - ['Unreleased', `${repositoryUrl}/compare/v1.6.0-rc.12...HEAD`], + ['Unreleased', `${repositoryUrl}/compare/v1.6.0-rc.13...HEAD`], + ['1.6.0-rc.13', `${repositoryUrl}/compare/v1.6.0-rc.12...v1.6.0-rc.13`], ['1.6.0-rc.12', `${repositoryUrl}/compare/v1.6.0-rc.11...v1.6.0-rc.12`], ['1.6.0-rc.11', `${repositoryUrl}/compare/v1.6.0-rc.10...v1.6.0-rc.11`], ['1.6.0-rc.10', `${repositoryUrl}/compare/v1.6.0-rc.9...v1.6.0-rc.10`], diff --git a/scripts/release-docs-identity.test.mjs b/scripts/release-docs-identity.test.mjs index 5f71f390b..af1a8fd50 100644 --- a/scripts/release-docs-identity.test.mjs +++ b/scripts/release-docs-identity.test.mjs @@ -2,10 +2,10 @@ import assert from 'node:assert/strict'; import { readdirSync, readFileSync } from 'node:fs'; import test from 'node:test'; -const RC_VERSION = '1.6.0-rc.12'; -const PREV_RC_VERSION = '1.6.0-rc.11'; -const RC_DATE = '2026-08-04'; -const RC_DISPLAY_DATE = 'August 4, 2026'; +const RC_VERSION = '1.6.0-rc.13'; +const PREV_RC_VERSION = '1.6.0-rc.12'; +const RC_DATE = '2026-08-08'; +const RC_DISPLAY_DATE = 'August 8, 2026'; const DOC_ROOTS = ['content/docs/current', 'content/docs/v1.5']; const BROAD_401_CLAIM = /(?:all|every) API (?:call|request)s?(?: (?:is|are) rejected with| returns?) `401`/iu; diff --git a/scripts/release-identity.test.mjs b/scripts/release-identity.test.mjs index 7d6dbfdc8..0a4d69d3a 100644 --- a/scripts/release-identity.test.mjs +++ b/scripts/release-identity.test.mjs @@ -3,7 +3,7 @@ import { readFileSync } from 'node:fs'; import test from 'node:test'; const BASE_VERSION = '1.6.0'; -const RC_VERSION = '1.6.0-rc.12'; +const RC_VERSION = '1.6.0-rc.13'; const DEMO_RELEASE_FIXTURES = [ { path: 'apps/demo/src/mocks/data/server.ts', diff --git a/ui/package-lock.json b/ui/package-lock.json index 9de7f3259..0d1990eb3 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -24,6 +24,7 @@ "@fontsource/inconsolata": "5.3.0", "@fontsource/jetbrains-mono": "5.3.0", "@fontsource/source-code-pro": "5.3.0", + "@iconify-json/fa6-brands": "1.2.6", "@iconify-json/fa6-solid": "1.2.4", "@iconify-json/heroicons": "1.2.3", "@iconify-json/iconoir": "1.2.11", @@ -899,6 +900,16 @@ "url": "https://github.com/sponsors/ayuhito" } }, + "node_modules/@iconify-json/fa6-brands": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@iconify-json/fa6-brands/-/fa6-brands-1.2.6.tgz", + "integrity": "sha512-twL3X4KWcxAhbc1vz/mIDsVr+CAItk1/EIfxKUVQtpv6O4eydk5KNYqTZWdvJNHGInUgd6vKg21aWfVgb5DXEg==", + "dev": true, + "license": "CC-BY-4.0", + "dependencies": { + "@iconify/types": "*" + } + }, "node_modules/@iconify-json/fa6-solid": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/@iconify-json/fa6-solid/-/fa6-solid-1.2.4.tgz", @@ -5277,9 +5288,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.16", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", - "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", "funding": [ { "type": "github", diff --git a/ui/package.json b/ui/package.json index 2c364527a..58aa1ad43 100644 --- a/ui/package.json +++ b/ui/package.json @@ -42,6 +42,7 @@ "@fontsource/inconsolata": "5.3.0", "@fontsource/jetbrains-mono": "5.3.0", "@fontsource/source-code-pro": "5.3.0", + "@iconify-json/fa6-brands": "1.2.6", "@iconify-json/fa6-solid": "1.2.4", "@iconify-json/heroicons": "1.2.3", "@iconify-json/iconoir": "1.2.11", @@ -72,6 +73,7 @@ "qs": "6.15.3", "yaml": "2.9.0", "@babel/core": "7.29.7", - "undici": "7.29.0" + "undici": "7.29.0", + "nanoid": "3.3.18" } } diff --git a/ui/scripts/extract-icons.mjs b/ui/scripts/extract-icons.mjs index 6567e9dc7..61ed39685 100644 --- a/ui/scripts/extract-icons.mjs +++ b/ui/scripts/extract-icons.mjs @@ -23,6 +23,27 @@ for (const match of iconsTs.matchAll(/'([a-z0-9-]+:[a-z0-9-]+)'/g)) { console.log(`Found ${iconRefs.size} icon references across all libraries`); +// Collections rename icons between releases by demoting the old name to an +// alias (lucide 1.2.121 did this to `history`), so a plain icons[] lookup +// silently drops icons the app still references. Follow the alias chain to +// the real icon. Aliases carrying transforms (rotate/flip) can't be expressed +// in this body-only bundle, so refuse those instead of shipping a wrong glyph. +function resolveIcon(collection, name, depth = 0) { + if (depth > 5) return undefined; + const icon = collection.icons[name]; + if (icon) return icon; + const alias = collection.aliases?.[name]; + if (!alias) return undefined; + if (alias.rotate || alias.hFlip || alias.vFlip) { + console.warn(` WARNING: alias ${name} needs a transform this bundle cannot represent`); + return undefined; + } + const parent = resolveIcon(collection, alias.parent, depth + 1); + if (!parent) return undefined; + const { parent: _ignored, ...overrides } = alias; + return { ...parent, ...overrides }; +} + // Group by prefix const byPrefix = {}; for (const ref of iconRefs) { @@ -49,7 +70,7 @@ for (const [prefix, names] of Object.entries(byPrefix)) { let missing = 0; for (const name of names) { - const iconData = collection.icons[name]; + const iconData = resolveIcon(collection, name); if (!iconData) { console.warn(` WARNING: ${prefix}:${name} not found in ${pkgName}`); missing++; diff --git a/ui/src/boot/icon-bundle.json b/ui/src/boot/icon-bundle.json index 87bb38009..5cdd27146 100644 --- a/ui/src/boot/icon-bundle.json +++ b/ui/src/boot/icon-bundle.json @@ -1290,7 +1290,7 @@ "height": 24 }, "lucide:zap": { - "body": "", + "body": "", "width": 24, "height": 24 }, @@ -1574,6 +1574,11 @@ "width": 24, "height": 24 }, + "lucide:more-vertical": { + "body": "", + "width": 24, + "height": 24 + }, "lucide:ban": { "body": "", "width": 24, @@ -1630,7 +1635,7 @@ "height": 24 }, "lucide:book-open": { - "body": "", + "body": "", "width": 24, "height": 24 }, @@ -2474,6 +2479,11 @@ "width": 24, "height": 24 }, + "iconoir:clock-rotate-right": { + "body": "", + "width": 24, + "height": 24 + }, "iconoir:page": { "body": "", "width": 24, @@ -2784,6 +2794,11 @@ "width": 24, "height": 24 }, + "iconoir:key-alt": { + "body": "", + "width": 24, + "height": 24 + }, "iconoir:log-in": { "body": "", "width": 24, @@ -2794,6 +2809,11 @@ "width": 24, "height": 24 }, + "iconoir:gitlab-full": { + "body": "", + "width": 24, + "height": 24 + }, "iconoir:google": { "body": "", "width": 24, @@ -2814,6 +2834,11 @@ "width": 24, "height": 24 }, + "iconoir:multiple-pages": { + "body": "", + "width": 24, + "height": 24 + }, "iconoir:windows": { "body": "", "width": 24, @@ -2833,5 +2858,25 @@ "body": "", "width": 24, "height": 24 + }, + "fa6-brands:github": { + "body": "", + "width": 496, + "height": 512 + }, + "fa6-brands:gitlab": { + "body": "", + "width": 512, + "height": 512 + }, + "fa6-brands:google": { + "body": "", + "width": 488, + "height": 512 + }, + "fa6-brands:microsoft": { + "body": "", + "width": 448, + "height": 512 } } diff --git a/ui/src/icons.ts b/ui/src/icons.ts index ecfb14baa..d179ff223 100644 --- a/ui/src/icons.ts +++ b/ui/src/icons.ts @@ -61,7 +61,7 @@ export const iconMap: Record> = { lucide: 'lucide:history', tabler: 'tabler:history', heroicons: 'heroicons:clock', - iconoir: 'iconoir:history', + iconoir: 'iconoir:clock-rotate-right', }, logs: { 'fa6-solid': 'fa6-solid:scroll', @@ -718,7 +718,7 @@ export const iconMap: Record> = { lucide: 'lucide:gitlab', tabler: 'tabler:brand-gitlab', heroicons: 'heroicons:code-bracket-square', - iconoir: 'iconoir:gitlab', + iconoir: 'iconoir:gitlab-full', }, google: { 'fa6-solid': 'fa6-brands:google', @@ -763,7 +763,7 @@ export const iconMap: Record> = { lucide: 'lucide:layers', tabler: 'tabler:stack-2', heroicons: 'heroicons:square-3-stack-3d', - iconoir: 'iconoir:stack', + iconoir: 'iconoir:multiple-pages', }, microsoft: { 'fa6-solid': 'fa6-brands:microsoft', diff --git a/ui/tests/boot/icon-bundle.spec.ts b/ui/tests/boot/icon-bundle.spec.ts new file mode 100644 index 000000000..de2cde27e --- /dev/null +++ b/ui/tests/boot/icon-bundle.spec.ts @@ -0,0 +1,22 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +// The bundle is regenerated from the locked @iconify-json packages at image +// build time, so a reference that only resolves against some other installed +// version ships as a blank icon. lucide 1.2.121 turned `history` into an +// alias, which the extractor used to drop silently — this pins every +// reference in icons.ts to an entry the extractor actually emitted. +describe('icon bundle', () => { + it('contains every icon referenced in icons.ts', () => { + const iconsTs = readFileSync(resolve(process.cwd(), 'src/icons.ts'), 'utf-8'); + const bundle = JSON.parse( + readFileSync(resolve(process.cwd(), 'src/boot/icon-bundle.json'), 'utf-8'), + ) as Record; + + const refs = [...iconsTs.matchAll(/'([a-z0-9-]+:[a-z0-9-]+)'/gu)].map((match) => match[1]); + expect(refs.length).toBeGreaterThan(0); + + const missing = refs.filter((ref) => !bundle[ref]?.body); + expect(missing).toEqual([]); + }); +});