Publish to npm #107
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |