Skip to content
Draft
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
17 changes: 17 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -136,5 +136,22 @@ jobs:
check-latest: true
- name: Update npm to 11.19.1
run: npm install -g npm@11.19.1
- name: Resolve min-release-age
id: age
run: |
age=""
if [ -f .npmrc ]; then
age=$(tr -d ' \t\r' < .npmrc | grep -E '^min-release-age=[0-9]+$' | tail -n 1 | cut -d= -f2)
fi
if [ -z "$age" ]; then
echo "::error::min-release-age not set in .npmrc"
exit 1
fi
echo "Using min-release-age=$age days"
echo "min-release-age=$age" >> "$GITHUB_OUTPUT"
- name: Check release age
run: npm run check-release-age
env:
MIN_RELEASE_AGE: ${{ steps.age.outputs.min-release-age }}
Comment on lines +152 to +155

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure there is a real risk here, our checks are ran in an isolated enviroment so its not the end of the world if we do an npm install elsewhere here.

I think all that really matters is that we throw an exit code 1 and the build fails so we can't merge the PR 👍

- run: npm ci
- run: npm run build
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
"build-storybook:prod": "npm run generate-tokens && dotenvx run -f .env.production -- storybook build -c .storybook",
"start:static": "npx http-server -p 9001 ./storybook-static",
"clean-lib": "rimraf ./lib && rimraf ./esm",
"check-release-age": "node ./scripts/check-release-age.mjs",
"commit": "node ./scripts/commit/commit.mjs",
"generate-metadata": "node ./scripts/generate_metadata/index.mjs",
"type-check": "tsc --noEmit",
Expand Down
219 changes: 219 additions & 0 deletions scripts/check-release-age.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
import fs from "node:fs/promises";

const DEFAULT_REGISTRY = "https://registry.npmjs.org";
const MILLISECONDS_IN_DAY = 24 * 60 * 60 * 1000;
const REGISTRY_REQUEST_TIMEOUT_MS = 30 * 1000;
const MAX_CONCURRENT_REQUESTS = 20;
const MAX_FAILURES_TO_PRINT = 25;

async function readMinReleaseAge() {
if (process.env.MIN_RELEASE_AGE) {
return Number.parseInt(process.env.MIN_RELEASE_AGE, 10);
}

let npmrc;
try {
npmrc = await fs.readFile(".npmrc", "utf8");
} catch (error) {
if (error.code === "ENOENT") {
throw new Error("min-release-age not set in .npmrc");
}
throw error;
}

const age = npmrc
.split("\n")
.map((line) => line.replace(/[ \t\r]/g, ""))
.filter((line) => /^min-release-age=[0-9]+$/.test(line))
.at(-1)
?.split("=")[1];

if (!age) {
throw new Error("min-release-age not set in .npmrc");
}

return Number.parseInt(age, 10);
}

function getPackageNameFromLockEntry(packagePath, packageInfo) {
if (packageInfo.name) {
return packageInfo.name;
}

const pathAfterNodeModules = packagePath.slice(
packagePath.lastIndexOf("node_modules/") + "node_modules/".length,
);
const [firstSegment, secondSegment] = pathAfterNodeModules.split("/");

return firstSegment.startsWith("@")
? `${firstSegment}/${secondSegment}`
: firstSegment;
}

async function readLockedPackages() {
const lockfile = JSON.parse(await fs.readFile("package-lock.json", "utf8"));

if (!lockfile.packages) {
throw new Error("package-lock.json does not contain a packages map");
}

const lockedPackages = new Map();

for (const [packagePath, packageInfo] of Object.entries(lockfile.packages)) {
if (
!packagePath.includes("node_modules/") ||
packageInfo.link ||
!packageInfo.version
) {
continue;
}

const name = getPackageNameFromLockEntry(packagePath, packageInfo);
lockedPackages.set(`${name}@${packageInfo.version}`, {
name,
version: packageInfo.version,
});
}

return [...lockedPackages.values()];
}

function getPackageUrl(registry, packageName) {
const encodedPackageName = encodeURIComponent(packageName);
return `${registry.replace(/\/$/, "")}/${encodedPackageName}`;
}

async function fetchPackageTimes(registry, packageName) {
let response;
try {
response = await fetch(getPackageUrl(registry, packageName), {
headers: { Accept: "application/json" },
signal: AbortSignal.timeout(REGISTRY_REQUEST_TIMEOUT_MS),
});
} catch (error) {
if (error.name === "TimeoutError" || error.name === "AbortError") {
throw new Error(
`Timed out fetching ${packageName} metadata after ${REGISTRY_REQUEST_TIMEOUT_MS / 1000} seconds`,
{ cause: error },
);
}
throw error;
}

if (!response.ok) {
throw new Error(
`Could not fetch ${packageName} metadata: ${response.status} ${response.statusText}`,
);
}

const packageMetadata = await response.json();

if (!packageMetadata.time) {
throw new Error(`Registry metadata for ${packageName} does not include time`);
}

return packageMetadata.time;
}

async function runConcurrently(items, worker) {
const results = [];
let nextIndex = 0;

async function runWorker() {
while (nextIndex < items.length) {
const currentIndex = nextIndex;
nextIndex += 1;
results[currentIndex] = await worker(items[currentIndex]);
}
}

await Promise.all(
Array.from(
{ length: Math.min(MAX_CONCURRENT_REQUESTS, items.length) },
runWorker,
),
);

return results;
}

function formatAge(ageInMilliseconds) {
return `${(ageInMilliseconds / MILLISECONDS_IN_DAY).toFixed(2)} days`;
}

async function main() {
const minReleaseAge = await readMinReleaseAge();

if (!Number.isInteger(minReleaseAge) || minReleaseAge < 0) {
throw new Error("MIN_RELEASE_AGE must be a non-negative integer");
}

const registry =
process.env.NPM_CONFIG_REGISTRY ||
process.env.npm_config_registry ||
DEFAULT_REGISTRY;
const lockedPackages = await readLockedPackages();
const packageNames = [...new Set(lockedPackages.map(({ name }) => name))];
const packageTimes = new Map();

console.log(
`Checking ${lockedPackages.length} locked package releases against min-release-age=${minReleaseAge} days`,
);

await runConcurrently(packageNames, async (packageName) => {
packageTimes.set(packageName, await fetchPackageTimes(registry, packageName));
});

const now = Date.now();
const minimumAgeInMilliseconds = minReleaseAge * MILLISECONDS_IN_DAY;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

min-release-age=3 will be counted as 3 days, not sure where its getting 3 seconds from. What we have here works as expected

const tooNewPackages = lockedPackages
.map(({ name, version }) => {
const publishedAt = packageTimes.get(name)[version];

if (!publishedAt) {
throw new Error(`No publish time found for ${name}@${version}`);
}

const publishedAtTime = new Date(publishedAt).getTime();
const ageInMilliseconds = now - publishedAtTime;

return {
name,
version,
publishedAt,
ageInMilliseconds,
isTooNew: ageInMilliseconds < minimumAgeInMilliseconds,
};
})
.filter(({ isTooNew }) => isTooNew)
.sort((a, b) => a.ageInMilliseconds - b.ageInMilliseconds);

if (tooNewPackages.length > 0) {
console.error(
`::error::${tooNewPackages.length} locked package release(s) do not meet min-release-age=${minReleaseAge} days`,
);

tooNewPackages
.slice(0, MAX_FAILURES_TO_PRINT)
.forEach(({ name, version, publishedAt, ageInMilliseconds }) => {
console.error(
`- ${name}@${version} was published at ${publishedAt} (${formatAge(ageInMilliseconds)} old)`,
);
});

if (tooNewPackages.length > MAX_FAILURES_TO_PRINT) {
console.error(
`...and ${tooNewPackages.length - MAX_FAILURES_TO_PRINT} more package release(s)`,
);
}

process.exit(1);
}

console.log("All locked package releases meet the configured release age.");
}

main().catch((error) => {
console.error(`::error::${error.message}`);
process.exit(1);
});
Loading