From a47cd050b46b92f02dec7d6239e71e78afd1c22c Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Thu, 7 May 2026 18:14:13 -0300 Subject: [PATCH] refactor(release): capability-based descriptors for stable orchestrator Extract resumePackagePublish switch into per-descriptor resumePublish functions and replace pkg.name === 'sdk' branches with capability checks (pkg.pythonPackages, pkg.preparePythonSnapshot). No behavior change: the recovery engine becomes generic so adding superdoc/react/vscode-ext in follow-up PRs only adds adapters, not new switch arms. The internal field state.sdkPythonPublished is renamed to state.pythonPublished and recovery's returned snapshot field sdkPythonSnapshot to pythonSnapshot. recordSdkPythonSnapshot keeps its name so it continues emitting the sdk_python_snapshot_* GITHUB_OUTPUT keys consumed by release-stable.yml. Existing helper tests updated to match the refactored structure (the intent - each package has its own explicit resume path - is preserved). --- scripts/__tests__/release-local.test.mjs | 4 +- scripts/release-local-stable.mjs | 120 ++++++++++++----------- 2 files changed, 66 insertions(+), 58 deletions(-) diff --git a/scripts/__tests__/release-local.test.mjs b/scripts/__tests__/release-local.test.mjs index eff886ab32..b553f49718 100644 --- a/scripts/__tests__/release-local.test.mjs +++ b/scripts/__tests__/release-local.test.mjs @@ -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( diff --git a/scripts/release-local-stable.mjs b/scripts/release-local-stable.mjs index fb3d416c07..b43a70dfaa 100644 --- a/scripts/release-local-stable.mjs +++ b/scripts/release-local-stable.mjs @@ -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, }; } @@ -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 }) { @@ -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); @@ -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', @@ -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) { @@ -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; @@ -754,7 +758,7 @@ async function maybeRecoverIncompleteRelease(pkg, branchRef) { branchRef, initialState: state, }); - recordSdkPythonSnapshot(recovery.sdkPythonSnapshot); + recordSdkPythonSnapshot(recovery.pythonSnapshot); return recovery; } @@ -810,6 +814,7 @@ const packages = [ tagPrefix: 'cli-v', tagPattern: 'cli-v*', npmPackages: CLI_NPM_PACKAGES, + resumePublish: resumeCliPublish, }, { name: 'sdk', @@ -818,6 +823,8 @@ const packages = [ tagPattern: 'sdk-v*', npmPackages: SDK_NODE_NPM_PACKAGES, pythonPackages: SDK_PYTHON_PACKAGES, + resumePublish: resumeSdkPublish, + preparePythonSnapshot: prepareSdkPythonSnapshot, }, { name: 'mcp', @@ -825,6 +832,7 @@ const packages = [ tagPrefix: 'mcp-v', tagPattern: 'mcp-v*', npmPackages: ['@superdoc-dev/mcp'], + resumePublish: resumeMcpPublish, }, ]; @@ -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) {