opencrabs 0.5.13

The autonomous, self-improving AI agent. Single Rust binary. Every channel. Recommended: the 40MB prebuilt binary for macOS, Linux and Windows: https://github.com/opencrabs/opencrabs/releases
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
name: Release

on:
  push:
    tags:
      - 'v*'
  # Manual escape hatch: if GitHub stops delivering push events (as it did for
  # the v0.3.80 attempt on 2026-08-12), trigger the release from the API instead:
  #   gh workflow run release.yml --ref v<version>
  workflow_dispatch:

permissions:
  contents: read

env:
  CARGO_TERM_COLOR: always
  FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true

jobs:
  # v0.5.10 was tagged and published six seconds after the CI run on that same
  # commit started, and that run concluded `failure` (#2078). This job used to
  # assume CI never runs on main and gate on nothing but the branch, so nothing
  # stopped a red commit from shipping. ci.yml does run on push to main, so the
  # tag path now reads that run's conclusion and publishes nothing else.
  wait-for-ci:
    name: Verify main branch
    runs-on: ubuntu-latest
    # `gh run list` reads Actions data, which `contents: read` alone does not
    # cover. Without `actions: read` the gate below cannot see the run it is
    # supposed to check (#2078).
    permissions:
      contents: read
      actions: read
    steps:
      - name: Confirm commit is on main
        env:
          GH_TOKEN: ${{ github.token }}
        run: |
          SHA="${{ github.sha }}"
          REPO="${{ github.repository }}"
          # Verify the tag points to a commit on main
          MAIN_SHA=$(gh api "repos/$REPO/branches/main" --jq '.commit.sha' 2>/dev/null || echo "")
          if [ -z "$MAIN_SHA" ]; then
            echo "ERROR: Could not fetch main branch HEAD"
            exit 1
          fi
          # Check if our SHA is reachable from main (may not be HEAD if tag is on older commit)
          MERGE_BASE=$(gh api "repos/$REPO/compare/${MAIN_SHA}...${SHA}" --jq '.status' 2>/dev/null || echo "error")
          case "$MERGE_BASE" in
            "identical"|"behind")
              echo "Commit $SHA is on main (main is at $MAIN_SHA)"
              ;;
            *)
              # Also accept if SHA IS main HEAD
              if [ "$SHA" = "$MAIN_SHA" ]; then
                echo "Commit $SHA is main HEAD"
              else
                # This used to warn and release anyway. An unverifiable commit
                # is not a release candidate (#2078).
                echo "ERROR: Commit $SHA is not on main (status: $MERGE_BASE). Refusing to release."
                exit 1
              fi
              ;;
          esac

      - name: Require a green CI run on this commit
        env:
          GH_TOKEN: ${{ github.token }}
        run: |
          set -uo pipefail
          SHA="${{ github.sha }}"
          REPO="${{ github.repository }}"

          # The step above proves the commit is on main, which it was for
          # v0.5.10. This step is the actual gate: it reads the conclusion of
          # the commit's own ci.yml run and refuses to publish anything but a
          # green one (#2078).
          #
          # "No run found" and "run in flight" are handled differently on
          # purpose. Waiting an hour for a run that will never appear is worse
          # than saying so: ci.yml skips docs-only commits via `paths-ignore`,
          # so a missing run is reachable, and the message below says what to
          # do about it. Either way a missing run FAILS, never skips. Deleting
          # this step is what made #2078 possible, so silence is not an option
          # here.
          if [ "${#SHA}" -ne 40 ]; then
            echo "ERROR: expected a full 40-character commit SHA, got '${SHA}'."
            exit 1
          fi

          # `gh run list --commit` matches on the full SHA and silently returns
          # nothing for an abbreviated one, which is why the check above exists.
          ci_run() {
            gh run list --repo "$REPO" --workflow ci.yml --commit "$SHA" \
              --limit 1 --json status,conclusion,databaseId \
              --jq '.[0] // empty' 2>/dev/null || echo ""
          }

          run=$(ci_run)
          found_deadline=$((SECONDS + 300))
          while [ -z "$run" ]; do
            if [ "$SECONDS" -ge "$found_deadline" ]; then
              echo "ERROR: no ci.yml run found for $SHA after 5 minutes, so this commit is unverified."
              echo "CI skips docs-only commits (paths-ignore). Either run 'gh workflow run ci.yml --ref main'"
              echo "against a commit CI will actually pick up, or tag a commit CI has already run on. (#2078)"
              exit 1
            fi
            echo "No ci.yml run for $SHA yet; waiting for it to appear..."
            sleep 15
            run=$(ci_run)
          done

          run_deadline=$((SECONDS + 3600))
          while :; do
            status=$(printf '%s' "$run" | jq -r '.status')
            conclusion=$(printf '%s' "$run" | jq -r '.conclusion')
            id=$(printf '%s' "$run" | jq -r '.databaseId')
            echo "ci.yml run $id on $SHA: status=$status conclusion=$conclusion"

            # An unreadable status must fail, not read as "still running". This
            # loop is the last thing between a red commit and a published
            # release, so a parse failure has to be loud (#2078).
            if [ -z "$status" ] || [ "$status" = "null" ]; then
              echo "ERROR: could not read the status of the ci.yml run for $SHA (got '${status}')."
              echo "Refusing to publish a commit whose CI cannot be verified (#2078)."
              exit 1
            fi

            if [ "$status" = "completed" ]; then
              if [ "$conclusion" = "success" ]; then
                echo "CI is green on $SHA. Ready to release."
                exit 0
              fi
              echo "ERROR: ci.yml run $id on $SHA concluded '$conclusion'. Refusing to publish a red commit (#2078)."
              exit 1
            fi

            if [ "$SECONDS" -ge "$run_deadline" ]; then
              echo "ERROR: ci.yml run $id on $SHA is still '$status' after 60 minutes. Refusing to publish unverified (#2078)."
              exit 1
            fi

            echo "Waiting for ci.yml run $id to finish (status=$status)..."
            sleep 30
            run=$(ci_run)
            if [ -z "$run" ]; then
              echo "ERROR: ci.yml run $id for $SHA disappeared while waiting (#2078)."
              exit 1
            fi
          done

  publish-crate:
    name: Publish to crates.io
    needs: [wait-for-ci, build-release]
    runs-on: ubuntu-latest
    outputs:
      version: ${{ steps.check.outputs.version }}
    steps:
      - uses: actions/checkout@v5

      - name: Check if already published
        id: check
        run: |
          VERSION=$(grep '^version' Cargo.toml | head -1 | sed 's/.*"\(.*\)"/\1/')
          echo "version=$VERSION" >> "$GITHUB_OUTPUT"
          if curl -sf "https://crates.io/api/v1/crates/opencrabs/$VERSION" > /dev/null 2>&1; then
            echo "published=true" >> "$GITHUB_OUTPUT"
            echo "v$VERSION already on crates.io — skipping build"
          else
            echo "published=false" >> "$GITHUB_OUTPUT"
          fi

      - name: Refuse to administer the host's rustup
        # dtolnay/rust-toolchain manages rustup on whatever machine it runs
        # on, and defaults CARGO_HOME to ~/.cargo when it is unset. On a
        # self-hosted runner that is somebody's workstation, so CI installs
        # toolchains into their installation: it removed the rustup binary
        # itself once, leaving every shim in ~/.cargo/bin dangling and no
        # working cargo for the machine owner (#1162).
        #
        # The runner sets CARGO_HOME and RUSTUP_HOME to its own directories.
        # This fails the job if that ever stops being true, rather than
        # letting the damage be discovered at the owner's next build.
        if: runner.environment == 'self-hosted'
        shell: bash
        run: |
          for var in CARGO_HOME RUSTUP_HOME; do
            value="${!var}"
            if [ -z "$value" ]; then
              echo "::error::$var is unset, so rustup would manage the host's own installation. Set it in the runner service environment."
              exit 1
            fi
            case "$value" in
              "$HOME/.cargo"|"$HOME/.rustup")
                echo "::error::$var points at the host's own installation ($value). CI must not administer it."
                exit 1
                ;;
            esac
            echo "$var=$value"
          done

      - name: Install Rust
        if: steps.check.outputs.published == 'false'
        uses: dtolnay/rust-toolchain@stable

      - name: Install ALSA dev
        if: steps.check.outputs.published == 'false'
        run: sudo apt-get update && sudo apt-get install -y libasound2-dev

      # No continue-on-error: a failed crates.io publish must FAIL this job so
      # the GitHub Release (binaries) is never dropped while crates.io lags
      # behind. An already-published version skips this step (job still succeeds).
      - name: Publish
        if: steps.check.outputs.published == 'false'
        env:
          CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
        run: cargo publish --allow-dirty

  build-release:
    name: Build Release
    needs: wait-for-ci
    runs-on: ${{ matrix.os }}
    permissions:
      contents: write
    strategy:
      fail-fast: false
      matrix:
        include:
          - os: ubuntu-latest
            target: x86_64-unknown-linux-gnu
            artifact_name: opencrabs
            asset_suffix: linux-amd64

          # Thin LTO on this target only. The fat-LTO link of the whole binary
          # takes a single rustc past 14.8 GB RSS on the hosted arm runner
          # (16 GB, no real swap): memory hits 63 MB available, the VM
          # freezes, and GitHub kills the runner with a shutdown signal and
          # exit 143 after 25 to 30 silent minutes. Measured on a sampled
          # build of v0.5.2; waproto 0.7.0 alone generates 164k lines that
          # all land in the fat-LTO module. The amd64 runner squeaks through
          # the same link, so every other target keeps the Cargo.toml
          # profile.
          - os: ubuntu-24.04-arm
            target: aarch64-unknown-linux-gnu
            artifact_name: opencrabs
            asset_suffix: linux-arm64
            release_lto: thin

          - os: windows-latest
            target: x86_64-pc-windows-msvc
            artifact_name: opencrabs.exe
            asset_suffix: windows-amd64

          # Both darwin targets build on our own Mac. GitHub bills macOS
          # minutes at 10x, Linux at 1x and Windows at 2x, so these two jobs
          # cost more than the other three combined; the Mac runs them for
          # free and natively. Linux and Windows stay hosted, where they are
          # cheap and native. This does mean a tag cannot finish its release
          # while the Mac runner is offline: the darwin jobs queue until it
          # comes back rather than failing over to a hosted runner.
          - os: self-hosted
            target: x86_64-apple-darwin
            artifact_name: opencrabs
            asset_suffix: macos-amd64

          - os: self-hosted
            target: aarch64-apple-darwin
            artifact_name: opencrabs
            asset_suffix: macos-arm64

    steps:
      - uses: actions/checkout@v5

      - name: Set version
        id: version
        shell: bash
        run: echo "version=${GITHUB_REF#refs/tags/v}" >> "$GITHUB_OUTPUT"

      - name: Refuse to administer the host's rustup
        # dtolnay/rust-toolchain manages rustup on whatever machine it runs
        # on, and defaults CARGO_HOME to ~/.cargo when it is unset. On a
        # self-hosted runner that is somebody's workstation, so CI installs
        # toolchains into their installation: it removed the rustup binary
        # itself once, leaving every shim in ~/.cargo/bin dangling and no
        # working cargo for the machine owner (#1162).
        #
        # The runner sets CARGO_HOME and RUSTUP_HOME to its own directories.
        # This fails the job if that ever stops being true, rather than
        # letting the damage be discovered at the owner's next build.
        if: runner.environment == 'self-hosted'
        shell: bash
        run: |
          for var in CARGO_HOME RUSTUP_HOME; do
            value="${!var}"
            if [ -z "$value" ]; then
              echo "::error::$var is unset, so rustup would manage the host's own installation. Set it in the runner service environment."
              exit 1
            fi
            case "$value" in
              "$HOME/.cargo"|"$HOME/.rustup")
                echo "::error::$var points at the host's own installation ($value). CI must not administer it."
                exit 1
                ;;
            esac
            echo "$var=$value"
          done

      - name: Install Rust
        uses: dtolnay/rust-toolchain@stable
        with:
          targets: ${{ matrix.target }}

      - name: Install Linux dependencies
        if: contains(matrix.os, 'ubuntu')
        run: |
          sudo apt-get update
          sudo apt-get install -y libasound2-dev

      - name: Install CMake (Windows)
        if: matrix.os == 'windows-latest'
        uses: lukka/get-cmake@latest

      - name: Install NASM (Windows)
        if: matrix.os == 'windows-latest'
        uses: ilammy/setup-nasm@v1

      - name: Build
        shell: bash
        env:
          CFLAGS: ${{ matrix.target == 'aarch64-apple-darwin' && '-march=armv8-a+crypto' || '' }}
          CXXFLAGS: ${{ matrix.target == 'aarch64-apple-darwin' && '-march=armv8-a+crypto' || '' }}
          AWS_LC_SYS_CMAKE_BUILDER: ${{ matrix.os == 'windows-latest' && '1' || '' }}
          # Explicit `fat` everywhere else: it is the Cargo.toml value, and an
          # empty string here would be parsed as an invalid lto setting.
          CARGO_PROFILE_RELEASE_LTO: ${{ matrix.release_lto || 'fat' }}
        # --all-features on purpose: release binaries carry every optional
        # feature, including `eval` and `profiling`. That is what has always
        # shipped and nobody has wanted less. The README used to claim the
        # eval harness never reaches a release binary; the claim was wrong,
        # not the build, and it has been corrected rather than the binary
        # narrowed (#1402).
        run: cargo build --locked --release --target ${{ matrix.target }} --all-features

      - name: Download and bundle RTK 0.40.0
        shell: bash
        run: |
          RTK_VERSION="0.40.0"
          RTK_TARGET="${{ matrix.target }}"
          
          # Map Rust targets to RTK release asset names (v0.40.0 actual names)
          case "$RTK_TARGET" in
            x86_64-unknown-linux-gnu)
              RTK_ASSET="rtk-x86_64-unknown-linux-musl.tar.gz"
              ;;
            aarch64-unknown-linux-gnu)
              RTK_ASSET="rtk-aarch64-unknown-linux-gnu.tar.gz"
              ;;
            x86_64-apple-darwin)
              RTK_ASSET="rtk-x86_64-apple-darwin.tar.gz"
              ;;
            aarch64-apple-darwin)
              RTK_ASSET="rtk-aarch64-apple-darwin.tar.gz"
              ;;
            x86_64-pc-windows-msvc)
              RTK_ASSET="rtk-x86_64-pc-windows-msvc.zip"
              ;;
            *)
              echo "Unknown target: $RTK_TARGET"
              exit 1
              ;;
          esac
          
          RTK_URL="https://github.com/rtk-ai/rtk/releases/download/v${RTK_VERSION}/${RTK_ASSET}"
          echo "Downloading RTK from: $RTK_URL"
          
          curl -L -o "/tmp/$RTK_ASSET" "$RTK_URL"
          
          # Extract RTK binary
          mkdir -p /tmp/rtk-extract
          if [[ "$RTK_ASSET" == *.zip ]]; then
            unzip -q "/tmp/$RTK_ASSET" -d /tmp/rtk-extract
          else
            tar xzf "/tmp/$RTK_ASSET" -C /tmp/rtk-extract
          fi
          
          # Find the rtk binary in the extracted archive
          RTK_BINARY=$(find /tmp/rtk-extract -name "rtk" -o -name "rtk.exe" | head -1)
          if [ -z "$RTK_BINARY" ]; then
            echo "RTK binary not found in archive"
            ls -laR /tmp/rtk-extract
            exit 1
          fi
          
          # Copy to the release directory alongside opencrabs
          RELEASE_DIR="target/${{ matrix.target }}/release"
          cp "$RTK_BINARY" "$RELEASE_DIR/"
          if [[ "${{ matrix.os }}" != "windows-latest" ]]; then
            chmod +x "$RELEASE_DIR/rtk"
          fi
          echo "RTK binary bundled at: $RELEASE_DIR/$(basename $RTK_BINARY)"
          ls -lh "$RELEASE_DIR/$(basename $RTK_BINARY)"

      - name: Strip binary (Unix)
        if: matrix.os != 'windows-latest'
        run: strip target/${{ matrix.target }}/release/${{ matrix.artifact_name }}

      # A Mach-O executable has no icon field, so macOS can only show an icon
      # for a bundle. Without this step the tarball ships a bare binary and the
      # system falls back to the generic exec glyph.
      - name: Bundle macOS app
        if: contains(matrix.asset_suffix, 'macos')
        shell: bash
        run: |
          ./scripts/package-macos-app.sh \
            --binary "target/${{ matrix.target }}/release/${{ matrix.artifact_name }}" \
            --rtk "target/${{ matrix.target }}/release/rtk" \
            --version "${{ steps.version.outputs.version }}" \
            --out dist

      - name: Create archive
        shell: bash
        run: |
          ASSET_NAME="opencrabs-v${{ steps.version.outputs.version }}-${{ matrix.asset_suffix }}"
          if [ "${{ matrix.os }}" = "windows-latest" ]; then
            cd target/${{ matrix.target }}/release
            7z a ../../../${ASSET_NAME}.zip ${{ matrix.artifact_name }} rtk.exe
          elif [[ "${{ matrix.asset_suffix }}" == macos-* ]]; then
            tar czf "${ASSET_NAME}.tar.gz" -C dist OpenCrabs.app
          else
            # A Linux desktop needs a menu entry and an icon next to the binary,
            # or a file manager shows the generic exec glyph.
            cp packaging/linux/opencrabs.desktop packaging/linux/opencrabs.png \
              target/${{ matrix.target }}/release/
            cd target/${{ matrix.target }}/release
            tar czf ../../../${ASSET_NAME}.tar.gz ${{ matrix.artifact_name }} rtk \
              opencrabs.desktop opencrabs.png
          fi

      - name: Upload artifact
        uses: actions/upload-artifact@v6
        with:
          name: opencrabs-v${{ steps.version.outputs.version }}-${{ matrix.asset_suffix }}
          path: |
            opencrabs-v${{ steps.version.outputs.version }}-${{ matrix.asset_suffix }}.tar.gz
            opencrabs-v${{ steps.version.outputs.version }}-${{ matrix.asset_suffix }}.zip

  create-release:
    name: Create Release
    needs: [publish-crate, build-release]
    # Drop the release binaries ONLY when everything succeeded — every platform
    # build AND the crates.io publish. Previously this was `always()`, so a
    # failed publish still shipped binaries: users auto-updated past the version
    # crates.io actually had, then had to update manually. All-or-nothing now.
    if: needs.build-release.result == 'success' && needs.publish-crate.result == 'success'
    runs-on: ubuntu-latest
    permissions:
      contents: write
    steps:
      - uses: actions/checkout@v5

      - name: Extract version from tag
        id: version
        run: echo "version=${GITHUB_REF#refs/tags/v}" >> "$GITHUB_OUTPUT"

      - name: Extract changelog for this version
        id: changelog
        run: |
          # Extract the section for this version from CHANGELOG.md
          version="${{ steps.version.outputs.version }}"
          body=$(awk "/^## \\[${version}\\]/{found=1; next} /^## \\[/{if(found) exit} found{print}" CHANGELOG.md)
          # Write to file to preserve newlines
          echo "$body" > /tmp/release_notes.md
          # Add full changelog link (extract previous version from CHANGELOG.md)
          echo "" >> /tmp/release_notes.md
          prev_version=$(awk "/^## \\[${version}\\]/{found=1; next} found && /^## \\[/{match(\$0, /\\[([^]]+)\\]/, m); print m[1]; exit}" CHANGELOG.md)
          if [ -n "$prev_version" ]; then
            echo "**Full Changelog:** https://github.com/${{ github.repository }}/compare/v${prev_version}...v${version}" >> /tmp/release_notes.md
          fi

      - name: Download all artifacts
        uses: actions/download-artifact@v7
        with:
          path: artifacts

      - name: Collect release assets
        run: |
          mkdir -p release-assets
          find artifacts -type f \( -name '*.tar.gz' -o -name '*.zip' \) -exec cp {} release-assets/ \;
          ls -la release-assets/

      # Homebrew verifies the archive hash on download and refuses to install
      # without one, so a formula cannot reference an asset that has no
      # published sha256 (#924). Emitted here rather than recomputed later so
      # the hash always describes the bytes this release actually shipped.
      - name: Generate checksums
        run: |
          cd release-assets
          sha256sum *.tar.gz *.zip > SHA256SUMS
          cat SHA256SUMS

      - name: Create GitHub Release
        env:
          GH_TOKEN: ${{ github.token }}
        run: |
          # Use --clobber so the workflow can attach binaries to a release
          # that was already created manually (e.g. via `gh release create`)
          if gh release view "v${{ steps.version.outputs.version }}" &>/dev/null; then
            # Release exists -- upload assets and ensure it's not a draft
            gh release upload "v${{ steps.version.outputs.version }}" \
              release-assets/* --clobber
            gh release edit "v${{ steps.version.outputs.version }}" --draft=false
          else
            gh release create "v${{ steps.version.outputs.version }}" \
              --title "v${{ steps.version.outputs.version }}" \
              --notes-file /tmp/release_notes.md \
              --latest \
              release-assets/*
          fi