From 020e8480758337ef3ca40cc1c5789d8d8d8db6ea Mon Sep 17 00:00:00 2001 From: Kevin Cui Date: Fri, 4 Sep 2026 06:01:52 -0400 Subject: [PATCH 1/3] ci(binary): upload the single-file executables to releases Releases had no downloadable binaries: the build workflow only ran on pushes and pull requests and kept its six executables as one-day workflow artifacts. It now also runs on `release: published`, and a final `release` job packs the smoke-tested binaries into `open-connector-.tar.gz` / `.zip` archives with a `SHA256SUMS` file and attaches them to the release. The job needs every smoke job, so one failing platform blocks the whole upload rather than shipping a partial set, and only this job gets `contents: write`. The macOS smoke jobs overwrite their artifact with the binary they re-signed, so the released darwin executables carry a valid signature instead of the Linux-built one that macOS 27 rejects. Asset names are stable across releases so `releases/latest/download/` resolves, `--clobber` lets a re-run replace attached assets, and a `release_tag` dispatch input backfills an existing release. _docs/single-binary.md_ gains a Download section. Signed-off-by: Kevin Cui --- .github/workflows/build-binary.yml | 87 ++++++++++++++++++++++++++++-- docs/single-binary.md | 34 ++++++++++-- 2 files changed, 115 insertions(+), 6 deletions(-) diff --git a/.github/workflows/build-binary.yml b/.github/workflows/build-binary.yml index 2f10791f7..b2fc50dfc 100644 --- a/.github/workflows/build-binary.yml +++ b/.github/workflows/build-binary.yml @@ -35,21 +35,39 @@ name: Build Binaries # writes a wrong page hash, which macOS 27 rejects), so the macOS smoke jobs # re-sign the binary with `codesign` and verify the signature before running it. # +# On a published release the same build and smoke jobs run against the release +# tag, and a final `release` job packs the six smoke-tested binaries (the darwin +# ones re-signed by the macOS smoke jobs) into `open-connector-.tar.gz` +# / `.zip` archives, writes a `SHA256SUMS` file, and uploads them as release +# assets. The archives keep a stable name so +# `releases/latest/download/open-connector-.tar.gz` always resolves. +# The upload only runs after every smoke job passed, so a release never ships +# a binary that failed on its own platform. To backfill an existing release, +# dispatch the workflow on its tag with `release_tag` set. +# # A `runs-on` label that no runner serves queues forever instead of failing, so # `workflow_dispatch` is here to verify the labels before relying on them. on: push: branches: [main] pull_request: + release: + types: [published] workflow_dispatch: + inputs: + release_tag: + description: "Upload the binaries to this existing release (leave empty to only build and smoke)" + required: false + type: string # Cancel superseded runs for the same ref (e.g. new push to an open PR). +# Release runs use their own per-tag group and are never cancelled. concurrency: group: build-binary-${{ github.ref }} - cancel-in-progress: true + cancel-in-progress: ${{ github.event_name != 'release' }} -# Least privilege: these jobs only read the repository; the binaries are -# workflow artifacts, nothing is published. +# Least privilege: the build and smoke jobs only read the repository; only the +# `release` job gets `contents: write`, to upload the release assets. permissions: contents: read @@ -59,6 +77,10 @@ env: # on by default only on Node >= 22.18 / >= 23.6, so pin an LTS that supports it. # Do NOT downgrade below 22.18 — `npm ci` (postinstall codegen) and typecheck fail. NODE_VERSION: "24" + # The release the binaries are uploaded to: the published release, or the + # `release_tag` input of a manual dispatch. Empty on push and pull_request + # runs, which build and smoke-test only. + RELEASE_TAG: ${{ github.event.release.tag_name || inputs.release_tag }} jobs: build: @@ -255,3 +277,62 @@ jobs: - name: Smoke test ${{ matrix.target }} run: node scripts/smoke-binary.ts dist/${{ matrix.file }} + + # The release must ship the re-signed darwin binary, not the Linux-built + # one with the invalid signature, so replace the build job's artifact with + # the file that just passed codesign --verify and the smoke test. + - name: Replace ${{ matrix.target }} artifact with the signed binary + if: runner.os == 'macOS' && env.RELEASE_TAG != '' + uses: actions/upload-artifact@v7 + with: + name: binary-${{ matrix.target }} + path: dist/${{ matrix.file }} + if-no-files-found: error + retention-days: 1 + overwrite: true + + # Pack the smoke-tested binaries and attach them to the release. Needs every + # smoke job, so one failing platform blocks the whole upload rather than + # publishing a partial set. + release: + name: Upload release assets + needs: [build, smoke] + if: github.event_name == 'release' || inputs.release_tag != '' + runs-on: blacksmith-2vcpu-ubuntu-2404 + timeout-minutes: 15 + permissions: + contents: write + steps: + - name: Download all binaries + uses: actions/download-artifact@v8 + with: + path: dist + pattern: binary-* + merge-multiple: true + + # tar keeps the mode, so restore the exec bit the artifact zip dropped + # before packing; Windows executables go into zip archives instead. + - name: Pack archives and checksums + working-directory: dist + run: | + set -euo pipefail + mkdir -p ../release-assets + for target in linux-x64 linux-arm64 darwin-x64 darwin-arm64; do + chmod +x "open-connector-${target}" + tar -czf "../release-assets/open-connector-${target}.tar.gz" "open-connector-${target}" + done + for target in windows-x64 windows-arm64; do + zip -j "../release-assets/open-connector-${target}.zip" "open-connector-${target}.exe" + done + cd ../release-assets + sha256sum open-connector-* > SHA256SUMS + ls -l + cat SHA256SUMS + + # --clobber lets a re-run (or a manual backfill) replace assets that are + # already attached instead of failing on the name conflict. + - name: Upload to release ${{ env.RELEASE_TAG }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + run: gh release upload "$RELEASE_TAG" release-assets/* --clobber diff --git a/docs/single-binary.md b/docs/single-binary.md index 0cda6fd71..3f265bec4 100644 --- a/docs/single-binary.md +++ b/docs/single-binary.md @@ -11,6 +11,34 @@ first time that provider is used, so the process only parses the server itself a what keeps the binary's resident memory well under what a single bundle needs, and it is invisible to clients: the same routes, responses, and ETags. +## Download + +Every [release](https://github.com/oomol-lab/open-connector/releases) attaches one archive per +platform, built and smoke-tested on that platform by CI, plus a `SHA256SUMS` file: + +| Asset | Platform | +| ------------------------------------ | ------------------- | +| `open-connector-linux-x64.tar.gz` | Linux x86_64 | +| `open-connector-linux-arm64.tar.gz` | Linux ARM64 | +| `open-connector-darwin-arm64.tar.gz` | macOS Apple Silicon | +| `open-connector-darwin-x64.tar.gz` | macOS Intel | +| `open-connector-windows-x64.zip` | Windows x86_64 | +| `open-connector-windows-arm64.zip` | Windows ARM64 | + +The names are stable across releases, so `releases/latest/download/` always resolves to the newest +release: + +```bash +curl -fsSLO https://github.com/oomol-lab/open-connector/releases/latest/download/open-connector-linux-x64.tar.gz +curl -fsSLO https://github.com/oomol-lab/open-connector/releases/latest/download/SHA256SUMS +sha256sum --ignore-missing -c SHA256SUMS +tar -xzf open-connector-linux-x64.tar.gz +./open-connector-linux-x64 +``` + +Replace `latest` with a tag such as `v1.4.1` to pin a release. The macOS archives contain binaries +that were re-signed on a macOS runner, so they run without the `codesign` step described below. + ## Build Building requires Node.js for the npm scripts and the Bun version pinned in `.bun-version`; the @@ -94,9 +122,9 @@ prints a notice that SQLite migrations are applied automatically and exits. - `NODE_ENV` is fixed to `production` inside the binary, so logs are always JSON (no pretty printing). `OOMOL_CONNECT_LOG_LEVEL` and every other environment variable are read at runtime as usual. -- macOS: binaries built on macOS are ad-hoc signed by the build script. Binaries built on another - operating system carry an invalid ad-hoc signature, and macOS 27 and newer refuses to run them - until you re-sign them: +- macOS: binaries built on macOS are ad-hoc signed by the build script, and the release archives + are re-signed on a macOS runner. Binaries built on another operating system carry an invalid + ad-hoc signature, and macOS 27 and newer refuses to run them until you re-sign them: ```bash codesign --force --sign - dist/open-connector-darwin-arm64 From 8bb4faabbbe377b9a6f78c06a8dc694b2be1a90e Mon Sep 17 00:00:00 2001 From: Kevin Cui Date: Fri, 4 Sep 2026 06:04:21 -0400 Subject: [PATCH 2/3] ci(binary): upload release binaries uncompressed The release job packed the executables into tar.gz and zip archives. Ship the files exactly as built instead, so `releases/latest/download/open-connector-` is the binary itself and a `curl` plus `chmod +x` install needs no archive tool. Only `SHA256SUMS` is generated alongside them. Signed-off-by: Kevin Cui --- .github/workflows/build-binary.yml | 41 +++++++++++++----------------- docs/single-binary.md | 34 ++++++++++++------------- 2 files changed, 35 insertions(+), 40 deletions(-) diff --git a/.github/workflows/build-binary.yml b/.github/workflows/build-binary.yml index b2fc50dfc..8842db83a 100644 --- a/.github/workflows/build-binary.yml +++ b/.github/workflows/build-binary.yml @@ -36,14 +36,14 @@ name: Build Binaries # re-sign the binary with `codesign` and verify the signature before running it. # # On a published release the same build and smoke jobs run against the release -# tag, and a final `release` job packs the six smoke-tested binaries (the darwin -# ones re-signed by the macOS smoke jobs) into `open-connector-.tar.gz` -# / `.zip` archives, writes a `SHA256SUMS` file, and uploads them as release -# assets. The archives keep a stable name so -# `releases/latest/download/open-connector-.tar.gz` always resolves. -# The upload only runs after every smoke job passed, so a release never ships -# a binary that failed on its own platform. To backfill an existing release, -# dispatch the workflow on its tag with `release_tag` set. +# tag, and a final `release` job uploads the six smoke-tested binaries (the +# darwin ones re-signed by the macOS smoke jobs) as they are, plus a +# `SHA256SUMS` file, as release assets. The files keep their build names, so +# `releases/latest/download/open-connector-` always resolves and a +# `curl | chmod +x` install needs no archive tool. The upload only runs after +# every smoke job passed, so a release never ships a binary that failed on its +# own platform. To backfill an existing release, dispatch the workflow on its +# tag with `release_tag` set. # # A `runs-on` label that no runner serves queues forever instead of failing, so # `workflow_dispatch` is here to verify the labels before relying on them. @@ -291,9 +291,9 @@ jobs: retention-days: 1 overwrite: true - # Pack the smoke-tested binaries and attach them to the release. Needs every - # smoke job, so one failing platform blocks the whole upload rather than - # publishing a partial set. + # Attach the smoke-tested binaries to the release. Needs every smoke job, so + # one failing platform blocks the whole upload rather than publishing a + # partial set. release: name: Upload release assets needs: [build, smoke] @@ -310,21 +310,16 @@ jobs: pattern: binary-* merge-multiple: true - # tar keeps the mode, so restore the exec bit the artifact zip dropped - # before packing; Windows executables go into zip archives instead. - - name: Pack archives and checksums + # The binaries are uploaded uncompressed, exactly as built. Fail early if + # any of the six is missing rather than publishing a partial set. + - name: Write checksums working-directory: dist run: | set -euo pipefail - mkdir -p ../release-assets - for target in linux-x64 linux-arm64 darwin-x64 darwin-arm64; do - chmod +x "open-connector-${target}" - tar -czf "../release-assets/open-connector-${target}.tar.gz" "open-connector-${target}" + for file in open-connector-linux-x64 open-connector-linux-arm64 open-connector-darwin-x64 \ + open-connector-darwin-arm64 open-connector-windows-x64.exe open-connector-windows-arm64.exe; do + test -f "${file}" || { echo "::error::missing ${file}"; exit 1; } done - for target in windows-x64 windows-arm64; do - zip -j "../release-assets/open-connector-${target}.zip" "open-connector-${target}.exe" - done - cd ../release-assets sha256sum open-connector-* > SHA256SUMS ls -l cat SHA256SUMS @@ -335,4 +330,4 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_REPO: ${{ github.repository }} - run: gh release upload "$RELEASE_TAG" release-assets/* --clobber + run: gh release upload "$RELEASE_TAG" dist/* --clobber diff --git a/docs/single-binary.md b/docs/single-binary.md index 3f265bec4..4a76ddbff 100644 --- a/docs/single-binary.md +++ b/docs/single-binary.md @@ -13,31 +13,31 @@ to clients: the same routes, responses, and ETags. ## Download -Every [release](https://github.com/oomol-lab/open-connector/releases) attaches one archive per -platform, built and smoke-tested on that platform by CI, plus a `SHA256SUMS` file: - -| Asset | Platform | -| ------------------------------------ | ------------------- | -| `open-connector-linux-x64.tar.gz` | Linux x86_64 | -| `open-connector-linux-arm64.tar.gz` | Linux ARM64 | -| `open-connector-darwin-arm64.tar.gz` | macOS Apple Silicon | -| `open-connector-darwin-x64.tar.gz` | macOS Intel | -| `open-connector-windows-x64.zip` | Windows x86_64 | -| `open-connector-windows-arm64.zip` | Windows ARM64 | +Every [release](https://github.com/oomol-lab/open-connector/releases) attaches the six executables +uncompressed, built and smoke-tested on each platform by CI, plus a `SHA256SUMS` file: + +| Asset | Platform | +| ---------------------------------- | ------------------- | +| `open-connector-linux-x64` | Linux x86_64 | +| `open-connector-linux-arm64` | Linux ARM64 | +| `open-connector-darwin-arm64` | macOS Apple Silicon | +| `open-connector-darwin-x64` | macOS Intel | +| `open-connector-windows-x64.exe` | Windows x86_64 | +| `open-connector-windows-arm64.exe` | Windows ARM64 | The names are stable across releases, so `releases/latest/download/` always resolves to the newest -release: +release. A plain download does not keep the executable bit, so `chmod +x` after it: ```bash -curl -fsSLO https://github.com/oomol-lab/open-connector/releases/latest/download/open-connector-linux-x64.tar.gz +curl -fsSLO https://github.com/oomol-lab/open-connector/releases/latest/download/open-connector-linux-x64 curl -fsSLO https://github.com/oomol-lab/open-connector/releases/latest/download/SHA256SUMS sha256sum --ignore-missing -c SHA256SUMS -tar -xzf open-connector-linux-x64.tar.gz +chmod +x open-connector-linux-x64 ./open-connector-linux-x64 ``` -Replace `latest` with a tag such as `v1.4.1` to pin a release. The macOS archives contain binaries -that were re-signed on a macOS runner, so they run without the `codesign` step described below. +Replace `latest` with a tag such as `v1.4.1` to pin a release. The macOS binaries were re-signed on +a macOS runner, so they run without the `codesign` step described below. ## Build @@ -122,7 +122,7 @@ prints a notice that SQLite migrations are applied automatically and exits. - `NODE_ENV` is fixed to `production` inside the binary, so logs are always JSON (no pretty printing). `OOMOL_CONNECT_LOG_LEVEL` and every other environment variable are read at runtime as usual. -- macOS: binaries built on macOS are ad-hoc signed by the build script, and the release archives +- macOS: binaries built on macOS are ad-hoc signed by the build script, and the release binaries are re-signed on a macOS runner. Binaries built on another operating system carry an invalid ad-hoc signature, and macOS 27 and newer refuses to run them until you re-sign them: From 3bb4d27e121ba194552932c94c49bafcc232a2a4 Mon Sep 17 00:00:00 2001 From: Kevin Cui Date: Fri, 4 Sep 2026 06:11:16 -0400 Subject: [PATCH 3/3] ci(binary): check out the release tag when backfilling assets The build and smoke jobs checked out the ref the run was started from, while the release job uploads to `RELEASE_TAG` with `--clobber`. A manual dispatch from main with `release_tag` set would therefore have replaced that release's assets with binaries built from main. Both checkout steps now pin `ref` to `RELEASE_TAG`, and an empty value on push and pull_request runs falls back to the action's default ref, so those paths are unchanged. Signed-off-by: Kevin Cui --- .github/workflows/build-binary.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/build-binary.yml b/.github/workflows/build-binary.yml index 8842db83a..9ba06c193 100644 --- a/.github/workflows/build-binary.yml +++ b/.github/workflows/build-binary.yml @@ -105,6 +105,12 @@ jobs: - name: Checkout uses: actions/checkout@v7 with: + # Build the release tag itself, never the ref the run was dispatched + # from: a manual backfill started on main with `release_tag` set + # would otherwise --clobber that release's assets with binaries built + # from main. An empty value (push, pull_request) falls back to the + # action's default ref. + ref: ${{ env.RELEASE_TAG }} # No step needs the persisted GITHUB_TOKEN in git config; dropping it # keeps `npm ci` postinstall scripts from reading it (zizmor hardening). persist-credentials: false @@ -247,6 +253,8 @@ jobs: - name: Checkout uses: actions/checkout@v7 with: + # Same ref as the build job, so the smoke script matches the binaries. + ref: ${{ env.RELEASE_TAG }} persist-credentials: false # The smoke script uses only node: builtins, so no `npm ci` is needed.