diff --git a/.github/workflows/native-prebuilds.yml b/.github/workflows/native-prebuilds.yml index 6af0e0d7cc..141f216fd5 100644 --- a/.github/workflows/native-prebuilds.yml +++ b/.github/workflows/native-prebuilds.yml @@ -1,6 +1,12 @@ name: Native Prebuilds on: + workflow_call: + inputs: + native_package_dir: + required: false + type: string + default: crates/senpi-pty workflow_dispatch: inputs: native_package_dir: @@ -116,7 +122,7 @@ jobs: shell: bash env: - NATIVE_PACKAGE_DIR: ${{ github.event.inputs.native_package_dir || 'crates/senpi-pty' }} + NATIVE_PACKAGE_DIR: ${{ inputs.native_package_dir || 'crates/senpi-pty' }} NAPI_TARGET: ${{ matrix.napi_target }} RUST_TARGET: ${{ matrix.rust_target }} NODE_PLATFORM: ${{ matrix.node_platform }} @@ -261,7 +267,10 @@ jobs: artifact_dir="${RUNNER_TEMP}/native-prebuild-artifact" output_dir="${RUNNER_TEMP}/native-prebuild-output" - mkdir -p "${artifact_dir}" + host="${NODE_PLATFORM}-${NODE_ARCH}" + prebuild_dir="${artifact_dir}/native/prebuilds/${host}" + + mkdir -p "${prebuild_dir}" # macOS runners ship bash 3.2, which lacks mapfile/readarray. node_files=() while IFS= read -r node_file; do @@ -278,20 +287,12 @@ jobs: exit 1 fi - cp "${node_files[0]}" "${artifact_dir}/" - { - echo "artifact=${{ matrix.artifact }}" - echo "napi_target=${NAPI_TARGET}" - echo "rust_target=${RUST_TARGET}" - echo "node_platform=${NODE_PLATFORM}" - echo "node_arch=${NODE_ARCH}" - echo "file=$(basename "${node_files[0]}")" - } > "${artifact_dir}/manifest.txt" + cp "${node_files[0]}" "${prebuild_dir}/senpi_pty.${host}.node" - name: Upload native prebuild uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 with: name: ${{ matrix.artifact }} - path: ${{ runner.temp }}/native-prebuild-artifact/* + path: ${{ runner.temp }}/native-prebuild-artifact if-no-files-found: error retention-days: 14 diff --git a/.github/workflows/publish-npm.yml b/.github/workflows/publish-npm.yml index a40b23afbc..4d2f34efba 100644 --- a/.github/workflows/publish-npm.yml +++ b/.github/workflows/publish-npm.yml @@ -20,6 +20,7 @@ name: Release default: false permissions: + actions: read contents: read id-token: write @@ -28,7 +29,13 @@ concurrency: cancel-in-progress: false jobs: + native-prebuilds: + if: ${{ inputs.publish-only == true }} + uses: ./.github/workflows/native-prebuilds.yml + release: + needs: native-prebuilds + if: ${{ always() && (inputs.publish-only != true || needs.native-prebuilds.result == 'success') }} runs-on: ubuntu-latest timeout-minutes: 30 steps: @@ -61,6 +68,14 @@ jobs: - name: Install dependencies run: npm install --ignore-scripts --no-audit --no-fund + - name: Download native prebuilds + if: inputs.publish-only == true + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + pattern: native-prebuild-* + path: packages/pty + merge-multiple: true + # coding-agent tool tests resolve rg/fd via system binaries first and only # download from GitHub releases as a fallback. Unauthenticated downloads # are rate-limited on shared runner egress IPs, which flakes the release @@ -113,7 +128,7 @@ jobs: if [ "$DRY_RUN" = "true" ]; then PUBLISH_ARGS+=(--dry-run) fi - node scripts/publish.mjs "${PUBLISH_ARGS[@]}" + node scripts/publish.mjs "${PUBLISH_ARGS[@]}" --require-native-prebuild=linux-x64 - name: Workflow summary if: always() diff --git a/scripts/changes.md b/scripts/changes.md index 0507e0aa1c..e54694be05 100644 --- a/scripts/changes.md +++ b/scripts/changes.md @@ -1,5 +1,32 @@ # changes +## Require Linux x64 PTY prebuild in publish-only packaging (2026-08-31) + +### What changed + +- `scripts/publish.mjs` accepts `--require-native-prebuild=` and passes + the required target through staging and final npm-pack validation. The + publish-only workflow now builds native artifacts through a reusable workflow, + downloads the consumer-relative `native/prebuilds//` tree, and requires + the Linux x64 PTY binary in the published Senpi package. + +### Why + +- The native workflow produced a Linux x64 addon, but release staging never + consumed it. The resulting npm package silently fell back to the pipe backend + on Linux x64 even though the loader expects a shipped native prebuild. + +### Why an extension could not handle it + +- Native compilation, GitHub Actions artifact transfer, and npm tarball + validation occur in release tooling before Senpi runtime extensions load. + +### Expected merge conflict zones + +- `.github/workflows/native-prebuilds.yml`, `.github/workflows/publish-npm.yml`, + `scripts/publish.mjs`, and `scripts/prepare-senpi-bundled-workspaces.mjs` + remain high-conflict release and staging paths. + ## Browser-smoke exempts @anthropic-ai/sdk-internal Node builtins (2026-08-26) ### What changed diff --git a/scripts/prepare-senpi-bundled-workspaces-pack.test.mjs b/scripts/prepare-senpi-bundled-workspaces-pack.test.mjs index e859e77a33..ee46cd79a1 100644 --- a/scripts/prepare-senpi-bundled-workspaces-pack.test.mjs +++ b/scripts/prepare-senpi-bundled-workspaces-pack.test.mjs @@ -243,8 +243,8 @@ describe("assertSenpiPackedWorkspaceFiles", () => { ); }); - it("accepts senpi package metadata that omits the host pty prebuild (pipe fallback)", () => { - // Given: all loader files present, but no host native prebuild. + it("issue 1193 rejects a tarball missing the release-built Linux x64 pty prebuild", () => { + // Given: all loader files are present, but the Linux x64 release artifact is absent. const packed = { files: [ { path: "package/dist/cli.js" }, @@ -266,14 +266,42 @@ describe("assertSenpiPackedWorkspaceFiles", () => { ], }; - // When / Then: the native prebuild is optional (pipe fallback), so this must not throw. - const originalWarn = console.warn; - console.warn = () => {}; - try { - assert.doesNotThrow(() => assertSenpiPackedWorkspaceFiles(packed)); - } finally { - console.warn = originalWarn; - } + // When / Then + assert.throws( + () => assertSenpiPackedWorkspaceFiles(packed, { requiredNativePrebuildTargets: ["linux-x64"] }), + /native\/prebuilds\/linux-x64\/senpi_pty\.linux-x64\.node/, + ); + }); + + it("accepts a tarball containing the required Linux x64 pty prebuild", () => { + // Given + const linuxPrebuild = nativePrebuildFile("linux-x64"); + const packed = { + files: [ + { path: "package/dist/cli.js" }, + ...clientProtocolFiles(), + ...telemetryFiles(), + { path: "package/node_modules/@earendil-works/pi-agent-core/package.json" }, + { path: "package/node_modules/@earendil-works/pi-agent-core/dist/index.js" }, + { path: "package/node_modules/@earendil-works/pi-ai/package.json" }, + { path: "package/node_modules/@earendil-works/pi-ai/dist/index.js" }, + { path: "package/node_modules/@earendil-works/pi-pty/package.json" }, + { path: "package/node_modules/@earendil-works/pi-pty/dist/index.js" }, + { path: "package/node_modules/@earendil-works/pi-pty/native/index.js" }, + { path: `package/node_modules/@earendil-works/pi-pty/${linuxPrebuild}` }, + { path: "package/node_modules/@earendil-works/pi-tui/package.json" }, + { path: "package/node_modules/@earendil-works/pi-tui/dist/index.js" }, + { path: "package/node_modules/@code-yeongyu/senpi-codemode/package.json" }, + { path: "package/node_modules/@code-yeongyu/senpi-codemode/src/index.ts" }, + { path: "package/node_modules/@code-yeongyu/senpi-codemode/src/kernels/py/prelude.py" }, + { path: "package/node_modules/@code-yeongyu/senpi-codemode/node_modules/@babel/parser/package.json" }, + ], + }; + + // When / Then + assert.doesNotThrow(() => + assertSenpiPackedWorkspaceFiles(packed, { requiredNativePrebuildTargets: ["linux-x64"] }), + ); }); it("accepts an all-OS check when a target's prebuild is absent (pipe fallback)", () => { diff --git a/scripts/prepare-senpi-bundled-workspaces.mjs b/scripts/prepare-senpi-bundled-workspaces.mjs index efd13bc896..f124a693c9 100644 --- a/scripts/prepare-senpi-bundled-workspaces.mjs +++ b/scripts/prepare-senpi-bundled-workspaces.mjs @@ -310,8 +310,12 @@ export function copyPublishDependencies(repoRoot) { } export function assertSenpiPackedWorkspaceFiles(packed, options = {}) { - const nativeTargets = options.nativePrebuildTargets ?? [nativePrebuildTarget()]; + const requiredNativePrebuildTargets = options.requiredNativePrebuildTargets ?? []; + const nativeTargets = [ + ...new Set([...(options.nativePrebuildTargets ?? [nativePrebuildTarget()]), ...requiredNativePrebuildTargets]), + ]; const prebuildFiles = new Set(nativeTargets.map(nativePrebuildFile)); + const requiredPrebuildFiles = new Set(requiredNativePrebuildTargets.map(nativePrebuildFile)); const filePaths = new Set((packed.files ?? []).map((file) => file.path)); const resolverVisibleVendor = [...filePaths].find( (path) => @@ -359,10 +363,13 @@ export function assertSenpiPackedWorkspaceFiles(packed, options = {}) { const path = `${packageRoot}/${requiredFile}`; const dryRunPath = `${dryRunPackageRoot}/${requiredFile}`; if (filePaths.has(path) || filePaths.has(dryRunPath)) continue; - // The platform native prebuild (.node) is optional — the pty loader falls back - // to a child_process pipe when it is absent, so a host without a committed/built - // prebuild (e.g. linux-x64 in the npm-publish job) must not fail the pack check. + // Native prebuilds are optional unless publishing explicitly requires the target: + // on every other host, the pty loader falls back to a child_process pipe. if (prebuildFiles.has(requiredFile)) { + if (requiredPrebuildFiles.has(requiredFile)) { + missing.push(`${path} or ${dryRunPath}`); + continue; + } console.warn(`Warning: packed ${packageName} has no native prebuild ${requiredFile} (pipe fallback at runtime).`); continue; } @@ -390,7 +397,9 @@ export function assertSenpiPackedWorkspaceFiles(packed, options = {}) { } } -export function prepareSenpiBundledWorkspaces(repoRoot = root) { +export function prepareSenpiBundledWorkspaces(repoRoot = root, options = {}) { + const requiredNativePrebuildTargets = options.requiredNativePrebuildTargets ?? []; + const requiredPrebuildFiles = new Set(requiredNativePrebuildTargets.map(nativePrebuildFile)); const publishDependencies = copyPublishDependencies(repoRoot); const codingAgentNodeModules = join(repoRoot, "packages/coding-agent/node_modules"); @@ -403,16 +412,18 @@ export function prepareSenpiBundledWorkspaces(repoRoot = root) { // Loader files (package.json, dist/index.js, native/index.js) are hard-required. // The platform-specific native prebuild (.node) is NOT: when it is absent the pty - // loader uses its child_process pipe fallback (same tolerance as build-binaries.sh, - // and the published package historically shipped with no prebuilds at all). So a - // missing host prebuild must warn, not fail the publish on a runner whose platform - // has no committed or built prebuild (e.g. linux-x64 in the npm-publish job). - const prebuildFiles = new Set(workspace.nativePrebuild ? [nativePrebuildFile(nativePrebuildTarget())] : []); - const requiredFiles = requiredFilesForWorkspace(workspace, [nativePrebuildTarget()]); + // loader uses its child_process pipe fallback unless this publish explicitly + // requires the target. A missing non-required prebuild must remain a warning. + const nativePrebuildTargets = [...new Set([nativePrebuildTarget(), ...requiredNativePrebuildTargets])]; + const prebuildFiles = new Set(workspace.nativePrebuild ? nativePrebuildTargets.map(nativePrebuildFile) : []); + const requiredFiles = requiredFilesForWorkspace(workspace, nativePrebuildTargets); for (const requiredFile of requiredFiles) { const requiredPath = join(sourceRoot, requiredFile); if (existsSync(requiredPath)) continue; if (prebuildFiles.has(requiredFile)) { + if (requiredPrebuildFiles.has(requiredFile)) { + throw new Error(`Missing ${requiredPath}. ${workspace.packageName} requires this native prebuild for publishing.`); + } console.warn( `Warning: ${workspace.packageName} has no native prebuild at ${requiredFile}; bundling without it (pipe fallback at runtime).`, ); diff --git a/scripts/prepare-senpi-bundled-workspaces.prepare.test.mjs b/scripts/prepare-senpi-bundled-workspaces.prepare.test.mjs index 39fcef3be2..0fe2040223 100644 --- a/scripts/prepare-senpi-bundled-workspaces.prepare.test.mjs +++ b/scripts/prepare-senpi-bundled-workspaces.prepare.test.mjs @@ -185,6 +185,23 @@ describe("prepareSenpiBundledWorkspaces", () => { ); }); + it("rejects staging when an explicitly required native target is absent", () => { + // Given: the source package has its loader but no release-built Linux x64 prebuild. + tempDir = mkdtempSync(join(tmpdir(), "senpi-bundle-required-pty-prebuild-")); + writeShrinkwrap(tempDir, { "": { dependencies: {} } }); + writeCodingAgentManifest(tempDir); + for (const workspace of ["agent", "ai", "client", "protocol", "pty", "telemetry", "tui", "senpi-codemode"]) { + writeBundledWorkspace(tempDir, workspace); + } + rmSync(join(tempDir, "packages", "pty", nativePrebuildFile("linux-x64")), { force: true }); + + // When / Then + assert.throws( + () => prepareSenpiBundledWorkspaces(tempDir, { requiredNativePrebuildTargets: ["linux-x64"] }), + /Missing .*native\/prebuilds\/linux-x64\/senpi_pty\.linux-x64\.node.*requires this native prebuild/, + ); + }); + it("rewrites the publish manifest so bundleDependencies covers every staged package", () => { // Given: a registry runtime dep (cross-spawn) plus its hoisted transitive (which) are // installed at the repo root and enumerated by the staging lock. diff --git a/scripts/publish-workflow.test.mjs b/scripts/publish-workflow.test.mjs index 00ba1fbb20..8d470977de 100644 --- a/scripts/publish-workflow.test.mjs +++ b/scripts/publish-workflow.test.mjs @@ -4,6 +4,7 @@ import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import { describe, it } from "node:test"; +const nativePrebuildWorkflow = readFileSync(new URL("../.github/workflows/native-prebuilds.yml", import.meta.url), "utf8"); const workflow = readFileSync(new URL("../.github/workflows/publish-npm.yml", import.meta.url), "utf8"); const codingAgentPackage = JSON.parse( readFileSync(new URL("../packages/coding-agent/package.json", import.meta.url), "utf8"), @@ -16,10 +17,47 @@ describe("publish-only workflow", () => { assert.match(installStep, /npm install --ignore-scripts --no-audit --no-fund/); }); + it("calls native prebuilds and stages their consumer-ready artifacts before building", () => { + assert.match(nativePrebuildWorkflow, /workflow_call:/); + assert.match( + nativePrebuildWorkflow, + /path: \$\{\{ runner\.temp \}\}\/native-prebuild-artifact\s*$/m, + ); + assert.match(workflow, /native-prebuilds:\s+(?:if: [^\n]+\s+)?uses: \.\/\.github\/workflows\/native-prebuilds\.yml/); + assert.match(workflow, /native-prebuilds:\s+if: \$\{\{ inputs\.publish-only == true \}\}\s+uses:/); + assert.match( + workflow, + /needs: native-prebuilds\s+if: \$\{\{ always\(\) && \(inputs\.publish-only != true \|\| needs\.native-prebuilds\.result == 'success'\) \}\}/, + ); + const nativeDownload = workflow.match(/- name: Download native prebuilds[\s\S]*?(?=\n - name: Build all workspaces)/)?.[0]; + assert.ok(nativeDownload, "expected native prebuild download before workspace builds"); + assert.match(nativeDownload, /if: inputs\.publish-only == true/); + assert.match(nativeDownload, /pattern: native-prebuild-\*/); + assert.match(nativeDownload, /path: packages\/pty/); + assert.match(nativeDownload, /merge-multiple: true/); + }); + + it("places the Linux x64 prebuild from the upload root in the pty package", () => { + // Given: the producer's upload root and the consumer's download destination. + const relativePrebuild = "native/prebuilds/linux-x64/senpi_pty.linux-x64.node"; + const nativeDownload = workflow.match(/- name: Download native prebuilds[\s\S]*?(?=\n - name: Build all workspaces)/)?.[0]; + assert.ok(nativeDownload, "expected native prebuild download before workspace builds"); + assert.match(nativePrebuildWorkflow, /prebuild_dir="\$\{artifact_dir\}\/native\/prebuilds\/\$\{host\}"/); + assert.match(nativePrebuildWorkflow, /cp "\$\{node_files\[0\]\}" "\$\{prebuild_dir\}\/senpi_pty\.\$\{host\}\.node"/); + assert.match(nativeDownload, /path: packages\/pty/); + + // When: the artifact downloader merges the producer's root into packages/pty. + const downloadedPrebuild = `packages/pty/${relativePrebuild}`; + + // Then: the packaged PTY loader sees the exact Linux x64 prebuild path. + assert.equal(downloadedPrebuild, "packages/pty/native/prebuilds/linux-x64/senpi_pty.linux-x64.node"); + }); + it("reuses the release validation instead of rerunning the full suite", () => { const publishStep = workflow.match(/- name: Publish prepared version[\s\S]*?(?=\n - name: Workflow summary)/)?.[0]; assert.ok(publishStep, "expected publish-only step"); assert.match(publishStep, /node scripts\/publish\.mjs/); + assert.match(publishStep, /--require-native-prebuild=linux-x64/); assert.doesNotMatch(publishStep, /npm run check|npm test/); }); diff --git a/scripts/publish.mjs b/scripts/publish.mjs index ef8a8328e0..1869265a3b 100644 --- a/scripts/publish.mjs +++ b/scripts/publish.mjs @@ -6,6 +6,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { assertSenpiPackedWorkspaceFiles, + SUPPORTED_NATIVE_PREBUILD_TARGETS, prepareSenpiBundledWorkspaces, rewriteOwnedRegistryAliases, } from "./prepare-senpi-bundled-workspaces.mjs"; @@ -33,10 +34,19 @@ const sourceOnlyPackages = new Set(["@code-yeongyu/senpi-codemode"]); const temporaryPublishDirectories = []; const dryRun = process.argv.includes("--dry-run"); -const unknownArgs = process.argv.slice(2).filter((arg) => arg !== "--dry-run"); - -if (unknownArgs.length > 0) { - console.error(`Usage: node scripts/publish.mjs [--dry-run]`); +const requiredNativePrebuildFlag = "--require-native-prebuild="; +const requiredNativePrebuildTargets = process.argv.slice(2).flatMap((arg) => + arg.startsWith(requiredNativePrebuildFlag) ? [arg.slice(requiredNativePrebuildFlag.length)] : [], +); +const unknownArgs = process.argv + .slice(2) + .filter((arg) => arg !== "--dry-run" && !arg.startsWith(requiredNativePrebuildFlag)); + +if ( + unknownArgs.length > 0 || + requiredNativePrebuildTargets.some((target) => !SUPPORTED_NATIVE_PREBUILD_TARGETS.includes(target)) +) { + console.error(`Usage: node scripts/publish.mjs [--dry-run] [--require-native-prebuild=-]`); process.exit(1); } @@ -104,6 +114,7 @@ function validatePack(directory, sourceDirectory) { const packageJson = readPackageJson(directory); if (sourceDirectory === "packages/coding-agent") { assertSenpiPackedWorkspaceFiles(packed, { + requiredNativePrebuildTargets, runtimeDependencies: [ ...Object.keys(packageJson.dependencies ?? {}), ...Object.keys(packageJson.optionalDependencies ?? {}), @@ -162,7 +173,7 @@ const publishArgs = dryRun ? undefined : buildPublishArgs({ githubActions: proce console.log(`Publishing senpi packages at ${versions[0]}${dryRun ? " (dry run)" : ""}\n`); await materializeMissingPublishRuntime(); -prepareSenpiBundledWorkspaces(); +prepareSenpiBundledWorkspaces(undefined, { requiredNativePrebuildTargets }); const packageStates = packages.map((pkg) => ({ ...pkg,