diff --git a/.github/workflows/publish-npm-dev.yml b/.github/workflows/publish-npm-dev.yml new file mode 100644 index 000000000..7cff496c5 --- /dev/null +++ b/.github/workflows/publish-npm-dev.yml @@ -0,0 +1,363 @@ +name: Publish npm dev packages + +on: + workflow_call: + inputs: + source_ref: + description: Exact 40-character lowercase commit SHA to package + required: true + type: string + dry_run: + description: Build, pack, and smoke-test without publishing + default: true + type: boolean + +permissions: + contents: read + id-token: write + +concurrency: + group: npm-dev-${{ inputs.source_ref }} + 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 + with: + ref: ${{ inputs.source_ref }} + + - uses: actions/checkout@v4 + with: + ref: ${{ github.ref }} + path: .npm-dev-tools + + - id: identity + name: Record immutable source identity + 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" + + - 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 + env: + NPM_DEV_SOURCE_ROOT: ${{ github.workspace }} + run: node --test .npm-dev-tools/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 + with: + ref: ${{ needs.prepare.outputs.git_sha }} + + - 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: 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 = "crates/alien-bindings-node/Cargo.toml" + const source = readFileSync(path, "utf8") + 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" + + - 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 + with: + ref: ${{ needs.prepare.outputs.git_sha }} + + - uses: actions/checkout@v4 + with: + ref: ${{ github.ref }} + path: .npm-dev-tools + + - 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 .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 .npm-dev-tools/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 .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' + 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 + 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" + import { AlienError, BindingNotConfiguredError, storage } from "@alienplatform/bindings" + 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") + try { + await storage("dev-package-smoke").list() + throw new Error("unconfigured binding unexpectedly succeeded") + } catch (error) { + if (!(error instanceof AlienError) || error.code !== BindingNotConfiguredError.metadata.code) throw error + } + 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 + tarball="$(realpath "$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]{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" || { + 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/.github/workflows/release.yml b/.github/workflows/release.yml index 6cb7a5a15..e93ae2045 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 40-character source commit 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/npm-dev-release.mjs b/scripts/npm-dev-release.mjs new file mode 100644 index 000000000..7e66eb39f --- /dev/null +++ b/scripts/npm-dev-release.mjs @@ -0,0 +1,134 @@ +#!/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 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.${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}`) + 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]{40}$/.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 +} + +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..25e8a62a3 --- /dev/null +++ b/scripts/npm-dev-release.test.mjs @@ -0,0 +1,61 @@ +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 test from "node:test" +import { fileURLToPath } from "node:url" + +import { packages, rewriteManifests, validateManifests } from "./npm-dev-release.mjs" + +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() { + 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 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"), `${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")) + + 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), 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 new file mode 100644 index 000000000..277c705aa --- /dev/null +++ b/scripts/release-routing.test.mjs @@ -0,0 +1,89 @@ +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 = /^ {2}([a-z0-9_-]+):$/.exec(line) + if (job) { + current = { if: "", uses: "" } + jobs.set(job[1], current) + continue + } + if (!current) continue + const condition = /^ {4}if: (.+)$/.exec(line) + if (condition) current.if = condition[1] + const uses = /^ {4}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 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 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()) + 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`, + ) + } +})