From de801995842deeb2521012d4c9e73222c8d3a2e7 Mon Sep 17 00:00:00 2001 From: mrbobbytables Date: Tue, 8 Sep 2026 18:14:53 +0000 Subject: [PATCH] fix(sbom): support version-qualified live tags in recent-release discovery Support version-qualified dated tags (e.g. stable-44.YYYYMMDD or testing-44.YYYYMMDD) in findRecentTagsForStream and retain a latest-release fallback when the fixed lookback finds no releases. Fixes #1082 Assisted-by: Gemini 3.8 Flash via GitHub Copilot Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: mrbobbytables --- scripts/fetch-github-sbom.test.js | 55 ++++++++++++++++++-- scripts/lib/sbom/parser.js | 84 +++++++++++++++++++++++-------- scripts/sbom-parser.test.js | 53 +++++++++++++++++++ 3 files changed, 168 insertions(+), 24 deletions(-) diff --git a/scripts/fetch-github-sbom.test.js b/scripts/fetch-github-sbom.test.js index 6c4cbe8de..f678fab38 100644 --- a/scripts/fetch-github-sbom.test.js +++ b/scripts/fetch-github-sbom.test.js @@ -339,11 +339,60 @@ test("findRecentTagsForStream: picks stable-YYYYMMDD tags from GHCR list", () => assert.equal(result[0].cacheKey, `stable-${FIXED_RECENT_DATE}`); }); -test("findRecentTagsForStream: excludes tags older than LOOKBACK_DAYS", () => { +test("findRecentTagsForStream: excludes tags older than LOOKBACK_DAYS when recent tags exist", () => { const oldDate = "20200101"; // way in the past - const ghcrTags = [`stable-${oldDate}`]; + const ghcrTags = [`stable-${FIXED_RECENT_DATE}`, `stable-${oldDate}`]; const result = findRecentTagsForStream(ghcrTags, MOCK_STABLE_SPEC); - assert.equal(result.length, 0, "old tags must be excluded"); + assert.equal( + result.length, + 1, + "old tags must be excluded when recent tags exist", + ); + assert.equal(result[0].tag, `stable-${FIXED_RECENT_DATE}`); +}); + +test("findRecentTagsForStream: retains latest-release fallback when lookback finds no releases", () => { + const oldDate1 = "20200101"; + const oldDate2 = "20200201"; + const ghcrTags = [`stable-${oldDate1}`, `stable-${oldDate2}`]; + const result = findRecentTagsForStream(ghcrTags, MOCK_STABLE_SPEC); + assert.equal( + result.length, + 1, + "retains latest release when lookback finds no releases", + ); + assert.equal(result[0].tag, `stable-${oldDate2}`); + assert.equal(result[0].cacheKey, `stable-${oldDate2}`); +}); + +test("findRecentTagsForStream: recognizes version-qualified dated tags", () => { + const ghcrTags = [ + `stable-44.${FIXED_RECENT_DATE}`, + `stable-44-${FIXED_RECENT_DATE}`, + ]; + const result = findRecentTagsForStream(ghcrTags, MOCK_STABLE_SPEC); + assert.equal(result.length, 1); + assert.equal(result[0].cacheKey, `stable-${FIXED_RECENT_DATE}`); + assert.equal(result[0].dateStr, FIXED_RECENT_DATE); +}); + +test("findRecentTagsForStream: matches live probe with version-qualified tags and fallback", () => { + const probeTags = [ + "stable-20260606", + "stable-44.20260606", + "testing-44.20260720", + ]; + const stableResult = findRecentTagsForStream(probeTags, MOCK_STABLE_SPEC); + assert.equal(stableResult.length, 1); + assert.equal(stableResult[0].dateStr, "20260606"); + assert.equal(stableResult[0].cacheKey, "stable-20260606"); + + const utahSpec = STREAM_SPECS.find((s) => s.id === "utah-testing"); + const utahResult = findRecentTagsForStream(probeTags, utahSpec); + assert.equal(utahResult.length, 1); + assert.equal(utahResult[0].dateStr, "20260720"); + assert.equal(utahResult[0].cacheKey, "testing-20260720"); + assert.equal(utahResult[0].tag, "testing-44.20260720"); }); test("findRecentTagsForStream: deduplicates same date", () => { diff --git a/scripts/lib/sbom/parser.js b/scripts/lib/sbom/parser.js index 3954f417b..aa48e3476 100644 --- a/scripts/lib/sbom/parser.js +++ b/scripts/lib/sbom/parser.js @@ -69,9 +69,11 @@ function stripRpmRelease(version) { /** * Extract the YYYYMMDD date from a GHCR tag. * Handles patterns: - * stable-20260331 → 20260331 - * lts-20260331 → 20260331 - * lts.20260331 → 20260331 + * stable-20260331 → 20260331 + * stable-44.20260606 → 20260606 + * testing-44.20260720 → 20260720 + * lts-20260331 → 20260331 + * lts.20260331 → 20260331 * lts-hwe-testing-20260331 → 20260331 */ function extractDateFromTag(tag) { @@ -84,12 +86,17 @@ function extractDateFromTag(tag) { * Handles: lts.20260331 → lts-20260331 * lts.20260331-hwe → lts-20260331-hwe * lts-hwe.20260501 → lts-hwe-20260501 + * latest.20260501 → latest-20260501 (Dakota date-stamped tags) + * lts.44.20260501 → lts-44.20260501 */ function normaliseLtsTag(tag) { // lts.20260501 → lts-20260501 // lts-hwe.20260501 → lts-hwe-20260501 // latest.20260501 → latest-20260501 (Dakota date-stamped tags) - return tag.replace(/^((?:lts|latest)[a-z-]*)\.(\d{8})/, "$1-$2"); + return tag.replace( + /^((?:lts|latest)[a-z-]*)\.((?:v?\d+(?:\.\d+)*[.-])?\d{8})/, + "$1-$2", + ); } /** @@ -101,8 +108,18 @@ function buildCacheKey(streamPrefix, dateStr) { return `${streamPrefix}-${dateStr}`; } +/** + * Escape regular expression special characters in a string. + */ +function escapeRegExp(string) { + return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + /** * Filter a list of GHCR tag strings to find recent dated tags for a given stream. + * Recognizes exact `-YYYYMMDD` and version-qualified tags such as + * `-44.YYYYMMDD`. Retains a latest-release fallback when the + * fixed lookback window finds no releases. * * @param {string[]} ghcrTags Raw tag strings from fetchGhcrTags(). * @param {object} spec Stream spec from STREAM_SPECS. @@ -112,17 +129,18 @@ function findRecentTagsForStream(ghcrTags, spec) { const lookbackDays = Number(process.env.SBOM_LOOKBACK_DAYS || 90); const maxReleases = Number(process.env.SBOM_MAX_RELEASES || 10); const cutoff = Date.now() - lookbackDays * 24 * 60 * 60 * 1000; + const escapedPrefix = escapeRegExp(spec.streamPrefix.toLowerCase()); + const tagPattern = new RegExp( + `^${escapedPrefix}-(?:(v?\\d+(?:\\.\\d+)*)[.-])?(\\d{8})$`, + ); const found = []; for (const tagName of ghcrTags) { const normalised = normaliseLtsTag(tagName.toLowerCase()); - if (!normalised.startsWith(`${spec.streamPrefix}-`)) continue; - const dateStr = extractDateFromTag(normalised); - if (!dateStr) continue; + const match = normalised.match(tagPattern); + if (!match) continue; - // Enforce canonical tag: only exact `-YYYYMMDD` is accepted. - const expectedCanonical = `${spec.streamPrefix}-${dateStr}`; - if (normalised !== expectedCanonical) continue; + const dateStr = match[2]; // Tags from GHCR have no publishedAt — derive from the date string. const year = dateStr.slice(0, 4); @@ -130,7 +148,7 @@ function findRecentTagsForStream(ghcrTags, spec) { const day = dateStr.slice(6, 8); const publishedAt = `${year}-${month}-${day}T00:00:00Z`; const publishedMs = Date.parse(publishedAt); - if (isNaN(publishedMs) || publishedMs < cutoff) continue; + if (isNaN(publishedMs)) continue; found.push({ tag: normalised, @@ -138,6 +156,7 @@ function findRecentTagsForStream(ghcrTags, spec) { dateStr, imageRef: `ghcr.io/${spec.org}/${spec.package}:${tagName}`, publishedAt, + publishedMs, }); } @@ -154,7 +173,21 @@ function findRecentTagsForStream(ghcrTags, spec) { // Sort descending by dateStr (YYYYMMDD sorts lexicographically) unique.sort((a, b) => b.dateStr.localeCompare(a.dateStr)); - return unique.slice(0, maxReleases); + // Filter within lookback window + const withinLookback = unique.filter((entry) => entry.publishedMs >= cutoff); + + const selected = + withinLookback.length > 0 + ? withinLookback.slice(0, maxReleases) + : unique.slice(0, 1); + + return selected.map(({ tag, cacheKey, dateStr, imageRef, publishedAt }) => ({ + tag, + cacheKey, + dateStr, + imageRef, + publishedAt, + })); } // --------------------------------------------------------------------------- @@ -206,7 +239,8 @@ function extractPackageVersions(sbomPath) { // Packages represent BST elements, not RPM packages. Extraction uses // (name, BST element path suffix) pairs to disambiguate between components // that share a name (e.g. the `linux` kernel element vs. Rust `linux` crates). - const isSpdx = Array.isArray(sbom?.packages) && typeof sbom?.spdxVersion === "string"; + const isSpdx = + Array.isArray(sbom?.packages) && typeof sbom?.spdxVersion === "string"; const isBstSpdx = isSpdx && (sbom.packages || []).some((pkg) => @@ -282,28 +316,36 @@ function extractPackageVersions(sbomPath) { kernelVersions.push(stripEpoch(String(version))); break; case "gnome-shell": - if (!result.gnome) result.gnome = stripRpmRelease(stripEpoch(String(version))); + if (!result.gnome) + result.gnome = stripRpmRelease(stripEpoch(String(version))); break; case "mesa-filesystem": - if (!result.mesa) result.mesa = stripRpmRelease(stripEpoch(String(version))); + if (!result.mesa) + result.mesa = stripRpmRelease(stripEpoch(String(version))); break; case "podman": - if (!result.podman) result.podman = stripRpmRelease(stripEpoch(String(version))); + if (!result.podman) + result.podman = stripRpmRelease(stripEpoch(String(version))); break; case "systemd": - if (!result.systemd) result.systemd = stripRpmRelease(stripEpoch(String(version))); + if (!result.systemd) + result.systemd = stripRpmRelease(stripEpoch(String(version))); break; case "bootc": - if (!result.bootc) result.bootc = stripRpmRelease(stripEpoch(String(version))); + if (!result.bootc) + result.bootc = stripRpmRelease(stripEpoch(String(version))); break; case "pipewire": - if (!result.pipewire) result.pipewire = stripRpmRelease(stripEpoch(String(version))); + if (!result.pipewire) + result.pipewire = stripRpmRelease(stripEpoch(String(version))); break; case "flatpak": - if (!result.flatpak) result.flatpak = stripRpmRelease(stripEpoch(String(version))); + if (!result.flatpak) + result.flatpak = stripRpmRelease(stripEpoch(String(version))); break; case "nvidia-driver": - if (!result.nvidia) result.nvidia = stripRpmRelease(stripEpoch(String(version))); + if (!result.nvidia) + result.nvidia = stripRpmRelease(stripEpoch(String(version))); break; case "fedora-release-common": { if (!result.fedora) { diff --git a/scripts/sbom-parser.test.js b/scripts/sbom-parser.test.js index eddc34849..97cf21d39 100644 --- a/scripts/sbom-parser.test.js +++ b/scripts/sbom-parser.test.js @@ -69,6 +69,8 @@ test("extractDateFromTag reads the trailing YYYYMMDD after . or -", () => { assert.equal(extractDateFromTag("stable-20260331"), "20260331"); assert.equal(extractDateFromTag("lts.20260331"), "20260331"); assert.equal(extractDateFromTag("lts-hwe-testing-20260331"), "20260331"); + assert.equal(extractDateFromTag("stable-44.20260606"), "20260606"); + assert.equal(extractDateFromTag("testing-44.20260720"), "20260720"); }); test("extractDateFromTag returns null when there is no trailing date", () => { @@ -180,6 +182,57 @@ test("findRecentTagsForStream deduplicates, sorts newest first, and caps the cou ); }); +test("findRecentTagsForStream recognizes version-qualified dated tags", () => { + const recent = daysAgoTag(3); + const found = findRecentTagsForStream( + [`stable-44.${recent}`, `stable-44-${recent}`], + SPEC, + ); + assert.equal(found.length, 1); + assert.equal(found[0].dateStr, recent); + assert.equal(found[0].cacheKey, `stable-${recent}`); +}); + +test("findRecentTagsForStream retains latest-release fallback when lookback finds no releases", (t) => { + t.after(() => { + delete process.env.SBOM_LOOKBACK_DAYS; + }); + process.env.SBOM_LOOKBACK_DAYS = "30"; + const old1 = daysAgoTag(120); + const old2 = daysAgoTag(100); + const found = findRecentTagsForStream( + [`stable-${old1}`, `stable-${old2}`], + SPEC, + ); + assert.equal(found.length, 1); + assert.equal(found[0].tag, `stable-${old2}`); + assert.equal(found[0].dateStr, old2); + assert.equal(found[0].cacheKey, `stable-${old2}`); +}); + +test("findRecentTagsForStream matches live probe with version-qualified tags and fallback", () => { + const probeTags = [ + "stable-20260606", + "stable-44.20260606", + "testing-44.20260720", + ]; + const stableFound = findRecentTagsForStream(probeTags, SPEC); + assert.equal(stableFound.length, 1); + assert.equal(stableFound[0].dateStr, "20260606"); + assert.equal(stableFound[0].cacheKey, "stable-20260606"); + + const testingSpec = { + streamPrefix: "testing", + org: "projectbluefin", + package: "utah", + }; + const testingFound = findRecentTagsForStream(probeTags, testingSpec); + assert.equal(testingFound.length, 1); + assert.equal(testingFound[0].dateStr, "20260720"); + assert.equal(testingFound[0].cacheKey, "testing-20260720"); + assert.equal(testingFound[0].tag, "testing-44.20260720"); +}); + // --------------------------------------------------------------------------- // extractPackageVersions // ---------------------------------------------------------------------------