diff --git a/.woodpecker/publish.yml b/.woodpecker/publish.yml index 418ab21f..4212c304 100644 --- a/.woodpecker/publish.yml +++ b/.woodpecker/publish.yml @@ -209,23 +209,38 @@ steps: const pipelineNumber = process.env.CI_PIPELINE_NUMBER; const roots = ['apps', 'packages', 'plugins']; const updated = []; + const exactVersions = new Map(); // name -> bumped next version - function walk(dir) { + function walk(dir, visit) { if (!fs.existsSync(dir)) return; for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { if (entry.name === 'node_modules' || entry.name === 'dist' || entry.name === '.turbo') continue; const fullPath = path.join(dir, entry.name); if (entry.isDirectory()) { const packagePath = path.join(fullPath, 'package.json'); - if (fs.existsSync(packagePath)) updatePackage(packagePath); - walk(fullPath); + if (fs.existsSync(packagePath)) { + const manifest = JSON.parse(fs.readFileSync(packagePath, 'utf8')); + if (manifest.name?.startsWith('@mosaicstack/') && !manifest.private) { + visit(manifest, packagePath); + } + } + walk(fullPath, visit); } } } - function updatePackage(packagePath) { - const manifest = JSON.parse(fs.readFileSync(packagePath, 'utf8')); - if (!manifest.name?.startsWith('@mosaicstack/') || manifest.private) return; + // #1389: two passes. Pass 1 bumps every publishable manifest to + // -next. exactly as before, recording name -> + // bumped version. Pass 2 rewrites every published manifest's + // @mosaicstack/* dependency entries (dependencies, devDependencies, + // peerDependencies, optionalDependencies) to the EXACT same-pipeline + // build. A caret range like ^0.0.3-next.2636 leaves the resolver free + // to pick any later build — and on a host with a stale cache, an + // installer-side scaffold pinned at stable, or a registry hiccup, that + // freedom is how a "next" install ends up executing stable-era code + // (web1 evidence: old tier validator, missing migrations). Exact pins + // make the defect class unrepresentable regardless of resolver path. + function bump(manifest, packagePath) { const stableMatch = /^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/.exec(manifest.version); if (!stableMatch) { throw new Error(manifest.name + " has unsupported semver version '" + manifest.version + "'"); @@ -234,13 +249,40 @@ steps: const oldVersion = manifest.version; manifest.version = major + '.' + minor + '.' + (Number(patch) + 1) + '-next.' + pipelineNumber; fs.writeFileSync(packagePath, JSON.stringify(manifest, null, 2) + '\n'); + exactVersions.set(manifest.name, manifest.version); updated.push(manifest.name + ' ' + oldVersion + ' -> ' + manifest.version); } - for (const root of roots) walk(root); + const DEP_FIELDS = ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies']; + let pinnedEntries = 0; + function pin(manifest, packagePath) { + let changed = false; + for (const field of DEP_FIELDS) { + const deps = manifest[field]; + if (!deps || typeof deps !== 'object') continue; + for (const [name, range] of Object.entries(deps)) { + if (!name.startsWith('@mosaicstack/')) continue; + const exact = exactVersions.get(name); + if (!exact) { + throw new Error( + manifest.name + ' depends on ' + name + + ' which has no bumped version in this publish set — cannot pin'); + } + if (range === exact) continue; + deps[name] = exact; + pinnedEntries++; + changed = true; + } + } + if (changed) fs.writeFileSync(packagePath, JSON.stringify(manifest, null, 2) + '\n'); + } + + for (const root of roots) walk(root, bump); + for (const root of roots) walk(root, pin); if (updated.length === 0) throw new Error('No publishable @mosaicstack/* packages found'); console.log('[publish-next] computed prerelease versions for ' + updated.length + ' packages:'); for (const line of updated) console.log('[publish-next] ' + line); + console.log('[publish-next] pinned ' + pinnedEntries + ' @mosaicstack/* dep entries to exact same-pipeline versions across ' + updated.length + ' manifests'); NODE pnpm --filter "@mosaicstack/*" --filter "!@mosaicstack/web" --filter "!@mosaicstack/mosaic-as" publish --no-git-checks --access public --tag next EXPECTED_VERSION="$(node -p "require('./packages/mosaic/package.json').version")" @@ -250,6 +292,71 @@ steps: exit 1 fi echo "[publish-next] @mosaicstack/mosaic@next resolves to $RESOLVED_VERSION" + # #1389 post-publish guard: every freshly published manifest must carry + # EXACT same-pipeline @mosaicstack/* dep pins (no ranges, no stable + # fallback). A leak here fails the pipeline instead of shipping. + node <<'GUARD' + const { execFileSync } = require('node:child_process'); + const fs = require('node:fs'); + const path = require('node:path'); + const pipelineNumber = process.env.CI_PIPELINE_NUMBER; + const registry = 'https://git.mosaicstack.dev/api/packages/mosaicstack/npm/'; + const roots = ['apps', 'packages', 'plugins']; + const published = []; + function walk(dir) { + if (!fs.existsSync(dir)) return; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + if (entry.name === 'node_modules' || entry.name === 'dist' || entry.name === '.turbo') continue; + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + const packagePath = path.join(fullPath, 'package.json'); + if (fs.existsSync(packagePath)) { + const m = JSON.parse(fs.readFileSync(packagePath, 'utf8')); + if (m.name?.startsWith('@mosaicstack/') && !m.private) published.push(m.name); + } + walk(fullPath); + } + } + } + for (const root of roots) walk(root); + let failures = 0; + for (const name of published) { + let manifest; + try { + const out = execFileSync('npm', ['view', name + '@next', '--json', '--registry', registry], + { encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 }); + const arr = JSON.parse(out); + manifest = Array.isArray(arr) ? arr[arr.length - 1] : arr; + } catch (e) { + console.error('[publish-next-guard] FAIL ' + name + ': npm view failed: ' + e.message); + failures++; + continue; + } + const fields = ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies']; + for (const field of fields) { + const deps = manifest[field]; + if (!deps || typeof deps !== 'object') continue; + for (const [dep, range] of Object.entries(deps)) { + if (!dep.startsWith('@mosaicstack/')) continue; + const expected = dep === name ? manifest.version : null; + const isExactPin = /^\d+\.\d+\.\d+-next\./.test(range); + const samePipeline = range.endsWith('-next.' + pipelineNumber); + if (!isExactPin) { + console.error('[publish-next-guard] FAIL ' + name + ' -> ' + dep + ' range "' + range + '" is not an exact -next pin (stable-leak class, #1389)'); + failures++; + } else if (!samePipeline) { + console.error('[publish-next-guard] FAIL ' + name + ' -> ' + dep + ' pinned "' + range + '" but this pipeline published -next.' + pipelineNumber + ' (cross-pipeline pin)'); + failures++; + } + } + } + } + if (failures > 0) { + console.error('[publish-next-guard] FATAL: ' + failures + ' dep-pin violation(s) — stable-dep leak into next publish (#1389)'); + process.exit(1); + } + console.log('[publish-next-guard] OK: all ' + published.length + ' published manifests carry exact same-pipeline @mosaicstack/* dep pins'); + GUARD depends_on: - build - verify