Skip to content
Open
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
25 changes: 13 additions & 12 deletions .github/workflows/native-prebuilds.yml
Original file line number Diff line number Diff line change
@@ -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:
Expand Down Expand Up @@ -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 }}
Expand Down Expand Up @@ -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
Expand All @@ -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
17 changes: 16 additions & 1 deletion .github/workflows/publish-npm.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ name: Release
default: false

permissions:
actions: read
contents: read
id-token: write

Expand All @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down
27 changes: 27 additions & 0 deletions scripts/changes.md
Original file line number Diff line number Diff line change
@@ -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=<target>` 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/<host>/` 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
Expand Down
48 changes: 38 additions & 10 deletions scripts/prepare-senpi-bundled-workspaces-pack.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand All @@ -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)", () => {
Expand Down
33 changes: 22 additions & 11 deletions scripts/prepare-senpi-bundled-workspaces.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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) =>
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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");

Expand All @@ -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).`,
);
Expand Down
17 changes: 17 additions & 0 deletions scripts/prepare-senpi-bundled-workspaces.prepare.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
38 changes: 38 additions & 0 deletions scripts/publish-workflow.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand All @@ -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/);
});

Expand Down
21 changes: 16 additions & 5 deletions scripts/publish.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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=<platform>-<arch>]`);
process.exit(1);
}

Expand Down Expand Up @@ -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 ?? {}),
Expand Down Expand Up @@ -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,
Expand Down