Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
162 changes: 155 additions & 7 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,23 +37,171 @@ jobs:

publish-npm:
needs: goreleaser
if: github.event.action == 'released'
if: github.event.action == 'released' || github.event.action == 'prereleased'
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
env:
VERSION: ${{ github.event.release.tag_name }}
# Full releases go to the default `latest` tag; pre-releases go to `next` so
# `npm i @zerops/zcli` is unaffected and they install via `@zerops/zcli@next`.
NPM_TAG: ${{ github.event.action == 'prereleased' && 'next' || 'latest' }}
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: 22
registry-url: https://registry.npmjs.org/
- run: |
cd tools/npm
npm ci --ignore-scripts
npm i -g replace-in-files-cli@2.2.0
replace-in-files --string='v0.0.0-zerops' --replacement='${{ github.event.release.tag_name }}' package.json
npm publish --access=public

# The git tag carries a leading "v" (e.g. v1.2.3); npm package versions must be
# plain semver. Keep the tag for the GitHub download, strip the "v" for package.json.
- name: Resolve npm version
run: echo "NPM_VERSION=${VERSION#v}" >> "$GITHUB_ENV"

# npm must ship the channel=npm binaries (so `zcli upgrade` defers to npm instead
# of self-updating), not the channel=manual raw binaries. Those live inside the
# *-npm.tar.gz archives as builds/zcli-<platform>; extract them into dist-bin under
# the asset names the build script expects (strip-components drops the builds/ dir).
- name: Download and extract npm-channel binaries
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
mkdir -p dist-tars dist-bin
gh release download "$VERSION" \
--repo "$GITHUB_REPOSITORY" \
--dir dist-tars \
--pattern 'zcli-linux-amd64-npm.tar.gz' \
--pattern 'zcli-linux-i386-npm.tar.gz' \
--pattern 'zcli-darwin-amd64-npm.tar.gz' \
--pattern 'zcli-darwin-arm64-npm.tar.gz' \
--pattern 'zcli-win-x64.exe-npm.tar.gz'
for t in dist-tars/*.tar.gz; do
tar -xzf "$t" -C dist-bin --strip-components=1
done

- name: Build platform packages
run: |
node tools/npm/scripts/build-platform-packages.mjs \
--binaries "$GITHUB_WORKSPACE/dist-bin" \
--version "$NPM_VERSION" \
--out "$GITHUB_WORKSPACE/dist-npm"

# npm publishes aren't transactional, so we enforce the consumer-facing invariant
# instead: publish the main package only after all platform packages exist at this
# version, so its optionalDependencies can never resolve to a missing package.
# Platform publishes are idempotent (skip versions already on the registry) so a
# re-run after a partial failure completes cleanly without bumping the version.
- name: Publish platform packages
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: |
for dir in "$GITHUB_WORKSPACE"/dist-npm/*/; do
name="$(node -p "require('$dir/package.json').name")"
if npm view "$name@$NPM_VERSION" version >/dev/null 2>&1; then
echo "$name@$NPM_VERSION already published, skipping"
continue
fi
npm publish "$dir" --access=public --tag "$NPM_TAG"
done

- name: Verify all platform packages are published
run: |
for dir in "$GITHUB_WORKSPACE"/dist-npm/*/; do
name="$(node -p "require('$dir/package.json').name")"
ok=0
for i in $(seq 1 12); do
if npm view "$name@$NPM_VERSION" version >/dev/null 2>&1; then
ok=1
break
fi
echo "waiting for $name@$NPM_VERSION to be resolvable..."
sleep 5
done
if [ "$ok" -ne 1 ]; then
echo "::error::$name@$NPM_VERSION is not on the registry; refusing to publish the main package"
exit 1
fi
done
echo "all platform packages present at $NPM_VERSION"

- name: Publish main package
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: |
cd tools/npm
npm i -g replace-in-files-cli@2.2.0
replace-in-files --string='v0.0.0-zerops' --replacement="$NPM_VERSION" package.json
npm publish --access=public --ignore-scripts --tag "$NPM_TAG"

# Install the just-published package across OS/arch + package managers and run the
# binary, so a broken release (missing platform package, os/cpu mismatch, Bun
# incompatibility, exec-bit loss) surfaces immediately instead of in user reports.
smoke-test:
needs: publish-npm
if: github.event.action == 'released' || github.event.action == 'prereleased'
runs-on: ${{ matrix.os }}
timeout-minutes: 10
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-latest # linux x64
manager: npm
- os: ubuntu-latest
manager: bun
- os: macos-latest # darwin arm64
manager: npm
- os: macos-latest
manager: bun
- os: macos-13 # darwin amd64
manager: npm
- os: windows-latest # win x64
manager: npm
env:
VERSION: ${{ github.event.release.tag_name }}
steps:
- uses: actions/setup-node@v6
with:
node-version: 22
registry-url: https://registry.npmjs.org/

- if: matrix.manager == 'bun'
uses: oven-sh/setup-bun@v2

- name: Wait for the published version to be resolvable
shell: bash
run: |
V="${VERSION#v}"
for i in $(seq 1 12); do
if npm view "@zerops/zcli@$V" version >/dev/null 2>&1; then
echo "found @zerops/zcli@$V"
exit 0
fi
echo "waiting for @zerops/zcli@$V to appear on the registry..."
sleep 10
done
echo "@zerops/zcli@$V did not become resolvable in time"
exit 1

- name: Install and run (npm)
if: matrix.manager == 'npm'
shell: bash
run: |
V="${VERSION#v}"
npm install -g "@zerops/zcli@$V"
zcli version

- name: Install and run (bun)
if: matrix.manager == 'bun'
shell: bash
run: |
V="${VERSION#v}"
dir="$(mktemp -d)"
cd "$dir"
echo '{"name":"zcli-smoke","version":"1.0.0"}' > package.json
bun add "@zerops/zcli@$V"
./node_modules/.bin/zcli version

report:
needs: goreleaser
Expand Down
24 changes: 24 additions & 0 deletions tools/npm/bin/zcli.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
#!/usr/bin/env node

const { spawnSync } = require("child_process");
const { getBinaryPath } = require("../lib/resolve");

let binaryPath;
try {
binaryPath = getBinaryPath();
} catch (e) {
process.stderr.write(e.message + "\n");
process.exit(1);
}

const result = spawnSync(binaryPath, process.argv.slice(2), {
cwd: process.cwd(),
stdio: "inherit",
});

if (result.error) {
process.stderr.write(`zcli: failed to start binary: ${result.error.message}\n`);
process.exit(1);
}

process.exit(result.status === null ? 1 : result.status);
23 changes: 23 additions & 0 deletions tools/npm/lib/platforms.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
// Single source of truth for the platforms we publish a prebuilt binary for.
// Keyed by `${process.platform} ${process.arch}` so the launcher can look up the
// current host directly, and reused by the release build script to emit one npm
// package per platform.
//
// Fields:
// pkg - unscoped package name; full name is `${SCOPE}/${pkg}`
// binary - filename of the executable inside the platform package's bin/ dir
// os/cpu - values for the platform package's package.json os/cpu fields, so npm
// (and Bun, which honors them) installs only the matching package
// asset - name of the raw binary asset on the GitHub release to package

const SCOPE = "@zerops";

const PLATFORMS = [
{ key: "linux x64", pkg: "zcli-linux-amd64", binary: "zcli-linux-amd64", os: "linux", cpu: "x64", asset: "zcli-linux-amd64" },
{ key: "linux ia32", pkg: "zcli-linux-i386", binary: "zcli-linux-i386", os: "linux", cpu: "ia32", asset: "zcli-linux-i386" },
{ key: "darwin x64", pkg: "zcli-darwin-amd64", binary: "zcli-darwin-amd64", os: "darwin", cpu: "x64", asset: "zcli-darwin-amd64" },
{ key: "darwin arm64", pkg: "zcli-darwin-arm64", binary: "zcli-darwin-arm64", os: "darwin", cpu: "arm64", asset: "zcli-darwin-arm64" },
{ key: "win32 x64", pkg: "zcli-win-x64", binary: "zcli-win-x64.exe", os: "win32", cpu: "x64", asset: "zcli-win-x64.exe" },
];

module.exports = { SCOPE, PLATFORMS };
30 changes: 30 additions & 0 deletions tools/npm/lib/resolve.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
const { SCOPE, PLATFORMS } = require("./platforms");

function currentPlatform() {
const key = `${process.platform} ${process.arch}`;
const entry = PLATFORMS.find(p => p.key === key);
if (!entry) {
const supported = PLATFORMS.map(p => p.key).join(", ");
throw new Error(`zcli: unsupported platform "${key}". Supported: ${supported}`);
}
return entry;
}

function getBinaryPath() {
const { pkg, binary } = currentPlatform();
const fullName = `${SCOPE}/${pkg}`;
try {
return require.resolve(`${fullName}/bin/${binary}`);
} catch {
throw new Error(
[
`zcli: could not find the platform binary package "${fullName}".`,
"This usually means optional dependencies were skipped during install",
"(e.g. --no-optional, --ignore-optional, or a package manager configured to skip them).",
"Reinstall @zerops/zcli with optional dependencies enabled.",
].join("\n")
);
}
}

module.exports = { currentPlatform, getBinaryPath };
Loading