fix(test): the content-cache answer for a repeated payload is not a d… #2
Workflow file for this run
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: Release | |
| on: | |
| push: | |
| tags: ['v*'] | |
| workflow_dispatch: | |
| inputs: | |
| dry_run: | |
| description: 'Build and verify without publishing' | |
| type: boolean | |
| default: true | |
| env: | |
| CARGO_TERM_COLOR: always | |
| NODE_DIR: src/bindings/nodejs | |
| jobs: | |
| # Both of these are cheap, and both catch a mistake that cannot be undone | |
| # afterwards: npm refuses to republish a version, and a tag that has already | |
| # been consumed by a release is awkward to retract. | |
| guard: | |
| name: Release preconditions | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: read | |
| checks: read | |
| steps: | |
| - uses: actions/checkout@v7 | |
| # The tag is the only input this workflow takes, but `npm publish` ships | |
| # whatever version package.json declares and never looks at it. When the | |
| # two disagree the tag names one release and the registry gets another. | |
| # Both published packages are checked, because one tag releases both and | |
| # @retrigger/daemon peer-depends on the exact @retrigger/core line this | |
| # workflow is about to publish. | |
| - name: Tag must match the versions that will be published | |
| shell: bash | |
| run: | | |
| if [ "$GITHUB_REF_TYPE" != tag ]; then | |
| echo "Not a tag build, so there is no tag to disagree with." | |
| exit 0 | |
| fi | |
| tagged="${GITHUB_REF_NAME#v}" | |
| status=0 | |
| for manifest in "$NODE_DIR/package.json" src/daemon/package.json; do | |
| declared=$(node -p "require('./$manifest').version") | |
| name=$(node -p "require('./$manifest').name") | |
| echo "tag: $tagged / $name: $declared" | |
| if [ "$tagged" != "$declared" ]; then | |
| echo "::error::tag $GITHUB_REF_NAME would publish $name@$declared" | |
| status=1 | |
| fi | |
| done | |
| exit $status | |
| # @retrigger/daemon declares the core line it works with. A release that | |
| # moved core's major without moving that range would publish a pair that | |
| # npm refuses to install together -- which is exactly what shipping core | |
| # 2.0.0 against the 1.0.4 daemon would have done. | |
| - name: The daemon must accept the core version being published | |
| shell: bash | |
| run: | | |
| node -e ' | |
| const core = require(`./${process.env.NODE_DIR}/package.json`).version; | |
| const range = require("./src/daemon/package.json").peerDependencies["@retrigger/core"]; | |
| const wanted = core.split(".")[0]; | |
| // Deliberately permissive about range syntax and strict about the | |
| // only thing that has ever been wrong here: the major. | |
| const named = [...range.matchAll(/(\d+)\.\d+\.\d+/g)].map((m) => m[1]); | |
| if (!named.includes(wanted)) { | |
| console.error(`::error::daemon peer range "${range}" does not admit core ${core}`); | |
| process.exit(1); | |
| } | |
| console.log(`daemon accepts core ${core} via "${range}"`); | |
| ' | |
| # Releases are cut from tags, and CI runs on branches and pull requests -- | |
| # so nothing in this repository previously established that the commit | |
| # being published had ever passed its own test suite. Rather than re-run | |
| # the matrix here, require the result that already exists for this exact | |
| # commit. | |
| - name: The commit being released must have passed CI | |
| if: github.event_name == 'push' | |
| shell: bash | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| run: | | |
| conclusion=$(gh api "repos/$GITHUB_REPOSITORY/commits/$GITHUB_SHA/check-runs" \ | |
| --jq '.check_runs[] | select(.name == "CI passed") | .conclusion' | head -n1) | |
| if [ "$conclusion" = success ]; then | |
| echo "CI passed on $GITHUB_SHA." | |
| exit 0 | |
| fi | |
| echo "::error::no successful 'CI passed' check on $GITHUB_SHA (found: ${conclusion:-none}). Push the commit to a branch, let CI finish, then tag it." | |
| exit 1 | |
| # Every target is built on a runner of that same OS and architecture rather | |
| # than cross-compiled. That costs a few more runners and buys two things: | |
| # the C hash engine needs no cross toolchain, and — more importantly — each | |
| # artifact can be executed on its real platform before it is published. | |
| # Publishing a binary nobody ever ran is how "works on my machine" ships. | |
| # | |
| # This matrix plus `build-musl` must cover exactly package.json#napi.targets. | |
| # `napi artifacts` in CLI v3 fails on a configured target with no binary | |
| # rather than skipping it the way v2 did, so a target added in one place and | |
| # not the other breaks the release instead of silently shipping nothing. | |
| # api-contract.test.mjs holds napi.targets and optionalDependencies together | |
| # from the other side, so the three lists cannot drift apart unnoticed. | |
| build: | |
| name: ${{ matrix.target }} | |
| runs-on: ${{ matrix.host }} | |
| needs: guard | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| include: | |
| - { host: ubuntu-latest, target: x86_64-unknown-linux-gnu, native: true } | |
| - { host: ubuntu-24.04-arm, target: aarch64-unknown-linux-gnu, native: true } | |
| # macos-15-intel is the last x86_64 macOS image Actions offers; the | |
| # macos-13 labels were retired on 2025-12-04 and a job requesting one | |
| # queues forever rather than failing, which would hang the release | |
| # with nothing anywhere saying why. | |
| - { host: macos-15-intel, target: x86_64-apple-darwin, native: true } | |
| - { host: macos-latest, target: aarch64-apple-darwin, native: true } | |
| - { host: windows-latest, target: x86_64-pc-windows-msvc, native: true } | |
| # No aarch64 Windows runner exists yet, so this one is cross-built | |
| # and therefore cannot be smoke-tested here. It is flagged as such. | |
| - { host: windows-latest, target: aarch64-pc-windows-msvc, native: false } | |
| steps: | |
| - uses: actions/checkout@v7 | |
| - uses: actions/setup-node@v7 | |
| with: | |
| node-version: 22 | |
| cache: npm | |
| cache-dependency-path: src/bindings/nodejs/package-lock.json | |
| - uses: dtolnay/rust-toolchain@stable | |
| with: | |
| targets: ${{ matrix.target }} | |
| - uses: Swatinem/rust-cache@v2 | |
| with: | |
| key: ${{ matrix.target }} | |
| - name: Install clang (Linux) | |
| if: runner.os == 'Linux' | |
| run: sudo apt-get update && sudo apt-get install -y clang | |
| - name: Install dependencies | |
| run: npm ci --no-audit --no-fund | |
| working-directory: ${{ env.NODE_DIR }} | |
| # Note the `--`: without it npm swallows the flag and silently builds for | |
| # the host. That bug shipped host binaries under foreign platform names. | |
| - name: Build native addon | |
| run: npm run build -- --target ${{ matrix.target }} | |
| working-directory: ${{ env.NODE_DIR }} | |
| - name: Verify the artifact loads and computes | |
| if: matrix.native | |
| shell: bash | |
| run: node scripts/verify-artifact.js | |
| working-directory: ${{ env.NODE_DIR }} | |
| - name: Note unverified cross build | |
| if: '!matrix.native' | |
| run: echo "::warning::${{ matrix.target }} was cross-built and could not be executed on this runner." | |
| - uses: actions/upload-artifact@v7 | |
| with: | |
| name: bindings-${{ matrix.target }} | |
| path: ${{ env.NODE_DIR }}/*.node | |
| if-no-files-found: error | |
| # musl gets its own job because it is built *inside* Alpine rather than | |
| # cross-compiled: the addon is then linked against the same libc it will be | |
| # loaded with, and — the reason this is worth a separate job at all — it can | |
| # be executed on the spot, so a musl binary is proven before it is published | |
| # exactly like every other native target. | |
| # | |
| # Two details are load-bearing and were each found the hard way: | |
| # | |
| # * `-C target-feature=-crt-static`. Rust's musl targets default to static | |
| # linking, which is right for an executable and fatal for a `cdylib`: the | |
| # addon has to resolve symbols from the Node process that dlopens it. | |
| # Without this the build succeeds and produces a `.node` that cannot load. | |
| # * No `--target` flag. The container is already musl, so this is a native | |
| # build and `napi build --platform` names the artifact `*-musl.node` on | |
| # its own. Passing the triple instead asks Alpine's system rustc for a | |
| # std it does not ship. | |
| build-musl: | |
| name: ${{ matrix.target }} | |
| runs-on: ${{ matrix.host }} | |
| container: node:22-alpine | |
| needs: guard | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| include: | |
| - { host: ubuntu-latest, target: x86_64-unknown-linux-musl } | |
| - { host: ubuntu-24.04-arm, target: aarch64-unknown-linux-musl } | |
| env: | |
| RUSTFLAGS: '-C target-feature=-crt-static' | |
| steps: | |
| - uses: actions/checkout@v7 | |
| - name: Install toolchain | |
| run: apk add --no-cache build-base clang clang-dev llvm-dev rust cargo make bash python3 git | |
| - name: Install dependencies | |
| run: npm ci --no-audit --no-fund | |
| working-directory: ${{ env.NODE_DIR }} | |
| - name: Build the C hash engine | |
| run: make build-core BUILD_TYPE=release | |
| - name: Build native addon | |
| run: npm run build | |
| working-directory: ${{ env.NODE_DIR }} | |
| # The whole point of building in Alpine instead of cross-compiling. | |
| - name: Verify the artifact loads and computes | |
| run: node scripts/verify-artifact.js | |
| working-directory: ${{ env.NODE_DIR }} | |
| # A native build is named by the host, so this is also the check that the | |
| # runner really was musl and not a glibc image that quietly stood in. | |
| - name: Confirm the artifact is the musl one | |
| shell: sh | |
| run: | | |
| cd "$NODE_DIR" | |
| ls *.node | |
| test -f retrigger-nodejs-bindings.$(node -p "process.arch === 'x64' ? 'linux-x64-musl' : 'linux-arm64-musl'").node | |
| - uses: actions/upload-artifact@v7 | |
| with: | |
| name: bindings-${{ matrix.target }} | |
| path: ${{ env.NODE_DIR }}/*.node | |
| if-no-files-found: error | |
| publish: | |
| name: Publish | |
| runs-on: ubuntu-latest | |
| needs: [build, build-musl] | |
| permissions: | |
| contents: write | |
| id-token: write | |
| steps: | |
| - uses: actions/checkout@v7 | |
| - uses: actions/setup-node@v7 | |
| with: | |
| node-version: 22 | |
| registry-url: 'https://registry.npmjs.org' | |
| cache: npm | |
| cache-dependency-path: src/bindings/nodejs/package-lock.json | |
| - name: Install dependencies | |
| run: npm ci --no-audit --no-fund | |
| working-directory: ${{ env.NODE_DIR }} | |
| - uses: actions/download-artifact@v8 | |
| with: | |
| path: ${{ env.NODE_DIR }}/artifacts | |
| # `napi artifacts` copies each .node into its per-platform npm/<platform>/ | |
| # package directory, and it fails on a binary with no matching package | |
| # rather than dropping it. That makes `create-npm-dirs` a prerequisite, | |
| # not a convenience: npm/ is generated from napi.targets and is not | |
| # committed. An earlier workflow copied the binaries to the package root | |
| # and skipped both steps, so the platform packages it published were empty. | |
| - name: Create the platform package directories | |
| run: npx napi create-npm-dirs | |
| working-directory: ${{ env.NODE_DIR }} | |
| - name: Distribute artifacts into platform packages | |
| run: npx napi artifacts --output-dir artifacts | |
| working-directory: ${{ env.NODE_DIR }} | |
| # `prepublish` does two separable things: it rewrites each platform | |
| # package's version, and — unless `--skip-optional-publish` — it npm | |
| # publishes them. Both halves matter here. | |
| # | |
| # The publishing half needs a registry token of its own. Without one the | |
| # eight platform packages never reach npm, and the root package below | |
| # still ships optionalDependencies naming them: every install then | |
| # resolves no native package and degrades to the JavaScript engine while | |
| # looking like a clean install. That is exactly how 1.0.4 shipped. | |
| # | |
| # The publishing half must also not run on a dry run. Publishing is the | |
| # one step in this workflow that cannot be undone — npm refuses to reuse | |
| # a version — so a dry run that published the platform packages would | |
| # burn the version it was meant to rehearse. | |
| - name: Prepare and publish the platform packages | |
| shell: bash | |
| env: | |
| NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} | |
| PUBLISHING: ${{ github.event_name == 'push' || inputs.dry_run == false }} | |
| run: | | |
| if [ "$PUBLISHING" = true ]; then | |
| npx napi prepublish -t npm --no-gh-release | |
| else | |
| npx napi prepublish -t npm --no-gh-release --skip-optional-publish | |
| fi | |
| working-directory: ${{ env.NODE_DIR }} | |
| - name: Show what would be published | |
| run: | | |
| echo "--- root package ---" | |
| npm pack --dry-run | |
| echo "--- platform packages ---" | |
| ls -R npm 2>/dev/null || echo "(no npm/ directory)" | |
| working-directory: ${{ env.NODE_DIR }} | |
| # Last, and only after the platform packages are on the registry: the | |
| # root package is the one that points at them, so publishing it first | |
| # would leave a window where installs resolve nothing native. | |
| - name: Publish | |
| if: github.event_name == 'push' || inputs.dry_run == false | |
| run: npm publish --access public --provenance | |
| working-directory: ${{ env.NODE_DIR }} | |
| env: | |
| NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} | |
| # @retrigger/daemon is a pure-JavaScript shim -- it has no artifact of its own | |
| # to build here, because the per-platform Rust binaries it looks for are not | |
| # published yet and it is written to degrade politely when they are absent. | |
| # It still has to be released in step with core: it peer-depends on the core | |
| # line, so leaving it a major version behind makes `npm install` of the two | |
| # together fail outright with ERESOLVE. | |
| # | |
| # It goes after core rather than beside it, so the peer it names already | |
| # exists on the registry by the time anyone can install it. | |
| publish-daemon: | |
| name: Publish the daemon package | |
| runs-on: ubuntu-latest | |
| needs: publish | |
| permissions: | |
| contents: read | |
| id-token: write | |
| steps: | |
| - uses: actions/checkout@v7 | |
| - uses: actions/setup-node@v7 | |
| with: | |
| node-version: 22 | |
| registry-url: 'https://registry.npmjs.org' | |
| # The shim, the shipped config, and the documented no-binary degradation. | |
| # Cheap, and it is the whole package. | |
| - name: Smoke test the shim | |
| run: node scripts/test-daemon.js | |
| working-directory: src/daemon | |
| - name: Show what would be published | |
| run: npm pack --dry-run | |
| working-directory: src/daemon | |
| - name: Publish | |
| if: github.event_name == 'push' || inputs.dry_run == false | |
| run: npm publish --access public --provenance | |
| working-directory: src/daemon | |
| env: | |
| NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} | |
| # Installs the just-published package from the real registry on every OS and | |
| # confirms it works there. Until this passes, a release is not proven; it is | |
| # only uploaded. | |
| verify-published: | |
| name: Verify install / ${{ matrix.os }} | |
| runs-on: ${{ matrix.os }} | |
| needs: publish | |
| if: github.event_name == 'push' | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| os: [ubuntu-latest, ubuntu-24.04-arm, macos-latest, macos-15-intel, windows-latest] | |
| steps: | |
| - uses: actions/setup-node@v7 | |
| with: { node-version: 22 } | |
| - name: Wait for registry propagation | |
| shell: bash | |
| run: sleep 45 | |
| - name: Install from the registry into a clean project | |
| shell: bash | |
| run: | | |
| mkdir -p "$RUNNER_TEMP/verify" && cd "$RUNNER_TEMP/verify" | |
| npm init -y >/dev/null | |
| npm install "@retrigger/core@${GITHUB_REF_NAME#v}" | |
| - name: Require it and exercise both engines | |
| shell: bash | |
| run: | | |
| cd "$RUNNER_TEMP/verify" | |
| node -e " | |
| const r = require('@retrigger/core'); | |
| const info = r.getEngineInfo(); | |
| console.log('engine:', JSON.stringify(info)); | |
| if (!r.hashBytesSync(Buffer.from('retrigger'))) throw new Error('hash failed'); | |
| if (info.engine !== 'native') { | |
| throw new Error('expected the native engine on this platform, got ' + info.engine); | |
| } | |
| console.log('ok'); | |
| " | |
| # The same proof as above on musl, which is the platform this release exists | |
| # to fix: before the musl packages were built, an Alpine install resolved to | |
| # no native package and degraded to the JavaScript engine without complaining. | |
| # Asserting `engine === 'native'` here is what makes that regression loud. | |
| verify-published-musl: | |
| name: Verify install / alpine-${{ matrix.arch }} | |
| runs-on: ${{ matrix.host }} | |
| container: node:22-alpine | |
| needs: publish | |
| if: github.event_name == 'push' | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| include: | |
| - { host: ubuntu-latest, arch: x64 } | |
| - { host: ubuntu-24.04-arm, arch: arm64 } | |
| steps: | |
| - name: Wait for registry propagation | |
| run: sleep 45 | |
| - name: Install from the registry and require it | |
| shell: sh | |
| run: | | |
| mkdir -p /tmp/verify && cd /tmp/verify | |
| npm init -y >/dev/null | |
| npm install "@retrigger/core@${GITHUB_REF_NAME#v}" | |
| node -e " | |
| const r = require('@retrigger/core'); | |
| const info = r.getEngineInfo(); | |
| console.log('engine:', JSON.stringify(info)); | |
| if (!r.hashBytesSync(Buffer.from('retrigger'))) throw new Error('hash failed'); | |
| if (info.engine !== 'native') { | |
| throw new Error('expected the native engine on musl, got ' + info.engine); | |
| } | |
| console.log('ok'); | |
| " | |
| # The two packages are released together because npm resolves them together. | |
| # A default `npm install` of both is the exact command that fails with | |
| # ERESOLVE when the daemon's peer range trails core's major, so it is the | |
| # command that proves the pair -- no --force, no --legacy-peer-deps. | |
| verify-published-pair: | |
| name: Verify install / core + daemon | |
| runs-on: ubuntu-latest | |
| needs: publish-daemon | |
| if: github.event_name == 'push' | |
| steps: | |
| - uses: actions/setup-node@v7 | |
| with: { node-version: 22 } | |
| - name: Wait for registry propagation | |
| run: sleep 45 | |
| - name: Install both from the registry | |
| shell: bash | |
| run: | | |
| mkdir -p "$RUNNER_TEMP/pair" && cd "$RUNNER_TEMP/pair" | |
| npm init -y >/dev/null | |
| version="${GITHUB_REF_NAME#v}" | |
| npm install "@retrigger/core@$version" "@retrigger/daemon@$version" | |
| node -e " | |
| const core = require('@retrigger/core'); | |
| require('@retrigger/daemon'); | |
| if (core.getEngineInfo().engine !== 'native') { | |
| throw new Error('expected the native engine alongside the daemon'); | |
| } | |
| console.log('ok'); | |
| " | |
| github-release: | |
| name: GitHub release | |
| runs-on: ubuntu-latest | |
| needs: [build, build-musl, publish, publish-daemon] | |
| if: startsWith(github.ref, 'refs/tags/v') | |
| permissions: | |
| contents: write | |
| steps: | |
| - uses: actions/checkout@v7 | |
| - uses: actions/download-artifact@v8 | |
| with: { path: artifacts } | |
| - uses: softprops/action-gh-release@v3 | |
| with: | |
| tag_name: ${{ github.ref_name }} | |
| generate_release_notes: true | |
| files: artifacts/**/*.node |