SourceReleasing LoomReleasing on a push

Part XI, Chapter 3

Part XI, Chapter 3: Releasing on a push

Package: .github/workflows/

Language: YAML

Raw source

Releasing on a push

When the gate passes on the default branch, this workflow publishes the npm packages, attaches the standalone executables to a GitHub release, and brings the Zed and Gram extension's mirror up to date. It does all three on every push, so npm, the releases and the mirror always follow the default branch. Running on every push is only safe because each job is idempotent: the release tool publishes only versions npm is missing, the executables go only to a release that lacks them, and the mirror receives only commits it lacks. A version goes out whole or not at all. The workflow builds and tests every executable first, and publishes nothing to npm or to GitHub until all five pass. The website's image ships from the gate's own workflow instead, and only when the website changed.

The workflow triggers on the gate's completion rather than on the push directly, so it runs only after the checks have passed. Its job checks that the gate succeeded, on the default branch, before it does anything, and checks out the exact commit the gate validated rather than whatever the default branch points at now.

The trigger

The workflow starts when the CI workflow finishes. It publishes to npm through Trusted Publishing, which needs the run to prove who it is, so it grants the token an identity and nothing to write. The one job that writes to the repository, by creating a release, asks for that right itself.

YAML
name: Release

on:
  workflow_run:
    workflows: [CI]
    types: [completed]

permissions:
  contents: read
  id-token: write

Publishing to npm

The npm job publishes through Trusted Publishing, so it stores no token. GitHub mints a short-lived identity for the job — this is what id-token: write grants — and npm exchanges it for publish rights, having been told on its side to trust this repository's release.yaml workflow. The job upgrades npm to a version that speaks the exchange, then runs the release tool, which publishes each package npm is missing and skips the rest, so a push that changes no version publishes nothing.

The job waits for the executables, which the next section builds, and publishes only when every one of them passed. A platform that fails therefore holds back npm as well as the GitHub release. The executables are skipped when their release already carries every file, and npm must still run then, since a run can fail after the release is attached and before npm has published. So the job's condition names both outcomes, success and skipped. A job normally starts only when everything it needs succeeded, and !cancelled() lets it weigh its own condition instead.

YAML
jobs:
  npm:
    needs: executable
    if: ${{ !cancelled() && (needs.executable.result == 'success' || needs.executable.result == 'skipped') && github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.head_branch == 'main' }}
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v5
        with:
          ref: ${{ github.event.workflow_run.head_sha }}
      - uses: oven-sh/setup-bun@v2
        with:
          bun-version: 1.2.20
      - run: npm install --global npm@latest
      - run: bun install --frozen-lockfile
      - name: Publish
        run: bun scripts/release.ts

Attaching the executables to a release

The standalone executable ships as a file on a GitHub release, one release for each version, tagged with that version and a v in front. Three jobs build the files and attach them. The first two run before the npm job, so a broken executable stops the whole release. The last runs after it, because an executable installs its language services from npm at its own version, and those must be published before anyone can download the executable.

The first job decides whether there is anything to do, and it is what keeps the three idempotent like the others. Like the npm job, it runs only when the gate passed on the default branch. It reads the version from the CLI's manifest and asks GitHub whether that version's release already carries SHA256SUMS, the list of checksums. The last job attaches the list last, so a release that has it has every file, and the other two jobs then never start. A release that a failed run left half-filled lacks the list, so the next run fills it in.

YAML
  release:
    if: ${{ github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.head_branch == 'main' }}
    runs-on: ubuntu-latest
    outputs:
      tag: ${{ steps.release.outputs.tag }}
      done: ${{ steps.release.outputs.done }}
    steps:
      - uses: actions/checkout@v5
        with:
          ref: ${{ github.event.workflow_run.head_sha }}
      - name: The release this version needs
        id: release
        env:
          GH_TOKEN: ${{ github.token }}
        run: |
          tag="v$(jq -r .version packages/loom/package.json)"
          echo "tag=$tag" >> "$GITHUB_OUTPUT"
          if gh release view "$tag" --json assets --jq '.assets[].name' 2>/dev/null | grep -qx SHA256SUMS; then
            echo "done=true" >> "$GITHUB_OUTPUT"
          fi

The second job builds each executable on a machine of its own platform, one GitHub runner apiece: macOS on Apple silicon and on Intel, Linux on x64 and on ARM, and Windows on x64. GitHub lends these runners free to a public repository. Building on the platform itself means a macOS executable is signed by the Bun that compiled it on a Mac, and the grammar library inside each one comes from that platform's own C compiler. Windows has no cc, so its runner names MinGW's gcc in CC. A run that fails on one platform goes on with the others, so a first run shows every platform that needs work.

Each runner then runs the smoke test against the executable it just built, so no executable reaches a release without having run where it will run. The steps run in Bash on every platform, which Windows runners carry with Git. A platform job that runs past twenty minutes is stopped, since the slowest takes a few, and a job that hangs would otherwise hold its runner for GitHub's limit of six hours.

YAML
  executable:
    needs: release
    if: needs.release.outputs.done != 'true'
    strategy:
      fail-fast: false
      matrix:
        include:
          - { platform: darwin-arm64, runner: macos-15 }
          - { platform: darwin-x64, runner: macos-15-intel }
          - { platform: linux-x64, runner: ubuntu-latest }
          - { platform: linux-arm64, runner: ubuntu-24.04-arm }
          - { platform: windows-x64, runner: windows-2025, cc: gcc }
    runs-on: ${{ matrix.runner }}
    timeout-minutes: 20
    defaults:
      run:
        shell: bash
    steps:
      - uses: actions/checkout@v5
        with:
          ref: ${{ github.event.workflow_run.head_sha }}
      - uses: oven-sh/setup-bun@v2
        with:
          bun-version: 1.2.20
      - name: Build the executable and run its smoke test
        env:
          CC: ${{ matrix.cc || 'cc' }}
        run: |
          bun install --frozen-lockfile
          cd packages/loom
          bun run build
          bun run compile
          bun test ./smoke/binary.smoke.ts ./smoke/install.smoke.ts

The executable that passed is named for its platform alone, with no version in the name, so whoever downloads one builds its name from the platform: loom-darwin-arm64, and loom-windows-x64.exe. An executable carries a whole runtime, and gzip shrinks it to about a third, so the release ships each one gzipped, loom-darwin-arm64.gz and loom-windows-x64.exe.gz. Each holds the one file, and the install script, loom upgrade and Zed's extension all unpack it after download.

The grammar library ships beside it, ungzipped, since it is small: tree-sitter-loom-darwin-arm64.so, and tree-sitter-loom-windows-x64.dll. An editor's own tooling can fetch it, and so could an npm install of Loom that finds no C compiler. The runner hands both files to the last job as an artifact of the run.

YAML
      - name: Name the files for the release
        run: |
          mkdir release
          if [ -f packages/loom/build/loom.exe ]; then exe=.exe; library=dll; else exe=; library=so; fi
          cp "packages/loom/build/loom$exe" "release/loom-${{ matrix.platform }}$exe"
          gzip -9 "release/loom-${{ matrix.platform }}$exe"
          cp "packages/loom/build/loom.$library" "release/tree-sitter-loom-${{ matrix.platform }}.$library"
      - uses: actions/upload-artifact@v4
        with:
          name: loom-${{ matrix.platform }}
          path: release/*

The last job runs once every platform has passed and npm has published, and it is the one job that writes to the repository. It gathers the five artifacts into one folder and copies the install script beside them, uncompressed, since sh reads it straight from the download. sha256sum writes the checksum of every file the release carries into SHA256SUMS, so each file is checked as it is downloaded.

The release is created at the commit the gate validated, which also creates its tag, unless an earlier run already created it. It is a full release, never a prerelease, because GitHub's address for the latest release and Zed's search for it both pass over prereleases. The executables, the libraries and the install script go up first and the list of checksums last. --clobber replaces a file an earlier run left half-uploaded.

The macOS executables carry an ad-hoc signature, which the compile makes over the finished file. It lets Apple silicon run them and passes a strict codesign check, but it is not a signature from Apple's Developer ID, and the executables have not been notarised. A file curl downloads carries no quarantine flag, so macOS runs it, and that is how the install script and loom upgrade fetch it. A file downloaded through a browser carries the flag, and macOS refuses to open it until its owner allows it. Signing and notarising them is work for a later release.

YAML
  publish:
    needs: [release, executable, npm]
    runs-on: ubuntu-latest
    permissions:
      contents: write
    steps:
      - uses: actions/checkout@v5
        with:
          ref: ${{ github.event.workflow_run.head_sha }}
      - uses: actions/download-artifact@v4
        with:
          path: release
          merge-multiple: true
      - name: Attach them to the release
        env:
          GH_TOKEN: ${{ github.token }}
          TAG: ${{ needs.release.outputs.tag }}
          COMMIT: ${{ github.event.workflow_run.head_sha }}
        run: |
          cp scripts/install.sh release/install.sh
          (cd release && sha256sum loom-*.gz tree-sitter-loom-* install.sh > SHA256SUMS)
          if ! gh release view "$TAG" > /dev/null 2>&1; then
            gh release create "$TAG" --target "$COMMIT" --title "Loom ${TAG#v}" \
              --notes "Install the standalone executable with \`curl -fsSL https://github.com/athrio-com/loom/releases/latest/download/install.sh | sh\`, or with npm as \`@athrio/loom\`. The macOS executables are not signed with a Developer ID or notarised yet."
          fi
          gh release upload "$TAG" release/loom-*.gz release/tree-sitter-loom-* release/install.sh --clobber
          gh release upload "$TAG" release/SHA256SUMS --clobber

Mirroring the Zed extension

The extension for Zed and Gram lives in this repository, in packages/tree-sitter-loom/editors/zed. Gram installs an extension from a repository URL and reads its manifest from the repository's root, so the folder is mirrored to a repository of its own, athrio-com/loom-zed, whose root is the extension. This job keeps that mirror in step with the default branch, so nobody edits or pushes it by hand.

git subtree split builds the mirror's history from the folder's: one commit for each commit that changed the folder, holding the folder's files at the root. The split is deterministic. The same history gives the same commits, and a later split extends an earlier one, so the push to the mirror is a fast-forward, and a push that changed nothing in the folder sends nothing. The split reads the whole history, so the checkout fetches all of it.

The job writes to the mirror with a deploy key, an SSH key that can push to athrio-com/loom-zed and to no other repository. Its private half is the LOOM_ZED_DEPLOY_KEY secret of this repository, and the job hands it to Git for the one push.

YAML
  zed:
    if: ${{ github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.head_branch == 'main' }}
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v5
        with:
          ref: ${{ github.event.workflow_run.head_sha }}
          fetch-depth: 0
      - name: Mirror the extension
        env:
          DEPLOY_KEY: ${{ secrets.LOOM_ZED_DEPLOY_KEY }}
        run: |
          key="$RUNNER_TEMP/loom-zed-key"
          printf '%s\n' "$DEPLOY_KEY" > "$key"
          chmod 600 "$key"
          split=$(git subtree split --prefix=packages/tree-sitter-loom/editors/zed HEAD)
          GIT_SSH_COMMAND="ssh -i $key -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new" \
            git push [email protected]:athrio-com/loom-zed.git "$split:refs/heads/main"
Loom