Skip to content
Merged
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
15 changes: 15 additions & 0 deletions .changeset/pnpm-migration.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
"@prosopo/config": patch
"@prosopo/workspace": patch
"@prosopo/util": patch
---

Move the repo from npm to pnpm 11.

- `pnpm-workspace.yaml` replaces the `workspaces` field and `.npmrc`, and `pnpm-lock.yaml` replaces `package-lock.json` (converted with `pnpm import`, so versions are unchanged).
- Internal `@prosopo/*` dependencies use `workspace:*`. `pnpm publish` (which `changeset publish` now runs) swaps each for the exact version, so published manifests are the same as before. Member packages no longer declare an npm engine.
- `@prosopo/config`'s `getDependencies` walks the installed `node_modules` tree instead of parsing `npm ls`, so bundle externals are computed the same way under any package manager.
- `@prosopo/workspace` exports `getWorkspacePatterns`, which builds glob patterns from the workspace's `pnpm-workspace.yaml` (keeping its exclusions); the lint commands use it instead of `package.json`'s `workspaces`.
- `verifyDepsBeforeRun` is off, because inside captcha-private a script run here would otherwise install captcha standalone over the parent's `node_modules`.
- Packages that relied on npm hoisting for types or tools now declare them.
- `@prosopo/util` declares the `url` package. `isMain` imports `url`, which Node serves from its built-in module and browser bundlers resolve to this package; npm used to provide it by hoisting.
4 changes: 2 additions & 2 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
# choose remote package-lock.json on merge
**/package-lock.json merge=theirs
# choose remote pnpm-lock.yaml on merge; `pnpm install` then re-resolves it
pnpm-lock.yaml merge=theirs
3 changes: 3 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ updates:
schedule:
interval: "weekly"
open-pull-requests-limit: 10
# Matches pnpm's minimumReleaseAge, which refuses versions younger than this.
cooldown:
default-days: 7
# Keep majors as standalone PRs (they may need manual review / a larger bump),
# group everything else to cut down on PR + changeset noise across the workspace.
groups:
Expand Down
70 changes: 37 additions & 33 deletions .github/scripts/check-pinned-versions.mjs
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
#!/usr/bin/env node
// Supply-chain guard: fail CI if any package.json declares a non-exact (floating)
// version in dependencies, devDependencies or optionalDependencies, or if
// package-lock.json is missing integrity hashes for resolved registry packages.
// pnpm-lock.yaml is missing integrity hashes for resolved registry packages.
// (peerDependencies are exempt — see the note below.)
//
// Floating ranges (^, ~, *, >=, "latest", ...) let `npm install` silently pull a
// Floating ranges (^, ~, *, >=, "latest", ...) let `pnpm install` silently pull a
// newer release than the one that was reviewed. When that newer release is
// malicious, every fresh install / CI run is compromised before anyone notices.
// Recent npm supply-chain attacks that worked exactly this way:
Expand All @@ -20,15 +20,17 @@
// password stealer on install.
// - Nov 2018 event-stream / flatmap-stream — transitive dep backdoored to steal
// bitcoin-wallet credentials.
// Pinning exact versions + committing the lockfile + `npm ci` means a new malicious
// release is NOT pulled until the version is explicitly bumped and reviewed.
// Pinning exact versions + committing the lockfile + `pnpm install
// --frozen-lockfile` means a new malicious release is NOT pulled until the
// version is explicitly bumped and reviewed.
//
// peerDependencies are intentionally exempt: they express a compatibility *range*
// against whatever the consumer installs, so pinning them would wrongly constrain
// downstream projects. The actually-installed peer is still pinned by the lockfile.

import { readFileSync, readdirSync, statSync } from "node:fs";
import { dirname, join, relative, resolve } from "node:path";
import { parsePnpmLock, parseResolution } from "./pnpm-lockfile.mjs";

const ROOT = process.cwd();

Expand Down Expand Up @@ -89,7 +91,8 @@ function findPackageJsons(dir, out = []) {
/**
* Is `spec` an acceptable, non-floating dependency specifier?
* Accepts: an exact semver (1.2.3, 1.2.3-rc.1+build), or a non-registry
* specifier that is inherently pinned (file:, link:, exact npm: alias).
* specifier that is inherently pinned (file:, link:, workspace:*, exact npm:
* alias).
* Rejects: ^, ~, *, x, latest, >=, <, ||, " - " ranges and bare/empty values.
*/
const EXACT_SEMVER = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
Expand All @@ -101,11 +104,12 @@ function isPinned(spec) {
// Local sources are pinned by definition.
if (v.startsWith("file:") || v.startsWith("link:")) return true;

// Workspace protocol with an explicit version (workspace:1.2.3). Reject
// floating workspace ranges (workspace:*, workspace:^).
// Workspace protocol: always the local copy, and `pnpm publish` rewrites
// `workspace:*` and `workspace:1.2.3` to that exact version. Reject
// workspace:^ / workspace:~, which publish as floating ranges.
if (v.startsWith("workspace:")) {
const rest = v.slice("workspace:".length);
return EXACT_SEMVER.test(rest);
return rest === "*" || EXACT_SEMVER.test(rest);
}

// npm alias: must point at an exact version (npm:pkg@1.2.3).
Expand Down Expand Up @@ -148,32 +152,32 @@ function checkPackageJson(file) {
* so a tampered tarball cannot be substituted for the reviewed one.
*/
function checkLockfile(file) {
let lock;
try {
lock = JSON.parse(readFileSync(file, "utf8"));
} catch (e) {
errors.push(`${relative(ROOT, file)}: invalid JSON (${e.message})`);
return;
}
const rel = relative(ROOT, file);
if ((lock.lockfileVersion ?? 0) < 2) {
const lock = parsePnpmLock(readFileSync(file, "utf8"));
if (!/^9\./.test(lock.lockfileVersion ?? "")) {
errors.push(
`${rel}: lockfileVersion ${lock.lockfileVersion} is too old; needs >= 2 for integrity hashes`,
`${rel}: lockfileVersion ${lock.lockfileVersion} is not supported; this check reads the v9 layout`,
);
return;
}
const packages = lock.packages || {};
for (const [key, entry] of Object.entries(packages)) {
// Root project and workspace members ("" and workspace dirs) and local
// links have no registry tarball / integrity — skip them.
if (key === "" || !key.includes("node_modules/")) continue;
if (entry.link === true) continue;
// Only registry-resolved deps must have integrity. git/file/url deps are
// pinned by their resolved field instead.
const resolved = entry.resolved || "";
if (lock.importers.size === 0 || lock.packages.size === 0) {
errors.push(`${rel}: no importers or packages found; cannot verify it`);
return;
}
for (const [key, entry] of lock.packages) {
if (entry.resolution === undefined) {
errors.push(`${rel} ${key}: no resolution`);
continue;
}
const resolution = parseResolution(entry.resolution);
if (resolution.integrity) continue;
// git and local directory deps are pinned by commit / path instead.
if (resolution.type === "git" || resolution.type === "directory") continue;
// A plain tarball URL outside a registry is pinned by the URL itself.
const tarball = resolution.tarball ?? "";
const isRegistry =
resolved === "" || /^https?:\/\/[^/]*registry\./.test(resolved);
if (isRegistry && !entry.integrity) {
tarball === "" || /^https?:\/\/[^/]*registry\./.test(tarball);
if (isRegistry) {
errors.push(`${rel} ${key}: missing integrity hash`);
}
}
Expand All @@ -185,15 +189,15 @@ for (const f of pkgFiles) checkPackageJson(f);
// Lockfiles live next to each package.json that owns one.
const seenLocks = new Set();
for (const f of pkgFiles) {
const lock = join(dirname(f), "package-lock.json");
const lock = join(dirname(f), "pnpm-lock.yaml");
if (seenLocks.has(lock)) continue;
try {
statSync(lock);
seenLocks.add(lock);
checkLockfile(lock);
} catch {
/* no lockfile here */
continue;
}
seenLocks.add(lock);
checkLockfile(lock);
}

if (errors.length > 0) {
Expand All @@ -204,7 +208,7 @@ if (errors.length > 0) {
console.error(
"\nDependencies in dependencies/devDependencies/optionalDependencies must use an" +
'\nexact version (e.g. "1.2.3", not "^1.2.3"). peerDependencies may use ranges.' +
"\nThis prevents `npm install` from silently pulling a malicious newer release." +
"\nThis prevents `pnpm install` from silently pulling a malicious newer release." +
"\nRun `node .github/scripts/check-pinned-versions.mjs` locally to reproduce.",
);
process.exit(1);
Expand Down
18 changes: 10 additions & 8 deletions .github/scripts/check-publishable.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,9 @@
// A trusted publisher is configured per package, on that package's settings
// page on npmjs.com — which means the package has to already exist before the
// trusted publisher can be attached to it. A package that has never been
// published therefore has no trusted publisher, npm rejects the OIDC exchange,
// and the npm CLI reports the failure as a generic `ENEEDAUTH` that looks like
// a broken token rather than a missing configuration (npm/cli#9088).
// published therefore has no trusted publisher, the registry rejects the OIDC
// exchange, and the publish fails with a generic auth error that looks like a
// broken token rather than a missing configuration (npm/cli#9088).
//
// `changeset publish` always attempts a brand-new package, because its local
// version is by definition not on the registry yet. So adding a new publishable
Expand All @@ -34,8 +34,10 @@
// A `private: true` workspace package is never published. If a *publishable*
// package lists one in dependencies / peerDependencies / optionalDependencies,
// the published tarball is installable only inside this monorepo: every
// consumer gets E404 on the private name. npm does not catch this at publish
// time, because the dependency is only resolved when someone installs.
// consumer gets E404 on the private name. The publish does not catch this,
// because the dependency is only resolved when someone installs. A
// `workspace:*` specifier does not help either: `pnpm publish` rewrites it to
// the private package's version, which the registry does not have.

import { readFileSync, readdirSync } from "node:fs";
import { join, relative, resolve } from "node:path";
Expand Down Expand Up @@ -186,12 +188,12 @@ if (unpublished.length > 0) {
console.error(
"\nA trusted publisher is attached to an existing package on npmjs.com, so the" +
"\nfirst version has to be published by hand once. `changeset publish` would" +
"\notherwise fail on these with a misleading ENEEDAUTH part way through the" +
"\notherwise fail on these with a misleading auth error part way through the" +
"\nrelease, after the earlier packages are already live and immutable." +
"\n\nFor each package above, from a machine logged in to npm as a @prosopo" +
"\npublisher:" +
"\n npm run build:all:tsc && npm run build:all && npm run build:all:cjs" +
"\n cd <package dir> && npm publish --access public" +
"\n pnpm run build:all:tsc && pnpm run build:all && pnpm run build:all:cjs" +
"\n cd <package dir> && pnpm publish --access public" +
"\nthen on https://www.npmjs.com/package/<name>/access add a GitHub Actions" +
"\ntrusted publisher for prosopo/captcha with workflow publish_release.yml," +
"\nand re-run this release.",
Expand Down
Loading
Loading