From 03a4f7de10e680e40d6186d2f579a5f0662c3b67 Mon Sep 17 00:00:00 2001 From: Mobeen Abdullah Date: Sat, 15 Aug 2026 10:25:41 +0500 Subject: [PATCH 1/5] fix(ci): give a packed peer a place in the scaffolded tree Scaffold blog-visual has been red on main. plugin-form-builder/dist/admin cannot resolve @nextlyhq/plugin-sdk/admin, which reads as a missing export and is a missing install. Two things combine. The pin loop only repoints names the manifest ALREADY declares, so a packed package that is nothing but a peer of another packed package never enters the tree. And the override makes that worse rather than better: pnpm applies overrides to peer RANGES, so the range becomes a file: spec and no resolved semver version can satisfy one. Measured on a minimal fixture. Without a direct dependency pnpm reports missing peer and declines to link it; with one it reports unmet peer, a warning, and links it anyway. That is why only the pnpm leg fails - npm's hoisted layout leaves the package reachable either way. The peer set is derived from the tarballs already being opened for their names, rather than listed, which is the rule this file states about itself. optionalDependencies joins the pin loop as the third place a manifest can name a package; a spec left unpinned there resolves from the registry exactly as an unpinned dependency would. --- scripts/pin-workspace-packages.mjs | 56 ++++++++++++++++++++++++------ 1 file changed, 45 insertions(+), 11 deletions(-) diff --git a/scripts/pin-workspace-packages.mjs b/scripts/pin-workspace-packages.mjs index 0d1944f7a5..933f9c232e 100644 --- a/scripts/pin-workspace-packages.mjs +++ b/scripts/pin-workspace-packages.mjs @@ -59,16 +59,19 @@ function readPackedNames(dir) { // and a filename is the packer's spelling of it rather than the thing itself // (a scope becomes a dash, so `@nextlyhq/ui` and `nextlyhq-ui` differ). execFileSync("tar", ["-xzf", tarball, "-C", scratch, "package/package.json"]); - const { name } = JSON.parse( + const { name, peerDependencies } = JSON.parse( readFileSync(join(scratch, "package", "package.json"), "utf-8") ); - map[name] = `file:${tarball}`; + map[name] = { spec: `file:${tarball}`, peers: Object.keys(peerDependencies ?? {}) }; } return map; } -const overrides = readPackedNames(packsDir); -const names = Object.keys(overrides); +const packed = readPackedNames(packsDir); +const names = Object.keys(packed); +const overrides = Object.fromEntries( + names.map(name => [name, packed[name].spec]) +); if (names.length === 0) { console.error(`No .tgz files in ${packsDir} — nothing was packed.`); @@ -79,17 +82,48 @@ const manifestPath = join(projectDir, "package.json"); const manifest = JSON.parse(readFileSync(manifestPath, "utf-8")); let pinned = 0; +// `optionalDependencies` is listed with the other two because it is the third place a manifest +// can name a package, and a spec left unpinned there resolves from the registry exactly as an +// unpinned dependency would. for (const [name, spec] of Object.entries(overrides)) { - if (manifest.dependencies?.[name]) { - manifest.dependencies[name] = spec; - pinned += 1; - } - if (manifest.devDependencies?.[name]) { - manifest.devDependencies[name] = spec; - pinned += 1; + for (const field of ["dependencies", "devDependencies", "optionalDependencies"]) { + if (manifest[field]?.[name]) { + manifest[field][name] = spec; + pinned += 1; + } } } +// A packed package that is only ever a PEER of another packed package is named by neither the +// manifest nor the pin loop above, and an override alone does not put it in the tree. Under npm's +// hoisted layout that goes unnoticed; under pnpm's isolated one the dependent cannot resolve it, +// which is a build failure that reads as a missing export rather than as a missing install. +// +// The override makes it worse rather than better: it rewrites the peer RANGE to the `file:` spec, +// and no resolved semver version satisfies one, so pnpm reports the peer unmet even when a correct +// copy is present. Adding a direct dependency is what puts the package in the tree regardless of +// whether its range can ever be satisfied. +// +// Derived from the tarballs rather than listed here, for the reason this file already gives about +// package names: the manifests answer it, and a list would be a second answer that has to be kept +// in step. +const peerOfPacked = new Set( + names.flatMap(name => packed[name].peers).filter(peer => names.includes(peer)) +); +const declared = name => + Boolean( + manifest.dependencies?.[name] || + manifest.devDependencies?.[name] || + manifest.optionalDependencies?.[name] + ); + +let added = 0; +for (const peer of peerOfPacked) { + if (declared(peer)) continue; + manifest.dependencies = { ...manifest.dependencies, [peer]: overrides[peer] }; + added += 1; +} + if (packageManager === "pnpm") { manifest.pnpm = { ...manifest.pnpm }; manifest.pnpm.overrides = { ...manifest.pnpm.overrides, ...overrides }; From 68221381682cb01c4d6bf6fd8ed25caff11367f4 Mon Sep 17 00:00:00 2001 From: Mobeen Abdullah Date: Sat, 15 Aug 2026 10:49:48 +0500 Subject: [PATCH 2/5] fix(ci): never add an OPTIONAL peer to the scaffolded app nextly declares all three database adapters as optional peers, and a scaffolded app installs exactly the one it was generated for. Adding the other two would not merely bloat the install: it would let an accidental import of an adapter the app never declared COMPILE, so the leg would pass on code a real user's install cannot run - the check weakened by the change meant to repair it. peerDependenciesMeta is read from the same tarball manifest already being opened, so the optional flag is derived rather than assumed. Measured with all three adapter tarballs packed and a manifest declaring only sqlite: sqlite is added, mysql and postgres are not. --- scripts/pin-workspace-packages.mjs | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/scripts/pin-workspace-packages.mjs b/scripts/pin-workspace-packages.mjs index 933f9c232e..8f81321a2c 100644 --- a/scripts/pin-workspace-packages.mjs +++ b/scripts/pin-workspace-packages.mjs @@ -59,10 +59,18 @@ function readPackedNames(dir) { // and a filename is the packer's spelling of it rather than the thing itself // (a scope becomes a dash, so `@nextlyhq/ui` and `nextlyhq-ui` differ). execFileSync("tar", ["-xzf", tarball, "-C", scratch, "package/package.json"]); - const { name, peerDependencies } = JSON.parse( + const { name, peerDependencies, peerDependenciesMeta } = JSON.parse( readFileSync(join(scratch, "package", "package.json"), "utf-8") ); - map[name] = { spec: `file:${tarball}`, peers: Object.keys(peerDependencies ?? {}) }; + // OPTIONAL peers are excluded. `nextly` declares all three database adapters as optional + // peers, and a scaffolded app installs exactly the one it was generated for. Adding the other + // two would not merely bloat the install: it would let an accidental import of an adapter the + // app never declared COMPILE, so the leg would pass on code a real user's install cannot run - + // the check weakened by the thing meant to repair it. + const required = Object.keys(peerDependencies ?? {}).filter( + peer => peerDependenciesMeta?.[peer]?.optional !== true + ); + map[name] = { spec: `file:${tarball}`, peers: required }; } return map; } @@ -106,7 +114,8 @@ for (const [name, spec] of Object.entries(overrides)) { // // Derived from the tarballs rather than listed here, for the reason this file already gives about // package names: the manifests answer it, and a list would be a second answer that has to be kept -// in step. +// in step. `peerDependenciesMeta` is read alongside, so an OPTIONAL peer is never added - see the +// note in `readPackedNames`. const peerOfPacked = new Set( names.flatMap(name => packed[name].peers).filter(peer => names.includes(peer)) ); From 2106e55003abe57d33de3713b4a61497bedf71b9 Mon Sep 17 00:00:00 2001 From: Mobeen Abdullah Date: Sat, 15 Aug 2026 11:11:43 +0500 Subject: [PATCH 3/5] fix(ci): walk peers from what the manifest installs, not every tarball A union over all packed tarballs adds a peer because SOME packed package wants it. Reproduced: a blank-like manifest gained @nextlyhq/plugin-sdk purely because plugin-form-builder declares it, and that scaffold installs no plugin. That is the same failure as adding an optional adapter - it lets an accidental import compile in a project whose real install would reject it, so the leg passes on code a user cannot run. The change meant to repair the check weakens it, twice over, in the same derivation. The set is now the reachability closure from the packages the manifest actually declares, followed through required peers only. Transitive, because a peer that gets added brings its own required peers and stopping at one level leaves the second unresolvable for the reason the first was. Paired controls: a blank-like manifest does NOT gain plugin-sdk, a manifest that installs plugin-form-builder DOES, and both keep ui, which admin requires. --- scripts/pin-workspace-packages.mjs | 28 ++++++++++++++++++++++++---- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/scripts/pin-workspace-packages.mjs b/scripts/pin-workspace-packages.mjs index 8f81321a2c..56892299a2 100644 --- a/scripts/pin-workspace-packages.mjs +++ b/scripts/pin-workspace-packages.mjs @@ -116,9 +116,14 @@ for (const [name, spec] of Object.entries(overrides)) { // package names: the manifests answer it, and a list would be a second answer that has to be kept // in step. `peerDependenciesMeta` is read alongside, so an OPTIONAL peer is never added - see the // note in `readPackedNames`. -const peerOfPacked = new Set( - names.flatMap(name => packed[name].peers).filter(peer => names.includes(peer)) -); +// Walked from what this manifest ACTUALLY installs, rather than unioned across every tarball. +// A union adds a peer because SOME packed package wants it: a blank scaffold, which installs no +// plugin, gained `@nextlyhq/plugin-sdk` because `plugin-form-builder` declares it. That is the +// same failure as adding an optional adapter - it lets an accidental import compile in a project +// whose real install would reject it, so the leg passes on code a user cannot run. +// +// Transitive, because a peer that gets added brings its own required peers with it, and stopping +// at one level would leave the second unresolvable for exactly the reason the first was. const declared = name => Boolean( manifest.dependencies?.[name] || @@ -126,8 +131,23 @@ const declared = name => manifest.optionalDependencies?.[name] ); +const reachable = new Set(names.filter(declared)); +const frontier = [...reachable]; +const needed = new Set(); +while (frontier.length > 0) { + const name = frontier.pop(); + for (const peer of packed[name]?.peers ?? []) { + // A peer that was not packed is left to the registry, which is where it would come from + // in a real install too. + if (!names.includes(peer)) continue; + if (reachable.has(peer) || needed.has(peer)) continue; + needed.add(peer); + frontier.push(peer); + } +} + let added = 0; -for (const peer of peerOfPacked) { +for (const peer of needed) { if (declared(peer)) continue; manifest.dependencies = { ...manifest.dependencies, [peer]: overrides[peer] }; added += 1; From d4dbe4e5b9d32df6821c84508267958b4c5b59f1 Mon Sep 17 00:00:00 2001 From: Mobeen Abdullah Date: Sun, 16 Aug 2026 02:40:34 +0500 Subject: [PATCH 4/5] ci(root): serve packed packages from a registry instead of file: specs The pin rewrote the generated manifest to `file:` specifiers, and pnpm applies that rewrite to peer RANGES as well as to dependency specs. No resolved semver version satisfies a `file:` range, so pnpm reported an unmet peer with a correct copy installed, never linked it, and the build failed on `Can't resolve '@nextlyhq/plugin-sdk/admin'`. npm's hoisted layout hid it. Publishing the tarballs to a registry inside the job and installing them by version removes that: peer ranges stay semver and the manifest is not modified, so the legs still test the dependency boundary the generator writes rather than one CI invented. A new assertion requires the manifest to carry no `file:` spec and no `pnpm.overrides`. Publishing needs both `--registry` and an `@nextlyhq:registry` config entry, and neither is sufficient alone: the flag overrides each package's own `publishConfig.registry`, and the config entry wins for a scoped name where the flag does not. A guard refuses to publish unless both resolve to localhost, because these packages name the real registry in `publishConfig`. --- .github/workflows/scaffold-build.yml | 189 ++++++++++++++++++++++----- scripts/pin-workspace-packages.mjs | 177 ------------------------- 2 files changed, 154 insertions(+), 212 deletions(-) delete mode 100644 scripts/pin-workspace-packages.mjs diff --git a/.github/workflows/scaffold-build.yml b/.github/workflows/scaffold-build.yml index 4f66f0756a..ed14bc6438 100644 --- a/.github/workflows/scaffold-build.yml +++ b/.github/workflows/scaffold-build.yml @@ -31,10 +31,6 @@ on: - "templates/blank/**" - "templates/blog/**" - "packages/create-nextly-app/**" - # The blog legs run this script to replace the registry packages with the workspace - # builds. A change to it alters what those legs actually test, so a pull request - # touching only the script must still run them. - - "scripts/pin-workspace-packages.mjs" - ".github/workflows/scaffold-build.yml" permissions: @@ -248,9 +244,8 @@ jobs: # `create-nextly-app` is excluded because it is the scaffolder rather than a # dependency of what it scaffolds; its own pack step covers it. # - # This is the rule `pin-workspace-packages.mjs` already states about ITSELF — the - # names are a question the manifests answer, and a second answer agrees only until - # someone adds a package. That is exactly what happened, one layer up. + # The names are a question the manifests answer, and a second answer agrees only + # until someone adds a package. That is exactly what happened here once already. PKGS=$(node -e ' const fs = require("node:fs"); const names = fs.readdirSync("packages").filter(dir => { @@ -330,6 +325,125 @@ jobs: } done + # The packed tarballs are served from a registry running inside the job, and the + # scaffold installs them BY VERSION. The mechanism this replaces rewrote the generated + # manifest to `file:` specifiers, and pnpm applies that rewrite to peer RANGES as well as + # to dependency specs. No resolved semver version satisfies a `file:` range, so pnpm + # reported `unmet peer nextly@file:...: found 0.0.2-alpha.57` with a correct copy + # installed, never linked the peer, and the build failed on `Can't resolve + # '@nextlyhq/plugin-sdk/admin'`. npm's hoisted layout hid the same defect. + # + # Publishing real tarballs removes that class rather than narrowing it: peer ranges stay + # semver, and the generated manifest is not modified at all. The generator already writes + # `^0.0.2-alpha.`, which the packed version satisfies, so the app resolves the + # packages under review while still declaring exactly the dependencies a user's project + # would. A boundary the resolver agrees with, rather than a check that looks for crossings. + - name: Serve the packed packages from a registry in this job + if: matrix.core == 'workspace' + shell: bash + run: | + mkdir -p /tmp/local-registry/storage + + # `nextly` and `@nextlyhq/*` are served with NO uplink, so a copy already on npm can + # never answer for them. Everything else proxies, because the scaffold still installs + # react, next and the rest the way a user would. + cat > /tmp/local-registry/config.yaml <<'YAML' + storage: ./storage + uplinks: + npmjs: + url: https://registry.npmjs.org/ + packages: + 'nextly': + access: $all + publish: $all + unpublish: $all + '@nextlyhq/*': + access: $all + publish: $all + unpublish: $all + '**': + access: $all + publish: $all + proxy: npmjs + log: { type: stdout, format: pretty, level: warn } + YAML + + # Written to the runner's home rather than passed as a flag, because the scaffold's + # install is run by the CLI rather than by this step, and pnpm and npm both read it. + # + # The scope line is not redundant with the registry line. A `@scope:registry` entry + # outranks even an explicit `--registry` argument, so without it every `@nextlyhq` + # request goes to npmjs and the leg silently tests the last release. + { + echo "registry=http://localhost:4873/" + echo "@nextlyhq:registry=http://localhost:4873/" + echo "//localhost:4873/:_authToken=local-registry" + echo "provenance=false" + } >> "$HOME/.npmrc" + + npx --yes verdaccio@6 --config /tmp/local-registry/config.yaml --listen 4873 \ + > /tmp/local-registry/server.log 2>&1 & + + for _ in $(seq 1 90); do + curl -sf http://localhost:4873/-/ping > /dev/null 2>&1 && break + sleep 1 + done + curl -sf http://localhost:4873/-/ping > /dev/null 2>&1 || { + echo "the local registry never came up" + tail -20 /tmp/local-registry/server.log + exit 1 + } + + # Refuse to publish anything until both names resolve locally. These packages carry + # `publishConfig.registry: https://registry.npmjs.org/`, so a publish that reaches the + # network is a publish to the real registry — measured locally, where exactly that + # happened and was stopped only by the absence of a credential. + for key in registry @nextlyhq:registry; do + resolved=$(cd /tmp && npm config get "$key") + case "$resolved" in + http://localhost:4873*) echo "$key resolves to $resolved" ;; + *) echo "refusing to publish: $key resolves to '$resolved'"; exit 1 ;; + esac + done + + # `--registry` is passed AS WELL as the config entry above, and neither is sufficient + # alone: the flag is what overrides each package's own `publishConfig.registry`, and + # the config entry is what wins for a scoped name where the flag does not. + # `--tag` is required because every version here is a prerelease. + published=0 + for f in /tmp/nextly-workspace-packs/*.tgz; do + (cd /tmp && npm publish "$f" \ + --registry http://localhost:4873 --tag latest --provenance=false) \ + >> /tmp/local-registry/publish.log 2>&1 && published=$((published + 1)) + done + + expected=$(ls /tmp/nextly-workspace-packs/*.tgz | wc -l | tr -d ' ') + echo "published $published of $expected" + [ "$published" -eq "$expected" ] || { + echo "not every packed package reached the local registry" + tail -30 /tmp/local-registry/publish.log + exit 1 + } + + if grep -q "registry.npmjs.org" /tmp/local-registry/publish.log; then + echo "a publish was aimed at the real registry" + exit 1 + fi + + # A publish loop that silently no-opped would satisfy the count above, so each + # package is asked for by the exact version the tarball carries. + for f in /tmp/nextly-workspace-packs/*.tgz; do + meta=$(tar -xzOf "$f" package/package.json) + name=$(node -e "let d='';process.stdin.on('data',c=>d+=c).on('end',()=>console.log(JSON.parse(d).name))" <<<"$meta") + version=$(node -e "let d='';process.stdin.on('data',c=>d+=c).on('end',()=>console.log(JSON.parse(d).version))" <<<"$meta") + got=$(cd /tmp && npm view "$name@$version" version 2>/dev/null) + [ "$got" = "$version" ] || { + echo "$name@$version is not resolvable from the local registry (got '$got')" + exit 1 + } + done + echo "all $expected packages resolve from the local registry" + - name: Scaffold with a real install shell: bash run: | @@ -430,47 +544,52 @@ jobs: echo "font packages imported by the scaffold: $found" [ "$found" -gt 0 ] || { echo "scanned no font imports at all"; exit 1; } - # After the scaffold's own install, so the lockfile and package-manager assertions above - # still describe what the CLI did. This repoints the workspace packages and installs - # again; everything else stays on the registry copies the first install resolved. - - name: Install the workspace packages under review + # The scaffold's own install already resolved these from the local registry, so there is + # nothing to repin. What remains is the assertion that it did: the install exits 0 either + # way, and a published copy carries the same name and version as the packed one, so + # nothing about the manifest distinguishes them. The packed tarball's entry file is diffed + # against the installed one. + - name: Check the packages under review are the ones installed if: matrix.core == 'workspace' shell: bash run: | cd /tmp/scaffold-build/scaffolded-app - node "$GITHUB_WORKSPACE/scripts/pin-workspace-packages.mjs" \ - /tmp/nextly-workspace-packs "${{ matrix.pm }}" - - # The pin rewrites dependency specifiers AFTER the scaffold generated its - # lockfile, so the two disagree by design at this point. pnpm turns - # frozen-lockfile ON by default when CI is set and then refuses the install with - # ERR_PNPM_OUTDATED_LOCKFILE — measured, with exactly that code — so the repin - # has to say it means it. npm has no equivalent default: `npm install` updates - # the lockfile, and it is `npm ci` that would refuse. - if [ "${{ matrix.pm }}" = "pnpm" ]; then - pnpm install --no-frozen-lockfile - else - npm install - fi - - # The assertion this whole step exists for, and it has to compare CONTENT: the - # install exits 0 either way, and a registry copy carries the same name and version - # as the packed one, so nothing about the manifest distinguishes them. - # - # The packed tarball's entry file is diffed against the installed one. Identical - # means the build is about to run the code under review; different means the pin - # silently fell through to the registry and every later assertion is about the last - # release instead. rm -rf /tmp/pack-check && mkdir -p /tmp/pack-check tar -xzf /tmp/nextly-workspace-packs/nextly-*.tgz -C /tmp/pack-check if ! diff -q /tmp/pack-check/package/dist/index.mjs \ node_modules/nextly/dist/index.mjs > /dev/null; then echo "installed nextly does NOT match the packed workspace build" - echo "the pin fell through to the registry; this leg would test the last release" + echo "the install fell through to a published copy; this leg would test the last release" exit 1 fi echo "installed nextly matches the packed workspace build" + # The generated manifest has to be exactly what the CLI wrote. What this replaces + # rewrote it, and every version of that rewrite widened the app's dependency graph: + # an import the generator deliberately never declares would then compile here and fail + # in a real project. Asserting the manifest is untouched is what keeps these legs a + # test of the dependency boundary rather than of a boundary CI invented. + node -e " + const manifest = require('/tmp/scaffold-build/scaffolded-app/package.json'); + const specs = { + ...manifest.dependencies, + ...manifest.devDependencies, + ...manifest.optionalDependencies, + }; + const rewritten = Object.entries(specs) + .filter(([, spec]) => String(spec).startsWith('file:')) + .map(([name]) => name); + if (rewritten.length > 0) { + console.error('the manifest carries file: specifiers: ' + rewritten.join(', ')); + process.exit(1); + } + if (manifest.pnpm && manifest.pnpm.overrides) { + console.error('the manifest carries pnpm.overrides'); + process.exit(1); + } + console.log('the generated manifest is unmodified'); + " + - name: Build the scaffolded project shell: bash run: | diff --git a/scripts/pin-workspace-packages.mjs b/scripts/pin-workspace-packages.mjs deleted file mode 100644 index 56892299a2..0000000000 --- a/scripts/pin-workspace-packages.mjs +++ /dev/null @@ -1,177 +0,0 @@ -#!/usr/bin/env node - -/** - * Point a scaffolded project at packed workspace packages instead of the registry. - * - * A scaffold installs `nextly` and its siblings by version, so a CI job that - * scaffolds and builds is testing the LAST PUBLISHED release rather than the - * commit under review. That is the wrong question for a change that spans a - * template and the core it depends on: the template is merged with core changes - * the published packages do not have, so the leg fails for a reason that is not - * a defect — and after release it would pass while a core regression went - * untested. - * - * Usage: - * node scripts/pin-workspace-packages.mjs [project-dir] - * - * Both a direct pin and an override are written, because they answer different - * halves: - * - * - The DIRECT dependencies must name the tarball. npm refuses an override that - * disagrees with a direct spec (`EOVERRIDE`), so redirecting `nextly` while - * the manifest still asks for `^0.0.2-alpha.x` aborts the install. - * - The OVERRIDE covers the same packages when they appear transitively. pnpm's - * layout is not hoisted, so `nextly`'s own dependency on an adapter resolves - * from the registry unless it is overridden — and `pnpm pack` rewrites - * `workspace:*` to the concrete published version, which exists on npm, so - * nothing about the tarball's own manifest prevents that. - * - * The package names are read from the tarballs rather than listed here. A list - * in this file would be a second answer to a question the manifests already - * answer, and the two agree only until someone adds a package. - */ - -import { execFileSync } from "node:child_process"; -import { mkdtempSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join, resolve } from "node:path"; - -const [packsDirArg, packageManager, projectDirArg = "."] = process.argv.slice(2); - -if (!packsDirArg || !["npm", "pnpm"].includes(packageManager ?? "")) { - console.error( - "usage: pin-workspace-packages.mjs [project-dir]" - ); - process.exit(1); -} - -const packsDir = resolve(packsDirArg); -const projectDir = resolve(projectDirArg); - -/** Every packed tarball, mapped from the package name it declares. */ -function readPackedNames(dir) { - const map = {}; - for (const file of readdirSync(dir)) { - if (!file.endsWith(".tgz")) continue; - const tarball = join(dir, file); - const scratch = mkdtempSync(join(tmpdir(), "pin-")); - // Only the manifest is extracted: the name is what identifies the package, - // and a filename is the packer's spelling of it rather than the thing itself - // (a scope becomes a dash, so `@nextlyhq/ui` and `nextlyhq-ui` differ). - execFileSync("tar", ["-xzf", tarball, "-C", scratch, "package/package.json"]); - const { name, peerDependencies, peerDependenciesMeta } = JSON.parse( - readFileSync(join(scratch, "package", "package.json"), "utf-8") - ); - // OPTIONAL peers are excluded. `nextly` declares all three database adapters as optional - // peers, and a scaffolded app installs exactly the one it was generated for. Adding the other - // two would not merely bloat the install: it would let an accidental import of an adapter the - // app never declared COMPILE, so the leg would pass on code a real user's install cannot run - - // the check weakened by the thing meant to repair it. - const required = Object.keys(peerDependencies ?? {}).filter( - peer => peerDependenciesMeta?.[peer]?.optional !== true - ); - map[name] = { spec: `file:${tarball}`, peers: required }; - } - return map; -} - -const packed = readPackedNames(packsDir); -const names = Object.keys(packed); -const overrides = Object.fromEntries( - names.map(name => [name, packed[name].spec]) -); - -if (names.length === 0) { - console.error(`No .tgz files in ${packsDir} — nothing was packed.`); - process.exit(1); -} - -const manifestPath = join(projectDir, "package.json"); -const manifest = JSON.parse(readFileSync(manifestPath, "utf-8")); - -let pinned = 0; -// `optionalDependencies` is listed with the other two because it is the third place a manifest -// can name a package, and a spec left unpinned there resolves from the registry exactly as an -// unpinned dependency would. -for (const [name, spec] of Object.entries(overrides)) { - for (const field of ["dependencies", "devDependencies", "optionalDependencies"]) { - if (manifest[field]?.[name]) { - manifest[field][name] = spec; - pinned += 1; - } - } -} - -// A packed package that is only ever a PEER of another packed package is named by neither the -// manifest nor the pin loop above, and an override alone does not put it in the tree. Under npm's -// hoisted layout that goes unnoticed; under pnpm's isolated one the dependent cannot resolve it, -// which is a build failure that reads as a missing export rather than as a missing install. -// -// The override makes it worse rather than better: it rewrites the peer RANGE to the `file:` spec, -// and no resolved semver version satisfies one, so pnpm reports the peer unmet even when a correct -// copy is present. Adding a direct dependency is what puts the package in the tree regardless of -// whether its range can ever be satisfied. -// -// Derived from the tarballs rather than listed here, for the reason this file already gives about -// package names: the manifests answer it, and a list would be a second answer that has to be kept -// in step. `peerDependenciesMeta` is read alongside, so an OPTIONAL peer is never added - see the -// note in `readPackedNames`. -// Walked from what this manifest ACTUALLY installs, rather than unioned across every tarball. -// A union adds a peer because SOME packed package wants it: a blank scaffold, which installs no -// plugin, gained `@nextlyhq/plugin-sdk` because `plugin-form-builder` declares it. That is the -// same failure as adding an optional adapter - it lets an accidental import compile in a project -// whose real install would reject it, so the leg passes on code a user cannot run. -// -// Transitive, because a peer that gets added brings its own required peers with it, and stopping -// at one level would leave the second unresolvable for exactly the reason the first was. -const declared = name => - Boolean( - manifest.dependencies?.[name] || - manifest.devDependencies?.[name] || - manifest.optionalDependencies?.[name] - ); - -const reachable = new Set(names.filter(declared)); -const frontier = [...reachable]; -const needed = new Set(); -while (frontier.length > 0) { - const name = frontier.pop(); - for (const peer of packed[name]?.peers ?? []) { - // A peer that was not packed is left to the registry, which is where it would come from - // in a real install too. - if (!names.includes(peer)) continue; - if (reachable.has(peer) || needed.has(peer)) continue; - needed.add(peer); - frontier.push(peer); - } -} - -let added = 0; -for (const peer of needed) { - if (declared(peer)) continue; - manifest.dependencies = { ...manifest.dependencies, [peer]: overrides[peer] }; - added += 1; -} - -if (packageManager === "pnpm") { - manifest.pnpm = { ...manifest.pnpm }; - manifest.pnpm.overrides = { ...manifest.pnpm.overrides, ...overrides }; -} else { - manifest.overrides = { ...manifest.overrides, ...overrides }; -} - -writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`); - -// A control on the rewrite. Overriding packages the project never depended on -// would leave the install pulling the registry copies while this script reports -// success, so the count of DIRECT dependencies actually repointed has to be -// non-zero. -console.log(`packed packages found: ${names.length} (${names.join(", ")})`); -console.log(`direct dependencies repointed at a tarball: ${pinned}`); -if (pinned === 0) { - console.error( - "None of the packed packages are dependencies of this project — the " + - "override would have no effect and the build would test the registry." - ); - process.exit(1); -} From 390748486173dc273ae145c06a667d3fd7ff62a7 Mon Sep 17 00:00:00 2001 From: Mobeen Abdullah Date: Sun, 16 Aug 2026 03:40:50 +0500 Subject: [PATCH 5/5] ci(root): start the registry before redirecting to it, and tag every version alpha MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two defects that a green run does not reveal. The registry redirect was written to `$HOME/.npmrc` BEFORE `npx` fetched verdaccio, so that fetch was aimed at a port nothing was listening on. It survived only because the step runs from the repository root, whose own `.npmrc` names npmjs and outranks the user-level file — a working directory away from hanging. Reproduced by moving the redirect ahead of the start locally: verdaccio never came up. Starting it first removes the dependency on config precedence. Every published version now also carries the `alpha` tag. The generator resolves a version by asking npmjs for dist-tags, and a package whose only published artifact is its `0.0.0` name-claiming placeholder has no `alpha` tag to find, so it writes the literal string `alpha` as the spec rather than a range. A registry carrying only `latest` cannot satisfy that, and the leg would fail on an unavailable tag instead of testing the new package. --- .github/workflows/scaffold-build.yml | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/.github/workflows/scaffold-build.yml b/.github/workflows/scaffold-build.yml index ed14bc6438..dd5c62f87d 100644 --- a/.github/workflows/scaffold-build.yml +++ b/.github/workflows/scaffold-build.yml @@ -374,6 +374,14 @@ jobs: # The scope line is not redundant with the registry line. A `@scope:registry` entry # outranks even an explicit `--registry` argument, so without it every `@nextlyhq` # request goes to npmjs and the leg silently tests the last release. + # STARTED BEFORE the redirect below, and the order is load-bearing. `verdaccio` is not a + # repository dependency, so this fetches it, and a redirect written first would point that + # fetch at a port nothing is listening on yet. It currently survives only because this + # step runs from the repository root, whose own `.npmrc` names npmjs and outranks the + # user-level file being written below — a working directory away from failing. + npx --yes verdaccio@6 --config /tmp/local-registry/config.yaml --listen 4873 \ + > /tmp/local-registry/server.log 2>&1 & + { echo "registry=http://localhost:4873/" echo "@nextlyhq:registry=http://localhost:4873/" @@ -381,9 +389,6 @@ jobs: echo "provenance=false" } >> "$HOME/.npmrc" - npx --yes verdaccio@6 --config /tmp/local-registry/config.yaml --listen 4873 \ - > /tmp/local-registry/server.log 2>&1 & - for _ in $(seq 1 90); do curl -sf http://localhost:4873/-/ping > /dev/null 2>&1 && break sleep 1 @@ -410,11 +415,23 @@ jobs: # alone: the flag is what overrides each package's own `publishConfig.registry`, and # the config entry is what wins for a scoped name where the flag does not. # `--tag` is required because every version here is a prerelease. + # + # Every published version also carries the `alpha` tag. The generator resolves a version + # by asking npmjs for a package's dist-tags, and for a package whose only published + # artifact is its `0.0.0` name-claiming placeholder there is no `alpha` tag to find — so + # it writes the literal string `alpha` as the spec rather than a range. A local registry + # carrying only `latest` cannot satisfy that, and the leg would fail on an unavailable + # tag instead of testing the new package. published=0 for f in /tmp/nextly-workspace-packs/*.tgz; do (cd /tmp && npm publish "$f" \ --registry http://localhost:4873 --tag latest --provenance=false) \ >> /tmp/local-registry/publish.log 2>&1 && published=$((published + 1)) + + meta=$(tar -xzOf "$f" package/package.json) + spec=$(node -e "let d='';process.stdin.on('data',c=>d+=c).on('end',()=>{const m=JSON.parse(d);console.log(m.name+'@'+m.version)})" <<<"$meta") + (cd /tmp && npm dist-tag add "$spec" alpha --registry http://localhost:4873) \ + >> /tmp/local-registry/publish.log 2>&1 done expected=$(ls /tmp/nextly-workspace-packs/*.tgz | wc -l | tr -d ' ')