mirror of
https://github.com/lexmount/moli.git
synced 2026-10-03 08:00:49 +00:00
feat(release): add npm CLI packaging
This commit is contained in:
@@ -80,6 +80,28 @@ jobs:
|
||||
- name: Run full workspace tests
|
||||
run: cargo nextest run --workspace --profile ci
|
||||
|
||||
npm-package:
|
||||
name: npm package
|
||||
if: github.event_name != 'pull_request' || github.event.action != 'closed'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: 24.15.0
|
||||
|
||||
- name: Test npm launcher
|
||||
run: npm test --prefix npm
|
||||
|
||||
- name: Test release packaging scripts
|
||||
run: python3 -m unittest discover --start-directory scripts/tests --verbose
|
||||
|
||||
protocol-smoke:
|
||||
name: CDP and WebDriver smoke
|
||||
if: github.event_name != 'pull_request' || github.event.action != 'closed'
|
||||
|
||||
@@ -17,6 +17,11 @@ on:
|
||||
required: true
|
||||
default: false
|
||||
type: boolean
|
||||
publish_npm:
|
||||
description: Publish @lexmount/moli after creating a non-draft release
|
||||
required: true
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
concurrency:
|
||||
group: release-${{ inputs.version }}
|
||||
@@ -52,9 +57,16 @@ jobs:
|
||||
shell: bash
|
||||
env:
|
||||
INPUT_VERSION: ${{ inputs.version }}
|
||||
INPUT_DRAFT: ${{ inputs.draft }}
|
||||
INPUT_PUBLISH_NPM: ${{ inputs.publish_npm }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
if [[ "$INPUT_DRAFT" == true && "$INPUT_PUBLISH_NPM" == true ]]; then
|
||||
echo "npm publishing cannot be enabled for a draft release." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
version="${INPUT_VERSION#v}"
|
||||
semver_pattern='^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$'
|
||||
if [[ ! "$version" =~ $semver_pattern ]]; then
|
||||
@@ -215,6 +227,85 @@ jobs:
|
||||
compression-level: 0
|
||||
retention-days: 7
|
||||
|
||||
package-npm:
|
||||
name: Package npm CLI
|
||||
needs:
|
||||
- validate
|
||||
- build-linux
|
||||
- build-macos
|
||||
- build-windows
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: 24.15.0
|
||||
|
||||
- name: Download native release artifacts
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
pattern: release-*
|
||||
path: dist
|
||||
merge-multiple: true
|
||||
|
||||
- name: Test npm launcher
|
||||
run: npm test --prefix npm
|
||||
|
||||
- name: Build npm packages
|
||||
run: >-
|
||||
python3 scripts/package_npm.py
|
||||
--version "${{ needs.validate.outputs.version }}"
|
||||
--input-dir dist
|
||||
--output-dir dist/npm
|
||||
|
||||
- name: Validate npm publish contents
|
||||
run: >-
|
||||
python3 scripts/publish_npm.py
|
||||
dist/npm/npm-packages.json
|
||||
--main-tag latest
|
||||
--dry-run
|
||||
|
||||
- name: Smoke-test packaged Linux CLI
|
||||
shell: bash
|
||||
env:
|
||||
RELEASE_VERSION: ${{ needs.validate.outputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
smoke_dir=$(mktemp -d "${RUNNER_TEMP}/moli-npm-smoke.XXXXXX")
|
||||
trap 'rm -rf "$smoke_dir"' EXIT
|
||||
manifest=dist/npm/npm-packages.json
|
||||
main_tarball=$(jq -r '.main.filename' "$manifest")
|
||||
linux_tarball=$(jq -r \
|
||||
'.platforms[] | select(.target == "x86_64-unknown-linux-gnu") | .filename' \
|
||||
"$manifest")
|
||||
main_root="$smoke_dir/node_modules/@lexmount/moli"
|
||||
platform_root="$smoke_dir/node_modules/@lexmount/moli-linux-x64"
|
||||
mkdir -p "$main_root" "$platform_root"
|
||||
tar -xzf "dist/npm/$main_tarball" -C "$main_root" --strip-components=1
|
||||
tar -xzf "dist/npm/$linux_tarball" -C "$platform_root" --strip-components=1
|
||||
|
||||
reported_version=$(node "$main_root/bin/moli.js" --version)
|
||||
if [[ "$reported_version" != "moli $RELEASE_VERSION" ]]; then
|
||||
echo "Unexpected packaged CLI version: $reported_version" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Upload npm packages
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: npm-packages
|
||||
path: dist/npm/
|
||||
if-no-files-found: error
|
||||
compression-level: 0
|
||||
retention-days: 7
|
||||
|
||||
publish:
|
||||
name: Create GitHub Release
|
||||
needs:
|
||||
@@ -222,6 +313,7 @@ jobs:
|
||||
- build-linux
|
||||
- build-macos
|
||||
- build-windows
|
||||
- package-npm
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
@@ -302,3 +394,56 @@ jobs:
|
||||
printf 'Created %s\n' "$release_url"
|
||||
printf '### Release created\n\n[%s](%s)\n' \
|
||||
"$RELEASE_TAG" "$release_url" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
publish-npm:
|
||||
name: Publish npm CLI
|
||||
if: ${{ inputs.publish_npm && !inputs.draft }}
|
||||
needs:
|
||||
- validate
|
||||
- package-npm
|
||||
- publish
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
environment: npm
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: 24.15.0
|
||||
registry-url: https://registry.npmjs.org
|
||||
|
||||
- name: Download npm packages
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: npm-packages
|
||||
path: dist/npm
|
||||
|
||||
- name: Verify trusted-publishing client versions
|
||||
shell: bash
|
||||
run: |
|
||||
node --version
|
||||
npm --version
|
||||
|
||||
- name: Publish platform packages, then launcher
|
||||
shell: bash
|
||||
env:
|
||||
RELEASE_PRERELEASE: ${{ inputs.prerelease }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
main_tag=latest
|
||||
if [[ "$RELEASE_PRERELEASE" == true ]]; then
|
||||
main_tag=next
|
||||
fi
|
||||
python3 scripts/publish_npm.py \
|
||||
dist/npm/npm-packages.json \
|
||||
--main-tag "$main_tag" \
|
||||
--trusted-publishing
|
||||
|
||||
+77
-1
@@ -14,6 +14,15 @@ version marker, and third-party license notices. The workflow also publishes
|
||||
`moli-installer.sh` and `moli-installer.ps1`. Skills are maintained separately
|
||||
in the repository and are not included in release assets.
|
||||
|
||||
The workflow also assembles an npm CLI package set from those same native
|
||||
archives. The public launcher is `@lexmount/moli@<version>`. Its optional
|
||||
dependencies select one of four platform versions such as
|
||||
`@lexmount/moli@<version>-linux-x64`, so npm downloads only the binary for the
|
||||
host operating system and architecture. The launcher exposes the `moli`
|
||||
command and forwards arguments, standard I/O, signals, and exit status to the
|
||||
native executable. Linux npm installs currently require x86-64 glibc; Linux
|
||||
ARM64 and musl are not advertised as supported targets.
|
||||
|
||||
Stable names are intentional: the latest non-prerelease asset is always
|
||||
available at
|
||||
`https://github.com/lexmount/moli/releases/latest/download/<asset-name>`.
|
||||
@@ -56,7 +65,9 @@ treats the Windows/MSVC combination as untested.
|
||||
1. Open **Actions** in GitHub and choose the **Release** workflow.
|
||||
2. Select **Run workflow** and choose the Git ref containing the release.
|
||||
3. Enter the version (with or without a leading `v`).
|
||||
4. Choose whether the release should be a prerelease or a draft, then run it.
|
||||
4. Choose whether the release should be a prerelease or a draft.
|
||||
5. Enable npm publishing only after the npm trusted publisher described below
|
||||
is configured, then run the workflow.
|
||||
|
||||
The workflow validates the selected commit, builds all four native artifacts
|
||||
in parallel, verifies the expected archives, creates the corresponding
|
||||
@@ -65,3 +76,68 @@ and two installers. It stops without creating a release if any platform fails,
|
||||
if the requested version does not match the manifest, or if the tag already
|
||||
exists. A published, non-prerelease release is explicitly marked as the latest
|
||||
release so the stable installer URLs switch to it immediately.
|
||||
|
||||
Before creating the GitHub Release, the workflow builds five npm tarballs and
|
||||
smoke-tests the Linux launcher against the real packaged binary. npm publishing
|
||||
is disabled by default and is never attempted for draft releases. For a stable
|
||||
release the launcher receives the `latest` dist-tag; for a prerelease it
|
||||
receives `next`. Native platform versions are published first under
|
||||
platform-specific dist-tags, and the launcher is published last so users never
|
||||
receive a package whose required binary versions are incomplete.
|
||||
|
||||
## Configure npm publishing
|
||||
|
||||
The unscoped `moli` package name is already owned by another publisher, so the
|
||||
release uses the public scoped package `@lexmount/moli`. Confirm that the npm
|
||||
organization or user controlling `@lexmount` can publish public packages before
|
||||
enabling the workflow option.
|
||||
|
||||
An npm Trusted Publisher can only be attached after the package exists. For the
|
||||
first npm release, leave npm publishing disabled when running the Release
|
||||
workflow, download its `npm-packages` artifact, sign in to npm interactively,
|
||||
and publish the verified package set from the extracted artifact:
|
||||
|
||||
```sh
|
||||
npm login
|
||||
python3 scripts/publish_npm.py /path/to/npm-packages/npm-packages.json \
|
||||
--main-tag latest
|
||||
```
|
||||
|
||||
Use `--main-tag next` instead when bootstrapping from a prerelease. The script
|
||||
publishes the four native versions first and the launcher last. It is safe to
|
||||
retry: versions whose registry integrity matches the artifact are skipped.
|
||||
After this one-time publication, configure Trusted Publishing for subsequent
|
||||
releases.
|
||||
|
||||
Configure an npm Trusted Publisher for the `lexmount/moli` GitHub repository
|
||||
with these values:
|
||||
|
||||
- Workflow filename: `release.yml`
|
||||
- Environment: `npm`
|
||||
- Allowed action: `npm publish`
|
||||
|
||||
The publish job runs on a GitHub-hosted runner with Node 24, npm 11.5.1 or
|
||||
newer, and the `id-token: write` permission. The publish script checks these
|
||||
minimum versions before touching the registry. It does not use a long-lived
|
||||
npm token, and npm generates provenance automatically for the public package.
|
||||
Add any required reviewers or branch/tag restrictions to the GitHub `npm`
|
||||
environment. After the first successful publication, installation is:
|
||||
|
||||
```sh
|
||||
npm install --global @lexmount/moli
|
||||
moli --version
|
||||
```
|
||||
|
||||
For local package validation, place all four native archives in `dist/`, then
|
||||
run:
|
||||
|
||||
```sh
|
||||
python3 scripts/package_npm.py --version 1.0.0
|
||||
python3 scripts/publish_npm.py dist/npm/npm-packages.json --dry-run
|
||||
```
|
||||
|
||||
The package manifest records the SHA-512 integrity of every tarball. A retried
|
||||
publish skips an existing version only when the registry reports the same
|
||||
integrity; a mismatch stops the release. npm does not allow a published
|
||||
name/version pair to be replaced, so release versions must be bumped before
|
||||
publishing changed contents.
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { spawn } from "node:child_process";
|
||||
import { existsSync, realpathSync } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import {
|
||||
assertSupportedLibc,
|
||||
platformDefinitionFor,
|
||||
} from "../lib/platform.js";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const packageRoot = realpathSync(
|
||||
path.join(path.dirname(fileURLToPath(import.meta.url)), ".."),
|
||||
);
|
||||
|
||||
function executableFor(definition) {
|
||||
let vendorRoot;
|
||||
try {
|
||||
const packageJson = require.resolve(`${definition.package}/package.json`);
|
||||
vendorRoot = path.join(path.dirname(packageJson), "vendor");
|
||||
} catch {
|
||||
// This fallback makes source checkouts and assembled package smoke tests
|
||||
// possible without weakening the normal optional-dependency lookup.
|
||||
vendorRoot = path.join(packageRoot, "vendor");
|
||||
}
|
||||
|
||||
const executable = path.join(
|
||||
vendorRoot,
|
||||
definition.target,
|
||||
"bin",
|
||||
definition.binary,
|
||||
);
|
||||
if (existsSync(executable)) {
|
||||
return executable;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Missing optional dependency ${definition.package}. ` +
|
||||
"Reinstall @lexmount/moli without omitting optional dependencies.",
|
||||
);
|
||||
}
|
||||
|
||||
async function run() {
|
||||
const definition = platformDefinitionFor(process.platform, process.arch);
|
||||
const runtimeReport = process.report?.getReport?.();
|
||||
assertSupportedLibc(definition, runtimeReport?.header?.glibcVersionRuntime);
|
||||
const child = spawn(executableFor(definition), process.argv.slice(2), {
|
||||
stdio: "inherit",
|
||||
});
|
||||
|
||||
const forwardedSignals =
|
||||
process.platform === "win32"
|
||||
? ["SIGINT", "SIGTERM"]
|
||||
: ["SIGINT", "SIGTERM", "SIGHUP"];
|
||||
const signalHandlers = new Map();
|
||||
for (const signal of forwardedSignals) {
|
||||
const handler = () => {
|
||||
if (!child.killed) {
|
||||
child.kill(signal);
|
||||
}
|
||||
};
|
||||
signalHandlers.set(signal, handler);
|
||||
process.on(signal, handler);
|
||||
}
|
||||
|
||||
let result;
|
||||
try {
|
||||
result = await new Promise((resolve, reject) => {
|
||||
child.once("error", reject);
|
||||
child.once("exit", (exitCode, signal) => {
|
||||
resolve(signal ? { signal } : { exitCode: exitCode ?? 1 });
|
||||
});
|
||||
});
|
||||
} finally {
|
||||
for (const [signal, handler] of signalHandlers) {
|
||||
process.off(signal, handler);
|
||||
}
|
||||
}
|
||||
|
||||
if ("signal" in result) {
|
||||
process.kill(process.pid, result.signal);
|
||||
return;
|
||||
}
|
||||
process.exitCode = result.exitCode;
|
||||
}
|
||||
|
||||
try {
|
||||
await run();
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.error(`moli: ${message}`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
const definitions = JSON.parse(
|
||||
readFileSync(new URL("../platforms.json", import.meta.url), "utf8"),
|
||||
);
|
||||
|
||||
export const PLATFORM_DEFINITIONS = Object.freeze(
|
||||
definitions.map((definition) => Object.freeze(definition)),
|
||||
);
|
||||
|
||||
export function platformDefinitionFor(platform, arch) {
|
||||
const definition = PLATFORM_DEFINITIONS.find(
|
||||
(candidate) =>
|
||||
candidate.platform === platform && candidate.arch === arch,
|
||||
);
|
||||
if (!definition) {
|
||||
throw new Error(`Unsupported platform: ${platform} (${arch})`);
|
||||
}
|
||||
return definition;
|
||||
}
|
||||
|
||||
export function assertSupportedLibc(definition, glibcVersionRuntime) {
|
||||
if (
|
||||
definition.libc?.includes("glibc") &&
|
||||
(typeof glibcVersionRuntime !== "string" || glibcVersionRuntime.length === 0)
|
||||
) {
|
||||
throw new Error(
|
||||
`Unsupported libc for ${definition.platform} (${definition.arch}): ` +
|
||||
"this Moli package requires glibc",
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "@lexmount/moli",
|
||||
"version": "0.0.0-development",
|
||||
"private": true,
|
||||
"description": "npm launcher sources for the Moli CLI",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"moli": "bin/moli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "node --test"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
[
|
||||
{
|
||||
"id": "linux-x64",
|
||||
"platform": "linux",
|
||||
"arch": "x64",
|
||||
"target": "x86_64-unknown-linux-gnu",
|
||||
"package": "@lexmount/moli-linux-x64",
|
||||
"archive": "moli-x86_64-unknown-linux-gnu.tar.gz",
|
||||
"binary": "moli",
|
||||
"libc": [
|
||||
"glibc"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "darwin-x64",
|
||||
"platform": "darwin",
|
||||
"arch": "x64",
|
||||
"target": "x86_64-apple-darwin",
|
||||
"package": "@lexmount/moli-darwin-x64",
|
||||
"archive": "moli-x86_64-apple-darwin.tar.gz",
|
||||
"binary": "moli"
|
||||
},
|
||||
{
|
||||
"id": "darwin-arm64",
|
||||
"platform": "darwin",
|
||||
"arch": "arm64",
|
||||
"target": "aarch64-apple-darwin",
|
||||
"package": "@lexmount/moli-darwin-arm64",
|
||||
"archive": "moli-aarch64-apple-darwin.tar.gz",
|
||||
"binary": "moli"
|
||||
},
|
||||
{
|
||||
"id": "win32-x64",
|
||||
"platform": "win32",
|
||||
"arch": "x64",
|
||||
"target": "x86_64-pc-windows-msvc",
|
||||
"package": "@lexmount/moli-win32-x64",
|
||||
"archive": "moli-x86_64-pc-windows-msvc.zip",
|
||||
"binary": "moli.exe"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,131 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { cp, chmod, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { spawn, spawnSync } from "node:child_process";
|
||||
import test from "node:test";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { platformDefinitionFor } from "../lib/platform.js";
|
||||
|
||||
const npmSourceRoot = fileURLToPath(new URL("..", import.meta.url));
|
||||
|
||||
async function createLauncherFixture(context, binarySource) {
|
||||
const fixtureRoot = await mkdtemp(path.join(os.tmpdir(), "moli-npm-launcher-"));
|
||||
context.after(() => rm(fixtureRoot, { recursive: true, force: true }));
|
||||
|
||||
await cp(path.join(npmSourceRoot, "bin"), path.join(fixtureRoot, "bin"), {
|
||||
recursive: true,
|
||||
});
|
||||
await cp(path.join(npmSourceRoot, "lib"), path.join(fixtureRoot, "lib"), {
|
||||
recursive: true,
|
||||
});
|
||||
await cp(
|
||||
path.join(npmSourceRoot, "platforms.json"),
|
||||
path.join(fixtureRoot, "platforms.json"),
|
||||
);
|
||||
|
||||
const definition = platformDefinitionFor(process.platform, process.arch);
|
||||
const fakeBinary = path.join(
|
||||
fixtureRoot,
|
||||
"vendor",
|
||||
definition.target,
|
||||
"bin",
|
||||
definition.binary,
|
||||
);
|
||||
await mkdir(path.dirname(fakeBinary), { recursive: true });
|
||||
await writeFile(fakeBinary, binarySource, "utf8");
|
||||
await chmod(fakeBinary, 0o755);
|
||||
return {
|
||||
fixtureRoot,
|
||||
launcher: path.join(fixtureRoot, "bin", "moli.js"),
|
||||
};
|
||||
}
|
||||
|
||||
test(
|
||||
"launcher forwards arguments, stdio, and exit status",
|
||||
{ skip: process.platform === "win32" },
|
||||
async (context) => {
|
||||
const { launcher } = await createLauncherFixture(
|
||||
context,
|
||||
[
|
||||
"#!/usr/bin/env node",
|
||||
"const result = {",
|
||||
" args: process.argv.slice(2),",
|
||||
"};",
|
||||
"process.stdout.write(JSON.stringify(result));",
|
||||
"process.stderr.write('native stderr');",
|
||||
"process.exit(Number(process.env.MOLI_TEST_EXIT_CODE));",
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[launcher, "argument with spaces", "--flag"],
|
||||
{
|
||||
encoding: "utf8",
|
||||
env: { ...process.env, MOLI_TEST_EXIT_CODE: "23" },
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(result.status, 23);
|
||||
assert.equal(result.stderr, "native stderr");
|
||||
assert.deepEqual(JSON.parse(result.stdout), {
|
||||
args: ["argument with spaces", "--flag"],
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
"launcher forwards termination signals and exits with the same signal",
|
||||
{ skip: process.platform === "win32" },
|
||||
async (context) => {
|
||||
const { launcher } = await createLauncherFixture(
|
||||
context,
|
||||
[
|
||||
"#!/usr/bin/env node",
|
||||
"process.stdout.write('ready\\n');",
|
||||
"setInterval(() => {}, 1000);",
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
const child = spawn(process.execPath, [launcher], {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
context.after(() => {
|
||||
if (child.exitCode === null && child.signalCode === null) {
|
||||
child.kill("SIGKILL");
|
||||
}
|
||||
});
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
child.once("error", reject);
|
||||
child.stdout.once("data", (chunk) => {
|
||||
assert.equal(chunk.toString(), "ready\n");
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
const exited = new Promise((resolve) => {
|
||||
child.once("exit", (...result) => resolve(result));
|
||||
});
|
||||
child.kill("SIGTERM");
|
||||
const [exitCode, signal] = await exited;
|
||||
|
||||
assert.equal(exitCode, null);
|
||||
assert.equal(signal, "SIGTERM");
|
||||
},
|
||||
);
|
||||
|
||||
test("launcher reports a missing optional dependency clearly", () => {
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[path.join(npmSourceRoot, "bin", "moli.js"), "--version"],
|
||||
{ encoding: "utf8" },
|
||||
);
|
||||
|
||||
assert.equal(result.status, 1);
|
||||
const definition = platformDefinitionFor(process.platform, process.arch);
|
||||
assert.match(result.stderr, new RegExp(`Missing optional dependency ${definition.package}`));
|
||||
assert.match(result.stderr, /without omitting optional dependencies/);
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
PLATFORM_DEFINITIONS,
|
||||
assertSupportedLibc,
|
||||
platformDefinitionFor,
|
||||
} from "../lib/platform.js";
|
||||
|
||||
test("maps every supported Node platform to one native target", () => {
|
||||
const expected = new Map([
|
||||
["linux:x64", "x86_64-unknown-linux-gnu"],
|
||||
["darwin:x64", "x86_64-apple-darwin"],
|
||||
["darwin:arm64", "aarch64-apple-darwin"],
|
||||
["win32:x64", "x86_64-pc-windows-msvc"],
|
||||
]);
|
||||
|
||||
assert.equal(PLATFORM_DEFINITIONS.length, expected.size);
|
||||
for (const [key, target] of expected) {
|
||||
const [platform, arch] = key.split(":");
|
||||
assert.equal(platformDefinitionFor(platform, arch).target, target);
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps package aliases and release assets unique", () => {
|
||||
for (const field of ["id", "target", "package", "archive"]) {
|
||||
const values = PLATFORM_DEFINITIONS.map((definition) => definition[field]);
|
||||
assert.equal(new Set(values).size, values.length, `${field} must be unique`);
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects unsupported platforms without falling back to a wrong binary", () => {
|
||||
assert.throws(
|
||||
() => platformDefinitionFor("linux", "arm64"),
|
||||
/Unsupported platform: linux \(arm64\)/,
|
||||
);
|
||||
assert.throws(
|
||||
() => platformDefinitionFor("freebsd", "x64"),
|
||||
/Unsupported platform: freebsd \(x64\)/,
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects musl before attempting to run the glibc Linux package", () => {
|
||||
const linux = platformDefinitionFor("linux", "x64");
|
||||
assert.doesNotThrow(() => assertSupportedLibc(linux, "2.36"));
|
||||
assert.throws(
|
||||
() => assertSupportedLibc(linux, undefined),
|
||||
/this Moli package requires glibc/,
|
||||
);
|
||||
|
||||
const darwin = platformDefinitionFor("darwin", "arm64");
|
||||
assert.doesNotThrow(() => assertSupportedLibc(darwin, undefined));
|
||||
});
|
||||
@@ -0,0 +1,568 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build npm launcher and native platform packages from Moli release archives."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tarfile
|
||||
import tempfile
|
||||
import tomllib
|
||||
import zipfile
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
MANIFEST_PATH = REPO_ROOT / "moli" / "Cargo.toml"
|
||||
NPM_SOURCE_DIR = REPO_ROOT / "npm"
|
||||
PLATFORMS_PATH = NPM_SOURCE_DIR / "platforms.json"
|
||||
PACKAGE_NAME = "@lexmount/moli"
|
||||
SEMVER_PATTERN = re.compile(
|
||||
r"^(0|[1-9][0-9]*)\."
|
||||
r"(0|[1-9][0-9]*)\."
|
||||
r"(0|[1-9][0-9]*)"
|
||||
r"(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?"
|
||||
r"(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$"
|
||||
)
|
||||
|
||||
|
||||
class NpmPackageError(RuntimeError):
|
||||
"""An npm package input or build step was invalid."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PlatformDefinition:
|
||||
id: str
|
||||
platform: str
|
||||
arch: str
|
||||
target: str
|
||||
package: str
|
||||
archive: str
|
||||
binary: str
|
||||
libc: tuple[str, ...] = ()
|
||||
|
||||
|
||||
def normalize_version(raw_version: str) -> str:
|
||||
version = raw_version.removeprefix("v")
|
||||
if not SEMVER_PATTERN.fullmatch(version):
|
||||
raise NpmPackageError(f"invalid semantic version: {raw_version}")
|
||||
return version
|
||||
|
||||
|
||||
def manifest_version() -> str:
|
||||
with MANIFEST_PATH.open("rb") as manifest_file:
|
||||
manifest = tomllib.load(manifest_file)
|
||||
try:
|
||||
version = manifest["package"]["version"]
|
||||
except (KeyError, TypeError) as error:
|
||||
raise NpmPackageError(
|
||||
f"package.version is missing from {MANIFEST_PATH}"
|
||||
) from error
|
||||
if not isinstance(version, str):
|
||||
raise NpmPackageError(
|
||||
f"package.version in {MANIFEST_PATH} is not a string"
|
||||
)
|
||||
return version
|
||||
|
||||
|
||||
def resolve_repo_path(raw_path: str) -> Path:
|
||||
path = Path(raw_path).expanduser()
|
||||
if not path.is_absolute():
|
||||
path = REPO_ROOT / path
|
||||
return path.resolve()
|
||||
|
||||
|
||||
def load_platforms() -> list[PlatformDefinition]:
|
||||
try:
|
||||
raw_platforms = json.loads(PLATFORMS_PATH.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as error:
|
||||
raise NpmPackageError(f"could not read {PLATFORMS_PATH}: {error}") from error
|
||||
if not isinstance(raw_platforms, list) or not raw_platforms:
|
||||
raise NpmPackageError(f"{PLATFORMS_PATH} must contain a non-empty array")
|
||||
|
||||
platforms: list[PlatformDefinition] = []
|
||||
for index, raw in enumerate(raw_platforms):
|
||||
if not isinstance(raw, dict):
|
||||
raise NpmPackageError(f"platform entry {index} must be an object")
|
||||
raw_libc = raw.get("libc", [])
|
||||
if not isinstance(raw_libc, list):
|
||||
raise NpmPackageError(f"platform entry {index} libc must be an array")
|
||||
try:
|
||||
definition = PlatformDefinition(
|
||||
id=raw["id"],
|
||||
platform=raw["platform"],
|
||||
arch=raw["arch"],
|
||||
target=raw["target"],
|
||||
package=raw["package"],
|
||||
archive=raw["archive"],
|
||||
binary=raw["binary"],
|
||||
libc=tuple(raw_libc),
|
||||
)
|
||||
except (KeyError, TypeError) as error:
|
||||
raise NpmPackageError(f"invalid platform entry {index}: {error}") from error
|
||||
values = (
|
||||
definition.id,
|
||||
definition.platform,
|
||||
definition.arch,
|
||||
definition.target,
|
||||
definition.package,
|
||||
definition.archive,
|
||||
definition.binary,
|
||||
*definition.libc,
|
||||
)
|
||||
if not all(isinstance(value, str) and value for value in values):
|
||||
raise NpmPackageError(
|
||||
f"platform entry {index} contains an empty or non-string value"
|
||||
)
|
||||
expected_package = f"{PACKAGE_NAME}-{definition.id}"
|
||||
if definition.package != expected_package:
|
||||
raise NpmPackageError(
|
||||
f"platform entry {index} package must be {expected_package}"
|
||||
)
|
||||
platforms.append(definition)
|
||||
|
||||
for field in ("id", "target", "package", "archive"):
|
||||
values = [getattr(platform, field) for platform in platforms]
|
||||
if len(values) != len(set(values)):
|
||||
raise NpmPackageError(f"platform definitions contain duplicate {field} values")
|
||||
node_platforms = [(platform.platform, platform.arch) for platform in platforms]
|
||||
if len(node_platforms) != len(set(node_platforms)):
|
||||
raise NpmPackageError(
|
||||
"platform definitions contain duplicate platform/architecture pairs"
|
||||
)
|
||||
return platforms
|
||||
|
||||
|
||||
def platform_version(version: str, platform_id: str) -> str:
|
||||
core, separator, build = version.partition("+")
|
||||
variant = f"{core}-{platform_id}"
|
||||
if separator:
|
||||
variant = f"{variant}+{build}"
|
||||
if not SEMVER_PATTERN.fullmatch(variant):
|
||||
raise NpmPackageError(f"invalid npm platform version: {variant}")
|
||||
return variant
|
||||
|
||||
|
||||
def repository_metadata() -> dict[str, str]:
|
||||
return {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/lexmount/moli.git",
|
||||
}
|
||||
|
||||
|
||||
def copy_project_file(destination: Path, relative_path: str) -> None:
|
||||
source = REPO_ROOT / relative_path
|
||||
if not source.is_file():
|
||||
raise NpmPackageError(f"required npm package file is missing: {source}")
|
||||
shutil.copy2(source, destination / source.name)
|
||||
|
||||
|
||||
def copy_package_metadata(destination: Path, *, third_party: bool) -> None:
|
||||
for relative_path in ("README.md", "LICENSE", "LICENSE-APACHE", "LICENSE-MIT"):
|
||||
copy_project_file(destination, relative_path)
|
||||
if not third_party:
|
||||
return
|
||||
|
||||
notices_root = destination / "third_party"
|
||||
notices_root.mkdir()
|
||||
for name in ("licenses", "notices"):
|
||||
source = REPO_ROOT / "third_party" / name
|
||||
if not source.is_dir():
|
||||
raise NpmPackageError(
|
||||
f"required third-party license directory is missing: {source}"
|
||||
)
|
||||
shutil.copytree(source, notices_root / name)
|
||||
|
||||
|
||||
def write_json(path: Path, value: Any) -> None:
|
||||
path.write_text(
|
||||
json.dumps(value, indent=2, ensure_ascii=False) + "\n", encoding="utf-8"
|
||||
)
|
||||
|
||||
|
||||
def main_package_manifest(
|
||||
version: str, platforms: list[PlatformDefinition]
|
||||
) -> dict[str, Any]:
|
||||
optional_dependencies = {
|
||||
platform.package: f"npm:{PACKAGE_NAME}@{platform_version(version, platform.id)}"
|
||||
for platform in platforms
|
||||
}
|
||||
return {
|
||||
"name": PACKAGE_NAME,
|
||||
"version": version,
|
||||
"description": (
|
||||
"A structured-first headless browser engine for AI agents"
|
||||
),
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"type": "module",
|
||||
"bin": {"moli": "bin/moli.js"},
|
||||
"engines": {"node": ">=18"},
|
||||
"files": [
|
||||
"bin",
|
||||
"lib",
|
||||
"platforms.json",
|
||||
"README.md",
|
||||
"LICENSE",
|
||||
"LICENSE-APACHE",
|
||||
"LICENSE-MIT",
|
||||
],
|
||||
"repository": repository_metadata(),
|
||||
"homepage": "https://github.com/lexmount/moli",
|
||||
"bugs": {"url": "https://github.com/lexmount/moli/issues"},
|
||||
"publishConfig": {"access": "public"},
|
||||
"optionalDependencies": optional_dependencies,
|
||||
}
|
||||
|
||||
|
||||
def platform_package_manifest(
|
||||
version: str, definition: PlatformDefinition
|
||||
) -> dict[str, Any]:
|
||||
manifest: dict[str, Any] = {
|
||||
"name": PACKAGE_NAME,
|
||||
"version": platform_version(version, definition.id),
|
||||
"description": f"Moli native binary for {definition.id}",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"engines": {"node": ">=18"},
|
||||
"os": [definition.platform],
|
||||
"cpu": [definition.arch],
|
||||
"files": [
|
||||
"vendor",
|
||||
"README.md",
|
||||
"LICENSE",
|
||||
"LICENSE-APACHE",
|
||||
"LICENSE-MIT",
|
||||
"third_party",
|
||||
],
|
||||
"repository": repository_metadata(),
|
||||
"homepage": "https://github.com/lexmount/moli",
|
||||
"bugs": {"url": "https://github.com/lexmount/moli/issues"},
|
||||
"publishConfig": {"access": "public"},
|
||||
}
|
||||
if definition.libc:
|
||||
manifest["libc"] = list(definition.libc)
|
||||
return manifest
|
||||
|
||||
|
||||
def extract_platform_binary(
|
||||
archive_path: Path,
|
||||
version: str,
|
||||
definition: PlatformDefinition,
|
||||
destination: Path,
|
||||
) -> None:
|
||||
package_root = f"moli-v{version}-{definition.target}"
|
||||
binary_member = f"{package_root}/{definition.binary}"
|
||||
version_member = f"{package_root}/VERSION"
|
||||
try:
|
||||
if archive_path.suffix == ".zip":
|
||||
with zipfile.ZipFile(archive_path, mode="r") as archive:
|
||||
archived_version = archive.read(version_member)
|
||||
with archive.open(binary_member, mode="r") as source, destination.open(
|
||||
"wb"
|
||||
) as output:
|
||||
shutil.copyfileobj(source, output)
|
||||
else:
|
||||
with tarfile.open(archive_path, mode="r:gz") as archive:
|
||||
version_entry = archive.getmember(version_member)
|
||||
binary_entry = archive.getmember(binary_member)
|
||||
if not version_entry.isfile() or not binary_entry.isfile():
|
||||
raise NpmPackageError(
|
||||
f"release archive contains a non-file package member: {archive_path}"
|
||||
)
|
||||
version_source = archive.extractfile(version_entry)
|
||||
binary_source = archive.extractfile(binary_entry)
|
||||
if version_source is None or binary_source is None:
|
||||
raise NpmPackageError(
|
||||
f"could not read package members from {archive_path}"
|
||||
)
|
||||
with version_source:
|
||||
archived_version = version_source.read()
|
||||
with binary_source, destination.open("wb") as output:
|
||||
shutil.copyfileobj(binary_source, output)
|
||||
except (KeyError, OSError, tarfile.TarError, zipfile.BadZipFile) as error:
|
||||
raise NpmPackageError(f"could not unpack {archive_path}: {error}") from error
|
||||
|
||||
try:
|
||||
decoded_version = archived_version.decode("utf-8").strip()
|
||||
except UnicodeDecodeError as error:
|
||||
raise NpmPackageError(
|
||||
f"release archive VERSION is not UTF-8: {archive_path}"
|
||||
) from error
|
||||
if decoded_version != version:
|
||||
raise NpmPackageError(
|
||||
f"release archive version mismatch for {archive_path.name}: "
|
||||
f"expected {version}, got {decoded_version or '<empty>'}"
|
||||
)
|
||||
destination.chmod(0o644 if definition.platform == "win32" else 0o755)
|
||||
|
||||
|
||||
def package_integrity(path: Path) -> str:
|
||||
digest = hashlib.sha512()
|
||||
with path.open("rb") as package_file:
|
||||
for chunk in iter(lambda: package_file.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return "sha512-" + base64.b64encode(digest.digest()).decode("ascii")
|
||||
|
||||
|
||||
def npm_pack(package_dir: Path, output_dir: Path) -> dict[str, Any]:
|
||||
command = [
|
||||
"npm",
|
||||
"pack",
|
||||
"--json",
|
||||
"--pack-destination",
|
||||
str(output_dir),
|
||||
]
|
||||
try:
|
||||
result = subprocess.run(
|
||||
command,
|
||||
cwd=package_dir,
|
||||
check=True,
|
||||
text=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
except FileNotFoundError as error:
|
||||
raise NpmPackageError("npm is required to build npm packages") from error
|
||||
except subprocess.CalledProcessError as error:
|
||||
detail = error.stderr.strip() or error.stdout.strip()
|
||||
raise NpmPackageError(f"npm pack failed: {detail}") from error
|
||||
|
||||
try:
|
||||
payload = json.loads(result.stdout)
|
||||
except json.JSONDecodeError as error:
|
||||
raise NpmPackageError(f"npm pack returned invalid JSON: {error}") from error
|
||||
if not isinstance(payload, list) or len(payload) != 1 or not isinstance(payload[0], dict):
|
||||
raise NpmPackageError("npm pack did not describe exactly one package")
|
||||
|
||||
packed = payload[0]
|
||||
required_fields = (
|
||||
"name",
|
||||
"version",
|
||||
"filename",
|
||||
"integrity",
|
||||
"shasum",
|
||||
"size",
|
||||
"unpackedSize",
|
||||
"files",
|
||||
)
|
||||
if any(field not in packed for field in required_fields):
|
||||
raise NpmPackageError("npm pack output is missing required metadata")
|
||||
for field in ("name", "version", "filename", "integrity", "shasum"):
|
||||
if not isinstance(packed[field], str) or not packed[field]:
|
||||
raise NpmPackageError(f"npm pack output contains invalid {field}")
|
||||
if Path(packed["filename"]).name != packed["filename"]:
|
||||
raise NpmPackageError("npm pack output filename must not contain a path")
|
||||
tarball = output_dir / packed["filename"]
|
||||
if not tarball.is_file():
|
||||
raise NpmPackageError(f"npm pack did not create {tarball}")
|
||||
actual_integrity = package_integrity(tarball)
|
||||
if packed["integrity"] != actual_integrity:
|
||||
raise NpmPackageError(
|
||||
f"npm package integrity mismatch for {tarball.name}: "
|
||||
f"expected {packed['integrity']}, got {actual_integrity}"
|
||||
)
|
||||
return packed
|
||||
|
||||
|
||||
def packed_file_paths(packed: dict[str, Any]) -> set[str]:
|
||||
files = packed["files"]
|
||||
if not isinstance(files, list):
|
||||
raise NpmPackageError("npm pack file metadata must be an array")
|
||||
paths: set[str] = set()
|
||||
for file in files:
|
||||
if not isinstance(file, dict) or not isinstance(file.get("path"), str):
|
||||
raise NpmPackageError("npm pack returned invalid file metadata")
|
||||
paths.add(file["path"])
|
||||
return paths
|
||||
|
||||
|
||||
def compact_pack_metadata(packed: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
field: packed[field]
|
||||
for field in (
|
||||
"name",
|
||||
"version",
|
||||
"filename",
|
||||
"integrity",
|
||||
"shasum",
|
||||
"size",
|
||||
"unpackedSize",
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
def build_main_package(
|
||||
stage: Path,
|
||||
output_dir: Path,
|
||||
version: str,
|
||||
platforms: list[PlatformDefinition],
|
||||
) -> dict[str, Any]:
|
||||
shutil.copytree(NPM_SOURCE_DIR / "bin", stage / "bin")
|
||||
shutil.copytree(NPM_SOURCE_DIR / "lib", stage / "lib")
|
||||
shutil.copy2(PLATFORMS_PATH, stage / "platforms.json")
|
||||
(stage / "bin" / "moli.js").chmod(0o755)
|
||||
copy_package_metadata(stage, third_party=False)
|
||||
write_json(stage / "package.json", main_package_manifest(version, platforms))
|
||||
|
||||
packed = npm_pack(stage, output_dir)
|
||||
if packed["name"] != PACKAGE_NAME or packed["version"] != version:
|
||||
raise NpmPackageError("npm packed an unexpected main package identity")
|
||||
expected_files = {
|
||||
"bin/moli.js",
|
||||
"lib/platform.js",
|
||||
"platforms.json",
|
||||
"package.json",
|
||||
}
|
||||
missing = expected_files - packed_file_paths(packed)
|
||||
if missing:
|
||||
raise NpmPackageError(
|
||||
f"main npm package is missing files: {', '.join(sorted(missing))}"
|
||||
)
|
||||
return compact_pack_metadata(packed)
|
||||
|
||||
|
||||
def build_platform_package(
|
||||
stage: Path,
|
||||
output_dir: Path,
|
||||
input_dir: Path,
|
||||
version: str,
|
||||
definition: PlatformDefinition,
|
||||
) -> dict[str, Any]:
|
||||
archive_path = input_dir / definition.archive
|
||||
if not archive_path.is_file():
|
||||
raise NpmPackageError(f"required release archive is missing: {archive_path}")
|
||||
|
||||
binary = stage / "vendor" / definition.target / "bin" / definition.binary
|
||||
binary.parent.mkdir(parents=True)
|
||||
extract_platform_binary(archive_path, version, definition, binary)
|
||||
copy_package_metadata(stage, third_party=True)
|
||||
write_json(stage / "package.json", platform_package_manifest(version, definition))
|
||||
|
||||
packed = npm_pack(stage, output_dir)
|
||||
expected_version = platform_version(version, definition.id)
|
||||
if packed["name"] != PACKAGE_NAME or packed["version"] != expected_version:
|
||||
raise NpmPackageError(
|
||||
f"npm packed an unexpected package identity for {definition.id}"
|
||||
)
|
||||
expected_binary = f"vendor/{definition.target}/bin/{definition.binary}"
|
||||
if expected_binary not in packed_file_paths(packed):
|
||||
raise NpmPackageError(
|
||||
f"platform npm package is missing binary: {expected_binary}"
|
||||
)
|
||||
metadata = compact_pack_metadata(packed)
|
||||
metadata.update(
|
||||
{
|
||||
"alias": definition.package,
|
||||
"target": definition.target,
|
||||
"distTag": definition.id,
|
||||
}
|
||||
)
|
||||
return metadata
|
||||
|
||||
|
||||
def build_packages(
|
||||
*, version: str, input_dir: Path, output_dir: Path
|
||||
) -> dict[str, Any]:
|
||||
declared_version = manifest_version()
|
||||
if version != declared_version:
|
||||
raise NpmPackageError(
|
||||
f"requested version {version} does not match "
|
||||
f"moli/Cargo.toml ({declared_version})"
|
||||
)
|
||||
if not input_dir.is_dir():
|
||||
raise NpmPackageError(f"release input directory does not exist: {input_dir}")
|
||||
if output_dir.exists():
|
||||
raise NpmPackageError(f"npm output path already exists: {output_dir}")
|
||||
output_dir.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
platforms = load_platforms()
|
||||
for definition in platforms:
|
||||
archive_path = input_dir / definition.archive
|
||||
if not archive_path.is_file():
|
||||
raise NpmPackageError(f"required release archive is missing: {archive_path}")
|
||||
|
||||
with tempfile.TemporaryDirectory(
|
||||
prefix=".moli-npm-", dir=output_dir.parent
|
||||
) as temporary:
|
||||
work_dir = Path(temporary)
|
||||
packed_dir = work_dir / "packed"
|
||||
packed_dir.mkdir()
|
||||
|
||||
platform_packages: list[dict[str, Any]] = []
|
||||
for definition in platforms:
|
||||
with tempfile.TemporaryDirectory(
|
||||
prefix=f"{definition.id}-", dir=work_dir
|
||||
) as platform_stage:
|
||||
platform_packages.append(
|
||||
build_platform_package(
|
||||
Path(platform_stage),
|
||||
packed_dir,
|
||||
input_dir,
|
||||
version,
|
||||
definition,
|
||||
)
|
||||
)
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="main-", dir=work_dir) as main_stage:
|
||||
main_package = build_main_package(
|
||||
Path(main_stage), packed_dir, version, platforms
|
||||
)
|
||||
|
||||
release_manifest = {
|
||||
"schemaVersion": 1,
|
||||
"package": PACKAGE_NAME,
|
||||
"version": version,
|
||||
"main": main_package,
|
||||
"platforms": platform_packages,
|
||||
}
|
||||
write_json(packed_dir / "npm-packages.json", release_manifest)
|
||||
packed_dir.rename(output_dir)
|
||||
return release_manifest
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--version", required=True, help="release version, with optional v")
|
||||
parser.add_argument(
|
||||
"--input-dir",
|
||||
default="dist",
|
||||
help="directory containing native release archives (default: dist)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output-dir",
|
||||
default="dist/npm",
|
||||
help="new directory for npm tarballs (default: dist/npm)",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
try:
|
||||
version = normalize_version(args.version)
|
||||
output_dir = resolve_repo_path(args.output_dir)
|
||||
manifest = build_packages(
|
||||
version=version,
|
||||
input_dir=resolve_repo_path(args.input_dir),
|
||||
output_dir=output_dir,
|
||||
)
|
||||
print(f"Created npm package set for {manifest['package']}@{version}")
|
||||
for platform in manifest["platforms"]:
|
||||
print(f"Created: {output_dir / platform['filename']}")
|
||||
print(f"Created: {output_dir / manifest['main']['filename']}")
|
||||
print(f"Created: {output_dir / 'npm-packages.json'}")
|
||||
return 0
|
||||
except NpmPackageError as error:
|
||||
print(f"npm package error: {error}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,348 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Publish a verified Moli npm package set, with the launcher published last."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
import tarfile
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
DIST_TAG_PATTERN = re.compile(r"^[A-Za-z][A-Za-z0-9._-]*$")
|
||||
TOOL_VERSION_PATTERN = re.compile(
|
||||
r"^v?(0|[1-9][0-9]*)\."
|
||||
r"(0|[1-9][0-9]*)\."
|
||||
r"(0|[1-9][0-9]*)"
|
||||
r"(?:-[0-9A-Za-z.-]+)?$"
|
||||
)
|
||||
MINIMUM_TRUSTED_NPM_VERSION = (11, 5, 1)
|
||||
MINIMUM_TRUSTED_NODE_VERSION = (22, 14, 0)
|
||||
MAXIMUM_PACKAGE_JSON_SIZE = 1024 * 1024
|
||||
|
||||
|
||||
class NpmPublishError(RuntimeError):
|
||||
"""An npm package set was invalid or could not be published safely."""
|
||||
|
||||
|
||||
def package_integrity(path: Path) -> str:
|
||||
digest = hashlib.sha512()
|
||||
with path.open("rb") as package_file:
|
||||
for chunk in iter(lambda: package_file.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return "sha512-" + base64.b64encode(digest.digest()).decode("ascii")
|
||||
|
||||
|
||||
def load_manifest(path: Path) -> dict[str, Any]:
|
||||
try:
|
||||
manifest = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as error:
|
||||
raise NpmPublishError(
|
||||
f"could not read npm package manifest {path}: {error}"
|
||||
) from error
|
||||
if not isinstance(manifest, dict) or manifest.get("schemaVersion") != 1:
|
||||
raise NpmPublishError("unsupported npm package manifest schema")
|
||||
if not isinstance(manifest.get("package"), str) or not isinstance(
|
||||
manifest.get("version"), str
|
||||
):
|
||||
raise NpmPublishError("npm package manifest is missing package identity")
|
||||
if not isinstance(manifest.get("main"), dict) or not isinstance(
|
||||
manifest.get("platforms"), list
|
||||
):
|
||||
raise NpmPublishError("npm package manifest is missing package entries")
|
||||
if not manifest["platforms"]:
|
||||
raise NpmPublishError("npm package manifest contains no platform packages")
|
||||
return manifest
|
||||
|
||||
|
||||
def validate_dist_tag(tag: str) -> str:
|
||||
if not DIST_TAG_PATTERN.fullmatch(tag):
|
||||
raise NpmPublishError(f"invalid npm dist-tag: {tag}")
|
||||
return tag
|
||||
|
||||
|
||||
def tool_version(command: str) -> tuple[int, int, int]:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[command, "--version"],
|
||||
check=True,
|
||||
text=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
except FileNotFoundError as error:
|
||||
raise NpmPublishError(
|
||||
f"{command} is required to publish npm packages"
|
||||
) from error
|
||||
except subprocess.CalledProcessError as error:
|
||||
detail = error.stderr.strip() or error.stdout.strip()
|
||||
raise NpmPublishError(
|
||||
f"could not determine {command} version: {detail}"
|
||||
) from error
|
||||
|
||||
raw_version = result.stdout.strip()
|
||||
match = TOOL_VERSION_PATTERN.fullmatch(raw_version)
|
||||
if match is None:
|
||||
raise NpmPublishError(f"could not parse {command} version: {raw_version}")
|
||||
return tuple(int(component) for component in match.groups())
|
||||
|
||||
|
||||
def require_trusted_publishing_toolchain() -> None:
|
||||
requirements = (
|
||||
("node", MINIMUM_TRUSTED_NODE_VERSION),
|
||||
("npm", MINIMUM_TRUSTED_NPM_VERSION),
|
||||
)
|
||||
for command, minimum in requirements:
|
||||
actual = tool_version(command)
|
||||
if actual < minimum:
|
||||
minimum_display = ".".join(str(component) for component in minimum)
|
||||
actual_display = ".".join(str(component) for component in actual)
|
||||
raise NpmPublishError(
|
||||
f"npm trusted publishing requires {command} >= {minimum_display}; "
|
||||
f"found {actual_display}"
|
||||
)
|
||||
|
||||
|
||||
def validate_package_entry(
|
||||
entry: dict[str, Any], package_dir: Path
|
||||
) -> tuple[Path, str, str]:
|
||||
for field in ("name", "version", "filename", "integrity"):
|
||||
if not isinstance(entry.get(field), str) or not entry[field]:
|
||||
raise NpmPublishError(f"npm package entry has invalid {field}")
|
||||
filename = entry["filename"]
|
||||
if Path(filename).name != filename:
|
||||
raise NpmPublishError(
|
||||
f"npm package filename must not contain a path: {filename}"
|
||||
)
|
||||
tarball = package_dir / filename
|
||||
if not tarball.is_file():
|
||||
raise NpmPublishError(f"npm package tarball is missing: {tarball}")
|
||||
actual_integrity = package_integrity(tarball)
|
||||
if actual_integrity != entry["integrity"]:
|
||||
raise NpmPublishError(
|
||||
f"npm package integrity mismatch for {filename}: "
|
||||
f"expected {entry['integrity']}, got {actual_integrity}"
|
||||
)
|
||||
|
||||
try:
|
||||
with tarfile.open(tarball, mode="r:gz") as archive:
|
||||
package_json_entry = archive.getmember("package/package.json")
|
||||
if (
|
||||
not package_json_entry.isfile()
|
||||
or package_json_entry.size > MAXIMUM_PACKAGE_JSON_SIZE
|
||||
):
|
||||
raise NpmPublishError(
|
||||
f"npm package has an invalid package/package.json: {filename}"
|
||||
)
|
||||
package_json = archive.extractfile(package_json_entry)
|
||||
if package_json is None:
|
||||
raise NpmPublishError(
|
||||
f"npm package is missing package/package.json: {filename}"
|
||||
)
|
||||
with package_json:
|
||||
identity = json.load(package_json)
|
||||
except (
|
||||
KeyError,
|
||||
OSError,
|
||||
UnicodeDecodeError,
|
||||
tarfile.TarError,
|
||||
json.JSONDecodeError,
|
||||
) as error:
|
||||
raise NpmPublishError(
|
||||
f"could not read npm package identity from {filename}: {error}"
|
||||
) from error
|
||||
if not isinstance(identity, dict):
|
||||
raise NpmPublishError(f"npm package identity is invalid in {filename}")
|
||||
if (
|
||||
identity.get("name") != entry["name"]
|
||||
or identity.get("version") != entry["version"]
|
||||
):
|
||||
raise NpmPublishError(
|
||||
f"npm package identity mismatch for {filename}: expected "
|
||||
f"{entry['name']}@{entry['version']}"
|
||||
)
|
||||
return tarball, entry["name"], entry["version"]
|
||||
|
||||
|
||||
def npm_view_integrity(name: str, version: str) -> str | None:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["npm", "view", f"{name}@{version}", "dist.integrity", "--json"],
|
||||
check=False,
|
||||
text=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
except FileNotFoundError as error:
|
||||
raise NpmPublishError("npm is required to publish npm packages") from error
|
||||
if result.returncode != 0:
|
||||
if "E404" in result.stderr or "E404" in result.stdout:
|
||||
return None
|
||||
detail = result.stderr.strip() or result.stdout.strip()
|
||||
raise NpmPublishError(f"npm view failed for {name}@{version}: {detail}")
|
||||
try:
|
||||
integrity = json.loads(result.stdout)
|
||||
except json.JSONDecodeError as error:
|
||||
raise NpmPublishError(
|
||||
f"npm view returned invalid JSON for {name}@{version}: {error}"
|
||||
) from error
|
||||
if not isinstance(integrity, str) or not integrity:
|
||||
raise NpmPublishError(f"npm view returned no integrity for {name}@{version}")
|
||||
return integrity
|
||||
|
||||
|
||||
def run_checked(command: list[str], description: str) -> None:
|
||||
print("+ " + shlex.join(command), flush=True)
|
||||
try:
|
||||
subprocess.run(command, check=True)
|
||||
except FileNotFoundError as error:
|
||||
raise NpmPublishError("npm is required to publish npm packages") from error
|
||||
except subprocess.CalledProcessError as error:
|
||||
raise NpmPublishError(
|
||||
f"{description} failed with exit code {error.returncode}"
|
||||
) from error
|
||||
|
||||
|
||||
def publish_package(
|
||||
entry: dict[str, Any],
|
||||
package_dir: Path,
|
||||
*,
|
||||
dist_tag: str,
|
||||
dry_run: bool,
|
||||
) -> None:
|
||||
tarball, name, version = validate_package_entry(entry, package_dir)
|
||||
dist_tag = validate_dist_tag(dist_tag)
|
||||
|
||||
if dry_run:
|
||||
command = [
|
||||
"npm",
|
||||
"publish",
|
||||
str(tarball),
|
||||
"--access",
|
||||
"public",
|
||||
"--tag",
|
||||
dist_tag,
|
||||
"--dry-run",
|
||||
]
|
||||
run_checked(command, f"npm publish dry run for {name}@{version}")
|
||||
return
|
||||
|
||||
published_integrity = npm_view_integrity(name, version)
|
||||
if published_integrity is not None:
|
||||
if published_integrity != entry["integrity"]:
|
||||
raise NpmPublishError(
|
||||
f"refusing to reuse {name}@{version}: registry integrity differs"
|
||||
)
|
||||
print(f"Already published with matching integrity: {name}@{version}")
|
||||
else:
|
||||
command = [
|
||||
"npm",
|
||||
"publish",
|
||||
str(tarball),
|
||||
"--access",
|
||||
"public",
|
||||
"--tag",
|
||||
dist_tag,
|
||||
]
|
||||
run_checked(command, f"npm publish for {name}@{version}")
|
||||
|
||||
|
||||
def publish_package_set(
|
||||
manifest_path: Path,
|
||||
*,
|
||||
main_tag: str,
|
||||
trusted_publishing: bool,
|
||||
dry_run: bool,
|
||||
) -> None:
|
||||
if trusted_publishing and not dry_run:
|
||||
require_trusted_publishing_toolchain()
|
||||
|
||||
manifest = load_manifest(manifest_path)
|
||||
package_dir = manifest_path.parent
|
||||
package_name = manifest["package"]
|
||||
version = manifest["version"]
|
||||
|
||||
platform_entries: list[tuple[dict[str, Any], str]] = []
|
||||
for entry in manifest["platforms"]:
|
||||
if not isinstance(entry, dict):
|
||||
raise NpmPublishError("npm platform package entry must be an object")
|
||||
if entry.get("name") != package_name:
|
||||
raise NpmPublishError("npm platform package name does not match package set")
|
||||
dist_tag = entry.get("distTag")
|
||||
if not isinstance(dist_tag, str):
|
||||
raise NpmPublishError("npm platform package is missing its dist-tag")
|
||||
validate_dist_tag(dist_tag)
|
||||
validate_package_entry(entry, package_dir)
|
||||
platform_entries.append((entry, dist_tag))
|
||||
|
||||
platform_versions = [entry["version"] for entry, _ in platform_entries]
|
||||
if len(platform_versions) != len(set(platform_versions)):
|
||||
raise NpmPublishError("npm platform package versions must be unique")
|
||||
|
||||
main = manifest["main"]
|
||||
if main.get("name") != package_name or main.get("version") != version:
|
||||
raise NpmPublishError("main npm package identity does not match package set")
|
||||
validate_dist_tag(main_tag)
|
||||
validate_package_entry(main, package_dir)
|
||||
|
||||
for entry, dist_tag in platform_entries:
|
||||
publish_package(
|
||||
entry,
|
||||
package_dir,
|
||||
dist_tag=dist_tag,
|
||||
dry_run=dry_run,
|
||||
)
|
||||
|
||||
publish_package(
|
||||
main,
|
||||
package_dir,
|
||||
dist_tag=main_tag,
|
||||
dry_run=dry_run,
|
||||
)
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("manifest", help="path to npm-packages.json")
|
||||
parser.add_argument(
|
||||
"--main-tag",
|
||||
default="latest",
|
||||
help="dist-tag for the launcher package (default: latest)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--trusted-publishing",
|
||||
action="store_true",
|
||||
help="require a supported Node/npm OIDC toolchain before publishing",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--dry-run",
|
||||
action="store_true",
|
||||
help="validate every tarball with npm publish --dry-run",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
try:
|
||||
publish_package_set(
|
||||
Path(args.manifest).resolve(),
|
||||
main_tag=args.main_tag,
|
||||
trusted_publishing=args.trusted_publishing,
|
||||
dry_run=args.dry_run,
|
||||
)
|
||||
return 0
|
||||
except NpmPublishError as error:
|
||||
print(f"npm publish error: {error}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
+8
-1
@@ -153,7 +153,14 @@ def strip_and_sign(binary: Path, target: str) -> tuple[int, int]:
|
||||
|
||||
|
||||
def binary_reported_version(binary: Path) -> str:
|
||||
return run_checked([str(binary), "version"], capture_output=True).stdout.strip()
|
||||
output = run_checked([str(binary), "--version"], capture_output=True).stdout.strip()
|
||||
match = re.fullmatch(r"moli\s+(\S+)", output)
|
||||
if match is None:
|
||||
raise ReleaseError(
|
||||
"packaged binary returned an unexpected `--version` response: "
|
||||
f"{output or '<empty>'}"
|
||||
)
|
||||
return match.group(1)
|
||||
|
||||
|
||||
def copy_release_materials(package_dir: Path) -> None:
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import tarfile
|
||||
import tempfile
|
||||
import tomllib
|
||||
import unittest
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
PACKAGE_SCRIPT = REPO_ROOT / "scripts" / "package_npm.py"
|
||||
PUBLISH_SCRIPT = REPO_ROOT / "scripts" / "publish_npm.py"
|
||||
PLATFORMS = json.loads(
|
||||
(REPO_ROOT / "npm" / "platforms.json").read_text(encoding="utf-8")
|
||||
)
|
||||
|
||||
|
||||
def current_version() -> str:
|
||||
with (REPO_ROOT / "moli" / "Cargo.toml").open("rb") as manifest_file:
|
||||
return tomllib.load(manifest_file)["package"]["version"]
|
||||
|
||||
|
||||
def add_tar_bytes(archive: tarfile.TarFile, name: str, value: bytes, mode: int) -> None:
|
||||
member = tarfile.TarInfo(name)
|
||||
member.size = len(value)
|
||||
member.mode = mode
|
||||
archive.addfile(member, io.BytesIO(value))
|
||||
|
||||
|
||||
def write_release_archive(directory: Path, platform: dict[str, object], version: str) -> None:
|
||||
target = str(platform["target"])
|
||||
binary = str(platform["binary"])
|
||||
archive_path = directory / str(platform["archive"])
|
||||
package_root = f"moli-v{version}-{target}"
|
||||
binary_contents = f"fixture binary for {target}\n".encode()
|
||||
if archive_path.suffix == ".zip":
|
||||
with zipfile.ZipFile(archive_path, mode="w") as archive:
|
||||
archive.writestr(f"{package_root}/VERSION", f"{version}\n")
|
||||
archive.writestr(f"{package_root}/{binary}", binary_contents)
|
||||
return
|
||||
|
||||
with tarfile.open(archive_path, mode="w:gz") as archive:
|
||||
add_tar_bytes(
|
||||
archive,
|
||||
f"{package_root}/VERSION",
|
||||
f"{version}\n".encode(),
|
||||
0o644,
|
||||
)
|
||||
add_tar_bytes(
|
||||
archive,
|
||||
f"{package_root}/{binary}",
|
||||
binary_contents,
|
||||
0o755,
|
||||
)
|
||||
|
||||
|
||||
def read_tgz_json(path: Path, member: str) -> dict[str, object]:
|
||||
with tarfile.open(path, mode="r:gz") as archive:
|
||||
source = archive.extractfile(member)
|
||||
if source is None:
|
||||
raise AssertionError(f"missing {member} in {path}")
|
||||
return json.load(source)
|
||||
|
||||
|
||||
class PackageNpmTests(unittest.TestCase):
|
||||
def test_builds_launcher_and_one_native_package_per_supported_platform(self) -> None:
|
||||
version = current_version()
|
||||
with tempfile.TemporaryDirectory(prefix="moli-npm-package-test-") as raw:
|
||||
root = Path(raw)
|
||||
input_dir = root / "release"
|
||||
output_dir = root / "npm"
|
||||
input_dir.mkdir()
|
||||
for platform in PLATFORMS:
|
||||
write_release_archive(input_dir, platform, version)
|
||||
|
||||
result = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
str(PACKAGE_SCRIPT),
|
||||
"--version",
|
||||
version,
|
||||
"--input-dir",
|
||||
str(input_dir),
|
||||
"--output-dir",
|
||||
str(output_dir),
|
||||
],
|
||||
cwd=REPO_ROOT,
|
||||
check=False,
|
||||
text=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
|
||||
package_set = json.loads(
|
||||
(output_dir / "npm-packages.json").read_text(encoding="utf-8")
|
||||
)
|
||||
self.assertEqual(package_set["package"], "@lexmount/moli")
|
||||
self.assertEqual(package_set["version"], version)
|
||||
self.assertEqual(len(package_set["platforms"]), len(PLATFORMS))
|
||||
|
||||
main_tarball = output_dir / package_set["main"]["filename"]
|
||||
main_manifest = read_tgz_json(main_tarball, "package/package.json")
|
||||
self.assertEqual(main_manifest["version"], version)
|
||||
self.assertEqual(main_manifest["bin"], {"moli": "bin/moli.js"})
|
||||
expected_aliases = {
|
||||
package["alias"]: (
|
||||
f"npm:@lexmount/moli@{package['version']}"
|
||||
)
|
||||
for package in package_set["platforms"]
|
||||
}
|
||||
self.assertEqual(
|
||||
main_manifest["optionalDependencies"], expected_aliases
|
||||
)
|
||||
with tarfile.open(main_tarball, mode="r:gz") as archive:
|
||||
launcher = archive.getmember("package/bin/moli.js")
|
||||
self.assertNotEqual(launcher.mode & 0o111, 0)
|
||||
|
||||
platform_by_target = {
|
||||
str(platform["target"]): platform for platform in PLATFORMS
|
||||
}
|
||||
for package in package_set["platforms"]:
|
||||
platform = platform_by_target[package["target"]]
|
||||
tarball = output_dir / package["filename"]
|
||||
manifest = read_tgz_json(tarball, "package/package.json")
|
||||
self.assertEqual(manifest["name"], "@lexmount/moli")
|
||||
self.assertEqual(manifest["os"], [platform["platform"]])
|
||||
self.assertEqual(manifest["cpu"], [platform["arch"]])
|
||||
if "libc" in platform:
|
||||
self.assertEqual(manifest["libc"], platform["libc"])
|
||||
|
||||
binary_member = (
|
||||
f"package/vendor/{platform['target']}/bin/{platform['binary']}"
|
||||
)
|
||||
with tarfile.open(tarball, mode="r:gz") as archive:
|
||||
member = archive.getmember(binary_member)
|
||||
source = archive.extractfile(member)
|
||||
self.assertIsNotNone(source)
|
||||
assert source is not None
|
||||
self.assertEqual(
|
||||
source.read(),
|
||||
f"fixture binary for {platform['target']}\n".encode(),
|
||||
)
|
||||
if platform["platform"] != "win32":
|
||||
self.assertNotEqual(member.mode & 0o111, 0)
|
||||
|
||||
publish_dry_run = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
str(PUBLISH_SCRIPT),
|
||||
str(output_dir / "npm-packages.json"),
|
||||
"--main-tag",
|
||||
"latest",
|
||||
"--dry-run",
|
||||
],
|
||||
cwd=REPO_ROOT,
|
||||
check=False,
|
||||
text=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
self.assertEqual(publish_dry_run.returncode, 0, publish_dry_run.stderr)
|
||||
|
||||
def test_rejects_an_incomplete_release_before_creating_output(self) -> None:
|
||||
version = current_version()
|
||||
with tempfile.TemporaryDirectory(prefix="moli-npm-package-test-") as raw:
|
||||
root = Path(raw)
|
||||
input_dir = root / "release"
|
||||
output_dir = root / "npm"
|
||||
input_dir.mkdir()
|
||||
for platform in PLATFORMS[:-1]:
|
||||
write_release_archive(input_dir, platform, version)
|
||||
|
||||
result = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
str(PACKAGE_SCRIPT),
|
||||
"--version",
|
||||
version,
|
||||
"--input-dir",
|
||||
str(input_dir),
|
||||
"--output-dir",
|
||||
str(output_dir),
|
||||
],
|
||||
cwd=REPO_ROOT,
|
||||
check=False,
|
||||
text=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("required release archive is missing", result.stderr)
|
||||
self.assertFalse(output_dir.exists())
|
||||
|
||||
def test_refuses_to_overwrite_an_existing_output_path(self) -> None:
|
||||
version = current_version()
|
||||
with tempfile.TemporaryDirectory(prefix="moli-npm-package-test-") as raw:
|
||||
root = Path(raw)
|
||||
input_dir = root / "release"
|
||||
output_dir = root / "npm"
|
||||
input_dir.mkdir()
|
||||
output_dir.mkdir()
|
||||
for platform in PLATFORMS:
|
||||
write_release_archive(input_dir, platform, version)
|
||||
|
||||
result = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
str(PACKAGE_SCRIPT),
|
||||
"--version",
|
||||
version,
|
||||
"--input-dir",
|
||||
str(input_dir),
|
||||
"--output-dir",
|
||||
str(output_dir),
|
||||
],
|
||||
cwd=REPO_ROOT,
|
||||
check=False,
|
||||
text=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("npm output path already exists", result.stderr)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,178 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import tarfile
|
||||
import tempfile
|
||||
import unittest
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
SCRIPTS_DIR = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(SCRIPTS_DIR))
|
||||
|
||||
import publish_npm # noqa: E402
|
||||
|
||||
|
||||
def integrity(value: bytes) -> str:
|
||||
digest = hashlib.sha512(value).digest()
|
||||
return "sha512-" + base64.b64encode(digest).decode("ascii")
|
||||
|
||||
|
||||
def write_package(path: Path, name: str, version: str) -> None:
|
||||
package_json = json.dumps({"name": name, "version": version}).encode()
|
||||
member = tarfile.TarInfo("package/package.json")
|
||||
member.size = len(package_json)
|
||||
with tarfile.open(path, mode="w:gz") as archive:
|
||||
archive.addfile(member, BytesIO(package_json))
|
||||
|
||||
|
||||
class PublishNpmTests(unittest.TestCase):
|
||||
def test_publishes_all_platform_packages_before_the_launcher(self) -> None:
|
||||
manifest = {
|
||||
"schemaVersion": 1,
|
||||
"package": "@lexmount/moli",
|
||||
"version": "1.0.0",
|
||||
"platforms": [
|
||||
{
|
||||
"name": "@lexmount/moli",
|
||||
"version": "1.0.0-linux-x64",
|
||||
"filename": "linux.tgz",
|
||||
"integrity": "unused",
|
||||
"distTag": "linux-x64",
|
||||
},
|
||||
{
|
||||
"name": "@lexmount/moli",
|
||||
"version": "1.0.0-darwin-arm64",
|
||||
"filename": "darwin.tgz",
|
||||
"integrity": "unused",
|
||||
"distTag": "darwin-arm64",
|
||||
},
|
||||
],
|
||||
"main": {
|
||||
"name": "@lexmount/moli",
|
||||
"version": "1.0.0",
|
||||
"filename": "main.tgz",
|
||||
"integrity": "unused",
|
||||
},
|
||||
}
|
||||
with tempfile.TemporaryDirectory(prefix="moli-npm-publish-test-") as raw:
|
||||
package_dir = Path(raw)
|
||||
for entry in [*manifest["platforms"], manifest["main"]]:
|
||||
tarball = package_dir / entry["filename"]
|
||||
write_package(tarball, entry["name"], entry["version"])
|
||||
entry["integrity"] = publish_npm.package_integrity(tarball)
|
||||
manifest_path = package_dir / "npm-packages.json"
|
||||
manifest_path.write_text(json.dumps(manifest), encoding="utf-8")
|
||||
calls: list[tuple[str, str]] = []
|
||||
|
||||
def record(entry, _package_dir, *, dist_tag, dry_run):
|
||||
self.assertFalse(dry_run)
|
||||
calls.append((entry["version"], dist_tag))
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
publish_npm, "require_trusted_publishing_toolchain"
|
||||
) as require_toolchain,
|
||||
patch.object(publish_npm, "publish_package", side_effect=record),
|
||||
):
|
||||
publish_npm.publish_package_set(
|
||||
manifest_path,
|
||||
main_tag="latest",
|
||||
trusted_publishing=True,
|
||||
dry_run=False,
|
||||
)
|
||||
require_toolchain.assert_called_once_with()
|
||||
|
||||
self.assertEqual(
|
||||
calls,
|
||||
[
|
||||
("1.0.0-linux-x64", "linux-x64"),
|
||||
("1.0.0-darwin-arm64", "darwin-arm64"),
|
||||
("1.0.0", "latest"),
|
||||
],
|
||||
)
|
||||
|
||||
def test_rejects_changed_tarball_contents(self) -> None:
|
||||
original = b"original package"
|
||||
with tempfile.TemporaryDirectory(prefix="moli-npm-publish-test-") as raw:
|
||||
package_dir = Path(raw)
|
||||
tarball = package_dir / "package.tgz"
|
||||
tarball.write_bytes(b"changed package")
|
||||
entry = {
|
||||
"name": "@lexmount/moli",
|
||||
"version": "1.0.0",
|
||||
"filename": tarball.name,
|
||||
"integrity": integrity(original),
|
||||
}
|
||||
with self.assertRaisesRegex(
|
||||
publish_npm.NpmPublishError, "integrity mismatch"
|
||||
):
|
||||
publish_npm.validate_package_entry(entry, package_dir)
|
||||
|
||||
def test_rejects_tarball_paths_outside_the_package_directory(self) -> None:
|
||||
with tempfile.TemporaryDirectory(prefix="moli-npm-publish-test-") as raw:
|
||||
entry = {
|
||||
"name": "@lexmount/moli",
|
||||
"version": "1.0.0",
|
||||
"filename": "../package.tgz",
|
||||
"integrity": "unused",
|
||||
}
|
||||
with self.assertRaisesRegex(
|
||||
publish_npm.NpmPublishError, "must not contain a path"
|
||||
):
|
||||
publish_npm.validate_package_entry(entry, Path(raw))
|
||||
|
||||
def test_rejects_package_identity_mismatch(self) -> None:
|
||||
with tempfile.TemporaryDirectory(prefix="moli-npm-publish-test-") as raw:
|
||||
package_dir = Path(raw)
|
||||
tarball = package_dir / "package.tgz"
|
||||
write_package(tarball, "@lexmount/not-moli", "1.0.0")
|
||||
entry = {
|
||||
"name": "@lexmount/moli",
|
||||
"version": "1.0.0",
|
||||
"filename": tarball.name,
|
||||
"integrity": publish_npm.package_integrity(tarball),
|
||||
}
|
||||
with self.assertRaisesRegex(
|
||||
publish_npm.NpmPublishError, "identity mismatch"
|
||||
):
|
||||
publish_npm.validate_package_entry(entry, package_dir)
|
||||
|
||||
def test_trusted_publishing_rejects_old_npm(self) -> None:
|
||||
versions = {"node": (24, 15, 0), "npm": (11, 5, 0)}
|
||||
with (
|
||||
patch.object(publish_npm, "tool_version", side_effect=versions.__getitem__),
|
||||
self.assertRaisesRegex(
|
||||
publish_npm.NpmPublishError,
|
||||
r"npm trusted publishing requires npm >= 11\.5\.1; found 11\.5\.0",
|
||||
),
|
||||
):
|
||||
publish_npm.require_trusted_publishing_toolchain()
|
||||
|
||||
def test_trusted_publishing_rejects_old_node(self) -> None:
|
||||
versions = {"node": (22, 13, 9), "npm": (11, 18, 0)}
|
||||
with (
|
||||
patch.object(publish_npm, "tool_version", side_effect=versions.__getitem__),
|
||||
self.assertRaisesRegex(
|
||||
publish_npm.NpmPublishError,
|
||||
r"npm trusted publishing requires node >= 22\.14\.0; found 22\.13\.9",
|
||||
),
|
||||
):
|
||||
publish_npm.require_trusted_publishing_toolchain()
|
||||
|
||||
def test_tool_version_accepts_node_prefix_and_prerelease(self) -> None:
|
||||
completed = subprocess.CompletedProcess(
|
||||
["node", "--version"], 0, stdout="v24.15.0-rc.1\n", stderr=""
|
||||
)
|
||||
with patch.object(publish_npm.subprocess, "run", return_value=completed):
|
||||
self.assertEqual(publish_npm.tool_version("node"), (24, 15, 0))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,39 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
SCRIPTS_DIR = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(SCRIPTS_DIR))
|
||||
|
||||
import release # noqa: E402
|
||||
|
||||
|
||||
class ReleaseVersionTests(unittest.TestCase):
|
||||
def test_reads_standard_cli_version_flag(self) -> None:
|
||||
completed = subprocess.CompletedProcess(
|
||||
args=[], returncode=0, stdout="moli 1.2.3\n", stderr=""
|
||||
)
|
||||
with patch.object(release, "run_checked", return_value=completed) as run:
|
||||
version = release.binary_reported_version(Path("/tmp/moli"))
|
||||
|
||||
self.assertEqual(version, "1.2.3")
|
||||
run.assert_called_once_with(["/tmp/moli", "--version"], capture_output=True)
|
||||
|
||||
def test_rejects_an_unexpected_version_response(self) -> None:
|
||||
completed = subprocess.CompletedProcess(
|
||||
args=[], returncode=0, stdout="1.2.3\n", stderr=""
|
||||
)
|
||||
with patch.object(release, "run_checked", return_value=completed):
|
||||
with self.assertRaisesRegex(
|
||||
release.ReleaseError, "unexpected `--version` response"
|
||||
):
|
||||
release.binary_reported_version(Path("/tmp/moli"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user