Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 52 additions & 3 deletions scripts/fetch-github-sbom.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
84 changes: 63 additions & 21 deletions scripts/lib/sbom/parser.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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",
);
}

/**
Expand All @@ -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 `<streamPrefix>-YYYYMMDD` and version-qualified tags such as
* `<streamPrefix>-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.
Expand All @@ -112,32 +129,34 @@ 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 `<streamPrefix>-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);
const month = dateStr.slice(4, 6);
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,
cacheKey: buildCacheKey(spec.streamPrefix, dateStr),
dateStr,
imageRef: `ghcr.io/${spec.org}/${spec.package}:${tagName}`,
publishedAt,
publishedMs,
});
}

Expand All @@ -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,
}));
}

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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) =>
Expand Down Expand Up @@ -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) {
Expand Down
53 changes: 53 additions & 0 deletions scripts/sbom-parser.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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
// ---------------------------------------------------------------------------
Expand Down