From eb8ef262e92b8cedb6bbf1d5a69bd415617f95b2 Mon Sep 17 00:00:00 2001 From: Alon Gubkin Date: Mon, 20 Jul 2026 13:09:53 +0300 Subject: [PATCH 01/15] ci: publish immutable npm dev packages --- .github/workflows/publish-npm-dev.yml | 288 ++++++++++++++++++++++++++ scripts/npm-dev-release.mjs | 126 +++++++++++ scripts/npm-dev-release.test.mjs | 49 +++++ 3 files changed, 463 insertions(+) create mode 100644 .github/workflows/publish-npm-dev.yml create mode 100644 scripts/npm-dev-release.mjs create mode 100644 scripts/npm-dev-release.test.mjs diff --git a/.github/workflows/publish-npm-dev.yml b/.github/workflows/publish-npm-dev.yml new file mode 100644 index 000000000..4ce088269 --- /dev/null +++ b/.github/workflows/publish-npm-dev.yml @@ -0,0 +1,288 @@ +name: Publish npm dev packages + +on: + workflow_dispatch: + inputs: + dry_run: + description: Build, pack, and smoke-test without publishing + required: true + default: true + type: boolean + +permissions: + contents: read + +concurrency: + group: npm-dev-${{ github.sha }} + cancel-in-progress: false + +jobs: + prepare: + runs-on: depot-ubuntu-24.04-arm-4 + timeout-minutes: 30 + outputs: + git_sha: ${{ steps.identity.outputs.git_sha }} + short_sha: ${{ steps.identity.outputs.short_sha }} + steps: + - uses: actions/checkout@v4 + + - id: identity + name: Record immutable source identity + shell: bash + run: | + set -euo pipefail + git_sha="$(git rev-parse HEAD)" + test "$git_sha" = "${GITHUB_SHA}" + echo "git_sha=$git_sha" >> "$GITHUB_OUTPUT" + echo "short_sha=${git_sha:0:12}" >> "$GITHUB_OUTPUT" + + - uses: pnpm/action-setup@v4 + with: + version: 10.11.0 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + + - name: Test version and dependency rewriting + run: node --test scripts/npm-dev-release.test.mjs + + build-addon: + needs: prepare + strategy: + fail-fast: false + matrix: + include: + - runner: depot-macos-15 + os: macos + target: aarch64-apple-darwin + triple: darwin-arm64 + expect_arch: arm64 + - runner: depot-macos-15 + os: macos + target: x86_64-apple-darwin + triple: darwin-x64 + expect_arch: x86_64 + - runner: depot-ubuntu-24.04-16 + os: linux + target: x86_64-unknown-linux-gnu + triple: linux-x64-gnu + expect_arch: x86-64 + - runner: depot-ubuntu-24.04-arm-16 + os: linux + target: aarch64-unknown-linux-gnu + triple: linux-arm64-gnu + expect_arch: aarch64 + runs-on: ${{ matrix.runner }} + timeout-minutes: 60 + env: + CARGO_INCREMENTAL: "0" + steps: + - uses: actions/checkout@v4 + + - name: Setup Rust (linux) + if: matrix.os == 'linux' + uses: ./.github/actions/setup-rust + with: + depot-project-id: ${{ vars.DEPOT_PROJECT_ID }} + repo-access-token: ${{ secrets.REPO_ACCESS_TOKEN }} + install-protoc: "true" + + - uses: dtolnay/rust-toolchain@nightly + if: matrix.os == 'macos' + + - name: Configure git credentials for cargo (macos) + if: matrix.os == 'macos' + run: git config --global url."https://x-access-token:${{ secrets.REPO_ACCESS_TOKEN }}@github.com/".insteadOf "https://github.com/" + + - name: Install protoc (macos) + if: matrix.os == 'macos' + run: brew install protobuf + + - name: Add cross target + if: matrix.target == 'x86_64-apple-darwin' + run: rustup target add x86_64-apple-darwin + + - uses: pnpm/action-setup@v4 + with: + version: 10.11.0 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build addon + env: + CARGO_NET_GIT_FETCH_WITH_CLI: "true" + run: | + pnpm --filter @alienplatform/bindings exec napi build \ + --platform --release --target ${{ matrix.target }} \ + --cwd "$GITHUB_WORKSPACE/crates/alien-bindings-node" + cp "crates/alien-bindings-node/alien-bindings-node.${{ matrix.triple }}.node" \ + "packages/bindings/npm/${{ matrix.triple }}/alien-bindings-node.${{ matrix.triple }}.node" + file "packages/bindings/npm/${{ matrix.triple }}/alien-bindings-node.${{ matrix.triple }}.node" \ + | grep -q "${{ matrix.expect_arch }}" + + - uses: actions/upload-artifact@v4 + with: + name: npm-dev-addon-${{ matrix.triple }} + retention-days: 1 + path: packages/bindings/npm/${{ matrix.triple }}/alien-bindings-node.${{ matrix.triple }}.node + + pack-and-publish: + needs: [prepare, build-addon] + runs-on: depot-ubuntu-24.04-arm-4 + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + with: + version: 10.11.0 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + registry-url: https://registry.npmjs.org + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build JavaScript packages + run: pnpm build + env: + NODE_OPTIONS: --max-old-space-size=8192 + + - name: Restore native addons + uses: actions/download-artifact@v4 + with: + pattern: npm-dev-addon-* + path: addons + + - name: Stage native addons + shell: bash + run: | + set -euo pipefail + for triple in darwin-arm64 darwin-x64 linux-x64-gnu linux-arm64-gnu; do + cp "addons/npm-dev-addon-${triple}/alien-bindings-node.${triple}.node" \ + "packages/bindings/npm/${triple}/alien-bindings-node.${triple}.node" + done + + - name: Rewrite package graph to immutable dev versions + run: | + node scripts/npm-dev-release.mjs rewrite "${{ needs.prepare.outputs.git_sha }}" | tee npm-dev-versions.json + node packages/bindings/scripts/inject-optional-deps.mjs + node scripts/npm-dev-release.mjs validate "${{ needs.prepare.outputs.git_sha }}" + { + echo '## npm dev package versions' + echo '```json' + cat npm-dev-versions.json + echo '```' + } >> "$GITHUB_STEP_SUMMARY" + + - name: Pack every publishable package + shell: bash + run: | + set -euo pipefail + mkdir -p npm-dev-tarballs npm-dev-pack-results + while IFS= read -r package_dir; do + safe_name="$(basename "$package_dir")-$(printf '%s' "$package_dir" | sha256sum | cut -c1-8)" + (cd "$package_dir" && npm pack --json --pack-destination "$GITHUB_WORKSPACE/npm-dev-tarballs") \ + > "npm-dev-pack-results/${safe_name}.json" + done <<'EOF' + packages/core + packages/commands + packages/bindings + packages/sdk + packages/testing + client-sdks/platform/typescript + client-sdks/manager/typescript + packages/bindings/npm/darwin-arm64 + packages/bindings/npm/darwin-x64 + packages/bindings/npm/linux-x64-gnu + packages/bindings/npm/linux-arm64-gnu + EOF + node scripts/npm-dev-release.mjs digest npm-dev-tarballs/*.tgz | tee npm-dev-digests.json + { + echo '## npm tarball SHA-256 digests' + echo '```json' + cat npm-dev-digests.json + echo '```' + } >> "$GITHUB_STEP_SUMMARY" + + - name: Smoke install packed dependency graph + shell: bash + run: | + set -euo pipefail + consumer="$(mktemp -d)" + npm init --yes --prefix "$consumer" >/dev/null + npm install --prefix "$consumer" "$GITHUB_WORKSPACE"/npm-dev-tarballs/*.tgz + cd "$consumer" + node --input-type=module <<'EOF' + import { Container } from "@alienplatform/core" + const resource = new Container("dev-package-smoke").commandsEnabled(true) + if (resource.config.commandsEnabled !== true) throw new Error("packed core lacks commandsEnabled") + await import("@alienplatform/commands") + await import("@alienplatform/sdk") + EOF + + - name: Validate npm authentication + if: ${{ !inputs.dry_run }} + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: npm whoami + + - name: Publish immutable packages under dev tag + if: ${{ !inputs.dry_run }} + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + shell: bash + run: | + set -euo pipefail + publish_tarball() { + local tarball="$1" + local manifest name version local_integrity remote_integrity + manifest="$(tar -xOzf "$tarball" package/package.json)" + name="$(jq -er '.name' <<< "$manifest")" + version="$(jq -er '.version' <<< "$manifest")" + local_integrity="sha512-$(openssl dgst -sha512 -binary "$tarball" | openssl base64 -A)" + [[ "$version" =~ -dev\.[0-9a-f]{12}$ ]] || { echo "Refusing stable version $name@$version" >&2; exit 1; } + remote_integrity="$(npm view "$name@$version" dist.integrity 2>/dev/null || true)" + if [ -n "$remote_integrity" ]; then + test "$remote_integrity" = "$local_integrity" || { + echo "Published $name@$version has different bytes" >&2 + exit 1 + } + echo "$name@$version already exists with matching integrity" + else + npm publish "$tarball" --access public --tag dev + fi + } + + # Native packages and leaf dependencies first; public entry points last. + for pattern in \ + '*bindings-darwin-arm64*' '*bindings-darwin-x64*' \ + '*bindings-linux-x64-gnu*' '*bindings-linux-arm64-gnu*' \ + '*core*' '*bindings-[0-9]*' '*commands*' '*sdk*' '*testing*' \ + '*platform-api*' '*manager-api*'; do + mapfile -t matches < <(compgen -G "npm-dev-tarballs/${pattern}.tgz" || true) + test "${#matches[@]}" -gt 0 || continue + for tarball in "${matches[@]}"; do publish_tarball "$tarball"; done + done + + - name: Upload package evidence + uses: actions/upload-artifact@v4 + with: + name: npm-dev-packages-${{ needs.prepare.outputs.short_sha }} + retention-days: 7 + path: | + npm-dev-versions.json + npm-dev-digests.json + npm-dev-pack-results + npm-dev-tarballs diff --git a/scripts/npm-dev-release.mjs b/scripts/npm-dev-release.mjs new file mode 100644 index 000000000..a8ac14c26 --- /dev/null +++ b/scripts/npm-dev-release.mjs @@ -0,0 +1,126 @@ +#!/usr/bin/env node + +import { createHash } from "node:crypto" +import { readFileSync, writeFileSync } from "node:fs" +import { basename, resolve } from "node:path" + +export const packages = [ + { path: "packages/core/package.json", publish: true }, + { path: "packages/commands/package.json", publish: true }, + { path: "packages/bindings/package.json", publish: true }, + { path: "packages/sdk/package.json", publish: true }, + { path: "packages/testing/package.json", publish: true }, + { path: "client-sdks/platform/typescript/package.json", publish: true }, + { path: "client-sdks/manager/typescript/package.json", publish: true }, + { path: "crates/alien-bindings-node/package.json", publish: false, versionFrom: "@alienplatform/bindings" }, + ...["darwin-arm64", "darwin-x64", "linux-x64-gnu", "linux-arm64-gnu"].map(triple => ({ + path: `packages/bindings/npm/${triple}/package.json`, + publish: true, + versionFrom: "@alienplatform/bindings", + })), +] + +const dependencyFields = ["dependencies", "devDependencies", "peerDependencies", "optionalDependencies"] + +function readJson(path) { + return JSON.parse(readFileSync(path, "utf8")) +} + +function writeJson(path, value) { + writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`) +} + +function baseVersion(version, name) { + const match = /^(\d+\.\d+\.\d+)(?:-[0-9A-Za-z.-]+)?$/.exec(version) + if (!match) throw new Error(`${name} has unsupported version ${version}`) + return match[1] +} + +export function computeVersions(root, sha) { + if (!/^[0-9a-f]{40}$/.test(sha)) throw new Error(`Expected a full lowercase git SHA, got ${sha}`) + const shortSha = sha.slice(0, 12) + const manifests = packages.map(entry => ({ ...entry, manifest: readJson(resolve(root, entry.path)) })) + const versions = new Map() + + for (const { manifest, versionFrom } of manifests) { + if (versionFrom) continue + versions.set(manifest.name, `${baseVersion(manifest.version, manifest.name)}-dev.${shortSha}`) + } + for (const { manifest, versionFrom } of manifests) { + if (!versionFrom) continue + const version = versions.get(versionFrom) + if (!version) throw new Error(`${manifest.name} references unknown version source ${versionFrom}`) + versions.set(manifest.name, version) + } + return versions +} + +export function rewriteManifests(root, sha) { + const versions = computeVersions(root, sha) + for (const { path } of packages) { + const absolutePath = resolve(root, path) + const manifest = readJson(absolutePath) + manifest.version = versions.get(manifest.name) + for (const field of dependencyFields) { + if (!manifest[field]) continue + for (const dependency of Object.keys(manifest[field])) { + const version = versions.get(dependency) + if (version) manifest[field][dependency] = version + } + } + writeJson(absolutePath, manifest) + } + return versions +} + +export function validateManifests(root, sha) { + const expected = computeVersions(root, sha) + for (const { path } of packages) { + const manifest = readJson(resolve(root, path)) + if (manifest.version !== expected.get(manifest.name)) { + throw new Error(`${manifest.name} version is ${manifest.version}; expected ${expected.get(manifest.name)}`) + } + if (!/-dev\.[0-9a-f]{12}$/.test(manifest.version)) { + throw new Error(`${manifest.name} version is not an immutable dev prerelease: ${manifest.version}`) + } + for (const field of dependencyFields) { + for (const [dependency, range] of Object.entries(manifest[field] ?? {})) { + const version = expected.get(dependency) + if (version && range !== version) { + throw new Error(`${manifest.name} ${field}.${dependency} is ${range}; expected ${version}`) + } + } + } + } + return expected +} + +export function describeTarballs(packResults) { + return packResults.map(result => ({ + name: result.name, + version: result.version, + filename: basename(result.filename), + integrity: result.integrity, + shasum: result.shasum, + })) +} + +function printVersions(versions) { + process.stdout.write(`${JSON.stringify(Object.fromEntries([...versions].sort()), null, 2)}\n`) +} + +const [command, argument] = process.argv.slice(2) +if (command === "rewrite") { + printVersions(rewriteManifests(process.cwd(), argument)) +} else if (command === "validate") { + printVersions(validateManifests(process.cwd(), argument)) +} else if (command === "digest") { + const files = process.argv.slice(3) + const output = files.sort().map(path => ({ + file: basename(path), + sha256: createHash("sha256").update(readFileSync(path)).digest("hex"), + })) + process.stdout.write(`${JSON.stringify(output, null, 2)}\n`) +} else if (import.meta.url === `file://${process.argv[1]}`) { + throw new Error("Usage: npm-dev-release.mjs ") +} diff --git a/scripts/npm-dev-release.test.mjs b/scripts/npm-dev-release.test.mjs new file mode 100644 index 000000000..2c4cfdb36 --- /dev/null +++ b/scripts/npm-dev-release.test.mjs @@ -0,0 +1,49 @@ +import assert from "node:assert/strict" +import { copyFileSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import { dirname, resolve } from "node:path" +import { fileURLToPath } from "node:url" +import test from "node:test" + +import { packages, rewriteManifests, validateManifests } from "./npm-dev-release.mjs" + +const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..") +const sha = "0123456789abcdef0123456789abcdef01234567" + +function fixture() { + const root = mkdtempSync(resolve(tmpdir(), "alien-npm-dev-")) + for (const { path } of packages) { + const source = resolve(repositoryRoot, path) + const target = resolve(root, path) + mkdirSync(dirname(target), { recursive: true }) + copyFileSync(source, target) + } + return root +} + +test("rewrites every published package and internal edge to commit-addressed versions", () => { + const root = fixture() + const versions = rewriteManifests(root, sha) + validateManifests(root, sha) + + assert.equal(versions.get("@alienplatform/core"), "1.14.1-dev.0123456789ab") + assert.equal(versions.get("@alienplatform/platform-api"), "1.14.3-dev.0123456789ab") + + const commands = JSON.parse(readFileSync(resolve(root, "packages/commands/package.json"), "utf8")) + assert.equal(commands.dependencies["@alienplatform/core"], versions.get("@alienplatform/core")) + + const sdk = JSON.parse(readFileSync(resolve(root, "packages/sdk/package.json"), "utf8")) + assert.equal(sdk.dependencies["@alienplatform/core"], versions.get("@alienplatform/core")) + assert.equal(sdk.dependencies["@alienplatform/bindings"], versions.get("@alienplatform/bindings")) +}) + +test("rejects a stable or mismatched package graph after rewrite", () => { + const root = fixture() + rewriteManifests(root, sha) + const path = resolve(root, "packages/commands/package.json") + const manifest = JSON.parse(readFileSync(path, "utf8")) + manifest.dependencies["@alienplatform/core"] = "^1.14.1" + writeFileSync(path, `${JSON.stringify(manifest)}\n`) + + assert.throws(() => validateManifests(root, sha), /expected 1\.14\.1-dev\.0123456789ab/) +}) From aeff0885edc7ea86a59ffc4e95993248df1897da Mon Sep 17 00:00:00 2001 From: Alon Gubkin Date: Mon, 20 Jul 2026 13:11:39 +0300 Subject: [PATCH 02/15] fix: decouple npm source and workflow refs --- .github/workflows/publish-npm-dev.yml | 34 ++++++++++++++++++++++----- scripts/npm-dev-release.test.mjs | 4 +++- 2 files changed, 31 insertions(+), 7 deletions(-) diff --git a/.github/workflows/publish-npm-dev.yml b/.github/workflows/publish-npm-dev.yml index 4ce088269..027824c83 100644 --- a/.github/workflows/publish-npm-dev.yml +++ b/.github/workflows/publish-npm-dev.yml @@ -3,6 +3,11 @@ name: Publish npm dev packages on: workflow_dispatch: inputs: + source_ref: + description: Exact branch, tag, or commit to package + required: true + default: main + type: string dry_run: description: Build, pack, and smoke-test without publishing required: true @@ -13,7 +18,7 @@ permissions: contents: read concurrency: - group: npm-dev-${{ github.sha }} + group: npm-dev-${{ inputs.source_ref }} cancel-in-progress: false jobs: @@ -25,6 +30,13 @@ jobs: short_sha: ${{ steps.identity.outputs.short_sha }} steps: - uses: actions/checkout@v4 + with: + ref: ${{ inputs.source_ref }} + + - uses: actions/checkout@v4 + with: + ref: ${{ github.ref }} + path: .npm-dev-tools - id: identity name: Record immutable source identity @@ -32,7 +44,6 @@ jobs: run: | set -euo pipefail git_sha="$(git rev-parse HEAD)" - test "$git_sha" = "${GITHUB_SHA}" echo "git_sha=$git_sha" >> "$GITHUB_OUTPUT" echo "short_sha=${git_sha:0:12}" >> "$GITHUB_OUTPUT" @@ -46,7 +57,9 @@ jobs: cache: pnpm - name: Test version and dependency rewriting - run: node --test scripts/npm-dev-release.test.mjs + env: + NPM_DEV_SOURCE_ROOT: ${{ github.workspace }} + run: node --test .npm-dev-tools/scripts/npm-dev-release.test.mjs build-addon: needs: prepare @@ -80,6 +93,8 @@ jobs: CARGO_INCREMENTAL: "0" steps: - uses: actions/checkout@v4 + with: + ref: ${{ inputs.source_ref }} - name: Setup Rust (linux) if: matrix.os == 'linux' @@ -140,6 +155,13 @@ jobs: timeout-minutes: 30 steps: - uses: actions/checkout@v4 + with: + ref: ${{ inputs.source_ref }} + + - uses: actions/checkout@v4 + with: + ref: ${{ github.ref }} + path: .npm-dev-tools - uses: pnpm/action-setup@v4 with: @@ -176,9 +198,9 @@ jobs: - name: Rewrite package graph to immutable dev versions run: | - node scripts/npm-dev-release.mjs rewrite "${{ needs.prepare.outputs.git_sha }}" | tee npm-dev-versions.json + node .npm-dev-tools/scripts/npm-dev-release.mjs rewrite "${{ needs.prepare.outputs.git_sha }}" | tee npm-dev-versions.json node packages/bindings/scripts/inject-optional-deps.mjs - node scripts/npm-dev-release.mjs validate "${{ needs.prepare.outputs.git_sha }}" + node .npm-dev-tools/scripts/npm-dev-release.mjs validate "${{ needs.prepare.outputs.git_sha }}" { echo '## npm dev package versions' echo '```json' @@ -208,7 +230,7 @@ jobs: packages/bindings/npm/linux-x64-gnu packages/bindings/npm/linux-arm64-gnu EOF - node scripts/npm-dev-release.mjs digest npm-dev-tarballs/*.tgz | tee npm-dev-digests.json + node .npm-dev-tools/scripts/npm-dev-release.mjs digest npm-dev-tarballs/*.tgz | tee npm-dev-digests.json { echo '## npm tarball SHA-256 digests' echo '```json' diff --git a/scripts/npm-dev-release.test.mjs b/scripts/npm-dev-release.test.mjs index 2c4cfdb36..03e6598ca 100644 --- a/scripts/npm-dev-release.test.mjs +++ b/scripts/npm-dev-release.test.mjs @@ -7,7 +7,9 @@ import test from "node:test" import { packages, rewriteManifests, validateManifests } from "./npm-dev-release.mjs" -const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..") +const repositoryRoot = process.env.NPM_DEV_SOURCE_ROOT + ? resolve(process.env.NPM_DEV_SOURCE_ROOT) + : resolve(dirname(fileURLToPath(import.meta.url)), "..") const sha = "0123456789abcdef0123456789abcdef01234567" function fixture() { From b4e5a4920f68335a4f642090456e902c0b1f9619 Mon Sep 17 00:00:00 2001 From: Alon Gubkin Date: Mon, 20 Jul 2026 13:16:51 +0300 Subject: [PATCH 03/15] fix: route dev packages through release workflow --- .github/workflows/publish-npm-dev.yml | 4 +- .github/workflows/release.yml | 50 ++++++++++++++------- scripts/release-routing.test.mjs | 64 +++++++++++++++++++++++++++ 3 files changed, 100 insertions(+), 18 deletions(-) create mode 100644 scripts/release-routing.test.mjs diff --git a/.github/workflows/publish-npm-dev.yml b/.github/workflows/publish-npm-dev.yml index 027824c83..6ac062b30 100644 --- a/.github/workflows/publish-npm-dev.yml +++ b/.github/workflows/publish-npm-dev.yml @@ -1,16 +1,14 @@ name: Publish npm dev packages on: - workflow_dispatch: + workflow_call: inputs: source_ref: description: Exact branch, tag, or commit to package required: true - default: main type: string dry_run: description: Build, pack, and smoke-test without publishing - required: true default: true type: boolean diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6cb7a5a15..ae9e12baf 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,8 +3,17 @@ name: Release on: workflow_dispatch: inputs: + mode: + description: Publication channel + type: choice + default: stable + options: [stable, dev] + source_ref: + description: Exact source ref for dev npm packages + type: string + default: main dry_run: - description: 'Print the computed version without publishing' + description: Validate without publishing type: boolean default: false @@ -14,7 +23,16 @@ permissions: id-token: write jobs: + publish-npm-dev: + if: inputs.mode == 'dev' + uses: ./.github/workflows/publish-npm-dev.yml + with: + source_ref: ${{ inputs.source_ref }} + dry_run: ${{ inputs.dry_run }} + secrets: inherit + prepare: + if: inputs.mode == 'stable' runs-on: depot-ubuntu-24.04-arm timeout-minutes: 10 outputs: @@ -138,7 +156,7 @@ jobs: generate-changelog: needs: prepare - if: ${{ !inputs.dry_run }} + if: ${{ inputs.mode == 'stable' && !inputs.dry_run }} runs-on: depot-ubuntu-24.04-arm timeout-minutes: 5 steps: @@ -167,7 +185,7 @@ jobs: publish-crates: needs: [prepare] - if: ${{ !inputs.dry_run }} + if: ${{ inputs.mode == 'stable' && !inputs.dry_run }} runs-on: depot-ubuntu-24.04-arm-4 timeout-minutes: 60 env: @@ -222,7 +240,7 @@ jobs: publish-npm: needs: [prepare] - if: ${{ !inputs.dry_run }} + if: ${{ inputs.mode == 'stable' && !inputs.dry_run }} runs-on: depot-ubuntu-24.04-arm-4 timeout-minutes: 30 steps: @@ -270,7 +288,7 @@ jobs: publish-client-sdks: needs: [prepare] - if: ${{ !inputs.dry_run }} + if: ${{ inputs.mode == 'stable' && !inputs.dry_run }} runs-on: depot-ubuntu-24.04-arm-4 timeout-minutes: 30 steps: @@ -321,6 +339,7 @@ jobs: build-addon: needs: prepare + if: inputs.mode == 'stable' strategy: fail-fast: false matrix: @@ -436,6 +455,7 @@ jobs: # installs with a real local-provider KV operation. smoke-addon: needs: [prepare, build-addon] + if: inputs.mode == 'stable' strategy: fail-fast: false matrix: @@ -509,7 +529,7 @@ jobs: # publish-npm to succeed: the wrapper's package.json pins # `@alienplatform/core: ^`, so publishing it while core's publish # failed leaves every `npm install @alienplatform/bindings` ETARGET-broken. - if: always() && needs.smoke-addon.result == 'success' && (inputs.dry_run || needs.publish-npm.result == 'success') + if: inputs.mode == 'stable' && always() && needs.smoke-addon.result == 'success' && (inputs.dry_run || needs.publish-npm.result == 'success') runs-on: depot-ubuntu-24.04-arm-4 timeout-minutes: 30 steps: @@ -617,7 +637,7 @@ jobs: build-binaries-linux-x86_64: needs: prepare - if: ${{ !inputs.dry_run }} + if: ${{ inputs.mode == 'stable' && !inputs.dry_run }} runs-on: depot-ubuntu-24.04-16 timeout-minutes: 60 env: @@ -672,7 +692,7 @@ jobs: build-binaries-linux-aarch64: needs: prepare - if: ${{ !inputs.dry_run }} + if: ${{ inputs.mode == 'stable' && !inputs.dry_run }} runs-on: depot-ubuntu-24.04-arm-16 timeout-minutes: 60 env: @@ -729,7 +749,7 @@ jobs: build-binaries-darwin: needs: prepare - if: ${{ !inputs.dry_run }} + if: ${{ inputs.mode == 'stable' && !inputs.dry_run }} runs-on: depot-macos-15 timeout-minutes: 60 env: @@ -799,7 +819,7 @@ jobs: build-binaries-windows: needs: prepare - if: ${{ !inputs.dry_run }} + if: ${{ inputs.mode == 'stable' && !inputs.dry_run }} runs-on: depot-windows-2025-16 timeout-minutes: 60 env: @@ -872,7 +892,7 @@ jobs: upload-binaries: needs: [prepare, build-binaries-linux-x86_64, build-binaries-linux-aarch64, build-binaries-darwin, build-binaries-windows] - if: ${{ !inputs.dry_run }} + if: ${{ inputs.mode == 'stable' && !inputs.dry_run }} runs-on: depot-ubuntu-24.04-arm timeout-minutes: 10 steps: @@ -931,7 +951,7 @@ jobs: create-github-release: needs: [prepare, generate-changelog, build-binaries-linux-x86_64, build-binaries-linux-aarch64, build-binaries-darwin, build-binaries-windows] - if: ${{ !inputs.dry_run }} + if: ${{ inputs.mode == 'stable' && !inputs.dry_run }} runs-on: depot-ubuntu-24.04-arm timeout-minutes: 10 steps: @@ -996,7 +1016,7 @@ jobs: publish-images: needs: [prepare, build-binaries-linux-x86_64, build-binaries-linux-aarch64, build-binaries-darwin, build-binaries-windows] - if: ${{ !inputs.dry_run }} + if: ${{ inputs.mode == 'stable' && !inputs.dry_run }} runs-on: depot-ubuntu-24.04-arm-16 timeout-minutes: 30 steps: @@ -1123,7 +1143,7 @@ jobs: publish-homebrew-tap: needs: [prepare, create-github-release] - if: ${{ !inputs.dry_run }} + if: ${{ inputs.mode == 'stable' && !inputs.dry_run }} runs-on: depot-ubuntu-24.04-arm timeout-minutes: 10 steps: @@ -1208,7 +1228,7 @@ jobs: publish-npm-cli-wrapper: needs: [prepare, build-binaries-linux-x86_64, build-binaries-linux-aarch64, build-binaries-darwin, build-binaries-windows] - if: ${{ !inputs.dry_run }} + if: ${{ inputs.mode == 'stable' && !inputs.dry_run }} runs-on: depot-ubuntu-24.04-arm timeout-minutes: 20 steps: diff --git a/scripts/release-routing.test.mjs b/scripts/release-routing.test.mjs new file mode 100644 index 000000000..b196e6d8f --- /dev/null +++ b/scripts/release-routing.test.mjs @@ -0,0 +1,64 @@ +import assert from "node:assert/strict" +import { readFileSync } from "node:fs" +import { resolve } from "node:path" +import test from "node:test" + +const workflow = readFileSync(resolve(process.cwd(), ".github/workflows/release.yml"), "utf8") + +function parseJobs(source) { + const jobs = new Map() + const lines = source.split("\n") + const jobsIndex = lines.findIndex(line => line === "jobs:") + assert.notEqual(jobsIndex, -1, "release workflow has jobs") + + let current + for (const line of lines.slice(jobsIndex + 1)) { + const job = /^ ([a-z0-9_-]+):$/.exec(line) + if (job) { + current = { if: "", uses: "" } + jobs.set(job[1], current) + continue + } + if (!current) continue + const condition = /^ if: (.+)$/.exec(line) + if (condition) current.if = condition[1] + const uses = /^ uses: (.+)$/.exec(line) + if (uses) current.uses = uses[1] + } + return jobs +} + +const stableJobs = [ + "prepare", + "generate-changelog", + "publish-crates", + "publish-npm", + "publish-client-sdks", + "build-addon", + "smoke-addon", + "publish-bindings", + "build-binaries-linux-x86_64", + "build-binaries-linux-aarch64", + "build-binaries-darwin", + "build-binaries-windows", + "upload-binaries", + "create-github-release", + "publish-images", + "publish-homebrew-tap", + "publish-npm-cli-wrapper", +] + +test("stable remains the default release mode", () => { + assert.match(workflow, /mode:\n\s+description: Publication channel\n\s+type: choice\n\s+default: stable\n\s+options: \[stable, dev\]/) +}) + +test("dev mode can reach only the reusable npm dev workflow", () => { + const jobs = parseJobs(workflow) + assert.deepEqual([...jobs.keys()].sort(), ["publish-npm-dev", ...stableJobs].sort()) + assert.equal(jobs.get("publish-npm-dev").if, "inputs.mode == 'dev'") + assert.equal(jobs.get("publish-npm-dev").uses, "./.github/workflows/publish-npm-dev.yml") + + for (const name of stableJobs) { + assert.match(jobs.get(name).if, /inputs\.mode == 'stable'/, `${name} must be unreachable in dev mode`) + } +}) From 688118583dd03900665061445793086dabe79721 Mon Sep 17 00:00:00 2001 From: Alon Gubkin Date: Mon, 20 Jul 2026 13:17:41 +0300 Subject: [PATCH 04/15] test: keep npm dev validation version agnostic --- scripts/npm-dev-release.mjs | 10 ---------- scripts/npm-dev-release.test.mjs | 10 ++++++++-- 2 files changed, 8 insertions(+), 12 deletions(-) diff --git a/scripts/npm-dev-release.mjs b/scripts/npm-dev-release.mjs index a8ac14c26..828ab4ab0 100644 --- a/scripts/npm-dev-release.mjs +++ b/scripts/npm-dev-release.mjs @@ -95,16 +95,6 @@ export function validateManifests(root, sha) { return expected } -export function describeTarballs(packResults) { - return packResults.map(result => ({ - name: result.name, - version: result.version, - filename: basename(result.filename), - integrity: result.integrity, - shasum: result.shasum, - })) -} - function printVersions(versions) { process.stdout.write(`${JSON.stringify(Object.fromEntries([...versions].sort()), null, 2)}\n`) } diff --git a/scripts/npm-dev-release.test.mjs b/scripts/npm-dev-release.test.mjs index 03e6598ca..8cc4362bf 100644 --- a/scripts/npm-dev-release.test.mjs +++ b/scripts/npm-dev-release.test.mjs @@ -25,11 +25,17 @@ function fixture() { test("rewrites every published package and internal edge to commit-addressed versions", () => { const root = fixture() + const coreBase = JSON.parse( + readFileSync(resolve(root, "packages/core/package.json"), "utf8"), + ).version.replace(/-.*/, "") + const platformBase = JSON.parse( + readFileSync(resolve(root, "client-sdks/platform/typescript/package.json"), "utf8"), + ).version.replace(/-.*/, "") const versions = rewriteManifests(root, sha) validateManifests(root, sha) - assert.equal(versions.get("@alienplatform/core"), "1.14.1-dev.0123456789ab") - assert.equal(versions.get("@alienplatform/platform-api"), "1.14.3-dev.0123456789ab") + assert.equal(versions.get("@alienplatform/core"), `${coreBase}-dev.0123456789ab`) + assert.equal(versions.get("@alienplatform/platform-api"), `${platformBase}-dev.0123456789ab`) const commands = JSON.parse(readFileSync(resolve(root, "packages/commands/package.json"), "utf8")) assert.equal(commands.dependencies["@alienplatform/core"], versions.get("@alienplatform/core")) From 9c38c4c20114a9610978574bbfd549dbf270d6fc Mon Sep 17 00:00:00 2001 From: Alon Gubkin Date: Mon, 20 Jul 2026 13:18:27 +0300 Subject: [PATCH 05/15] fix: allow trusted dev package builds --- .github/workflows/publish-npm-dev.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/publish-npm-dev.yml b/.github/workflows/publish-npm-dev.yml index 6ac062b30..962ee95d1 100644 --- a/.github/workflows/publish-npm-dev.yml +++ b/.github/workflows/publish-npm-dev.yml @@ -14,6 +14,7 @@ on: permissions: contents: read + id-token: write concurrency: group: npm-dev-${{ inputs.source_ref }} From b945baba884b1ef10215c89fa4b9b352cc89e0e9 Mon Sep 17 00:00:00 2001 From: Alon Gubkin Date: Mon, 20 Jul 2026 13:21:09 +0300 Subject: [PATCH 06/15] fix: use full commit identity for dev packages --- .github/workflows/publish-npm-dev.yml | 14 ++++++++++---- .github/workflows/release.yml | 2 +- scripts/npm-dev-release.mjs | 5 ++--- scripts/npm-dev-release.test.mjs | 10 +++++++--- scripts/release-routing.test.mjs | 9 +++++++++ 5 files changed, 29 insertions(+), 11 deletions(-) diff --git a/.github/workflows/publish-npm-dev.yml b/.github/workflows/publish-npm-dev.yml index 962ee95d1..44f9d2b36 100644 --- a/.github/workflows/publish-npm-dev.yml +++ b/.github/workflows/publish-npm-dev.yml @@ -4,7 +4,7 @@ on: workflow_call: inputs: source_ref: - description: Exact branch, tag, or commit to package + description: Exact 40-character lowercase commit SHA to package required: true type: string dry_run: @@ -42,7 +42,13 @@ jobs: shell: bash run: | set -euo pipefail + source_ref="${{ inputs.source_ref }}" + [[ "$source_ref" =~ ^[0-9a-f]{40}$ ]] || { + echo "source_ref must be an exact 40-character lowercase commit SHA" >&2 + exit 1 + } git_sha="$(git rev-parse HEAD)" + test "$git_sha" = "$source_ref" echo "git_sha=$git_sha" >> "$GITHUB_OUTPUT" echo "short_sha=${git_sha:0:12}" >> "$GITHUB_OUTPUT" @@ -93,7 +99,7 @@ jobs: steps: - uses: actions/checkout@v4 with: - ref: ${{ inputs.source_ref }} + ref: ${{ needs.prepare.outputs.git_sha }} - name: Setup Rust (linux) if: matrix.os == 'linux' @@ -155,7 +161,7 @@ jobs: steps: - uses: actions/checkout@v4 with: - ref: ${{ inputs.source_ref }} + ref: ${{ needs.prepare.outputs.git_sha }} - uses: actions/checkout@v4 with: @@ -273,7 +279,7 @@ jobs: name="$(jq -er '.name' <<< "$manifest")" version="$(jq -er '.version' <<< "$manifest")" local_integrity="sha512-$(openssl dgst -sha512 -binary "$tarball" | openssl base64 -A)" - [[ "$version" =~ -dev\.[0-9a-f]{12}$ ]] || { echo "Refusing stable version $name@$version" >&2; exit 1; } + [[ "$version" =~ -dev\.[0-9a-f]{40}$ ]] || { echo "Refusing stable version $name@$version" >&2; exit 1; } remote_integrity="$(npm view "$name@$version" dist.integrity 2>/dev/null || true)" if [ -n "$remote_integrity" ]; then test "$remote_integrity" = "$local_integrity" || { diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ae9e12baf..e93ae2045 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -9,7 +9,7 @@ on: default: stable options: [stable, dev] source_ref: - description: Exact source ref for dev npm packages + description: Exact 40-character source commit for dev npm packages type: string default: main dry_run: diff --git a/scripts/npm-dev-release.mjs b/scripts/npm-dev-release.mjs index 828ab4ab0..6e19322a0 100644 --- a/scripts/npm-dev-release.mjs +++ b/scripts/npm-dev-release.mjs @@ -38,13 +38,12 @@ function baseVersion(version, name) { export function computeVersions(root, sha) { if (!/^[0-9a-f]{40}$/.test(sha)) throw new Error(`Expected a full lowercase git SHA, got ${sha}`) - const shortSha = sha.slice(0, 12) const manifests = packages.map(entry => ({ ...entry, manifest: readJson(resolve(root, entry.path)) })) const versions = new Map() for (const { manifest, versionFrom } of manifests) { if (versionFrom) continue - versions.set(manifest.name, `${baseVersion(manifest.version, manifest.name)}-dev.${shortSha}`) + versions.set(manifest.name, `${baseVersion(manifest.version, manifest.name)}-dev.${sha}`) } for (const { manifest, versionFrom } of manifests) { if (!versionFrom) continue @@ -80,7 +79,7 @@ export function validateManifests(root, sha) { if (manifest.version !== expected.get(manifest.name)) { throw new Error(`${manifest.name} version is ${manifest.version}; expected ${expected.get(manifest.name)}`) } - if (!/-dev\.[0-9a-f]{12}$/.test(manifest.version)) { + if (!/-dev\.[0-9a-f]{40}$/.test(manifest.version)) { throw new Error(`${manifest.name} version is not an immutable dev prerelease: ${manifest.version}`) } for (const field of dependencyFields) { diff --git a/scripts/npm-dev-release.test.mjs b/scripts/npm-dev-release.test.mjs index 8cc4362bf..18115ea96 100644 --- a/scripts/npm-dev-release.test.mjs +++ b/scripts/npm-dev-release.test.mjs @@ -34,8 +34,8 @@ test("rewrites every published package and internal edge to commit-addressed ver const versions = rewriteManifests(root, sha) validateManifests(root, sha) - assert.equal(versions.get("@alienplatform/core"), `${coreBase}-dev.0123456789ab`) - assert.equal(versions.get("@alienplatform/platform-api"), `${platformBase}-dev.0123456789ab`) + assert.equal(versions.get("@alienplatform/core"), `${coreBase}-dev.${sha}`) + assert.equal(versions.get("@alienplatform/platform-api"), `${platformBase}-dev.${sha}`) const commands = JSON.parse(readFileSync(resolve(root, "packages/commands/package.json"), "utf8")) assert.equal(commands.dependencies["@alienplatform/core"], versions.get("@alienplatform/core")) @@ -53,5 +53,9 @@ test("rejects a stable or mismatched package graph after rewrite", () => { manifest.dependencies["@alienplatform/core"] = "^1.14.1" writeFileSync(path, `${JSON.stringify(manifest)}\n`) - assert.throws(() => validateManifests(root, sha), /expected 1\.14\.1-dev\.0123456789ab/) + assert.throws(() => validateManifests(root, sha), new RegExp(`expected 1\\.14\\.1-dev\\.${sha}`)) +}) + +test("rejects abbreviated commit identities", () => { + assert.throws(() => rewriteManifests(fixture(), sha.slice(0, 12)), /full lowercase git SHA/) }) diff --git a/scripts/release-routing.test.mjs b/scripts/release-routing.test.mjs index b196e6d8f..90ba3c8e1 100644 --- a/scripts/release-routing.test.mjs +++ b/scripts/release-routing.test.mjs @@ -52,6 +52,15 @@ test("stable remains the default release mode", () => { assert.match(workflow, /mode:\n\s+description: Publication channel\n\s+type: choice\n\s+default: stable\n\s+options: \[stable, dev\]/) }) +test("dev publication requires an explicit full source commit", () => { + assert.match(workflow, /source_ref:\n\s+description: Exact 40-character source commit/) + const reusable = readFileSync( + resolve(process.cwd(), ".github/workflows/publish-npm-dev.yml"), + "utf8", + ) + assert.match(reusable, /\^\[0-9a-f\]\{40\}\$/) +}) + test("dev mode can reach only the reusable npm dev workflow", () => { const jobs = parseJobs(workflow) assert.deepEqual([...jobs.keys()].sort(), ["publish-npm-dev", ...stableJobs].sort()) From d584439375fc29e5296dfea907df6c2d57f56cb2 Mon Sep 17 00:00:00 2001 From: Alon Gubkin Date: Mon, 20 Jul 2026 13:27:55 +0300 Subject: [PATCH 07/15] style: format npm dev release scripts --- scripts/npm-dev-release.mjs | 33 +++++++++++++++++++++++++------- scripts/npm-dev-release.test.mjs | 2 +- scripts/release-routing.test.mjs | 17 +++++++++++----- 3 files changed, 39 insertions(+), 13 deletions(-) diff --git a/scripts/npm-dev-release.mjs b/scripts/npm-dev-release.mjs index 6e19322a0..7e66eb39f 100644 --- a/scripts/npm-dev-release.mjs +++ b/scripts/npm-dev-release.mjs @@ -12,7 +12,11 @@ export const packages = [ { path: "packages/testing/package.json", publish: true }, { path: "client-sdks/platform/typescript/package.json", publish: true }, { path: "client-sdks/manager/typescript/package.json", publish: true }, - { path: "crates/alien-bindings-node/package.json", publish: false, versionFrom: "@alienplatform/bindings" }, + { + path: "crates/alien-bindings-node/package.json", + publish: false, + versionFrom: "@alienplatform/bindings", + }, ...["darwin-arm64", "darwin-x64", "linux-x64-gnu", "linux-arm64-gnu"].map(triple => ({ path: `packages/bindings/npm/${triple}/package.json`, publish: true, @@ -20,7 +24,12 @@ export const packages = [ })), ] -const dependencyFields = ["dependencies", "devDependencies", "peerDependencies", "optionalDependencies"] +const dependencyFields = [ + "dependencies", + "devDependencies", + "peerDependencies", + "optionalDependencies", +] function readJson(path) { return JSON.parse(readFileSync(path, "utf8")) @@ -38,7 +47,10 @@ function baseVersion(version, name) { export function computeVersions(root, sha) { if (!/^[0-9a-f]{40}$/.test(sha)) throw new Error(`Expected a full lowercase git SHA, got ${sha}`) - const manifests = packages.map(entry => ({ ...entry, manifest: readJson(resolve(root, entry.path)) })) + const manifests = packages.map(entry => ({ + ...entry, + manifest: readJson(resolve(root, entry.path)), + })) const versions = new Map() for (const { manifest, versionFrom } of manifests) { @@ -48,7 +60,8 @@ export function computeVersions(root, sha) { for (const { manifest, versionFrom } of manifests) { if (!versionFrom) continue const version = versions.get(versionFrom) - if (!version) throw new Error(`${manifest.name} references unknown version source ${versionFrom}`) + if (!version) + throw new Error(`${manifest.name} references unknown version source ${versionFrom}`) versions.set(manifest.name, version) } return versions @@ -77,16 +90,22 @@ export function validateManifests(root, sha) { for (const { path } of packages) { const manifest = readJson(resolve(root, path)) if (manifest.version !== expected.get(manifest.name)) { - throw new Error(`${manifest.name} version is ${manifest.version}; expected ${expected.get(manifest.name)}`) + throw new Error( + `${manifest.name} version is ${manifest.version}; expected ${expected.get(manifest.name)}`, + ) } if (!/-dev\.[0-9a-f]{40}$/.test(manifest.version)) { - throw new Error(`${manifest.name} version is not an immutable dev prerelease: ${manifest.version}`) + throw new Error( + `${manifest.name} version is not an immutable dev prerelease: ${manifest.version}`, + ) } for (const field of dependencyFields) { for (const [dependency, range] of Object.entries(manifest[field] ?? {})) { const version = expected.get(dependency) if (version && range !== version) { - throw new Error(`${manifest.name} ${field}.${dependency} is ${range}; expected ${version}`) + throw new Error( + `${manifest.name} ${field}.${dependency} is ${range}; expected ${version}`, + ) } } } diff --git a/scripts/npm-dev-release.test.mjs b/scripts/npm-dev-release.test.mjs index 18115ea96..25e8a62a3 100644 --- a/scripts/npm-dev-release.test.mjs +++ b/scripts/npm-dev-release.test.mjs @@ -2,8 +2,8 @@ import assert from "node:assert/strict" import { copyFileSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs" import { tmpdir } from "node:os" import { dirname, resolve } from "node:path" -import { fileURLToPath } from "node:url" import test from "node:test" +import { fileURLToPath } from "node:url" import { packages, rewriteManifests, validateManifests } from "./npm-dev-release.mjs" diff --git a/scripts/release-routing.test.mjs b/scripts/release-routing.test.mjs index 90ba3c8e1..a1247a8be 100644 --- a/scripts/release-routing.test.mjs +++ b/scripts/release-routing.test.mjs @@ -13,16 +13,16 @@ function parseJobs(source) { let current for (const line of lines.slice(jobsIndex + 1)) { - const job = /^ ([a-z0-9_-]+):$/.exec(line) + const job = /^ {2}([a-z0-9_-]+):$/.exec(line) if (job) { current = { if: "", uses: "" } jobs.set(job[1], current) continue } if (!current) continue - const condition = /^ if: (.+)$/.exec(line) + const condition = /^ {4}if: (.+)$/.exec(line) if (condition) current.if = condition[1] - const uses = /^ uses: (.+)$/.exec(line) + const uses = /^ {4}uses: (.+)$/.exec(line) if (uses) current.uses = uses[1] } return jobs @@ -49,7 +49,10 @@ const stableJobs = [ ] test("stable remains the default release mode", () => { - assert.match(workflow, /mode:\n\s+description: Publication channel\n\s+type: choice\n\s+default: stable\n\s+options: \[stable, dev\]/) + assert.match( + workflow, + /mode:\n\s+description: Publication channel\n\s+type: choice\n\s+default: stable\n\s+options: \[stable, dev\]/, + ) }) test("dev publication requires an explicit full source commit", () => { @@ -68,6 +71,10 @@ test("dev mode can reach only the reusable npm dev workflow", () => { assert.equal(jobs.get("publish-npm-dev").uses, "./.github/workflows/publish-npm-dev.yml") for (const name of stableJobs) { - assert.match(jobs.get(name).if, /inputs\.mode == 'stable'/, `${name} must be unreachable in dev mode`) + assert.match( + jobs.get(name).if, + /inputs\.mode == 'stable'/, + `${name} must be unreachable in dev mode`, + ) } }) From c6968a67f731394e2c3a200b6a06713110f2210e Mon Sep 17 00:00:00 2001 From: Alon Gubkin Date: Mon, 20 Jul 2026 13:31:25 +0300 Subject: [PATCH 08/15] fix: smoke test compatible native package --- .github/workflows/publish-npm-dev.yml | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/.github/workflows/publish-npm-dev.yml b/.github/workflows/publish-npm-dev.yml index 44f9d2b36..8ce938df2 100644 --- a/.github/workflows/publish-npm-dev.yml +++ b/.github/workflows/publish-npm-dev.yml @@ -249,7 +249,25 @@ jobs: set -euo pipefail consumer="$(mktemp -d)" npm init --yes --prefix "$consumer" >/dev/null - npm install --prefix "$consumer" "$GITHUB_WORKSPACE"/npm-dev-tarballs/*.tgz + case "$(uname -s)-$(uname -m)" in + Linux-x86_64) native_package_prefix=alienplatform-bindings-linux-x64-gnu ;; + Linux-aarch64) native_package_prefix=alienplatform-bindings-linux-arm64-gnu ;; + Darwin-x86_64) native_package_prefix=alienplatform-bindings-darwin-x64 ;; + Darwin-arm64) native_package_prefix=alienplatform-bindings-darwin-arm64 ;; + *) echo "Unsupported smoke-test platform: $(uname -s)-$(uname -m)" >&2; exit 1 ;; + esac + tarballs=("$GITHUB_WORKSPACE"/npm-dev-tarballs/*.tgz) + install_tarballs=() + for tarball in "${tarballs[@]}"; do + filename="$(basename "$tarball")" + case "$filename" in + alienplatform-bindings-darwin-arm64-*.tgz|alienplatform-bindings-darwin-x64-*.tgz|alienplatform-bindings-linux-x64-gnu-*.tgz|alienplatform-bindings-linux-arm64-gnu-*.tgz) + [[ "$filename" == "$native_package_prefix"-*.tgz ]] && install_tarballs+=("$tarball") + ;; + *) install_tarballs+=("$tarball") ;; + esac + done + npm install --prefix "$consumer" "${install_tarballs[@]}" cd "$consumer" node --input-type=module <<'EOF' import { Container } from "@alienplatform/core" From 294df3134de74ae527d4fa4a1055e00592028fcc Mon Sep 17 00:00:00 2001 From: Alon Gubkin Date: Mon, 20 Jul 2026 13:32:40 +0300 Subject: [PATCH 09/15] test: load native addon in package smoke --- .github/workflows/publish-npm-dev.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/publish-npm-dev.yml b/.github/workflows/publish-npm-dev.yml index 8ce938df2..a3bc226a4 100644 --- a/.github/workflows/publish-npm-dev.yml +++ b/.github/workflows/publish-npm-dev.yml @@ -271,10 +271,17 @@ jobs: cd "$consumer" node --input-type=module <<'EOF' import { Container } from "@alienplatform/core" + import { BindingNotConfiguredError, storage } from "@alienplatform/bindings" const resource = new Container("dev-package-smoke").commandsEnabled(true) if (resource.config.commandsEnabled !== true) throw new Error("packed core lacks commandsEnabled") await import("@alienplatform/commands") await import("@alienplatform/sdk") + try { + await storage("dev-package-smoke").list() + throw new Error("unconfigured binding unexpectedly succeeded") + } catch (error) { + if (!(error instanceof BindingNotConfiguredError)) throw error + } EOF - name: Validate npm authentication From af7308ad49e969e5f1ff4890ef951f4d4da27a62 Mon Sep 17 00:00:00 2001 From: Alon Gubkin Date: Mon, 20 Jul 2026 13:42:27 +0300 Subject: [PATCH 10/15] test: build container in package smoke --- .github/workflows/publish-npm-dev.yml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/.github/workflows/publish-npm-dev.yml b/.github/workflows/publish-npm-dev.yml index a3bc226a4..b55e3e4db 100644 --- a/.github/workflows/publish-npm-dev.yml +++ b/.github/workflows/publish-npm-dev.yml @@ -272,7 +272,13 @@ jobs: node --input-type=module <<'EOF' import { Container } from "@alienplatform/core" import { BindingNotConfiguredError, storage } from "@alienplatform/bindings" - const resource = new Container("dev-package-smoke").commandsEnabled(true) + const resource = new Container("dev-package-smoke") + .code({ type: "image", image: "busybox:stable" }) + .cpu(0.25) + .memory("256Mi") + .permissions("dev-package-smoke") + .commandsEnabled(true) + .build() if (resource.config.commandsEnabled !== true) throw new Error("packed core lacks commandsEnabled") await import("@alienplatform/commands") await import("@alienplatform/sdk") From 9409ff26bbb3771834c967347e1090bc2d31527c Mon Sep 17 00:00:00 2001 From: Alon Gubkin Date: Mon, 20 Jul 2026 13:51:58 +0300 Subject: [PATCH 11/15] test: assert binding error by public code --- .github/workflows/publish-npm-dev.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/publish-npm-dev.yml b/.github/workflows/publish-npm-dev.yml index b55e3e4db..6d3c8a086 100644 --- a/.github/workflows/publish-npm-dev.yml +++ b/.github/workflows/publish-npm-dev.yml @@ -271,7 +271,7 @@ jobs: cd "$consumer" node --input-type=module <<'EOF' import { Container } from "@alienplatform/core" - import { BindingNotConfiguredError, storage } from "@alienplatform/bindings" + import { AlienError, BindingNotConfiguredError, storage } from "@alienplatform/bindings" const resource = new Container("dev-package-smoke") .code({ type: "image", image: "busybox:stable" }) .cpu(0.25) @@ -286,7 +286,7 @@ jobs: await storage("dev-package-smoke").list() throw new Error("unconfigured binding unexpectedly succeeded") } catch (error) { - if (!(error instanceof BindingNotConfiguredError)) throw error + if (!(error instanceof AlienError) || error.code !== BindingNotConfiguredError.metadata.code) throw error } EOF From 0e8630b15294d875fc0ea03119ae1df6c9109de9 Mon Sep 17 00:00:00 2001 From: Alon Gubkin Date: Mon, 20 Jul 2026 14:02:46 +0300 Subject: [PATCH 12/15] fix: stamp dev version into native addons --- .github/workflows/publish-npm-dev.yml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/.github/workflows/publish-npm-dev.yml b/.github/workflows/publish-npm-dev.yml index 6d3c8a086..9a3e714ba 100644 --- a/.github/workflows/publish-npm-dev.yml +++ b/.github/workflows/publish-npm-dev.yml @@ -136,6 +136,22 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile + - name: Stamp native addon version + shell: bash + run: | + set -euo pipefail + npm_version="$(node -p "require('./packages/bindings/package.json').version")-dev.${{ needs.prepare.outputs.git_sha }}" + node --input-type=module "$npm_version" <<'EOF' + import { readFileSync, writeFileSync } from "node:fs" + const [version] = process.argv.slice(2) + const path = "Cargo.toml" + const source = readFileSync(path, "utf8") + const updated = source.replace(/^(version = ")[^"]+("\s*)$/m, `$1${version}$2`) + if (updated === source) throw new Error("workspace Cargo version was not updated") + writeFileSync(path, updated) + EOF + test "$(cargo metadata --no-deps --format-version 1 | jq -r '.packages[] | select(.name == "alien-bindings-node") | .version')" = "$npm_version" + - name: Build addon env: CARGO_NET_GIT_FETCH_WITH_CLI: "true" From 5fd019c45af45adbd28bf61a0c654d46abe5c3e2 Mon Sep 17 00:00:00 2001 From: Alon Gubkin Date: Mon, 20 Jul 2026 14:07:18 +0300 Subject: [PATCH 13/15] fix: execute version stamping script from stdin --- .github/workflows/publish-npm-dev.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish-npm-dev.yml b/.github/workflows/publish-npm-dev.yml index 9a3e714ba..1af80b3d9 100644 --- a/.github/workflows/publish-npm-dev.yml +++ b/.github/workflows/publish-npm-dev.yml @@ -141,7 +141,7 @@ jobs: run: | set -euo pipefail npm_version="$(node -p "require('./packages/bindings/package.json').version")-dev.${{ needs.prepare.outputs.git_sha }}" - node --input-type=module "$npm_version" <<'EOF' + node --input-type=module - "$npm_version" <<'EOF' import { readFileSync, writeFileSync } from "node:fs" const [version] = process.argv.slice(2) const path = "Cargo.toml" From 59080f3e4e748714a77cf5e3df3ed4ef0eea27c6 Mon Sep 17 00:00:00 2001 From: Alon Gubkin Date: Mon, 20 Jul 2026 14:13:07 +0300 Subject: [PATCH 14/15] fix: version only native addon crate --- .github/workflows/publish-npm-dev.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/publish-npm-dev.yml b/.github/workflows/publish-npm-dev.yml index 1af80b3d9..a8c6df88b 100644 --- a/.github/workflows/publish-npm-dev.yml +++ b/.github/workflows/publish-npm-dev.yml @@ -144,10 +144,10 @@ jobs: node --input-type=module - "$npm_version" <<'EOF' import { readFileSync, writeFileSync } from "node:fs" const [version] = process.argv.slice(2) - const path = "Cargo.toml" + const path = "crates/alien-bindings-node/Cargo.toml" const source = readFileSync(path, "utf8") - const updated = source.replace(/^(version = ")[^"]+("\s*)$/m, `$1${version}$2`) - if (updated === source) throw new Error("workspace Cargo version was not updated") + const updated = source.replace(/^version\.workspace = true$/m, `version = "${version}"`) + if (updated === source) throw new Error("native addon Cargo version was not updated") writeFileSync(path, updated) EOF test "$(cargo metadata --no-deps --format-version 1 | jq -r '.packages[] | select(.name == "alien-bindings-node") | .version')" = "$npm_version" From 0468d6c27458bfb8d49b8741d1abcffd4a5cfd3e Mon Sep 17 00:00:00 2001 From: Alon Gubkin Date: Mon, 20 Jul 2026 14:33:27 +0300 Subject: [PATCH 15/15] fix(release): publish dev packages from local tarballs --- .github/workflows/publish-npm-dev.yml | 3 ++- scripts/release-routing.test.mjs | 9 +++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/.github/workflows/publish-npm-dev.yml b/.github/workflows/publish-npm-dev.yml index a8c6df88b..7cff496c5 100644 --- a/.github/workflows/publish-npm-dev.yml +++ b/.github/workflows/publish-npm-dev.yml @@ -320,7 +320,8 @@ jobs: run: | set -euo pipefail publish_tarball() { - local tarball="$1" + local tarball + tarball="$(realpath "$1")" local manifest name version local_integrity remote_integrity manifest="$(tar -xOzf "$tarball" package/package.json)" name="$(jq -er '.name' <<< "$manifest")" diff --git a/scripts/release-routing.test.mjs b/scripts/release-routing.test.mjs index a1247a8be..277c705aa 100644 --- a/scripts/release-routing.test.mjs +++ b/scripts/release-routing.test.mjs @@ -64,6 +64,15 @@ test("dev publication requires an explicit full source commit", () => { assert.match(reusable, /\^\[0-9a-f\]\{40\}\$/) }) +test("dev publication passes npm an unambiguous local tarball path", () => { + const reusable = readFileSync( + resolve(process.cwd(), ".github/workflows/publish-npm-dev.yml"), + "utf8", + ) + assert.match(reusable, /tarball="\$\(realpath "\$1"\)"/) + assert.match(reusable, /npm publish "\$tarball" --access public --tag dev/) +}) + test("dev mode can reach only the reusable npm dev workflow", () => { const jobs = parseJobs(workflow) assert.deepEqual([...jobs.keys()].sort(), ["publish-npm-dev", ...stableJobs].sort())