name: CI on: push: branches: [main] pull_request: branches: [main] workflow_dispatch: inputs: skip_npm: description: "Skip npm publish" type: boolean default: false concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: # scripts/release.ts pushes the version-bump commit and its `v*` tag # atomically (`git push --atomic origin main refs/tags/v*`), so a release # now arrives as a single `push` to `refs/heads/main` — we no longer trigger # on the tag ref at all (see `on.push`). This one branch-push run is therefore # authoritative: it runs the full build AND, when HEAD carries a release tag, # the release/publish jobs. `gate` resolves that tag once so downstream jobs # switch on `is-release` and address the tag by name — `github.ref` is # `refs/heads/main` here, not the tag. A `workflow_dispatch` from a `v*` tag # ref is also treated as a release (the manual re-publish escape hatch). gate: runs-on: ubuntu-22.04 outputs: is-release: ${{ steps.check.outputs.is-release }} release-tag: ${{ steps.check.outputs.release-tag }} steps: # Only a main-branch push needs tags fetched, so `git tag --points-at # HEAD` can see the freshly-pushed `v*`. A tag-ref dispatch reads the # tag straight from `github.ref_name`, and fetching `--tags` while # checkout uses an explicit tag refspec makes git refuse — so scope # fetch-tags to main pushes. - uses: actions/checkout@v4 with: fetch-tags: ${{ github.ref == 'refs/heads/main' }} - name: Detect release tag at HEAD id: check shell: bash run: | is_release=false release_tag="" case "${{ github.ref }}" in refs/tags/v[0-9]*) release_tag="${{ github.ref_name }}" ;; refs/heads/main) if [ "${{ github.event_name }}" != "pull_request" ]; then release_tag=$(git tag --points-at HEAD | grep -E '^v[0-9]' | head -n1 || true) fi ;; esac if [ -n "$release_tag" ]; then echo "HEAD carries release tag $release_tag; this run builds and publishes the release." is_release=true fi { echo "is-release=$is_release" echo "release-tag=$release_tag" } >> "$GITHUB_OUTPUT" # Compute a stable hash of every input that affects the native cdylib output, # then look for any prior successful main run that already uploaded the # native artifacts for this hash. Two independent outputs: # * `linux-run-id` — set when the linux x64 canary (`pi-natives-linux-x64-modern-h`) # is present on a prior main run, so `test`/`native_linux` can reuse it. # * `release-run-id` — set when ALL native_release platforms also have # non-expired artifacts on that same prior run, so `native_release` can # skip the cold rebuild on main pushes after dep changes have already # warmed sccache there. # Non-tag native jobs are skipped when their canary hits; the canary # retention window (see build-native action) is the effective TTL. rust-hash: runs-on: ubuntu-22.04 outputs: hash: ${{ steps.compute.outputs.hash }} linux-run-id: ${{ steps.find.outputs.linux-run-id }} release-run-id: ${{ steps.find.outputs.release-run-id }} steps: - uses: actions/checkout@v4 - name: Compute rust source hash id: compute shell: bash run: | hash=$(find crates Cargo.toml Cargo.lock rust-toolchain.toml \ packages/natives/scripts packages/natives/package.json \ scripts/ci-build-native.ts scripts/host-detect.ts \ -type f -print0 \ | sort -z \ | xargs -0 sha256sum \ | sha256sum \ | cut -c1-16) echo "hash=$hash" >> "$GITHUB_OUTPUT" echo "Rust source hash: $hash" - name: Find prior main build with matching native artifacts id: find env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} shell: bash run: | hash="${{ steps.compute.outputs.hash }}" # Canary for native_linux: presence of the modern artifact implies # the baseline sibling is also there (they upload from the same job). linux_canary="pi-natives-linux-x64-modern-h${hash}" # Required set for native_release reuse — names must match the # `actions/upload-artifact` `name:` template in build-native action. release_required=( "pi-natives-linux-arm64-h${hash}" "pi-natives-darwin-x64-baseline-h${hash}" "pi-natives-darwin-arm64-h${hash}" "pi-natives-win32-x64-baseline-h${hash}" ) linux_run_id="" release_run_id="" for candidate in $(gh run list \ --workflow=ci.yml --branch=main --status=success --event=push \ --limit=20 --json databaseId --jq='.[].databaseId'); do names=$(gh api "/repos/${{ github.repository }}/actions/runs/$candidate/artifacts?per_page=100" \ --jq '.artifacts[] | select(.expired == false) | .name') if [ -z "$linux_run_id" ] && echo "$names" | grep -qFx "$linux_canary"; then linux_run_id="$candidate" fi if [ -z "$release_run_id" ]; then all_found=true # Release reuse requires the linux canary AND every cross-platform # artifact, since release_binary downloads them from the same run. if ! echo "$names" | grep -qFx "$linux_canary"; then all_found=false else for req in "${release_required[@]}"; do if ! echo "$names" | grep -qFx "$req"; then all_found=false break fi done fi if $all_found; then release_run_id="$candidate" fi fi if [ -n "$linux_run_id" ] && [ -n "$release_run_id" ]; then break fi done if [ -n "$linux_run_id" ]; then echo "Reusing native_linux artifacts from run $linux_run_id" else echo "No cached native_linux artifacts for hash $hash; native_linux will rebuild." fi if [ -n "$release_run_id" ]; then echo "Reusing native_release artifacts from run $release_run_id" else echo "No cached native_release artifacts for hash $hash; native_release will rebuild on main." fi { echo "linux-run-id=$linux_run_id" echo "release-run-id=$release_run_id" } >> "$GITHUB_OUTPUT" # Fast lint + type check (no Rust, no native build needed) check: runs-on: ubuntu-22.04 steps: - uses: actions/checkout@v4 - uses: oven-sh/setup-bun@v2 with: bun-version: "1.3" - name: Cache bun dependencies uses: actions/cache@v4 with: path: ~/.bun/install/cache key: bun-${{ runner.os }}-${{ hashFiles('**/bun.lock') }} - run: bun install --frozen-lockfile - name: Type check workspace run: bun run ci:check:full # Linux x64 baseline + modern: required by `test`, so it runs on every PR # unless rust-hash found a cached run. Release pushes always rebuild for fresh artifacts. native_linux: needs: [gate, rust-hash] if: ${{ needs.gate.outputs.is-release == 'true' || needs.rust-hash.outputs.linux-run-id == '' }} runs-on: ubuntu-22.04 strategy: fail-fast: false matrix: include: - { variant: baseline, rust_checks: true } - { variant: modern } steps: - uses: actions/checkout@v4 - uses: ./.github/actions/build-native with: hash: ${{ needs.rust-hash.outputs.hash }} platform: linux arch: x64 variant: ${{ matrix.variant }} rust_checks: ${{ matrix.rust_checks && 'true' || 'false' }} save_cache: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }} # Pre-warm the cross-platform native build cache on `main`, in addition to # building the artifacts that ship in release tags. Skipped on main when the # rust-hash canary already found a recent run with all artifacts intact. native_release: needs: [gate, rust-hash] if: ${{ needs.gate.outputs.is-release == 'true' || (github.event_name == 'push' && github.ref == 'refs/heads/main' && needs.rust-hash.outputs.release-run-id == '') }} strategy: fail-fast: false matrix: include: - { os: ubuntu-22.04, platform: linux, arch: arm64, target: aarch64-unknown-linux-gnu } - { os: macos-15-intel, platform: darwin, arch: x64, variant: baseline } - { os: macos-14, platform: darwin, arch: arm64 } - { os: ubuntu-22.04, platform: win32, arch: x64, target: x86_64-pc-windows-msvc, variant: baseline } runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 - uses: ./.github/actions/build-native with: hash: ${{ needs.rust-hash.outputs.hash }} platform: ${{ matrix.platform }} arch: ${{ matrix.arch }} variant: ${{ matrix.variant }} target: ${{ matrix.target }} save_cache: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }} test: runs-on: ubuntu-22.04 needs: [native_linux, rust-hash] if: ${{ !cancelled() && needs.native_linux.result != 'failure' }} timeout-minutes: 30 steps: - uses: actions/checkout@v4 - uses: oven-sh/setup-bun@v2 with: bun-version: "1.3" - name: Cache bun dependencies uses: actions/cache@v4 with: path: ~/.bun/install/cache key: bun-${{ runner.os }}-${{ hashFiles('**/bun.lock') }} - name: Install system deps run: | sudo apt-get update sudo apt-get install -y libcairo2-dev libpango1.0-dev libjpeg-dev libgif-dev librsvg2-dev fd-find ripgrep imagemagick sudo ln -s $(which fdfind) /usr/local/bin/fd sudo ln -sf /usr/bin/convert /usr/local/bin/magick - run: bun install --frozen-lockfile - name: Resolve native source run id: source shell: bash run: | if [ "${{ needs.native_linux.result }}" = "success" ]; then echo "run-id=${{ github.run_id }}" >> "$GITHUB_OUTPUT" else echo "run-id=${{ needs.rust-hash.outputs.linux-run-id }}" >> "$GITHUB_OUTPUT" fi - name: Download native addons uses: actions/download-artifact@v4 with: pattern: pi-natives-linux-x64-*-h${{ needs.rust-hash.outputs.hash }} path: packages/natives/native merge-multiple: true run-id: ${{ steps.source.outputs.run-id }} github-token: ${{ secrets.GITHUB_TOKEN }} - name: Test workspace (TS) # `test:ts` sets GITHUB_ACTIONS=0 inline so `bun test` skips its # per-file `::group::`/`::endgroup::` annotations. Under `--workspaces` # every line is prefixed with ` test: `, which breaks GHA's # column-0 parsing and would leak those markers as literal log spam. run: bun run test:ts - name: CLI smoke test run: bun run ci:test:smoke install_methods: runs-on: ubuntu-22.04 steps: - uses: actions/checkout@v4 - uses: oven-sh/setup-bun@v2 with: bun-version: "1.3" - uses: dtolnay/rust-toolchain@nightly with: toolchain: nightly-2026-04-29 - uses: Swatinem/rust-cache@v2 with: shared-key: install-methods-linux-x64 cache-on-failure: true save-if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }} cache-workspace-crates: true # Layer sccache on top of rust-cache for the same reason as the # build-native action: tag pushes bump workspace versions and bust # the target/ cache, but sccache hits at the rustc-unit level survive. - name: Setup sccache uses: mozilla-actions/sccache-action@v0.0.10 - name: Enable sccache for cargo shell: bash run: | { echo "SCCACHE_GHA_ENABLED=true" echo "RUSTC_WRAPPER=sccache" echo "CARGO_INCREMENTAL=0" } >> "$GITHUB_ENV" - name: Cache bun dependencies uses: actions/cache@v4 with: path: ~/.bun/install/cache key: bun-${{ runner.os }}-${{ hashFiles('**/bun.lock') }} - name: Install system deps run: | sudo apt-get update sudo apt-get install -y libcairo2-dev libpango1.0-dev libjpeg-dev libgif-dev librsvg2-dev fd-find ripgrep imagemagick sudo ln -s $(which fdfind) /usr/local/bin/fd sudo ln -sf /usr/bin/convert /usr/local/bin/magick - run: bun install --frozen-lockfile - name: Install method smoke tests run: bun run ci:test:install-methods release_binary: if: ${{ needs.gate.outputs.is-release == 'true' && !cancelled() && needs.native_linux.result == 'success' && needs.native_release.result == 'success' && needs.test.result == 'success' && needs.check.result == 'success' && needs.install_methods.result == 'success' }} needs: [gate, check, native_linux, native_release, test, install_methods, rust-hash] strategy: fail-fast: false matrix: include: - { os: ubuntu-22.04, platform: linux, arch: x64, target_id: linux-x64, binary_path: packages/coding-agent/binaries/omp-linux-x64, } - { os: ubuntu-24.04-arm, platform: linux, arch: arm64, target_id: linux-arm64, binary_path: packages/coding-agent/binaries/omp-linux-arm64, } - { os: macos-15-intel, platform: darwin, arch: x64, target_id: darwin-x64, binary_path: packages/coding-agent/binaries/omp-darwin-x64, } - { os: macos-14, platform: darwin, arch: arm64, target_id: darwin-arm64, binary_path: packages/coding-agent/binaries/omp-darwin-arm64, } - { os: ubuntu-22.04, platform: win32, arch: x64, target_id: win32-x64, binary_path: packages/coding-agent/binaries/omp-windows-x64.exe, } runs-on: ${{ matrix.os }} permissions: contents: read id-token: write steps: - uses: actions/checkout@v4 - uses: oven-sh/setup-bun@v2 with: bun-version: "1.3" - uses: actions/setup-node@v4 with: node-version: "24" registry-url: "https://registry.npmjs.org" # Trusted publishing allowed-actions flags require npm >= 11.16.0. - name: Ensure npm supports trusted publishing if: ${{ !inputs.skip_npm }} run: npm install -g npm@latest - name: Cache bun dependencies uses: actions/cache@v4 with: path: ~/.bun/install/cache key: bun-${{ runner.os }}-${{ hashFiles('**/bun.lock') }} - run: bun install --frozen-lockfile - name: Download native addon(s) uses: actions/download-artifact@v4 with: pattern: pi-natives-${{ matrix.platform }}-${{ matrix.arch }}*-h${{ needs.rust-hash.outputs.hash }} path: packages/natives/native merge-multiple: true - name: Build release binary env: RELEASE_TARGETS: ${{ matrix.target_id }} run: bun run ci:release:build-binaries # Windows binary is cross-built on Linux, so we have no Windows runner # to smoke it on. Cross-build correctness is verified via the napi # entry-point exports (see build-native action) and the bun # `--compile --target=bun-windows-x64-*` cross-compile. - name: Smoke release binary if: matrix.platform != 'win32' run: | runtime_dir="$(mktemp -d)" HOME="$runtime_dir/home" XDG_DATA_HOME="$runtime_dir/xdg" "${{ matrix.binary_path }}" --version HOME="$runtime_dir/home" XDG_DATA_HOME="$runtime_dir/xdg" "${{ matrix.binary_path }}" --smoke-test - name: Publish native addon package if: ${{ !inputs.skip_npm }} env: # Fallback auth: setup-node wrote an .npmrc referencing # NODE_AUTH_TOKEN; npm uses it only when OIDC has no trusted # publisher for the package (or on a first publish). NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} run: bun run ci:release:publish-native-leaf ${{ matrix.target_id }} - name: Upload release binary artifact uses: actions/upload-artifact@v4 with: name: omp-binary-${{ matrix.target_id }} path: ${{ matrix.binary_path }} release-github: if: ${{ needs.gate.outputs.is-release == 'true' && !cancelled() && needs.release_binary.result == 'success' }} needs: [gate, release_binary] runs-on: ubuntu-22.04 permissions: contents: write steps: - uses: actions/checkout@v4 - uses: oven-sh/setup-bun@v2 with: bun-version: "1.3" - name: Generate release notes from CHANGELOGs run: bun scripts/ci-release-notes.ts ${{ needs.gate.outputs.release-tag }} - name: Download release binaries uses: actions/download-artifact@v4 with: pattern: omp-binary-* path: packages/coding-agent/binaries merge-multiple: true - name: Create GitHub Release uses: softprops/action-gh-release@v2 with: tag_name: ${{ needs.gate.outputs.release-tag }} files: | packages/coding-agent/binaries/omp-* body_path: release-notes.md generate_release_notes: true release_github_verify: if: ${{ needs.gate.outputs.is-release == 'true' && !cancelled() && needs['release-github'].result == 'success' }} needs: [gate, release-github] runs-on: macos-14 permissions: contents: read steps: - name: Download published macOS arm64 binary run: | curl -fsSL -o omp-darwin-arm64 "https://github.com/${{ github.repository }}/releases/download/${{ needs.gate.outputs.release-tag }}/omp-darwin-arm64" chmod +x omp-darwin-arm64 - name: Verify published macOS arm64 binary run: | codesign -dv ./omp-darwin-arm64 runtime_dir="$(mktemp -d)" HOME="$runtime_dir/home" XDG_DATA_HOME="$runtime_dir/xdg" ./omp-darwin-arm64 --version release-npm: if: ${{ needs.gate.outputs.is-release == 'true' && !cancelled() && needs.release_binary.result == 'success' && needs.release_github_verify.result == 'success' && !inputs.skip_npm }} needs: [gate, release_binary, release_github_verify] runs-on: ubuntu-22.04 # `id-token: write` lets npm mint the GitHub OIDC token it exchanges for a # short-lived publish token (trusted publishing + provenance). When a # package has no matching trusted publisher configured, npm silently falls # back to NODE_AUTH_TOKEN below — which also covers first-ever publishes. permissions: id-token: write contents: read steps: - uses: actions/checkout@v4 - uses: oven-sh/setup-bun@v2 with: bun-version: "1.3" - uses: actions/setup-node@v4 with: node-version: "24" registry-url: "https://registry.npmjs.org" # Trusted publishing (OIDC) and auto-provenance need npm >= 11.5.1. - name: Ensure npm supports OIDC trusted publishing run: npm install -g npm@latest - name: Cache bun dependencies uses: actions/cache@v4 with: path: ~/.bun/install/cache key: bun-${{ runner.os }}-${{ hashFiles('**/bun.lock') }} - run: bun install --frozen-lockfile - name: Publish to npm env: # Fallback auth: setup-node wrote an .npmrc referencing # NODE_AUTH_TOKEN; npm uses it only when OIDC has no trusted # publisher for the package (or on a first publish). NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} run: bun run ci:release:publish