Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions scripts/__tests__/release-local.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -434,11 +434,11 @@ test('stable orchestrator recovers incomplete merged tags and defers stale check
'scripts/release-local-stable.mjs: SDK reruns must resume npm publish explicitly',
);
assert.ok(
content.includes("case 'mcp'") && content.includes('apps/mcp'),
content.includes('resumeMcpPublish') && content.includes('apps/mcp'),
'scripts/release-local-stable.mjs: MCP reruns must have an explicit resume path',
);
assert.ok(
content.includes("case 'cli'") && content.includes('apps/cli/scripts/publish.js'),
content.includes('resumeCliPublish') && content.includes('apps/cli/scripts/publish.js'),
'scripts/release-local-stable.mjs: CLI reruns must resume via its dedicated publish script',
);
assert.ok(
Expand Down
120 changes: 64 additions & 56 deletions scripts/release-local-stable.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -580,18 +580,18 @@ async function inspectPackageReleaseState(pkg, { tag, version, workspaceRoot = R
const release = hasGitHubReleaseContext() ? await getGitHubReleaseByTag(tag) : null;
const githubComplete = isGitHubReleaseComplete(pkg, release);

let sdkPythonPublished = true;
let pythonPublished = true;
if (pkg.pythonPackages) {
const publishedFlags = await Promise.all(
pkg.pythonPackages.map((packageName) => isPyPiVersionPublished(packageName, version)),
);
sdkPythonPublished = publishedFlags.every(Boolean);
pythonPublished = publishedFlags.every(Boolean);
}

return {
publishComplete,
githubComplete,
sdkPythonPublished,
pythonPublished,
release,
};
}
Expand All @@ -617,48 +617,53 @@ function ensureBranchHeadCurrent(branchName) {
}
}

function resumePackagePublish(pkg, distTag, options = {}) {
const { workspaceRoot = REPO_ROOT, skipBuild = workspaceRoot === REPO_ROOT } = options;
// Per-package publish-resume adapters. Each runs after a tagged snapshot is
// checked out (or in-place when the tag is at HEAD), and is responsible for
// republishing whatever the original release attempt left missing on npm.
// PyPI publishing for the SDK lives in the workflow, not here - see
// preparePythonSnapshot for the snapshot artifact handoff.

switch (pkg.name) {
case 'cli':
runInWorkspace(workspaceRoot, 'pnpm', ['--prefix', join(workspaceRoot, 'apps/cli'), 'run', 'build:prepublish']);
runInWorkspace(workspaceRoot, 'node', [join(workspaceRoot, 'apps/cli/scripts/publish.js'), '--tag', distTag]);
break;
case 'sdk':
runInWorkspace(workspaceRoot, 'node', [
join(workspaceRoot, 'packages/sdk/scripts/sdk-release-publish.mjs'),
'--tag',
distTag,
'--npm-only',
]);
break;
case 'mcp': {
// MCP recovery snapshots only run `pnpm install`, so MCP's build output
// isn't on disk. Rebuild before publishing so the `dist/` tarball
// declared in apps/mcp/package.json files actually ships.
if (!skipBuild) {
runInWorkspace(workspaceRoot, 'pnpm', ['run', 'generate:all']);
runInWorkspace(workspaceRoot, 'pnpm', ['run', 'build:superdoc']);
runInWorkspace(workspaceRoot, 'pnpm', ['--prefix', join(workspaceRoot, 'packages/sdk/langs/node'), 'run', 'build']);
}
const mcpRoot = join(workspaceRoot, 'apps/mcp');
runInWorkspace(mcpRoot, 'pnpm', ['run', 'build']);
// `pnpm publish` does not honor `--prefix` (passes through to npm and
// errors with EUSAGE); it must run with cwd at the package root.
runInWorkspace(mcpRoot, 'pnpm', [
'publish',
'--no-git-checks',
'--access',
'public',
'--tag',
distTag,
]);
break;
}
default:
throw new Error(`No resume command configured for ${pkg.name}`);
}
function resumeCliPublish(workspaceRoot, distTag) {
runInWorkspace(workspaceRoot, 'pnpm', ['--prefix', join(workspaceRoot, 'apps/cli'), 'run', 'build:prepublish']);
runInWorkspace(workspaceRoot, 'node', [join(workspaceRoot, 'apps/cli/scripts/publish.js'), '--tag', distTag]);
}

function resumeSdkPublish(workspaceRoot, distTag) {
runInWorkspace(workspaceRoot, 'node', [
join(workspaceRoot, 'packages/sdk/scripts/sdk-release-publish.mjs'),
'--tag',
distTag,
'--npm-only',
]);
}

function resumeMcpPublish(workspaceRoot, distTag, options = {}) {
const { skipBuild = workspaceRoot === REPO_ROOT } = options;
// MCP recovery snapshots only run `pnpm install`, so MCP's build output
// isn't on disk. Rebuild before publishing so the `dist/` tarball
// declared in apps/mcp/package.json files actually ships.
if (!skipBuild) {
runInWorkspace(workspaceRoot, 'pnpm', ['run', 'generate:all']);
runInWorkspace(workspaceRoot, 'pnpm', ['run', 'build:superdoc']);
runInWorkspace(workspaceRoot, 'pnpm', ['--prefix', join(workspaceRoot, 'packages/sdk/langs/node'), 'run', 'build']);
}
const mcpRoot = join(workspaceRoot, 'apps/mcp');
runInWorkspace(mcpRoot, 'pnpm', ['run', 'build']);
// `pnpm publish` does not honor `--prefix` (passes through to npm and
// errors with EUSAGE); it must run with cwd at the package root.
runInWorkspace(mcpRoot, 'pnpm', [
'publish',
'--no-git-checks',
'--access',
'public',
'--tag',
distTag,
]);
}

function prepareSdkPythonSnapshot(workspaceRoot, tag) {
runInWorkspace(workspaceRoot, 'node', [join(workspaceRoot, 'packages/sdk/scripts/build-python-sdk.mjs')]);
return copySdkPythonArtifacts(workspaceRoot, tag);
}

async function recoverPackageRelease(pkg, { tag, version, distTag, branchRef, initialState = null }) {
Expand All @@ -671,22 +676,21 @@ async function recoverPackageRelease(pkg, { tag, version, distTag, branchRef, in
}

let state = initialState ?? (await inspectPackageReleaseState(pkg, { tag, version, workspaceRoot }));
const needsSnapshotPython = pkg.name === 'sdk' && !state.sdkPythonPublished && snapshot;
const needsSnapshotPython = Boolean(pkg.pythonPackages) && !state.pythonPublished && snapshot;
const needsPublishResume = !state.publishComplete || needsSnapshotPython;

if (needsPublishResume) {
console.log(
`${pkg.name} release ${tag} is incomplete; resuming publish (${distTag})${snapshot ? ' from tagged snapshot' : ''}.`,
);
resumePackagePublish(pkg, distTag, { workspaceRoot, skipBuild: !snapshot });
pkg.resumePublish(workspaceRoot, distTag, { skipBuild: !snapshot });
state = await inspectPackageReleaseState(pkg, { tag, version, workspaceRoot });
}

let sdkPythonSnapshot = null;
if (pkg.name === 'sdk' && !state.sdkPythonPublished && snapshot) {
console.log(`Preparing Python SDK artifacts for recovered ${tag} snapshot.`);
runInWorkspace(workspaceRoot, 'node', [join(workspaceRoot, 'packages/sdk/scripts/build-python-sdk.mjs')]);
sdkPythonSnapshot = copySdkPythonArtifacts(workspaceRoot, tag);
let pythonSnapshot = null;
if (pkg.preparePythonSnapshot && !state.pythonPublished && snapshot) {
console.log(`Preparing Python artifacts for recovered ${tag} snapshot.`);
pythonSnapshot = pkg.preparePythonSnapshot(workspaceRoot, tag);
}

const previousTag = getPreviousMergedReleaseTag(pkg.tagPattern, tag, branchRef);
Expand All @@ -698,7 +702,7 @@ async function recoverPackageRelease(pkg, { tag, version, distTag, branchRef, in
});

const finalState = await inspectPackageReleaseState(pkg, { tag, version, workspaceRoot });
const readyForWorkflowPython = pkg.name !== 'sdk' || finalState.sdkPythonPublished || sdkPythonSnapshot || tagAtHead;
const readyForWorkflowPython = !pkg.pythonPackages || finalState.pythonPublished || pythonSnapshot || tagAtHead;
const missingParts = [
finalState.publishComplete ? '' : 'package publish',
finalState.githubComplete ? '' : 'GitHub release',
Expand All @@ -709,7 +713,7 @@ async function recoverPackageRelease(pkg, { tag, version, distTag, branchRef, in
throw new Error(`Recovery for ${pkg.name} ${tag} is still incomplete: ${missingParts.join(', ')}`);
}

return { tag, version, distTag, sdkPythonSnapshot };
return { tag, version, distTag, pythonSnapshot };
};

if (tagAtHead) {
Expand Down Expand Up @@ -738,7 +742,7 @@ async function maybeRecoverIncompleteRelease(pkg, branchRef) {
version,
});

const needsSnapshotPython = pkg.name === 'sdk' && !state.sdkPythonPublished && !isTagAtHead(latestTag);
const needsSnapshotPython = Boolean(pkg.pythonPackages) && !state.pythonPublished && !isTagAtHead(latestTag);
const needsRecovery = !state.publishComplete || !state.githubComplete || needsSnapshotPython;
if (!needsRecovery) {
return null;
Expand All @@ -754,7 +758,7 @@ async function maybeRecoverIncompleteRelease(pkg, branchRef) {
branchRef,
initialState: state,
});
recordSdkPythonSnapshot(recovery.sdkPythonSnapshot);
recordSdkPythonSnapshot(recovery.pythonSnapshot);
return recovery;
}

Expand Down Expand Up @@ -810,6 +814,7 @@ const packages = [
tagPrefix: 'cli-v',
tagPattern: 'cli-v*',
npmPackages: CLI_NPM_PACKAGES,
resumePublish: resumeCliPublish,
},
{
name: 'sdk',
Expand All @@ -818,13 +823,16 @@ const packages = [
tagPattern: 'sdk-v*',
npmPackages: SDK_NODE_NPM_PACKAGES,
pythonPackages: SDK_PYTHON_PACKAGES,
resumePublish: resumeSdkPublish,
preparePythonSnapshot: prepareSdkPythonSnapshot,
},
{
name: 'mcp',
packageCwd: 'apps/mcp',
tagPrefix: 'mcp-v',
tagPattern: 'mcp-v*',
npmPackages: ['@superdoc-dev/mcp'],
resumePublish: resumeMcpPublish,
},
];

Expand Down Expand Up @@ -943,7 +951,7 @@ for (let index = 0; index < packages.length; index += 1) {
distTag: recoveryDistTag,
branchRef,
});
recordSdkPythonSnapshot(recovery.sdkPythonSnapshot);
recordSdkPythonSnapshot(recovery.pythonSnapshot);
results.set(pkg.name, { status: 'released', newTags });
continue;
} catch (recoveryError) {
Expand Down
Loading