Skip to content

Publish to npm

Publish to npm #107

Workflow file for this run

name: Publish to npm
on:
workflow_dispatch:
inputs:
version:
description: 'Version to publish (e.g., v0.1.1). Must already exist as a GitHub Release.'
required: true
type: string
workflow_run:
workflows: ['Release']
types: [completed]
permissions:
contents: read
id-token: write
# Belt-and-suspenders: release.yml dispatches us explicitly because
# `workflow_run` is unreliable when the upstream `Release` run was itself
# triggered by a GITHUB_TOKEN workflow_dispatch. If both paths happen to
# fire for the same release, this static concurrency group serializes them
# and the second run exits cleanly when the version is already on npm.
# Releases are version-monotonic so blanket serialisation is safe; there
# isn't a scenario where two concurrent publishes should both succeed.
concurrency:
group: npm-publish
cancel-in-progress: false
jobs:
npm-publish:
if: >-
(github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main') ||
(github.event_name == 'workflow_run' &&
github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.head_branch == 'main')
runs-on: ubuntu-latest
name: Publish coven-code to npm
steps:
- name: Resolve requested version
if: github.event_name == 'workflow_dispatch'
id: requested_version
env:
REQUESTED_VERSION: ${{ inputs.version }}
run: |
set -euo pipefail
VERSION="${REQUESTED_VERSION#v}"
if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "::error::Invalid npm publish version: $VERSION"
exit 1
fi
echo "tag=v$VERSION" >> "$GITHUB_OUTPUT"
echo "tag_ref=refs/tags/v$VERSION" >> "$GITHUB_OUTPUT"
- uses: actions/checkout@v5
if: github.event_name == 'workflow_dispatch'
with:
ref: ${{ steps.requested_version.outputs.tag_ref }}
fetch-depth: 0
- uses: actions/checkout@v5
if: github.event_name == 'workflow_run'
with:
ref: ${{ github.event.workflow_run.head_sha }}
fetch-depth: 0
- name: Resolve version
id: version
env:
EVENT_NAME: ${{ github.event_name }}
REQUESTED_TAG: ${{ steps.requested_version.outputs.tag }}
run: |
set -euo pipefail
if [[ "$EVENT_NAME" == "workflow_dispatch" ]]; then
VERSION="${REQUESTED_TAG#v}"
else
VERSION="$(grep '^version' src-rust/Cargo.toml | head -1 | sed 's/.*"\(.*\)".*/\1/')"
fi
if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "::error::Invalid npm publish version: $VERSION"
exit 1
fi
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "tag=v$VERSION" >> "$GITHUB_OUTPUT"
echo "Publishing npm packages for Coven Code $VERSION"
- name: Set up Node.js
uses: actions/setup-node@v6
with:
node-version: '24'
registry-url: 'https://registry.npmjs.org'
package-manager-cache: false
- name: Verify npm
run: npm --version
- name: Verify GitHub Release and source version
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ steps.version.outputs.tag }}
VERSION: ${{ steps.version.outputs.version }}
run: |
set -euo pipefail
TAG_REF="refs/tags/$TAG"
gh release view "$TAG" --repo "${{ github.repository }}" >/dev/null
TAG_COMMIT="$(git rev-list -n 1 "$TAG_REF")"
HEAD_COMMIT="$(git rev-parse HEAD)"
if [[ "$TAG_COMMIT" != "$HEAD_COMMIT" ]]; then
echo "::error::Checked-out source ($HEAD_COMMIT) does not match $TAG ($TAG_COMMIT)."
exit 1
fi
CARGO_VERSION="$(grep '^version' src-rust/Cargo.toml | head -1 | sed 's/.*"\(.*\)".*/\1/')"
if [[ "$CARGO_VERSION" != "$VERSION" ]]; then
echo "::error::npm publish version ($VERSION) does not match Cargo.toml ($CARGO_VERSION)."
exit 1
fi
echo "Release source verified for $TAG at $HEAD_COMMIT."
- name: Verify trusted publisher identity
working-directory: npm
run: |
set -euo pipefail
node <<'NODE'
const fs = require('fs');
const path = require('path');
const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8'));
const repo = process.env.GITHUB_REPOSITORY;
const [owner, repoName] = repo.split('/');
const workflowRef = process.env.GITHUB_WORKFLOW_REF || '';
const workflowFile = path.basename(workflowRef.split('@')[0]);
const repositoryUrl = typeof pkg.repository === 'string'
? pkg.repository
: pkg.repository && pkg.repository.url;
const expectedRepositoryUrl = `git+https://github.com/${repo}.git`;
console.log('Expected npm Trusted Publisher configuration:');
console.log(` Organization or user: ${owner}`);
console.log(` Repository: ${repoName}`);
console.log(` Workflow filename: ${workflowFile}`);
console.log(' Environment name: (empty)');
if (repositoryUrl !== expectedRepositoryUrl) {
console.error(`::error::npm/package.json repository.url must be ${expectedRepositoryUrl} for trusted publishing; found ${repositoryUrl || '(missing)'}`);
process.exit(1);
}
NODE
- name: Generate npm checksum manifest
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
TAG="${{ steps.version.outputs.tag }}"
mkdir -p release-assets
gh release download "$TAG" \
--repo "${{ github.repository }}" \
--pattern 'coven-code-*.tar.gz' \
--pattern 'coven-code-*.zip' \
--dir release-assets
node <<'NODE'
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const expectedArchives = [
'coven-code-windows-x86_64.zip',
'coven-code-linux-x86_64.tar.gz',
'coven-code-linux-aarch64.tar.gz',
'coven-code-macos-x86_64.tar.gz',
'coven-code-macos-aarch64.tar.gz',
];
const manifest = {};
for (const archive of expectedArchives) {
const archivePath = path.join('release-assets', archive);
if (!fs.existsSync(archivePath)) {
console.error(`::error::Missing release archive ${archive}`);
process.exit(1);
}
const hash = crypto.createHash('sha256');
hash.update(fs.readFileSync(archivePath));
manifest[archive] = { sha256: hash.digest('hex') };
}
fs.writeFileSync('npm/checksums.json', JSON.stringify(manifest, null, 2) + '\n');
NODE
cat npm/checksums.json
- name: Publish npm packages
env:
VERSION: ${{ steps.version.outputs.version }}
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: |
set -euo pipefail
prepare_package() {
local package_name="$1"
node - "$package_name" "$VERSION" <<'NODE'
const fs = require('fs');
const packageName = process.argv[2];
const version = process.argv[3];
const supportedPackageNames = new Set(['@opencoven/coven-code']);
if (!supportedPackageNames.has(packageName)) {
console.error(`::error::Unsupported npm package name: ${packageName}`);
process.exit(1);
}
if (!/^\d+\.\d+\.\d+$/.test(version)) {
console.error(`::error::Invalid npm package version: ${version}`);
process.exit(1);
}
function stringifyJson(value) {
return JSON.stringify(value, null, 2).replace(/[^\x00-\x7F]/g, (char) => {
return `\\u${char.codePointAt(0).toString(16).padStart(4, '0')}`;
}) + '\n';
}
function replaceRequired(text, pattern, replacement, label) {
if (!pattern.test(text)) {
console.error(`::error::Failed to update ${label}`);
process.exit(1);
}
return text.replace(pattern, replacement);
}
const pkg = JSON.parse(fs.readFileSync('npm/package.json', 'utf8'));
pkg.name = packageName;
pkg.version = version;
fs.writeFileSync('npm/package.json', stringifyJson(pkg));
let readme = fs.readFileSync('npm/README.md', 'utf8');
readme = replaceRequired(readme, /^# .+$/m, `# ${packageName}`, 'README heading');
readme = replaceRequired(readme, /npm\/v\/[^?]+(\?style=flat-square)/, `npm/v/${packageName}$1`, 'README npm badge');
readme = replaceRequired(readme, /npmjs\.com\/package\/[^\)\s]+/, `npmjs.com/package/${packageName}`, 'README npm package link');
readme = replaceRequired(readme, /^npm install -g .+$/m, `npm install -g ${packageName}`, 'README npm install command');
readme = replaceRequired(readme, /^bun install -g .+$/m, `bun install -g ${packageName}`, 'README bun install command');
fs.writeFileSync('npm/README.md', readme);
NODE
}
packages=("@opencoven/coven-code")
for package_name in "${packages[@]}"; do
if npm view "${package_name}@${VERSION}" version >/dev/null 2>&1; then
echo "${package_name}@${VERSION} is already published; skipping."
continue
fi
echo "${package_name}@${VERSION} is not published yet; publishing."
prepare_package "$package_name"
publish_args=(--access public --provenance --ignore-scripts)
# The pre-publish `npm view` guard above can race npm's
# read-after-write propagation when this workflow's two triggers
# (release.yml dispatch + workflow_run) fire back-to-back: the
# concurrency group serializes the *runs*, but a publish that
# landed seconds ago may not be visible to `npm view` yet. In
# that case our own publish fails with E403 "cannot publish over
# the previously published versions" — which means the version
# is already on the registry, i.e. success. Treat it as such.
publish_log="$(mktemp)"
if (cd npm && npm publish "${publish_args[@]}") 2>&1 | tee "$publish_log"; then
echo "${package_name}@${VERSION} published."
elif grep -q "cannot publish over the previously published versions" "$publish_log"; then
echo "${package_name}@${VERSION} was already published by a concurrent run; treating as success."
else
# Last resort: give the registry a moment and re-check whether
# the version exists before declaring failure.
for _ in 1 2 3; do
sleep 5
if npm view "${package_name}@${VERSION}" version >/dev/null 2>&1; then
echo "${package_name}@${VERSION} is on the registry; treating as success."
continue 2
fi
done
echo "::error::npm publish failed for ${package_name}@${VERSION}; see log above."
exit 1
fi
done