diff --git a/distribution/channels.yaml b/distribution/channels.yaml index ebf2504d0..2aa2ca483 100644 --- a/distribution/channels.yaml +++ b/distribution/channels.yaml @@ -183,7 +183,7 @@ channels: strategy: local_file files: - charts/libredb-studio/Chart.yaml - extract: 'appVersion: "(\d+\.\d+\.\d+)"' + extract: "appVersion:\\s*[\"']?(\\d+\\.\\d+\\.\\d+)" note: Enforcement lives in the required chart:check CI gate (#138); this row is visibility. - id: homebrew @@ -206,7 +206,7 @@ channels: pin: strategy: remote_file url: https://raw.githubusercontent.com/libredb/homebrew-tap/main/Formula/libredb-studio.rb - extract: 'version "(\d+\.\d+\.\d+)"' + extract: "version\\s+[\"'](\\d+\\.\\d+\\.\\d+)" note: Confirms the release-CI tap push actually landed. - id: snap @@ -425,7 +425,7 @@ channels: pin: strategy: remote_file url: https://raw.githubusercontent.com/caprover/one-click-apps/master/public/v4/apps/libredb-studio.yml - extract: "defaultValue: '(\\d+\\.\\d+\\.\\d+)'" + extract: "defaultValue:\\s*[\"']?(\\d+\\.\\d+\\.\\d+)" note: The single CapRover row. A LibreDB-owned 3rd-party repo (libredb/caprover-one-click-apps) served the app while this submission was in review and was retired once it merged; it had its own inventory entry until that duplicated CapRover in the @@ -508,7 +508,7 @@ channels: pin: strategy: remote_file url: https://raw.githubusercontent.com/kubero-dev/kubero/main/services/libredb-studio/app.yaml - extract: 'ghcr\.io/libredb/libredb-studio\s+tag:\s*(\d+\.\d+\.\d+)' + extract: "ghcr\\.io/libredb/libredb-studio\\s+tag:\\s*[\"']?(\\d+\\.\\d+\\.\\d+)" - id: sealos name: Sealos App Store template @@ -561,7 +561,7 @@ channels: pin: strategy: remote_file url: https://raw.githubusercontent.com/truenas/apps/master/ix-dev/community/libredb-studio/ix_values.yaml - extract: 'ghcr\.io/libredb/libredb-studio\s+tag:\s*(\d+\.\d+\.\d+)' + extract: "ghcr\\.io/libredb/libredb-studio\\s+tag:\\s*[\"']?(\\d+\\.\\d+\\.\\d+)" note: Merged in truenas/apps#5501 (14 Aug 2026); live in the TrueNAS SCALE community catalog. The pin reads the ghcr repo and tag in ix-dev/community/libredb-studio/ix_values.yaml (the maintainer-edited source a bump PR touches) - the container_utils_image tag is a different repository and must not be @@ -785,7 +785,7 @@ channels: pin: strategy: remote_file url: https://raw.githubusercontent.com/YunoHost-Apps/libredb_studio_ynh/master/manifest.toml - extract: 'version = "(\d+\.\d+\.\d+)~ynh\d+"' + extract: "version\\s*=\\s*[\"']?(\\d+\\.\\d+\\.\\d+)" note: Listed by YunoHost/apps#3618, merged 2026-09-20. Unlike the other self-hosting catalogs here, the package installs no container; it unpacks the standalone Linux tarball under systemd and fetches its own Node 24 through YunoHost's `n`, which is why platforms is linux and the runtime is diff --git a/tests/unit/distribution-check.test.ts b/tests/unit/distribution-check.test.ts index c5521679b..7ad76d743 100644 --- a/tests/unit/distribution-check.test.ts +++ b/tests/unit/distribution-check.test.ts @@ -425,6 +425,18 @@ describe("extractPin", () => { "0.9.27", ); }); + + // TrueNAS started quoting and digest-pinning the same field, and the pattern + // then matched nothing, so the channel reported UNKNOWN on every run for weeks + // and nobody saw it. This is that exact shape, so the quote cannot go + // unreadable again without a red test. + test("extracts a quoted, digest-pinned tag (truenas style)", () => { + const content = + ' repository: ghcr.io/libredb/libredb-studio\n tag: "0.17.0@sha256:ce4d58724e2507a4467bcce6702d00760475e0f339071d878fce83e90c99718f"\n'; + expect( + extractPin(content, 'ghcr\\.io/libredb/libredb-studio\\s+tag:\\s*"?(\\d+\\.\\d+\\.\\d+)', "truenas-scale"), + ).toBe("0.17.0"); + }); }); function helmChannel() { @@ -686,6 +698,55 @@ describe("update.ci_enabled", () => { const declared = channels.filter((c: { update: { ci_enabled?: boolean } }) => c.update.ci_enabled !== undefined); expect(declared.map((c: { id: string }) => c.id).sort()).toEqual([...SWITCHABLE_CHANNEL_IDS].sort()); }); + + // TrueNAS quoted and digest-pinned a tag it had always written bare, the + // pattern stopped matching, and the channel reported UNKNOWN on every run for + // weeks with nobody noticing: strictFailures only covers local_file channels + // on every_release, so a remote pin that measures nothing never fails anything. + // Every pin below reads a field whose quoting the upstream owner can change + // without telling us, so each is asserted against all three legal spellings. + test("every quote-sensitive pin reads the value bare, single- and double-quoted", () => { + const channels = parseChannels(readFileSync(join(import.meta.dir, "../../distribution/channels.yaml"), "utf8")); + const shapes: Record string> = { + "truenas-scale": (v) => ` repository: ghcr.io/libredb/libredb-studio\n tag: ${v}\n`, + kubero: (v) => ` repository: ghcr.io/libredb/libredb-studio\n tag: ${v}\n`, + helm: (v) => `appVersion: ${v}\n`, + homebrew: (v) => ` version ${v}\n`, + "caprover-official": (v) => ` defaultValue: ${v}\n`, + yunohost: (v) => `version = ${v}\n`, + }; + // A formatter run upstream can turn one space into two, or into a tab, as + // easily as it can change a quote. Homebrew's pattern used to spell the gap + // as a single literal space while every other one used \\s. + const gaps: Record = { homebrew: [" version ", " version ", " version\t"] }; + for (const [id, shape] of Object.entries(shapes)) { + const channel = channels.find((c: { id: string }) => c.id === id); + expect(channel, `${id} is missing from the inventory`).toBeDefined(); + const pattern = channel.pin.extract as string; + // Homebrew writes a Ruby string literal, so bare is not a spelling there: + // `version 0.17.0` is not valid Ruby, and accepting it would only widen the + // pattern towards matching something that is not the version. + const spellings = id === "homebrew" ? ['"9.9.9"', "'9.9.9'"] : ["9.9.9", '"9.9.9"', "'9.9.9'"]; + for (const spelling of spellings) { + expect(extractPin(shape(spelling), pattern, id), `${id} cannot read ${spelling}`).toBe("9.9.9"); + } + for (const gap of gaps[id] ?? []) { + expect(extractPin(`${gap}"9.9.9"\n`, pattern, id), `${id} cannot read the gap ${JSON.stringify(gap)}`).toBe( + "9.9.9", + ); + } + } + }); + + // The digest is part of the tag TrueNAS publishes, and the capture has to stop + // before it or the channel reads a version no release ever carried. + test("a digest-pinned tag captures the version and stops at the digest", () => { + const channels = parseChannels(readFileSync(join(import.meta.dir, "../../distribution/channels.yaml"), "utf8")); + const truenas = channels.find((c: { id: string }) => c.id === "truenas-scale"); + const body = + ' image:\n repository: ghcr.io/libredb/libredb-studio\n tag: "0.17.0@sha256:ce4d58724e2507a4467bcce6702d00760475e0f339071d878fce83e90c99718f"\n container_utils_image:\n repository: ixsystems/container-utils\n tag: "1.0.2@sha256:46eba20714c1cc6784f60e245c32c33a2d9f616e47d804694a9854248c89a992"\n'; + expect(extractPin(body, truenas.pin.extract, "truenas-scale")).toBe("0.17.0"); + }); }); const SCRIPT = join(import.meta.dir, "../../scripts/distribution-check.mjs");