Skip to content

Commit 282ef3c

Browse files
committed
chore: enforce selective publishing and upstream version sync
Update Cursor/Claude policy guidance and release tooling to publish only targeted packages while preserving upstream package versions during upstream:update syncs. Made-with: Cursor
1 parent 5e79ecd commit 282ef3c

7 files changed

Lines changed: 242 additions & 17 deletions

File tree

.claude/instructions.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,9 @@ oclif-based CLI for:
225225
- **Automated releases**: Via GitHub Actions
226226
- **Default bump policy**: Always use `patch` by default for releases/versioning.
227227
- Use `minor` or `major` only when the user explicitly requests it.
228+
- **Selective publish only**: Publish only selected packages and changeset-propagated dependents, never all unpublished packages.
229+
- **Independent versions (current policy)**: Packages version independently (no workspace-wide lockstep assumption).
230+
- **Upstream sync versioning**: `upstream:update` must preserve/copy upstream package versions for synced `elements-react` and `lib-react` packages.
228231

229232
## Current Work Focus
230233

.cursor/rules/upstream-sync-edit-policy.mdc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ When a fix is needed in those areas:
1818
2. Sync changes into this repository:
1919
- Prefer `bun run upstream:update`
2020
- For targeted updates, use `bun run upstream:sync --element=<name>`
21+
- Preserve/copy upstream package versions into synced package.json files (do not reset to local defaults)
2122
3. Re-verify behavior in `apps/element-demo` and run diagnostics on touched files.
2223

2324
Do not bypass this workflow unless the user explicitly asks for a temporary emergency local patch.

.cursorrules

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,6 @@
1111
- Only make temporary direct edits in synced files if the user explicitly approves an emergency local-only debugging patch.
1212
- Release/versioning default: always use a patch bump by default.
1313
- For Changesets-driven releases, default to `patch` unless the user explicitly asks for `minor` or `major`.
14+
- Publish policy: publish only explicitly selected packages (and packages bumped by changeset dependency propagation), never "all unpublished packages".
15+
- Versioning policy (current): packages are versioned independently; do not assume workspace-wide lockstep versions.
16+
- Upstream sync policy: `upstream:update` should preserve/copy package versions from upstream package.json when syncing `packages/elements-react/*` and `packages/lib-react/*`.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
"release": "bun run release:publish",
4545
"release:publish": "bun run build && bun run cli verify:controllers && node ./scripts/changeset-publish-resolved-workspaces.mjs",
4646
"release:manual": "bun run release:publish",
47+
"release:publish:packages": "node ./scripts/release-publish-selective.mjs",
4748
"release:label": "node ./scripts/create-release-label.mjs",
4849
"release:label:push": "node ./scripts/create-release-label.mjs --push",
4950
"clean": "turbo run clean && rm -rf node_modules",

scripts/changeset-publish-resolved-workspaces.mjs

Lines changed: 166 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,15 @@
1-
import { spawn } from 'node:child_process';
1+
import { spawn, spawnSync } from 'node:child_process';
22
import { readFileSync, writeFileSync } from 'node:fs';
33
import { join } from 'node:path';
44
import { globSync } from 'glob';
55

66
const repoRoot = process.cwd();
77
const depSections = ['dependencies', 'peerDependencies', 'optionalDependencies', 'devDependencies'];
8+
const publishAttempts = Number(process.env.RELEASE_PUBLISH_ATTEMPTS || 2);
9+
const explicitPackages = (process.env.RELEASE_PACKAGES || '')
10+
.split(',')
11+
.map((s) => s.trim())
12+
.filter(Boolean);
813

914
const rootPackage = JSON.parse(readFileSync(join(repoRoot, 'package.json'), 'utf8'));
1015
const workspacePatterns = Array.isArray(rootPackage.workspaces) ? rootPackage.workspaces : [];
@@ -116,43 +121,162 @@ const restoreWorkspaceRanges = () => {
116121

117122
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
118123

119-
const runChangesetPublishOnce = () =>
124+
const run = (cmd, args, options = {}) => {
125+
const result = spawnSync(cmd, args, {
126+
cwd: repoRoot,
127+
encoding: 'utf8',
128+
...options,
129+
});
130+
if (result.error) throw result.error;
131+
return result;
132+
};
133+
134+
const toLines = (value) =>
135+
String(value || '')
136+
.split('\n')
137+
.map((line) => line.trim())
138+
.filter(Boolean);
139+
140+
const getCurrentPackageInfo = (packageJsonPath) => {
141+
const pkg = JSON.parse(readFileSync(packageJsonPath, 'utf8'));
142+
return {
143+
name: pkg?.name,
144+
version: pkg?.version,
145+
private: pkg?.private === true,
146+
};
147+
};
148+
149+
const getPackageInfoAtRef = (ref, packageJsonPathRelative) => {
150+
const result = run('git', ['show', `${ref}:${packageJsonPathRelative}`]);
151+
if (result.status !== 0) return null;
152+
try {
153+
const pkg = JSON.parse(result.stdout);
154+
return { name: pkg?.name, version: pkg?.version, private: pkg?.private === true };
155+
} catch {
156+
return null;
157+
}
158+
};
159+
160+
const listChangedPackageJsons = () => {
161+
const unstaged = run('git', [
162+
'diff',
163+
'--name-only',
164+
'--',
165+
':(glob)packages/**/package.json',
166+
':(glob)tools/**/package.json',
167+
]);
168+
const staged = run('git', [
169+
'diff',
170+
'--name-only',
171+
'--cached',
172+
'--',
173+
':(glob)packages/**/package.json',
174+
':(glob)tools/**/package.json',
175+
]);
176+
177+
const paths = [...toLines(unstaged.stdout), ...toLines(staged.stdout)];
178+
return [...new Set(paths)];
179+
};
180+
181+
const listVersionBumpedPackages = () => {
182+
const candidates = listChangedPackageJsons();
183+
const bumped = new Map();
184+
185+
for (const relativePath of candidates) {
186+
const absolutePath = join(repoRoot, relativePath);
187+
const current = getCurrentPackageInfo(absolutePath);
188+
const atHead = getPackageInfoAtRef('HEAD', relativePath);
189+
if (!current?.name || current.private) continue;
190+
if (!atHead || atHead.version !== current.version) {
191+
bumped.set(current.name, current.version);
192+
}
193+
}
194+
195+
if (bumped.size > 0) return bumped;
196+
197+
// Fallback for CI publish commits: compare HEAD~1..HEAD
198+
const rangeDiff = run('git', [
199+
'diff',
200+
'--name-only',
201+
'HEAD~1..HEAD',
202+
'--',
203+
':(glob)packages/**/package.json',
204+
':(glob)tools/**/package.json',
205+
]);
206+
const rangePaths = [...new Set(toLines(rangeDiff.stdout))];
207+
208+
for (const relativePath of rangePaths) {
209+
const current = getPackageInfoAtRef('HEAD', relativePath);
210+
const previous = getPackageInfoAtRef('HEAD~1', relativePath);
211+
if (!current?.name || current.private) continue;
212+
if (!previous || previous.version !== current.version) {
213+
bumped.set(current.name, current.version);
214+
}
215+
}
216+
217+
return bumped;
218+
};
219+
220+
const resolveExplicitPackages = () => {
221+
const selected = new Map();
222+
for (const packageJsonPath of packageJsonPaths) {
223+
const pkg = JSON.parse(readFileSync(packageJsonPath, 'utf8'));
224+
if (!pkg?.name || pkg.private === true) continue;
225+
if (explicitPackages.includes(pkg.name)) {
226+
selected.set(pkg.name, pkg.version);
227+
}
228+
}
229+
return selected;
230+
};
231+
232+
const getPublishedVersion = (packageName) => {
233+
const result = run('npm', ['view', packageName, 'version', '--json']);
234+
if (result.status !== 0) return null;
235+
const text = String(result.stdout || '').trim();
236+
if (!text) return null;
237+
try {
238+
const parsed = JSON.parse(text);
239+
if (Array.isArray(parsed)) return parsed[parsed.length - 1] || null;
240+
return parsed || null;
241+
} catch {
242+
return text || null;
243+
}
244+
};
245+
246+
const publishWorkspaceOnce = (packageName) =>
120247
new Promise((resolve, reject) => {
121-
const child = spawn('bunx', ['changeset', 'publish'], {
248+
const child = spawn('npm', ['publish', '--workspace', packageName, '--access', 'public'], {
122249
cwd: repoRoot,
123250
stdio: 'inherit',
124251
env: process.env,
125252
});
126253

127254
child.on('close', (code) => {
128255
if (code === 0) resolve();
129-
else reject(new Error(`changeset publish exited with code ${code}`));
256+
else reject(new Error(`npm publish failed for ${packageName} with code ${code}`));
130257
});
131258
child.on('error', reject);
132259
});
133260

134-
const runChangesetPublish = async () => {
135-
// Retry once to recover from transient npm issues or partial publishes.
136-
// On retry, Changesets skips versions that are already published.
137-
const maxAttempts = 2;
261+
const publishWorkspaceWithRetry = async (packageName) => {
138262
let lastError;
139263

140-
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
264+
for (let attempt = 1; attempt <= publishAttempts; attempt++) {
141265
try {
142-
console.log(`[release] Running changeset publish (attempt ${attempt}/${maxAttempts})`);
143-
await runChangesetPublishOnce();
266+
console.log(`[release] Publishing ${packageName} (attempt ${attempt}/${publishAttempts})`);
267+
await publishWorkspaceOnce(packageName);
144268
return;
145269
} catch (error) {
146270
lastError = error;
147-
if (attempt === maxAttempts) break;
271+
if (attempt === publishAttempts) break;
148272
console.warn(
149-
`[release] changeset publish failed on attempt ${attempt}; retrying once in 5s...`
273+
`[release] publish failed for ${packageName} on attempt ${attempt}; retrying in 5s...`
150274
);
151275
await sleep(5000);
152276
}
153277
}
154278

155-
throw lastError;
279+
throw lastError || new Error(`publish failed for ${packageName}`);
156280
};
157281

158282
try {
@@ -176,7 +300,34 @@ try {
176300
);
177301
}
178302

179-
await runChangesetPublish();
303+
const targetPackages =
304+
explicitPackages.length > 0 ? resolveExplicitPackages() : listVersionBumpedPackages();
305+
306+
if (targetPackages.size === 0) {
307+
const explicitHint =
308+
explicitPackages.length > 0
309+
? ` (requested RELEASE_PACKAGES=${explicitPackages.join(',')})`
310+
: '';
311+
throw new Error(
312+
`[release] No version-bumped publish targets found${explicitHint}. Refusing to publish all packages.`
313+
);
314+
}
315+
316+
const packageList = [...targetPackages.entries()].map(([name, version]) => ({ name, version }));
317+
console.log(
318+
`[release] Selected publish targets (${packageList.length}): ${packageList
319+
.map((p) => `${p.name}@${p.version}`)
320+
.join(', ')}`
321+
);
322+
323+
for (const { name, version } of packageList) {
324+
const published = getPublishedVersion(name);
325+
if (published === version) {
326+
console.log(`[release] Skipping ${name}@${version} (already published)`);
327+
continue;
328+
}
329+
await publishWorkspaceWithRetry(name);
330+
}
180331
} finally {
181332
restoreWorkspaceRanges();
182333
if (changedFiles.length > 0) {
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { spawn } from 'node:child_process';
2+
3+
const usage = () => {
4+
console.log(`Usage:
5+
node scripts/release-publish-selective.mjs --packages <pkg1,pkg2>
6+
7+
Examples:
8+
bun run release:publish:packages -- --packages @pie-element/mc-populated-blank
9+
bun run release:publish:packages -- --packages @pie-element/charting,@pie-element/multiple-choice
10+
`);
11+
};
12+
13+
const parsePackages = (argv) => {
14+
for (let i = 0; i < argv.length; i++) {
15+
const arg = argv[i];
16+
if (arg === '--help' || arg === '-h') {
17+
usage();
18+
process.exit(0);
19+
}
20+
if (arg === '--packages') {
21+
const value = String(argv[i + 1] || '')
22+
.split(',')
23+
.map((s) => s.trim())
24+
.filter(Boolean);
25+
return [...new Set(value)];
26+
}
27+
}
28+
return [];
29+
};
30+
31+
const packages = parsePackages(process.argv.slice(2));
32+
if (packages.length === 0) {
33+
usage();
34+
throw new Error('Missing required --packages argument.');
35+
}
36+
37+
const child = spawn('bun', ['run', 'release:publish'], {
38+
stdio: 'inherit',
39+
env: {
40+
...process.env,
41+
RELEASE_PACKAGES: packages.join(','),
42+
},
43+
});
44+
45+
child.on('close', (code) => {
46+
process.exit(code ?? 1);
47+
});
48+
49+
child.on('error', (error) => {
50+
console.error(error);
51+
process.exit(1);
52+
});

tools/cli/src/lib/upstream/sync-package-manager.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -396,6 +396,18 @@ export function extractUpstreamDependencies(
396396
return expectedDeps;
397397
}
398398

399+
function resolveSyncedVersion(
400+
upstreamPkg: PackageJson | null,
401+
existingPkg: PackageJson | null
402+
): string {
403+
const upstreamVersion = typeof upstreamPkg?.version === 'string' ? upstreamPkg.version : null;
404+
if (upstreamVersion) {
405+
return upstreamVersion;
406+
}
407+
const existingVersion = typeof existingPkg?.version === 'string' ? existingPkg.version : null;
408+
return existingVersion || '0.1.0';
409+
}
410+
399411
/**
400412
* Ensure devDependencies include all required build tools
401413
*/
@@ -503,7 +515,7 @@ export async function ensureElementPackageJson(
503515
pkg = {
504516
name: `${WORKSPACE.PIE_ELEMENT_PREFIX}${elementName}`,
505517
private: true,
506-
version: '0.1.0',
518+
version: resolveSyncedVersion(upstreamPkg, null),
507519
description:
508520
(upstreamPkg?.description as string | undefined) ??
509521
`React implementation of ${elementName} element synced from pie-elements`,
@@ -581,6 +593,7 @@ export async function ensureElementPackageJson(
581593

582594
// Set core package.json fields
583595
pkg.name = `${WORKSPACE.PIE_ELEMENT_PREFIX}${elementName}`;
596+
pkg.version = resolveSyncedVersion(upstreamPkg, pkg);
584597
pkg.type = PACKAGE_DEFAULTS.TYPE;
585598
pkg.main = './dist/index.js';
586599
pkg.types = './dist/index.d.ts';
@@ -711,7 +724,7 @@ export async function ensurePieLibPackageJson(
711724
pkg = {
712725
name: `${WORKSPACE.PIE_LIB_PREFIX}${pkgName}`,
713726
private: true,
714-
version: '0.1.0',
727+
version: resolveSyncedVersion(upstreamPkg, null),
715728
description:
716729
(upstreamPkg?.description as string | undefined) ??
717730
`React implementation of @pie-lib/${pkgName} synced from pie-lib`,
@@ -742,6 +755,7 @@ export async function ensurePieLibPackageJson(
742755
};
743756

744757
pkg.name = `${WORKSPACE.PIE_LIB_PREFIX}${pkgName}`;
758+
pkg.version = resolveSyncedVersion(upstreamPkg, pkg);
745759
pkg.type = PACKAGE_DEFAULTS.TYPE;
746760
pkg.main = './dist/index.js';
747761
pkg.types = './dist/index.d.ts';

0 commit comments

Comments
 (0)