diff --git a/.github/workflows/build-binary.yml b/.github/workflows/build-binary.yml index 2f10791f7..9ba06c193 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 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. 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: @@ -83,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 @@ -225,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. @@ -255,3 +285,57 @@ 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 + + # 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] + 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 + + # 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 + 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 + 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" dist/* --clobber diff --git a/docs/single-binary.md b/docs/single-binary.md index 0cda6fd71..4a76ddbff 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 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. 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 +curl -fsSLO https://github.com/oomol-lab/open-connector/releases/latest/download/SHA256SUMS +sha256sum --ignore-missing -c SHA256SUMS +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 binaries 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 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: ```bash codesign --force --sign - dist/open-connector-darwin-arm64