OverviewFilesCommitsIssuesDocsReleasesPeers

pgh commits / 57170e3

pgh 0.1.119: guard publish against unresolved file: workspace deps
collectDepsRecursive silently skips broken symlinks, so a declared file: dep whose target is absent (e.g. publishing from an isolated CI git-archive checkout where ../<sibling> doesn't exist) vanishes from the closure with no error and ships a tree that crashes at import. This shipped pear-git-v3 0.1.147/680439eb with all 13 workspace deps dropped. computeLockfile now verifies every declared file: dep resolves and throws a clear error otherwise. Also syncs package-lock (was lagging at 0.1.111) — the same loose version discipline that let pear-git-v3 0.1.147 exist as two trees. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
57170e31af77b9a9ed00aaadfac78cf364ecba3b
by plankatron <fleeky@gmail.com> · 2026-07-17T18:57:24.000Z

Changed files (3) +44 -3

lib/publish.js +41 -0
……@@ -108,6 +108,47 @@ export async function computeLockfile (appDir, opts = {}) {
108108 const seen = new Set()
109109 if (fs.existsSync(depDir)) collectDepsRecursive(depDir, deps, seen, strategy)
110110
111 // Guard: every declared file: (workspace) dependency MUST resolve into the
112 // closure. collectDepsRecursive() silently skips broken symlinks (it has to,
113 // to survive npm's hoisting leftovers), so a workspace dep whose file: target
114 // is absent — e.g. `pgh publish` from an isolated CI git-archive checkout
115 // where ../<sibling> doesn't exist — vanishes with no error and ships a tree
116 // that crashes at import. This was the root cause of the 2026-07-15
117 // pear-git-v3 0.1.147 broken publish (all 13 workspace deps dropped). Refuse
118 // to publish a tree that would crash at import; fail loudly instead.
119 if (pkg && pkg.dependencies) {
120 const dropped = []
121 for (const [depName, spec] of Object.entries(pkg.dependencies)) {
122 if (typeof spec !== 'string' || !spec.startsWith('file:')) continue
123 const entry = path.join(depDir, ...depName.split('/'))
124 let why = null
125 try {
126 const st = fs.statSync(entry) // follows the symlink; throws if broken/missing
127 if (!st.isDirectory()) why = 'not a directory'
128 else if (!fs.existsSync(path.join(entry, 'package.json'))) why = 'no package.json'
129 } catch {
130 try {
131 const lst = fs.lstatSync(entry)
132 why = lst.isSymbolicLink()
133 ? 'broken symlink -> ' + fs.readlinkSync(entry)
134 : 'unreadable'
135 } catch { why = 'missing from node_modules' }
136 }
137 if (why) dropped.push(' ' + depName + ' (' + spec + '): ' + why)
138 }
139 if (dropped.length) {
140 throw new Error(
141 'publish: ' + dropped.length + ' declared file: dependenc' +
142 (dropped.length === 1 ? 'y' : 'ies') + ' did not resolve into the closure — ' +
143 'refusing to publish a tree that would crash at import:\n' + dropped.join('\n') +
144 '\n\nThis usually means publish ran from an isolated checkout (e.g. a CI ' +
145 'git-archive) where the workspace siblings are absent. Vendor them into ' +
146 'node_modules before publishing, or publish from a tree where the file: ' +
147 'targets resolve.'
148 )
149 }
150 }
151
111152 return {
112153 name,
113154 version,
package-lock.json +2 -2
……@@ -1,12 +1,12 @@
11{
22 "name": "pgh",
3 "version": "0.1.111",
3 "version": "0.1.119",
44 "lockfileVersion": 3,
55 "requires": true,
66 "packages": {
77 "": {
88 "name": "pgh",
9 "version": "0.1.111",
9 "version": "0.1.119",
1010 "license": "AGPL-3.0",
1111 "dependencies": {
1212 "autobase": "^7.0.0",
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "pgh",
3 "version": "0.1.118",
3 "version": "0.1.119",
44 "description": "pgh — P2P content-addressed package manager and app launcher",
55 "type": "module",
66 "main": "index.js",