ci(publish): pin next-channel @mosaicstack deps to exact same-pipeline builds (#1389) (#1400)
ci/woodpecker/push/publish Pipeline was canceled

Co-authored-by: code-be-01 <[email protected]>
This commit was merged in pull request #1400.
This commit is contained in:
2026-08-24 23:05:37 +00:00
committed by orch-01
parent d7b1dd9601
commit d790572e2e
+114 -7
View File
@@ -209,23 +209,38 @@ steps:
const pipelineNumber = process.env.CI_PIPELINE_NUMBER; const pipelineNumber = process.env.CI_PIPELINE_NUMBER;
const roots = ['apps', 'packages', 'plugins']; const roots = ['apps', 'packages', 'plugins'];
const updated = []; const updated = [];
const exactVersions = new Map(); // name -> bumped next version
function walk(dir) { function walk(dir, visit) {
if (!fs.existsSync(dir)) return; if (!fs.existsSync(dir)) return;
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (entry.name === 'node_modules' || entry.name === 'dist' || entry.name === '.turbo') continue; if (entry.name === 'node_modules' || entry.name === 'dist' || entry.name === '.turbo') continue;
const fullPath = path.join(dir, entry.name); const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) { if (entry.isDirectory()) {
const packagePath = path.join(fullPath, 'package.json'); const packagePath = path.join(fullPath, 'package.json');
if (fs.existsSync(packagePath)) updatePackage(packagePath); if (fs.existsSync(packagePath)) {
walk(fullPath); const manifest = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
if (manifest.name?.startsWith('@mosaicstack/') && !manifest.private) {
visit(manifest, packagePath);
}
}
walk(fullPath, visit);
} }
} }
} }
function updatePackage(packagePath) { // #1389: two passes. Pass 1 bumps every publishable manifest to
const manifest = JSON.parse(fs.readFileSync(packagePath, 'utf8')); // <stable+1>-next.<pipeline> exactly as before, recording name ->
if (!manifest.name?.startsWith('@mosaicstack/') || manifest.private) return; // 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); const stableMatch = /^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/.exec(manifest.version);
if (!stableMatch) { if (!stableMatch) {
throw new Error(manifest.name + " has unsupported semver version '" + manifest.version + "'"); throw new Error(manifest.name + " has unsupported semver version '" + manifest.version + "'");
@@ -234,13 +249,40 @@ steps:
const oldVersion = manifest.version; const oldVersion = manifest.version;
manifest.version = major + '.' + minor + '.' + (Number(patch) + 1) + '-next.' + pipelineNumber; manifest.version = major + '.' + minor + '.' + (Number(patch) + 1) + '-next.' + pipelineNumber;
fs.writeFileSync(packagePath, JSON.stringify(manifest, null, 2) + '\n'); fs.writeFileSync(packagePath, JSON.stringify(manifest, null, 2) + '\n');
exactVersions.set(manifest.name, manifest.version);
updated.push(manifest.name + ' ' + oldVersion + ' -> ' + 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'); if (updated.length === 0) throw new Error('No publishable @mosaicstack/* packages found');
console.log('[publish-next] computed prerelease versions for ' + updated.length + ' packages:'); console.log('[publish-next] computed prerelease versions for ' + updated.length + ' packages:');
for (const line of updated) console.log('[publish-next] ' + line); 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 NODE
pnpm --filter "@mosaicstack/*" --filter "!@mosaicstack/web" --filter "!@mosaicstack/mosaic-as" publish --no-git-checks --access public --tag next 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")" EXPECTED_VERSION="$(node -p "require('./packages/mosaic/package.json').version")"
@@ -250,6 +292,71 @@ steps:
exit 1 exit 1
fi fi
echo "[publish-next] @mosaicstack/mosaic@next resolves to $RESOLVED_VERSION" 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: depends_on:
- build - build
- verify - verify