From 9af9785e2577fb07aaf20b025047965de158702f Mon Sep 17 00:00:00 2001 From: Marshall Ku Date: Tue, 18 Aug 2026 18:22:29 +0900 Subject: [PATCH 1/9] chore: Fail CI when the committed binding is stale index.js and index.d.ts are generated but committed, and the release job publishes what is committed rather than what it builds. Nothing checked that the two agreed, and they had not agreed since the 0.7.0 bump: the committed binding still expected 0.6.0. Build on one leg of the CI matrix and fail when a fresh build disagrees with what is in the tree, so the drift is caught on the pull request that causes it. Regenerating index.js along the way is the 0.6.0 to 0.7.0 correction. --- .github/workflows/ci.yml | 8 +++ index.js | 104 +++++++++++++++++++-------------------- 2 files changed, 60 insertions(+), 52 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 892c6d8..7b357d7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -146,6 +146,14 @@ jobs: - name: Build run: ${{ matrix.settings.build }} shell: bash + - name: Check generated bindings are up to date + if: ${{ matrix.settings.target == 'aarch64-apple-darwin' }} + run: | + if ! git diff --exit-code -- index.js index.d.ts; then + echo "::error::index.js / index.d.ts are out of date. Run \`yarn build\` and commit the result." + exit 1 + fi + shell: bash - name: Upload artifact uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: diff --git a/index.js b/index.js index 614f2a4..02b24f3 100644 --- a/index.js +++ b/index.js @@ -77,8 +77,8 @@ function requireNative() { try { const binding = require('es-git-android-arm64') const bindingPackageVersion = require('es-git-android-arm64/package.json').version - if (bindingPackageVersion !== '0.6.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.6.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.7.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.7.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -93,8 +93,8 @@ function requireNative() { try { const binding = require('es-git-android-arm-eabi') const bindingPackageVersion = require('es-git-android-arm-eabi/package.json').version - if (bindingPackageVersion !== '0.6.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.6.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.7.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.7.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -114,8 +114,8 @@ function requireNative() { try { const binding = require('es-git-win32-x64-gnu') const bindingPackageVersion = require('es-git-win32-x64-gnu/package.json').version - if (bindingPackageVersion !== '0.6.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.6.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.7.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.7.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -130,8 +130,8 @@ function requireNative() { try { const binding = require('es-git-win32-x64-msvc') const bindingPackageVersion = require('es-git-win32-x64-msvc/package.json').version - if (bindingPackageVersion !== '0.6.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.6.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.7.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.7.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -147,8 +147,8 @@ function requireNative() { try { const binding = require('es-git-win32-ia32-msvc') const bindingPackageVersion = require('es-git-win32-ia32-msvc/package.json').version - if (bindingPackageVersion !== '0.6.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.6.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.7.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.7.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -163,8 +163,8 @@ function requireNative() { try { const binding = require('es-git-win32-arm64-msvc') const bindingPackageVersion = require('es-git-win32-arm64-msvc/package.json').version - if (bindingPackageVersion !== '0.6.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.6.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.7.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.7.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -182,8 +182,8 @@ function requireNative() { try { const binding = require('es-git-darwin-universal') const bindingPackageVersion = require('es-git-darwin-universal/package.json').version - if (bindingPackageVersion !== '0.6.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.6.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.7.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.7.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -198,8 +198,8 @@ function requireNative() { try { const binding = require('es-git-darwin-x64') const bindingPackageVersion = require('es-git-darwin-x64/package.json').version - if (bindingPackageVersion !== '0.6.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.6.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.7.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.7.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -214,8 +214,8 @@ function requireNative() { try { const binding = require('es-git-darwin-arm64') const bindingPackageVersion = require('es-git-darwin-arm64/package.json').version - if (bindingPackageVersion !== '0.6.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.6.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.7.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.7.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -234,8 +234,8 @@ function requireNative() { try { const binding = require('es-git-freebsd-x64') const bindingPackageVersion = require('es-git-freebsd-x64/package.json').version - if (bindingPackageVersion !== '0.6.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.6.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.7.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.7.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -250,8 +250,8 @@ function requireNative() { try { const binding = require('es-git-freebsd-arm64') const bindingPackageVersion = require('es-git-freebsd-arm64/package.json').version - if (bindingPackageVersion !== '0.6.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.6.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.7.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.7.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -271,8 +271,8 @@ function requireNative() { try { const binding = require('es-git-linux-x64-musl') const bindingPackageVersion = require('es-git-linux-x64-musl/package.json').version - if (bindingPackageVersion !== '0.6.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.6.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.7.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.7.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -287,8 +287,8 @@ function requireNative() { try { const binding = require('es-git-linux-x64-gnu') const bindingPackageVersion = require('es-git-linux-x64-gnu/package.json').version - if (bindingPackageVersion !== '0.6.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.6.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.7.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.7.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -305,8 +305,8 @@ function requireNative() { try { const binding = require('es-git-linux-arm64-musl') const bindingPackageVersion = require('es-git-linux-arm64-musl/package.json').version - if (bindingPackageVersion !== '0.6.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.6.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.7.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.7.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -321,8 +321,8 @@ function requireNative() { try { const binding = require('es-git-linux-arm64-gnu') const bindingPackageVersion = require('es-git-linux-arm64-gnu/package.json').version - if (bindingPackageVersion !== '0.6.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.6.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.7.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.7.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -339,8 +339,8 @@ function requireNative() { try { const binding = require('es-git-linux-arm-musleabihf') const bindingPackageVersion = require('es-git-linux-arm-musleabihf/package.json').version - if (bindingPackageVersion !== '0.6.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.6.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.7.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.7.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -355,8 +355,8 @@ function requireNative() { try { const binding = require('es-git-linux-arm-gnueabihf') const bindingPackageVersion = require('es-git-linux-arm-gnueabihf/package.json').version - if (bindingPackageVersion !== '0.6.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.6.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.7.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.7.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -373,8 +373,8 @@ function requireNative() { try { const binding = require('es-git-linux-loong64-musl') const bindingPackageVersion = require('es-git-linux-loong64-musl/package.json').version - if (bindingPackageVersion !== '0.6.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.6.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.7.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.7.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -389,8 +389,8 @@ function requireNative() { try { const binding = require('es-git-linux-loong64-gnu') const bindingPackageVersion = require('es-git-linux-loong64-gnu/package.json').version - if (bindingPackageVersion !== '0.6.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.6.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.7.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.7.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -407,8 +407,8 @@ function requireNative() { try { const binding = require('es-git-linux-riscv64-musl') const bindingPackageVersion = require('es-git-linux-riscv64-musl/package.json').version - if (bindingPackageVersion !== '0.6.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.6.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.7.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.7.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -423,8 +423,8 @@ function requireNative() { try { const binding = require('es-git-linux-riscv64-gnu') const bindingPackageVersion = require('es-git-linux-riscv64-gnu/package.json').version - if (bindingPackageVersion !== '0.6.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.6.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.7.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.7.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -440,8 +440,8 @@ function requireNative() { try { const binding = require('es-git-linux-ppc64-gnu') const bindingPackageVersion = require('es-git-linux-ppc64-gnu/package.json').version - if (bindingPackageVersion !== '0.6.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.6.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.7.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.7.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -456,8 +456,8 @@ function requireNative() { try { const binding = require('es-git-linux-s390x-gnu') const bindingPackageVersion = require('es-git-linux-s390x-gnu/package.json').version - if (bindingPackageVersion !== '0.6.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.6.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.7.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.7.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -476,8 +476,8 @@ function requireNative() { try { const binding = require('es-git-openharmony-arm64') const bindingPackageVersion = require('es-git-openharmony-arm64/package.json').version - if (bindingPackageVersion !== '0.6.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.6.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.7.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.7.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -492,8 +492,8 @@ function requireNative() { try { const binding = require('es-git-openharmony-x64') const bindingPackageVersion = require('es-git-openharmony-x64/package.json').version - if (bindingPackageVersion !== '0.6.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.6.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.7.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.7.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -508,8 +508,8 @@ function requireNative() { try { const binding = require('es-git-openharmony-arm') const bindingPackageVersion = require('es-git-openharmony-arm/package.json').version - if (bindingPackageVersion !== '0.6.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.6.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.7.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.7.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { From f4c9921c6b4ccb33ee70b360d966a5ed5834d52d Mon Sep 17 00:00:00 2001 From: Marshall Ku Date: Tue, 18 Aug 2026 18:22:29 +0900 Subject: [PATCH 2/9] chore: Publish a freshly built binding and refuse backwards versions The release job never builds -- it downloads .node artifacts and publishes -- so index.js and index.d.ts ship exactly as committed. Have the darwin-arm64 build leg upload the binding it just generated and have the release job download it before publishing, so the tarball always carries a binding built from the commit being released. The JS binding is target-independent, so one leg is enough. npm assigns the latest dist-tag to whatever is published last with no regard for semver order, so tagging v0.6.9 after 0.7.0 is out would quietly move latest backwards and npm would not stop it. Gate the tagged release on the tag matching package.json and on that version being strictly newer than the one npm currently serves as latest. --- .github/workflows/release.yml | 20 ++++ .scripts/verify-release-version.spec.ts | 50 ++++++++++ .scripts/verify-release-version.ts | 126 ++++++++++++++++++++++++ 3 files changed, 196 insertions(+) create mode 100644 .scripts/verify-release-version.spec.ts create mode 100644 .scripts/verify-release-version.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 53dbb1b..07ca370 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -94,6 +94,17 @@ jobs: name: bindings-${{ matrix.settings.target }} path: ${{ env.APP_NAME }}.*.node if-no-files-found: error + # The JS binding is identical for every target, so one leg publishes it and the release job + # ships that instead of whatever is committed. + - name: Upload JS binding + if: ${{ matrix.settings.target == 'aarch64-apple-darwin' }} + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: js-binding + path: | + index.js + index.d.ts + if-no-files-found: error release: runs-on: ubuntu-latest permissions: @@ -114,10 +125,19 @@ jobs: run: npm install -g npm@11.5.1 - name: Install dependencies run: yarn install + - name: Verify release version + if: github.ref_type == 'tag' + run: node --no-warnings=ExperimentalWarning --experimental-strip-types ./.scripts/verify-release-version.ts - name: Download all artifacts uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: + pattern: bindings-* path: release-artifacts + - name: Download JS binding + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: js-binding + path: . - name: Prepare next release if: github.ref_type == 'branch' run: node --no-warnings=ExperimentalWarning --experimental-strip-types ./.scripts/prepare-next-release.ts ${{ github.run_number }} ${{ github.sha }} diff --git a/.scripts/verify-release-version.spec.ts b/.scripts/verify-release-version.spec.ts new file mode 100644 index 0000000..b7386b1 --- /dev/null +++ b/.scripts/verify-release-version.spec.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest'; +import { compareSemver, parseSemver } from './verify-release-version.ts'; + +describe('compareSemver', () => { + it('orders by major, minor and patch', () => { + expect(compareSemver('1.0.0', '0.9.9')).toBeGreaterThan(0); + expect(compareSemver('0.7.0', '0.6.0')).toBeGreaterThan(0); + expect(compareSemver('0.7.1', '0.7.0')).toBeGreaterThan(0); + expect(compareSemver('0.7.0', '0.7.0')).toBe(0); + }); + + it('refuses a patch of an older minor as newer', () => { + // The mistake this guards: releasing 1.0.1 while 1.1.0 is already published. + expect(compareSemver('1.0.1', '1.1.0')).toBeLessThan(0); + }); + + it('places a prerelease before its release', () => { + expect(compareSemver('0.7.0-next.1', '0.7.0')).toBeLessThan(0); + expect(compareSemver('0.7.0', '0.7.0-next.1')).toBeGreaterThan(0); + }); + + it('compares prerelease identifiers field by field', () => { + expect(compareSemver('0.7.0-next.2', '0.7.0-next.10')).toBeLessThan(0); + expect(compareSemver('0.7.0-alpha', '0.7.0-beta')).toBeLessThan(0); + expect(compareSemver('0.7.0-next.1', '0.7.0-next')).toBeGreaterThan(0); + // A numeric identifier always precedes an alphanumeric one. + expect(compareSemver('0.7.0-1', '0.7.0-alpha')).toBeLessThan(0); + }); + + it('ignores build metadata', () => { + expect(compareSemver('0.7.0-next.42+0aabbcc', '0.7.0-next.42')).toBe(0); + expect(compareSemver('0.7.0+build', '0.7.0')).toBe(0); + }); +}); + +describe('parseSemver', () => { + it('splits prerelease identifiers', () => { + expect(parseSemver('0.7.0-next.42+0aabbcc')).toEqual({ + major: 0, + minor: 7, + patch: 0, + prerelease: ['next', '42'], + }); + }); + + it('rejects a version it cannot compare', () => { + expect(() => parseSemver('v0.7.0')).toThrow('Not a valid semver version'); + expect(() => parseSemver('0.7')).toThrow('Not a valid semver version'); + }); +}); diff --git a/.scripts/verify-release-version.ts b/.scripts/verify-release-version.ts new file mode 100644 index 0000000..e327ba2 --- /dev/null +++ b/.scripts/verify-release-version.ts @@ -0,0 +1,126 @@ +#!/usr/bin/env -S node --no-warnings=ExperimentalWarning --experimental-strip-types +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const SEMVER_PATTERN = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z-.]+))?(?:\+[0-9A-Za-z-.]+)?$/; + +interface Semver { + major: number; + minor: number; + patch: number; + prerelease: string[]; +} + +export function parseSemver(version: string): Semver { + const matched = SEMVER_PATTERN.exec(version); + if (matched == null) { + throw new Error(`Not a valid semver version: ${version}`); + } + const [, major, minor, patch, prerelease] = matched; + return { + major: Number(major), + minor: Number(minor), + patch: Number(patch), + prerelease: prerelease == null || prerelease === '' ? [] : prerelease.split('.'), + }; +} + +/** + * Compare two versions by semver precedence. Build metadata is ignored. + * Returns a negative number when `a` precedes `b`, positive when it follows, 0 when equal. + */ +export function compareSemver(a: string, b: string): number { + const left = parseSemver(a); + const right = parseSemver(b); + if (left.major !== right.major) { + return left.major - right.major; + } + if (left.minor !== right.minor) { + return left.minor - right.minor; + } + if (left.patch !== right.patch) { + return left.patch - right.patch; + } + return comparePrerelease(left.prerelease, right.prerelease); +} + +/** + * A version with a prerelease precedes the same version without one, and identifiers are compared + * field by field: numeric ones numerically, and a numeric field always precedes an alphanumeric one. + */ +function comparePrerelease(a: string[], b: string[]): number { + if (a.length === 0 || b.length === 0) { + return b.length - a.length; + } + for (let index = 0; index < Math.max(a.length, b.length); index++) { + const left = a[index]; + const right = b[index]; + if (left == null || right == null) { + // Whichever ran out of identifiers first has lower precedence. + return left == null ? -1 : 1; + } + const leftIsNumeric = /^\d+$/.test(left); + const rightIsNumeric = /^\d+$/.test(right); + if (leftIsNumeric !== rightIsNumeric) { + return leftIsNumeric ? -1 : 1; + } + if (leftIsNumeric && rightIsNumeric) { + if (Number(left) !== Number(right)) { + return Number(left) - Number(right); + } + continue; + } + if (left !== right) { + return left < right ? -1 : 1; + } + } + return 0; +} + +/** + * Look up the version the `latest` dist-tag currently points at, or `null` when nothing is published. + */ +export async function fetchPublishedVersion(name: string): Promise { + const response = await fetch(`https://registry.npmjs.org/${encodeURIComponent(name)}/latest`); + if (response.status === 404) { + return null; + } + if (!response.ok) { + throw new Error(`Cannot read the published version of ${name}: ${response.status} ${response.statusText}`); + } + const { version } = (await response.json()) as { version: string }; + return version; +} + +/** + * Guard the `latest` publish. npm assigns the `latest` dist-tag to whatever is published last with no + * regard for semver order, so publishing an older version silently moves `latest` backwards. + */ +async function main() { + const rootdir = path.join(path.dirname(fileURLToPath(import.meta.url)), '..'); + const { name, version } = JSON.parse(await fs.readFile(path.join(rootdir, 'package.json'), 'utf8')); + + const tag = process.env.GITHUB_REF_NAME; + if (tag !== `v${version}`) { + throw new Error(`Tag ${tag} does not match the package version ${version}. Bump package.json or retag.`); + } + + const published = await fetchPublishedVersion(name); + if (published == null) { + console.info(`${name} has no published version yet. Releasing ${version}.`); + return; + } + if (compareSemver(version, published) <= 0) { + throw new Error( + `Refusing to release ${version}: it is not newer than the published ${published}. ` + + 'Publishing it would move the `latest` dist-tag backwards.' + ); + } + + console.info(`Releasing ${version} over the published ${published}.`); +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + await main(); +} From 0b2cb128fdc68e3ff193578de53bdc8a5d2d19ed Mon Sep 17 00:00:00 2001 From: Marshall Ku Date: Tue, 18 Aug 2026 18:22:29 +0900 Subject: [PATCH 3/9] chore: Prepare releases from a workflow Cutting a release meant hand-writing the CHANGELOG section, hand-editing the version, committing it as vX.Y.Z and pushing a tag. Everything there is mechanical except deciding how big the release is, so ask only for that: a choice of patch, minor or major, never a version to type, which is what makes releasing 1.0.1 while 1.1.0 is out impossible rather than merely discouraged. It runs as a workflow because a fresh checkout of main answers by itself the questions a local script would have to ask, namely whether the tree is clean and whether main is level with origin. npm version raises the version, git-cliff writes the changelog into the shape release.yml expects to find with submark, and yarn build regenerates the binding the bump would otherwise leave behind. The workflow pushes the branch and stops. Opening the pull request is left to a person, because a pull request opened with GITHUB_TOKEN gets no CI run and CI on the release branch is what checks the binding it carries. Tagging stays manual, so release.yml is triggered by a human push exactly as before. Refused: a bump from a prerelease version, which npm version resolves rather than raises; a tag or release branch that already exists; nothing to release since the last tag. The two races the minutes-long build opens are closed at the end, when main is rechecked and the branch is created with a lease rather than pushed. --- .github/workflows/prepare-release.yml | 141 ++++++++++++++++++++++++++ cliff.toml | 23 +++++ 2 files changed, 164 insertions(+) create mode 100644 .github/workflows/prepare-release.yml create mode 100644 cliff.toml diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml new file mode 100644 index 0000000..ba04943 --- /dev/null +++ b/.github/workflows/prepare-release.yml @@ -0,0 +1,141 @@ +name: Prepare release +on: + workflow_dispatch: + inputs: + bump: + description: Which part of the version to raise + required: true + type: choice + options: + - patch + - minor + - major +env: + DEBUG: napi:* +concurrency: + group: prepare-release +jobs: + prepare: + # Releases are cut from main. A clean checkout of it is also why this needs none of the + # "is the working tree dirty / is main behind origin" checks a local script would. + if: github.ref_name == 'main' + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + # git-cliff reads the history and the tags to work out what is unreleased. + fetch-depth: 0 + - name: Setup node + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 + with: + node-version-file: ".node-version" + check-latest: true + cache: yarn + - name: Install rust + uses: dtolnay/rust-toolchain@4be9e76fd7c4901c61fb841f559994984270fce7 # stable + with: + toolchain: "1.91.0" + - name: Cache cargo + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + with: + path: | + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + .cargo-cache + target/ + key: prepare-release-cargo + - name: Install dependencies + run: yarn install --immutable + - name: Raise the version + id: version + env: + BUMP: ${{ inputs.bump }} + run: | + set -eu + # npm version resolves a prerelease down to its release rather than raising it, so + # 0.8.0-next.5 would quietly become 0.8.0. main should never carry one. + current=$(node -p "require('./package.json').version") + case "$current" in + *-*) + echo "::error::package.json is at the prerelease version $current. Releases are cut from plain versions." + exit 1 + ;; + esac + tag=$(npm version --no-git-tag-version "$BUMP") + echo "tag=$tag" >> "$GITHUB_OUTPUT" + echo "branch=release/$tag" >> "$GITHUB_OUTPUT" + - name: Refuse a release that already exists + env: + TAG: ${{ steps.version.outputs.tag }} + BRANCH: ${{ steps.version.outputs.branch }} + run: | + set -eu + if git rev-parse -q --verify "refs/tags/$TAG" >/dev/null; then + echo "::error::Tag $TAG already exists." + exit 1 + fi + if git ls-remote --exit-code --heads origin "$BRANCH" >/dev/null 2>&1; then + echo "::error::Branch $BRANCH already exists." + exit 1 + fi + - name: Write the changelog + env: + TAG: ${{ steps.version.outputs.tag }} + run: | + set -eu + # git-cliff prepends a section whether or not anything landed, so ask it what it would + # write before letting it write. + if ! yarn dlx git-cliff@2.13.1 --unreleased --tag "$TAG" | grep -q '^- '; then + echo "::error::Nothing to release: no commits since the last tag." + exit 1 + fi + yarn dlx git-cliff@2.13.1 --unreleased --tag "$TAG" --prepend CHANGELOG.md + # The generated binding is committed, and the release job publishes what a build produces. + # Build here so the release branch carries a binding that matches the crate at this commit. + - name: Build + run: yarn build + - name: Push the release branch + env: + TAG: ${{ steps.version.outputs.tag }} + BRANCH: ${{ steps.version.outputs.branch }} + run: | + set -eu + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git checkout -b "$BRANCH" + git add CHANGELOG.md package.json index.js index.d.ts + git commit -m "$TAG" + leftover=$(git status --porcelain) + if [ -n "$leftover" ]; then + echo "::error::The build touched files the release commit did not stage:" + echo "$leftover" + exit 1 + fi + # The build takes minutes, and the changelog was rendered from the commit this run checked + # out. If main moved meanwhile, the eventual tag would cover commits no section mentions. + git fetch origin main + if [ "$(git rev-parse FETCH_HEAD)" != "$GITHUB_SHA" ]; then + echo "::error::main moved while this ran, so the changelog would miss what landed. Re-run." + exit 1 + fi + # Create-only: an empty expected value makes git refuse if the branch appeared since the + # guard above, even when it could be fast-forwarded. + if ! git push --force-with-lease="refs/heads/$BRANCH:" origin "$BRANCH"; then + echo "::error::Could not create $BRANCH. It may have appeared while this ran." + exit 1 + fi + - name: Summarise + env: + TAG: ${{ steps.version.outputs.tag }} + BRANCH: ${{ steps.version.outputs.branch }} + run: | + { + echo "### $TAG is ready on \`$BRANCH\`" + echo + echo "1. Add the thank-you line to CHANGELOG.md if outside contributors landed anything." + echo "2. [Open the pull request](${{ github.server_url }}/${{ github.repository }}/compare/main...$BRANCH?expand=1) and merge it. Opening it yourself is what gets CI to run." + echo "3. \`git checkout main && git pull && git tag $TAG && git push origin $TAG\` starts the release." + } >> "$GITHUB_STEP_SUMMARY" diff --git a/cliff.toml b/cliff.toml new file mode 100644 index 0000000..d411725 --- /dev/null +++ b/cliff.toml @@ -0,0 +1,23 @@ +[changelog] +header = "# Changelog\n" +body = """ +## Version {{ version }} + +Released on {{ timestamp | date(format="%B %-d, %Y") }}. +{% for commit in commits %} +- {{ commit.message | split(pat="\n") | first | trim }} +{%- endfor %} +""" +trim = false + +[git] +conventional_commits = false +filter_commits = false +sort_commits = "newest" +commit_parsers = [ + # The previous release commits are named after the bare version. + { message = "^v[0-9]+\\.[0-9]+\\.[0-9]+$", skip = true }, + # Merges GitHub writes itself. This matches on the subject rather than on parent count, which + # git-cliff cannot see, so a merge someone gave a real subject to still shows up. + { message = "^Merge ", skip = true }, +] From 4a31b64ce717d0edc52a3990580f7a3e12a49710 Mon Sep 17 00:00:00 2001 From: Marshall Ku Date: Tue, 18 Aug 2026 18:55:57 +0900 Subject: [PATCH 4/9] chore: Release by merging rather than by pushing a tag The tag was the last thing done by hand, and the thing most able to go wrong: cut from the wrong commit, or naming a version package.json does not carry. Nothing needed it to stay manual except an earlier decision to leave this file alone. Merging the release pull request is now the release. A push to main whose package.json version has no tag yet is a release; every other push carries an already-tagged version and goes to the next channel as before. Creating the GitHub release is what creates the tag, pinned to the commit this run built rather than to wherever the default branch has got to by the time the API call lands. The token problem that ruled out release-please does not arise here. Nothing bot-created has to trigger anything: a person opens the pull request and a person merges it, and that merge is an ordinary push. Runs on main serialise, and every waiting one is kept. Without that, two pushes in flight would both find the tag missing and both publish, and the default queue would let a later push cancel a pending release. The tag lookup reads git ls-remote by exit code rather than by truthiness, because 'not found' and 'the lookup failed' are different answers and only one of them may publish to latest. verify-release-version drops the tag comparison. The tag is derived from package.json now, so it cannot disagree with it; what remains is the check that matters, that the version outranks what npm serves as latest. --- .github/workflows/prepare-release.yml | 4 +- .github/workflows/release.yml | 68 ++++++++++++++++++++++----- .scripts/verify-release-version.ts | 5 -- 3 files changed, 58 insertions(+), 19 deletions(-) diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml index ba04943..eefbc98 100644 --- a/.github/workflows/prepare-release.yml +++ b/.github/workflows/prepare-release.yml @@ -136,6 +136,6 @@ jobs: echo "### $TAG is ready on \`$BRANCH\`" echo echo "1. Add the thank-you line to CHANGELOG.md if outside contributors landed anything." - echo "2. [Open the pull request](${{ github.server_url }}/${{ github.repository }}/compare/main...$BRANCH?expand=1) and merge it. Opening it yourself is what gets CI to run." - echo "3. \`git checkout main && git pull && git tag $TAG && git push origin $TAG\` starts the release." + echo "2. [Open the pull request](${{ github.server_url }}/${{ github.repository }}/compare/main...$BRANCH?expand=1) yourself, which is what gets CI to run on it." + echo "3. Merge it. Landing a version no tag exists for is what releases it: the tag, the GitHub release and the npm publish all follow from the merge." } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 07ca370..ec7938b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,8 +3,13 @@ on: push: branches: - 'main' - tags: - - 'v*.*.*' +concurrency: + # One release run at a time: two pushes in flight would both see the tag missing and both publish. + # queue: max keeps every waiting run, because the default cancels a pending run when a newer one + # arrives, and the run it cancelled could be the release. + group: release-${{ github.ref }} + cancel-in-progress: false + queue: max env: DEBUG: napi:* APP_NAME: es-git @@ -12,6 +17,38 @@ env: VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} jobs: + # Merging the branch `Prepare release` pushed is what makes a release: the version it raised has + # no tag yet. Any other push to main carries a version that is already tagged and goes to `next`. + resolve: + runs-on: ubuntu-latest + outputs: + tag: ${{ steps.resolve.outputs.tag }} + is_release: ${{ steps.resolve.outputs.is_release }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - id: resolve + run: | + set -eu + tag="v$(node -p "require('./package.json').version")" + echo "tag=$tag" >> "$GITHUB_OUTPUT" + # --exit-code answers 0 for found and 2 for not found. Anything else is the lookup itself + # failing, and guessing "not found" there would publish to latest on a network blip. + lookup=0 + git ls-remote --exit-code --tags origin "refs/tags/$tag" >/dev/null 2>&1 || lookup=$? + case "$lookup" in + 0) + echo "is_release=false" >> "$GITHUB_OUTPUT" + echo "$tag is already tagged. Publishing to the next channel." + ;; + 2) + echo "is_release=true" >> "$GITHUB_OUTPUT" + echo "$tag has no tag yet. Releasing it." + ;; + *) + echo "::error::Cannot tell whether $tag exists: git ls-remote exited $lookup." + exit 1 + ;; + esac build: strategy: fail-fast: false @@ -112,6 +149,7 @@ jobs: contents: write deployments: write needs: + - resolve - build steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -126,7 +164,7 @@ jobs: - name: Install dependencies run: yarn install - name: Verify release version - if: github.ref_type == 'tag' + if: needs.resolve.outputs.is_release == 'true' run: node --no-warnings=ExperimentalWarning --experimental-strip-types ./.scripts/verify-release-version.ts - name: Download all artifacts uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 @@ -139,7 +177,7 @@ jobs: name: js-binding path: . - name: Prepare next release - if: github.ref_type == 'branch' + if: needs.resolve.outputs.is_release != 'true' run: node --no-warnings=ExperimentalWarning --experimental-strip-types ./.scripts/prepare-next-release.ts ${{ github.run_number }} ${{ github.sha }} - name: Create npm dirs run: yarn napi create-npm-dirs @@ -153,15 +191,20 @@ jobs: with: input-file: CHANGELOG.md heading-level: 2 - heading-title-text: version ${{ github.ref_name }} + heading-title-text: version ${{ needs.resolve.outputs.tag }} ignore-case: true omit-heading: true - run: 'cat ${{ steps.extract-changelog.outputs.output-file }}' - name: Release id: gh-release uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0 - if: github.ref_type == 'tag' + if: needs.resolve.outputs.is_release == 'true' with: + # The tag does not exist yet; creating the release is what creates it. Pin where, because + # the API otherwise tags the default branch as it stands when the call lands, which may + # have moved past the commit this run built and is about to publish. + tag_name: ${{ needs.resolve.outputs.tag }} + target_commitish: ${{ github.sha }} body_path: ${{ steps.extract-changelog.outputs.output-file }} repository: toss/es-git generate_release_notes: false @@ -170,23 +213,24 @@ jobs: id: publish run: | set -ex - if [[ "$GITHUB_REF_TYPE" = "tag" ]]; then - yarn napi prepublish -t npm --gh-release-id ${{ steps.gh-release.outputs.id }} + if [[ "$IS_RELEASE" = "true" ]]; then + yarn napi prepublish -t npm --gh-release-id "$GH_RELEASE_ID" npm publish --provenance --access public - elif [[ "$GITHUB_REF_TYPE" = "branch" ]]; then + else npm config set tag next yarn napi prepublish -t npm --no-gh-release npm publish --provenance --access public --tag next - else - echo "Skip publish" fi env: GITHUB_TOKEN: ${{ github.token }} + IS_RELEASE: ${{ needs.resolve.outputs.is_release }} + GH_RELEASE_ID: ${{ steps.gh-release.outputs.id }} publish-docs: runs-on: ubuntu-latest needs: + - resolve - release - if: github.ref_type == 'tag' + if: needs.resolve.outputs.is_release == 'true' steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup node diff --git a/.scripts/verify-release-version.ts b/.scripts/verify-release-version.ts index e327ba2..325233e 100644 --- a/.scripts/verify-release-version.ts +++ b/.scripts/verify-release-version.ts @@ -101,11 +101,6 @@ async function main() { const rootdir = path.join(path.dirname(fileURLToPath(import.meta.url)), '..'); const { name, version } = JSON.parse(await fs.readFile(path.join(rootdir, 'package.json'), 'utf8')); - const tag = process.env.GITHUB_REF_NAME; - if (tag !== `v${version}`) { - throw new Error(`Tag ${tag} does not match the package version ${version}. Bump package.json or retag.`); - } - const published = await fetchPublishedVersion(name); if (published == null) { console.info(`${name} has no published version yet. Releasing ${version}.`); From 16e5a5037db4d6683d8252aacecb97897d49a6ee Mon Sep 17 00:00:00 2001 From: Marshall Ku Date: Wed, 19 Aug 2026 00:07:36 +0900 Subject: [PATCH 5/9] chore: Write the thank-you line instead of leaving it to the maintainer The one thing left to edit by hand after preparing a release was the sentence thanking outside contributors. GitHub already knows who they are: the role badge it shows on a pull request is author_association, so the release can ask for it. Which people it names had to be read off the existing changelog rather than guessed. @racgoo appears in two releases running and @other-yuka in three, so it is not a list of first-timers -- it thanks people outside the org, every time. That rules out git-cliff's GitHub integration, which knows usernames and first contributions but nothing about membership. The filter names the insiders and keeps everyone else, rather than naming the one outsider value. Someone's first pull request is a FIRST_TIME_CONTRIBUTOR, not a CONTRIBUTOR, and picking out the latter would have missed exactly the people this line is for. Bots are dropped explicitly, since dependabot counts as a contributor, and so are authors GitHub cannot resolve, which would otherwise read as @null. A lookup that fails ends the step. Thanking a shorter list than the release earned is worse than not writing the line at all. --- .github/workflows/prepare-release.yml | 45 +++++++++++++++++++++++++-- 1 file changed, 42 insertions(+), 3 deletions(-) diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml index eefbc98..4d3f8ff 100644 --- a/.github/workflows/prepare-release.yml +++ b/.github/workflows/prepare-release.yml @@ -22,6 +22,7 @@ jobs: runs-on: ubuntu-latest permissions: contents: write + pull-requests: read steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: @@ -93,6 +94,45 @@ jobs: exit 1 fi yarn dlx git-cliff@2.13.1 --unreleased --tag "$TAG" --prepend CHANGELOG.md + - name: Thank the outside contributors + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + previous=$(git describe --tags --abbrev=0 HEAD 2>/dev/null || true) + range=${previous:+$previous..}HEAD + # author_association is the field behind the role badge GitHub shows on a pull request. + # The line thanks people from outside the org -- @racgoo appears in two releases running, + # so it is not a first-timers list. Name the insiders and keep everyone else, because the + # outsider side of the enum has more values than CONTRIBUTOR: someone's first pull request + # is FIRST_TIME_CONTRIBUTOR, and dropping those would miss exactly who this line is for. + # Bots open pull requests too, and dependabot's association is CONTRIBUTOR. + # A failed lookup exits rather than quietly thanking a shorter list. + handles=$( + for sha in $(git log --no-merges --format=%H "$range"); do + gh api "repos/$GITHUB_REPOSITORY/commits/$sha/pulls" --jq ' + .[]? + | select(.user.login != null and .user.type != "Bot") + | select(.author_association != "OWNER" and .author_association != "MEMBER" and .author_association != "COLLABORATOR") + | .user.login + ' || exit 1 + done | sort -u + ) + if [ -z "$handles" ]; then + echo "No outside contributors in $range." + exit 0 + fi + list=$(echo "$handles" | awk '{ printf "%s@%s", sep, $0; sep = ", " }') + line="We sincerely thank $list for their contributions. We appreciate your great efforts!" + echo "$line" + # The section just written is the first one, so it ends where the previous release starts. + awk -v line="$line" ' + /^## Version / { seen++ } + seen == 2 && !done { print line; print ""; done = 1 } + { print } + END { if (!done) { print ""; print line } } + ' CHANGELOG.md > CHANGELOG.next + mv CHANGELOG.next CHANGELOG.md # The generated binding is committed, and the release job publishes what a build produces. # Build here so the release branch carries a binding that matches the crate at this commit. - name: Build @@ -135,7 +175,6 @@ jobs: { echo "### $TAG is ready on \`$BRANCH\`" echo - echo "1. Add the thank-you line to CHANGELOG.md if outside contributors landed anything." - echo "2. [Open the pull request](${{ github.server_url }}/${{ github.repository }}/compare/main...$BRANCH?expand=1) yourself, which is what gets CI to run on it." - echo "3. Merge it. Landing a version no tag exists for is what releases it: the tag, the GitHub release and the npm publish all follow from the merge." + echo "1. [Open the pull request](${{ github.server_url }}/${{ github.repository }}/compare/main...$BRANCH?expand=1) yourself, which is what gets CI to run on it. Read the changelog while you are there." + echo "2. Merge it. Landing a version no tag exists for is what releases it: the tag, the GitHub release and the npm publish all follow from the merge." } >> "$GITHUB_STEP_SUMMARY" From 352e1831c5845b27fae2c429952d464949779dc0 Mon Sep 17 00:00:00 2001 From: Marshall Ku Date: Wed, 19 Aug 2026 09:41:33 +0900 Subject: [PATCH 6/9] doc: Write down how a release happens and why the binding is committed Neither was written anywhere. The release procedure lived only in the workflow files, and CONTRIBUTING mentioned just build without saying that index.js and index.d.ts are committed -- so a contributor who changes the Rust API meets a CI failure with nothing to read about it. --- .github/CONTRIBUTING.md | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 1231b27..60c0143 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -56,6 +56,15 @@ just typecheck just docs ``` +### Generated Files + +`index.js` and `index.d.ts` are written by the build, but they are committed to the repository: +`index.js` is the package entry point, and the [reference documentation](https://es-git.slash.page) +is generated from `index.d.ts`. + +If you change the public API on the Rust side, run `just build` and commit the result together with +your change. CI builds the project and fails when what you committed differs from a fresh build. + ## Pull Requests Please open a Pull Request to merge changes. @@ -79,6 +88,26 @@ docs: fix link to website page chore: upgrade vitest to v3 ``` +## Releasing + +Maintainers release from `main`. + +1. Run the [Prepare release](https://github.com/toss/es-git/actions/workflows/prepare-release.yml) + workflow and choose whether to raise the major, minor or patch version. It raises the version, + writes the changelog section from the commits since the last release, thanks the outside + contributors among them, rebuilds the binding and pushes a `release/vX.Y.Z` branch. +2. Open a pull request from that branch and merge it. Open it yourself rather than leaving it to a + bot, because a pull request a bot opens gets no CI run. + +Merging is what releases. A commit landing on `main` whose version has no tag yet is tagged, gets a +GitHub release built from its changelog section, and is published to npm. Every other push to `main` +publishes a prerelease under the `next` dist-tag instead, so there is nothing to do between releases. + +A release is refused if the version is not newer than the one npm currently serves as `latest`, +which is what stops a release from moving the tag backwards. The binding is rebuilt during the +release and the freshly built one is what gets published, so what ships always matches the commit it +was built from. + ## Documentation This project aims to maintain high documentation quality. From 613f681d89d36ec43c67f4c80e8d05ae24d67e97 Mon Sep 17 00:00:00 2001 From: Marshall Ku Date: Wed, 19 Aug 2026 09:54:08 +0900 Subject: [PATCH 7/9] chore: Open the release pull request from the workflow Opening it by hand was the last manual step, and it existed only because CI does not run on a pull request GITHUB_TOKEN opened. A maintainer's token gets around that, which is the same workaround changesets and release-please document for the same constraint. The token is an optimisation over a flow that already works, not a dependency of it. It expires in a year at most and this repository releases about twice a year, so it will sometimes be dead when reached for. Pushing the branch still uses GITHUB_TOKEN, which cannot expire, and only opening the pull request needs the personal one; the step tolerates its own failure, and the summary falls back to the compare link. An expired token costs a click, not a release. A missing secret exits clean rather than failing. Not being configured is not a fault, and a step that is red every run teaches you to ignore it. A token that is set and does not work still goes red. --- .github/CONTRIBUTING.md | 6 ++++-- .github/workflows/prepare-release.yml | 27 ++++++++++++++++++++++++++- 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 60c0143..0cc792c 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -96,8 +96,10 @@ Maintainers release from `main`. workflow and choose whether to raise the major, minor or patch version. It raises the version, writes the changelog section from the commits since the last release, thanks the outside contributors among them, rebuilds the binding and pushes a `release/vX.Y.Z` branch. -2. Open a pull request from that branch and merge it. Open it yourself rather than leaving it to a - bot, because a pull request a bot opens gets no CI run. +2. Read the pull request it opens, and merge it. The pull request is opened with a maintainer's + token rather than the workflow's own, because CI does not run on one the workflow opens itself. + If that token is missing or has expired the branch is still pushed, so open the pull request by + hand and nothing else changes. Merging is what releases. A commit landing on `main` whose version has no tag yet is tagged, gets a GitHub release built from its changelog section, and is published to npm. Every other push to `main` diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml index 4d3f8ff..7e8ea21 100644 --- a/.github/workflows/prepare-release.yml +++ b/.github/workflows/prepare-release.yml @@ -167,14 +167,39 @@ jobs: echo "::error::Could not create $BRANCH. It may have appeared while this ran." exit 1 fi + # Opened with a personal token rather than GITHUB_TOKEN, because CI does not run on a pull + # request GITHUB_TOKEN opened. If the secret is missing or has expired this step is skipped and + # the branch is still there to open by hand, which is what the summary below falls back to. + - name: Open the pull request + id: pr + continue-on-error: true + env: + GH_TOKEN: ${{ secrets.RELEASE_PR_TOKEN }} + TAG: ${{ steps.version.outputs.tag }} + BRANCH: ${{ steps.version.outputs.branch }} + run: | + set -eu + if [ -z "${GH_TOKEN:-}" ]; then + # Not configured is not a failure. Only a token that is set and does not work is. + echo "No RELEASE_PR_TOKEN. Leaving the pull request to be opened by hand." + exit 0 + fi + body="Prepared by the Prepare release workflow. Merging this releases $TAG: the tag, the GitHub release and the npm publish all follow from it. Read the changelog section it carries first." + url=$(gh pr create --base main --head "$BRANCH" --title "$TAG" --body "$body") + echo "url=$url" >> "$GITHUB_OUTPUT" - name: Summarise env: TAG: ${{ steps.version.outputs.tag }} BRANCH: ${{ steps.version.outputs.branch }} + PR_URL: ${{ steps.pr.outputs.url }} run: | { echo "### $TAG is ready on \`$BRANCH\`" echo - echo "1. [Open the pull request](${{ github.server_url }}/${{ github.repository }}/compare/main...$BRANCH?expand=1) yourself, which is what gets CI to run on it. Read the changelog while you are there." + if [ -n "${PR_URL:-}" ]; then + echo "1. Read [the pull request]($PR_URL), and the changelog section it carries." + else + echo "1. [Open the pull request](${{ github.server_url }}/${{ github.repository }}/compare/main...$BRANCH?expand=1) yourself, which is what gets CI to run on it. Read the changelog while you are there." + fi echo "2. Merge it. Landing a version no tag exists for is what releases it: the tag, the GitHub release and the npm publish all follow from the merge." } >> "$GITHUB_STEP_SUMMARY" From cd39304e461c387dbdf31ba6b48c64e35a317667 Mon Sep 17 00:00:00 2001 From: Marshall Ku Date: Wed, 19 Aug 2026 11:10:46 +0900 Subject: [PATCH 8/9] chore: Modify codeowners --- .github/CODEOWNERS | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index da154c0..0c7b472 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1 +1,2 @@ -* @seokju-na \ No newline at end of file +* @seokju-na @marshallku + From d3414bee5ef5e6bc881a46a1a92896700c064a54 Mon Sep 17 00:00:00 2001 From: Marshall Ku Date: Thu, 20 Aug 2026 12:53:25 +0900 Subject: [PATCH 9/9] chore: Unbreak the format and Windows build jobs cliff.toml went in without taplo ever being run on it, and taplo.toml asks for reordered and aligned keys, so the format job failed on the file this branch added. Reordering keys means nothing to git-cliff, but that is an assumption rather than a fact, so the changelog was regenerated for the real v0.6.0..v0.7.0 range either side of the reformat and compared: identical. The Windows failure predates this branch. windows-latest now resolves to Windows Server 2025 with Visual Studio 2026, and the cmake crate knows generators up to Visual Studio 17 2022, so it cannot pick one and libz-sys panics before compiling. The image does carry the ARM64 components, so nothing is missing but the detection. Upgrading does not help yet: cmake-rs has Visual Studio 18 2026 on master, but none of 0.1.55 through 0.1.58 carry it. So the two legs that build Rust are pinned to windows-2022, the mitigation the runner-images README points at and the image that was green until the migration, with a note saying when to undo it. Setting CMAKE_GENERATOR would avoid pinning -- 0.1.54 does read it -- but every value worth setting is a guess from here, and pinning restores a state known to work. The Windows test jobs stay on the moving labels. They download an artifact and run vitest; no Rust, no cmake. --- .github/workflows/ci.yml | 8 ++++++-- .github/workflows/release.yml | 8 ++++++-- cliff.toml | 8 ++++---- 3 files changed, 16 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7b357d7..4399872 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -80,10 +80,14 @@ jobs: - host: macos-latest target: aarch64-apple-darwin build: yarn build --target aarch64-apple-darwin - - host: windows-latest + # Pinned: windows-latest now means Windows Server 2025 with Visual Studio 2026, and the + # cmake crate cannot work out a generator for it, so libz-sys fails to build. Support + # exists on cmake-rs master but is not in a published version yet. Move back to + # windows-latest once it is released. + - host: windows-2022 target: x86_64-pc-windows-msvc build: yarn build --target x86_64-pc-windows-msvc - - host: windows-latest + - host: windows-2022 target: aarch64-pc-windows-msvc build: yarn build --target aarch64-pc-windows-msvc - host: ubuntu-22.04 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ec7938b..6db7f65 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -60,10 +60,14 @@ jobs: - host: macos-latest target: aarch64-apple-darwin build: yarn build --target aarch64-apple-darwin - - host: windows-latest + # Pinned: windows-latest now means Windows Server 2025 with Visual Studio 2026, and the + # cmake crate cannot work out a generator for it, so libz-sys fails to build. Support + # exists on cmake-rs master but is not in a published version yet. Move back to + # windows-latest once it is released. + - host: windows-2022 target: x86_64-pc-windows-msvc build: yarn build --target x86_64-pc-windows-msvc - - host: windows-latest + - host: windows-2022 target: aarch64-pc-windows-msvc build: yarn build --target aarch64-pc-windows-msvc - host: ubuntu-latest diff --git a/cliff.toml b/cliff.toml index d411725..70b68bc 100644 --- a/cliff.toml +++ b/cliff.toml @@ -1,5 +1,4 @@ [changelog] -header = "# Changelog\n" body = """ ## Version {{ version }} @@ -8,12 +7,10 @@ Released on {{ timestamp | date(format="%B %-d, %Y") }}. - {{ commit.message | split(pat="\n") | first | trim }} {%- endfor %} """ +header = "# Changelog\n" trim = false [git] -conventional_commits = false -filter_commits = false -sort_commits = "newest" commit_parsers = [ # The previous release commits are named after the bare version. { message = "^v[0-9]+\\.[0-9]+\\.[0-9]+$", skip = true }, @@ -21,3 +18,6 @@ commit_parsers = [ # git-cliff cannot see, so a merge someone gave a real subject to still shows up. { message = "^Merge ", skip = true }, ] +conventional_commits = false +filter_commits = false +sort_commits = "newest"