bobbin-ai 0.25.2

Local-first context injection engine for AI coding agents
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
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
name: Release

on:
  push:
    tags:
      - 'v*'
    branches: [main]
  workflow_dispatch:
    inputs:
      version:
        description: 'Version tag (e.g., v0.1.0)'
        required: true
        type: string

env:
  CARGO_TERM_COLOR: always

# NOTE: VERSION is deliberately NOT set here. Workflow-level `env` cannot read the
# `needs` context, and on the release-merge path the version comes from the
# release-please job's tag_name output (github.ref_name is `main` there, which
# would name every asset "bobbin-main-..."). It is set per-job below instead.

jobs:
  build:
    name: Build ${{ matrix.target }}
    needs: release-please
    # ⚠ A TAG PUSH IS NOT ENOUGH, AND THAT IS NOT A BUG IN THIS FILE (aegis-g2gpw5).
    #
    # release-please creates the tag and a DRAFT release using GITHUB_TOKEN, and
    # GitHub deliberately does not re-trigger workflows from token-authored tag
    # pushes. So the `on: push: tags` arm never fired: measured 0 of the last 100
    # Release runs were tag-triggered, and every published bobbin release was a
    # human running workflow_dispatch on the tag by hand (aegis-ttk1y).
    #
    # The fix is the one yupana and quipu already use (aegis-e8j3hh): build in the
    # SAME RUN as release-please, gated on its releases_created output, rather than
    # on a trigger that cannot fire. `always()` is required because release-please
    # is SKIPPED on the tag-push and workflow_dispatch arms, which would otherwise
    # skip this job with it.
    if: >-
      always() &&
      (startsWith(github.ref, 'refs/tags/') ||
       github.event_name == 'workflow_dispatch' ||
       needs.release-please.outputs.releases_created == 'true')
    runs-on: ${{ matrix.os }}
    env:
      VERSION: ${{ inputs.version || (startsWith(github.ref, 'refs/tags/') && github.ref_name) || needs.release-please.outputs.tag_name }}
    permissions:
      # Each matrix leg publishes its own archive as soon as it is ready.  Do
      # not put this permission on the workflow globally: only build and the
      # finalizer need to mutate release assets.
      contents: write
      id-token: write
      attestations: write
    strategy:
      fail-fast: false
      matrix:
        include:
          - target: x86_64-unknown-linux-gnu
            os: ubuntu-22.04
            archive: tar.gz
            ort_asset: onnxruntime-linux-x64
            ort_version: "1.24.1"
          - target: aarch64-unknown-linux-gnu
            os: ubuntu-22.04
            archive: tar.gz
            ort_asset: onnxruntime-linux-aarch64
            ort_version: "1.24.1"
          - target: aarch64-apple-darwin
            os: macos-14
            archive: tar.gz
            ort_asset: onnxruntime-osx-arm64
            ort_version: "1.24.1"
          - target: x86_64-apple-darwin
            os: macos-15-intel
            archive: tar.gz
            # ORT 1.24+ dropped x86_64 macOS; 1.23.2 is the last version with support
            ort_asset: onnxruntime-osx-x86_64
            ort_version: "1.23.2"

    steps:
      # ref: the resolved VERSION tag, not the trigger ref. On the release-merge
      # path the ref is `main`, and a binary labelled v0.x.y that was built from a
      # later main is a mislabelled binary. fetch-depth: 0 so the tag created
      # moments earlier in this same run is present locally.
      - name: Checkout
        uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
        with:
          ref: ${{ env.VERSION }}
          fetch-depth: 0

      - name: Verify publishing guard
        run: bash scripts/ci/crates-publish-guard.sh --selftest

      - name: Install protoc
        uses: arduino/setup-protoc@c65c819552d16ad3c9b72d9dfd5ba5237b9c906b # v3
        with:
          repo-token: ${{ secrets.GITHUB_TOKEN }}

      - name: Install Rust
        # EXACT toolchain, not `stable`: the release binary's compiler was whatever
        # stable was on the day the tag ran, recorded nowhere. Bump deliberately.
        uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de # master 2026-09-12
        with:
          toolchain: "1.98.1"
          targets: ${{ matrix.target }}

      - name: Cache cargo
        uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2
        with:
          key: ${{ matrix.target }}-${{ matrix.os }}

      - name: Install cmake (for aws-lc-sys source build)
        if: runner.os == 'Linux'
        run: sudo apt-get update && sudo apt-get install -y cmake

      - name: Install aarch64 cross-compilation toolchain
        if: matrix.target == 'aarch64-unknown-linux-gnu'
        run: sudo apt-get install -y gcc-aarch64-linux-gnu g++-aarch64-linux-gnu

      - name: Install release metadata tools
        run: |
          cargo install cargo-auditable --version 0.7.6 --locked
          cargo install cargo-cyclonedx --version 0.5.9 --locked

      - name: Build
        shell: bash
        env:
          # Build aws-lc-sys from source to avoid prebuilt glibc 2.38 dependency
          AWS_LC_SYS_CMAKE_BUILDER: "1"
        run: |
          if [ "${{ matrix.target }}" = "aarch64-unknown-linux-gnu" ]; then
            export CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc
            export CC_aarch64_unknown_linux_gnu=aarch64-linux-gnu-gcc
            export CXX_aarch64_unknown_linux_gnu=aarch64-linux-gnu-g++
            export AR_aarch64_unknown_linux_gnu=aarch64-linux-gnu-ar

            # THIS LEG OOM-KILLS THE RUNNER UNDER FAT LTO. Not a guess -- measured.
            #
            # [profile.release] is lto = true + codegen-units = 1, i.e. a
            # single-threaded whole-program link. That fits on the x86_64 legs. On this
            # one it does not, and it has silently cost us every release since v0.6.0:
            # tags v0.6.1, v0.6.2, v0.6.3 and v0.6.4 were all pushed and all produced NO
            # GitHub Release, because `checksums` needs: build and `release` needs:
            # [build, checksums] -- so this single leg took the whole lane down, four
            # times, for 19 days, with no alert (a tag with no Release is
            # indistinguishable from no tag at all).
            #
            # The variable is in the repo's own history: v0.6.0 built WITHOUT
            # `--features knowledge` and released fine; commit 296238c added the flag and
            # every one of the 5 subsequent tag runs died here. The feature pulls the
            # quipu/RDF dependency tree through that fat-LTO link.
            #
            # Measured on a 4-core/16GB runner, one run, single variable
            # (probe/aarch64-oom, run 30753070604):
            #
            #   baseline (lto=true, cgu=1)  peak 15719M used / 21M available /
            #                               3071M swap exhausted -> exit 143 at 24m
            #   thin (this setting)         peak  3440M used / 12154M available
            #                               -> SUCCESS in 18m37s, max RSS 2.4GB
            #
            # So: ~4.6x less peak memory, and it builds. Scoped to this target on
            # purpose -- the x86_64 legs keep fat LTO because they are the artifacts
            # anyone actually runs, and they are not the ones failing.
            #
            # KNOWN UNMEASURED: the x86_64 legs' remaining headroom. They pass today,
            # but nothing here tells us by how much, so a future dependency bump could
            # walk them into the same wall. If a linux leg starts dying at ~20+ minutes
            # of log silence with exit 143, this is the cause -- come straight here.
            export CARGO_PROFILE_RELEASE_LTO=thin
            export CARGO_PROFILE_RELEASE_CODEGEN_UNITS=16
          fi
          # --features knowledge is REQUIRED, not optional: it gates bobbin's entire
          # Quipu integration (MCP surface, coupling exporter, PPR reranking) and is
          # not a cargo default. Every release before this line shipped with all of it
          # silently compiled out (bobbin-jdlkh). Do not drop this flag.
          # --locked: build exactly the committed Cargo.lock. Without it cargo may
          # re-resolve and ship a dependency graph no one reviewed.
          cargo auditable build --release --locked --features knowledge --target ${{ matrix.target }}

      - name: Download ONNX Runtime
        shell: bash
        run: |
          ORT_TAG="v${{ matrix.ort_version }}"
          ORT_TARBALL="${{ matrix.ort_asset }}-${{ matrix.ort_version }}.tgz"
          # --fail so a missing/renamed asset errors here, not 2 steps later.
          curl -sSL --fail "https://github.com/microsoft/onnxruntime/releases/download/${ORT_TAG}/${ORT_TARBALL}" \
            -o /tmp/ort.tgz
          mkdir -p /tmp/ort-extracted
          # NOTE: do NOT use --strip-components here. The osx-x86_64 tarball entries
          # carry a leading "./" component (others don't), so strip-components=1 would
          # remove "." and leave the lib one level too deep. Extract verbatim and let
          # the Package step locate lib/ wherever it lands. See bo-ewk.
          tar xzf /tmp/ort.tgz -C /tmp/ort-extracted

      - name: Package
        shell: bash
        run: |
          STAGING="bobbin-${{ env.VERSION }}-${{ matrix.target }}"
          mkdir -p "$STAGING/lib"
          cp target/${{ matrix.target }}/release/bobbin "$STAGING/"

          # Bundle ONNX Runtime shared library from extracted tarball.
          # Locate the lib/ dir regardless of how deep the tarball nests it
          # (osx-x86_64 nests one level deeper — see bo-ewk).
          ORT_LIB_DIR=$(find /tmp/ort-extracted -type d -name lib | head -1)
          if [ -z "$ORT_LIB_DIR" ]; then
            echo "ERROR: no lib/ dir found under /tmp/ort-extracted" >&2
            find /tmp/ort-extracted -maxdepth 2 >&2
            exit 1
          fi
          copied=0
          for f in $(find "$ORT_LIB_DIR" -maxdepth 1 -name "libonnxruntime*" \
            -not -name "*providers*" -not -name "*.pc" -not -type d); do
            cp "$f" "$STAGING/lib/"
            copied=$((copied + 1))
          done
          if [ "$copied" -eq 0 ]; then
            echo "ERROR: no libonnxruntime* libraries copied from $ORT_LIB_DIR" >&2
            ls -la "$ORT_LIB_DIR" >&2
            exit 1
          fi

          tar czvf "${STAGING}.tar.gz" "$STAGING"

      - name: Generate Rust dependency SBOM
        shell: bash
        run: |
          cargo cyclonedx --format json --spec-version 1.5 --all \
            --features knowledge --target "${{ matrix.target }}" \
            --override-filename "bobbin-${{ env.VERSION }}-${{ matrix.target }}.cdx"
          # Metadata generation must not silently re-resolve the release lockfile.
          git diff --exit-code -- Cargo.lock
          test -s "bobbin-${{ env.VERSION }}-${{ matrix.target }}.cdx.json"

      # Production verification pins release.yml@refs/heads/main. The normal
      # release-please path runs on main even though Checkout reads VERSION.
      # Recovery runs from tags retain their own identity; do not rewrite it.
      - name: Attest build provenance
        id: provenance
        uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
        with:
          subject-path: |
            bobbin-${{ env.VERSION }}-${{ matrix.target }}.${{ matrix.archive }}
            target/${{ matrix.target }}/release/bobbin

      - name: Attest Rust dependency SBOM
        id: sbom
        uses: actions/attest-sbom@c604332985a26aa8cf1bdc465b92731239ec6b9e # v4.1.0
        with:
          subject-path: bobbin-${{ env.VERSION }}-${{ matrix.target }}.${{ matrix.archive }}
          sbom-path: bobbin-${{ env.VERSION }}-${{ matrix.target }}.cdx.json

      - name: Preserve offline attestation bundles
        shell: bash
        env:
          PROVENANCE_BUNDLE: ${{ steps.provenance.outputs.bundle-path }}
          SBOM_BUNDLE: ${{ steps.sbom.outputs.bundle-path }}
        run: |
          cp "$PROVENANCE_BUNDLE" "bobbin-${{ env.VERSION }}-${{ matrix.target }}.${{ matrix.archive }}.sigstore.json"
          cp "$SBOM_BUNDLE" "bobbin-${{ env.VERSION }}-${{ matrix.target }}.${{ matrix.archive }}.sbom.sigstore.json"

      - name: Build repository index pack
        if: matrix.target == 'x86_64-unknown-linux-gnu'
        shell: bash
        run: |
          # Bobbin's own canonical pack rides each version release. The filename
          # gives clone/bootstrap consumers an immutable URL, while the embedded
          # manifest remains the authority for the exact repository SHA and
          # embedding/tool identity. Rebuild it at every tag; never carry a pack
          # forward merely because the source diff looks irrelevant to indexing.
          PACK="bobbin-${{ env.VERSION }}-repository.bbpack"
          PACK_HOME=$(mktemp -d)
          mkdir -p "$PACK_HOME/home"
          ORT_LIB_DIR=$(find /tmp/ort-extracted -type d -name lib | head -1)
          export LD_LIBRARY_PATH="$ORT_LIB_DIR${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
          BOBBIN="target/${{ matrix.target }}/release/bobbin"

          "$BOBBIN" init "$PACK_HOME/home"
          "$BOBBIN" index "$PACK_HOME/home" \
            --repo bobbin \
            --source "$GITHUB_WORKSPACE" \
            --skip-calibrate
          "$BOBBIN" pack export "$PACK_HOME/home" \
            --repo bobbin \
            --source "$GITHUB_WORKSPACE" \
            --output "$PACK"
          "$BOBBIN" pack verify "$PACK" --path "$PACK_HOME/home"
          sha256sum "$PACK" > "$PACK.sha256"

      - name: Upload artifact
        uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
        with:
          name: bobbin-${{ matrix.target }}
          path: |
            bobbin-${{ env.VERSION }}-${{ matrix.target }}.${{ matrix.archive }}
            bobbin-${{ env.VERSION }}-${{ matrix.target }}.cdx.json
            bobbin-${{ env.VERSION }}-${{ matrix.target }}.${{ matrix.archive }}.sigstore.json
            bobbin-${{ env.VERSION }}-${{ matrix.target }}.${{ matrix.archive }}.sbom.sigstore.json
            bobbin-${{ env.VERSION }}-repository.bbpack
            bobbin-${{ env.VERSION }}-repository.bbpack.sha256

      # Release Please creates a DRAFT release and forces the tag so this
      # workflow can start without exposing an uninstallable `latest` release.
      # Upload each archive to that draft here instead of waiting for
      # `needs: build` below:
      # the macOS cross-builds have taken ~100 minutes, while the Linux deploy
      # needs only x86_64 Linux.  Serialising publication behind the slowest
      # matrix leg kept a perfectly healthy deploy stale long enough to page
      # BobbinDeployNotFired on consecutive releases (aegis-if8dp).
      #
      # The x86_64 Linux leg also publishes a deliberately partial checksum
      # manifest.  deploy-from-release.sh downloads exactly that tarball and
      # refuses it without a matching checksum, so the pair makes the release
      # deployable immediately.  The final release job replaces this manifest
      # after every target completes; its SHA256SUMS.txt covers every final
      # asset and remains the authoritative completed-release manifest.
      - name: Generate early Linux deploy checksum
        if: matrix.target == 'x86_64-unknown-linux-gnu'
        shell: bash
        run: |
          sha256sum \
            "bobbin-${{ env.VERSION }}-${{ matrix.target }}.${{ matrix.archive }}" \
            "bobbin-${{ env.VERSION }}-repository.bbpack" \
            > SHA256SUMS.txt

      # Every GitHub-mutating publish step below runs once, backs off, and
      # retries ONCE on failure (aegis-25sovf). The first attempt is
      # continue-on-error; the retry is not, so a sustained failure still fails
      # the job. Retrying is safe because softprops/action-gh-release finds the
      # release by tag (drafts included) and replaces same-named assets, so a
      # second run converges the SAME draft instead of creating another. A single
      # transient HttpError here once left a draft missing its deploy asset and
      # stalled the deploy ~2h (aegis-ttk1y.1). Keep each retry's `with:` block
      # identical to its first attempt.
      - name: Publish Linux deploy archive immediately
        id: publish_deploy
        if: matrix.target == 'x86_64-unknown-linux-gnu'
        continue-on-error: true
        uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2
        with:
          tag_name: ${{ env.VERSION }}
          # Load-bearing: omitting this publishes the existing draft during
          # the first matrix upload, before the deploy pair is complete.
          draft: true
          files: |
            bobbin-${{ env.VERSION }}-${{ matrix.target }}.${{ matrix.archive }}
            bobbin-${{ env.VERSION }}-${{ matrix.target }}.cdx.json
            bobbin-${{ env.VERSION }}-${{ matrix.target }}.${{ matrix.archive }}.sigstore.json
            bobbin-${{ env.VERSION }}-${{ matrix.target }}.${{ matrix.archive }}.sbom.sigstore.json
            bobbin-${{ env.VERSION }}-repository.bbpack
            bobbin-${{ env.VERSION }}-repository.bbpack.sha256
            SHA256SUMS.txt
          fail_on_unmatched_files: true

      - name: Back off before retrying publish Linux deploy archive immediately
        if: steps.publish_deploy.outcome == 'failure'
        run: sleep 30

      - name: Retry publish Linux deploy archive immediately after a transient failure
        if: steps.publish_deploy.outcome == 'failure'
        uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2
        with:
          tag_name: ${{ env.VERSION }}
          # Load-bearing: omitting this publishes the existing draft during
          # the first matrix upload, before the deploy pair is complete.
          draft: true
          files: |
            bobbin-${{ env.VERSION }}-${{ matrix.target }}.${{ matrix.archive }}
            bobbin-${{ env.VERSION }}-${{ matrix.target }}.cdx.json
            bobbin-${{ env.VERSION }}-${{ matrix.target }}.${{ matrix.archive }}.sigstore.json
            bobbin-${{ env.VERSION }}-${{ matrix.target }}.${{ matrix.archive }}.sbom.sigstore.json
            bobbin-${{ env.VERSION }}-repository.bbpack
            bobbin-${{ env.VERSION }}-repository.bbpack.sha256
            SHA256SUMS.txt
          fail_on_unmatched_files: true

      - name: Publish target archive immediately
        id: publish_target
        if: matrix.target != 'x86_64-unknown-linux-gnu'
        continue-on-error: true
        uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2
        with:
          tag_name: ${{ env.VERSION }}
          draft: true
          files: |
            bobbin-${{ env.VERSION }}-${{ matrix.target }}.${{ matrix.archive }}
            bobbin-${{ env.VERSION }}-${{ matrix.target }}.cdx.json
            bobbin-${{ env.VERSION }}-${{ matrix.target }}.${{ matrix.archive }}.sigstore.json
            bobbin-${{ env.VERSION }}-${{ matrix.target }}.${{ matrix.archive }}.sbom.sigstore.json
          fail_on_unmatched_files: true

      - name: Back off before retrying publish target archive immediately
        if: steps.publish_target.outcome == 'failure'
        run: sleep 30

      - name: Retry publish target archive immediately after a transient failure
        if: steps.publish_target.outcome == 'failure'
        uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2
        with:
          tag_name: ${{ env.VERSION }}
          draft: true
          files: |
            bobbin-${{ env.VERSION }}-${{ matrix.target }}.${{ matrix.archive }}
            bobbin-${{ env.VERSION }}-${{ matrix.target }}.cdx.json
            bobbin-${{ env.VERSION }}-${{ matrix.target }}.${{ matrix.archive }}.sigstore.json
            bobbin-${{ env.VERSION }}-${{ matrix.target }}.${{ matrix.archive }}.sbom.sigstore.json
          fail_on_unmatched_files: true

  checksums:
    name: Generate checksums
    needs: build
    runs-on: ubuntu-latest
    steps:
      - name: Download all artifacts
        uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
        with:
          path: artifacts

      - name: Generate SHA256 checksums
        run: |
          cd artifacts
          find . -type f \( -name "*.tar.gz" -o -name "*.bbpack" -o -name "*.bbpack.sha256" -o -name "*.cdx.json" -o -name "*.sigstore.json" \) -exec mv {} . \;
          rm -rf bobbin-*/
          sha256sum * > SHA256SUMS.txt
          cat SHA256SUMS.txt

      - name: Upload checksums
        uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
        with:
          name: checksums
          path: artifacts/SHA256SUMS.txt

  release:
    name: Create release
    needs: [release-please, build, checksums]
    # `always()` because release-please is SKIPPED on the tag-push and
    # workflow_dispatch arms, and a skipped dependency would otherwise skip this
    # job with it. The explicit result checks keep the ordinary main push (where
    # build itself is skipped) from finalizing a release that was never built.
    if: >-
      always() &&
      needs.build.result == 'success' &&
      needs.checksums.result == 'success'
    runs-on: ubuntu-latest
    env:
      VERSION: ${{ inputs.version || (startsWith(github.ref, 'refs/tags/') && github.ref_name) || needs.release-please.outputs.tag_name }}
    permissions:
      contents: write
    steps:
      - name: Download all artifacts
        uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
        with:
          path: artifacts

      - name: Prepare release assets
        run: |
          cd artifacts
          # Extract the linux-amd64 binary as a standalone for homelab deployment
          find . -name "bobbin-*-x86_64-unknown-linux-gnu.tar.gz" -exec tar xzf {} -C . \;
          LINUX_DIR=$(find . -maxdepth 1 -type d -name "bobbin-*-x86_64-unknown-linux-gnu" | head -1)
          cp "$LINUX_DIR/bobbin" bobbin-linux-amd64
          # Also publish the ONNX Runtime .so for homelab deployment
          cp "$LINUX_DIR"/lib/libonnxruntime.so.* libonnxruntime-linux-amd64.so 2>/dev/null || true
          rm -rf "$LINUX_DIR"

          # Extract the linux-arm64 binary as a standalone
          find . -name "bobbin-*-aarch64-unknown-linux-gnu.tar.gz" -exec tar xzf {} -C . \;
          ARM_DIR=$(find . -maxdepth 1 -type d -name "bobbin-*-aarch64-unknown-linux-gnu" | head -1)
          if [ -n "$ARM_DIR" ]; then
            cp "$ARM_DIR/bobbin" bobbin-linux-arm64
            cp "$ARM_DIR"/lib/libonnxruntime.so.* libonnxruntime-linux-arm64.so 2>/dev/null || true
            rm -rf "$ARM_DIR"
          fi

          find . -type f \( -name "*.tar.gz" -o -name "*.bbpack" -o -name "*.bbpack.sha256" -o -name "*.cdx.json" -o -name "*.sigstore.json" -o -name "SHA256SUMS.txt" -o -name "bobbin-linux-*" -o -name "libonnxruntime-linux-*.so" \) -exec mv {} . \; 2>/dev/null || true
          rm -rf bobbin-*/ checksums/

          # The checksums job runs before the standalone Linux binaries are
          # extracted, so its manifest can only cover the archives. Regenerate
          # the release manifest here, after every published asset exists in the
          # same directory. This keeps the convenient raw binaries verifiable
          # instead of making them an attractive unchecked download path.
          rm -f SHA256SUMS.txt
          find . -maxdepth 1 -type f ! -name SHA256SUMS.txt -printf '%f\0' \
            | sort -z \
            | xargs -0 sha256sum > SHA256SUMS.txt
          echo "Final release checksums:"
          cat SHA256SUMS.txt
          ls -la

      - name: Finalize GitHub Release assets and checksums
        id: finalize_assets
        continue-on-error: true
        uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2
        with:
          tag_name: ${{ env.VERSION }}
          draft: true
          files: artifacts/*
          generate_release_notes: true
          fail_on_unmatched_files: true

      - name: Back off before retrying finalize GitHub Release assets and checksums
        if: steps.finalize_assets.outcome == 'failure'
        run: sleep 30

      - name: Retry finalize GitHub Release assets and checksums after a transient failure
        if: steps.finalize_assets.outcome == 'failure'
        uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2
        with:
          tag_name: ${{ env.VERSION }}
          draft: true
          files: artifacts/*
          generate_release_notes: true
          fail_on_unmatched_files: true

      # Publish, and claim `latest` only if this really IS the latest.
      #
      # WHY (aegis-egqrv4). This step used to pass `--latest` unconditionally, so
      # the LAST release run to reach it won `latest` regardless of version. That
      # is reachable rather than theoretical: the job gates on `needs.build`, i.e.
      # ALL FOUR matrix legs, and the macOS legs dominate — a measured 79m41s end
      # to end for v0.15.0, against the ~100 minutes recorded at the top of this
      # file. Merge a second release PR inside that window, which is ordinary when
      # changes are landing, and two ~80-minute matrices run concurrently with
      # nothing ordering their finishes.
      #
      # The consequence is silent. Bobbin deploys by `github-release`: aegis-cd
      # resolves the latest PUBLISHED release every 15 minutes and cuts over to
      # it. So an inversion does not mislabel a page, it DEPLOYS THE OLDER BUILD
      # OVER THE NEWER ONE — with no failed run, no alert, and both releases
      # present and complete. The newer release simply is not `latest`.
      #
      # `--latest` cannot just be dropped: a release published with no latest
      # marker leaves a healthy deploy stale until something pages, which is
      # aegis-if8dp. The property to hold is narrower — a completing OLDER release
      # must not demote a published NEWER one — so the flag is decided rather than
      # assumed.
      - name: Publish complete GitHub Release
        shell: bash
        run: |
          set -euo pipefail
          version="${{ env.VERSION }}"

          # Bounded retry for transient GitHub API failures (aegis-25sovf). Each
          # call is idempotent: a list is a read, and `release edit` sets state.
          retry() {
            local attempt
            for attempt in 1 2 3; do
              "$@" && return 0
              [ "$attempt" -lt 3 ] || break
              echo "attempt $attempt failed: $*; retrying in $((attempt * 15))s" >&2
              sleep $((attempt * 15))
            done
            return 1
          }

          # Highest published (non-draft, non-prerelease) version, by semver.
          # `sort -V` is version-aware, so 0.9.0 sorts below 0.10.0 where a
          # lexical sort would not.
          highest="$(retry gh release list --repo "${{ github.repository }}" \
                       --exclude-drafts --exclude-pre-releases --limit 200 \
                       --json tagName --jq '.[].tagName' \
                     | sed 's/^v//' | sort -V | tail -1)"
          mine="${version#v}"

          # A PRERELEASE must never claim `latest`, whatever it sorts against.
          # Two reasons, the second being why this is not merely tidiness
          # (wu, reviewing aegis-egqrv4; quipu hit the same shape in #142):
          #   * `latest` is what the deploy timer resolves, so a prerelease
          #     claiming it would be DEPLOYED — the exact inversion this guard
          #     exists to stop, arriving by another route.
          #   * `sort -V` is not semver-aware about prereleases: it ranks
          #     0.17.0-rc1 ABOVE both 0.16.0 and 0.17.0, where semver puts a
          #     prerelease BELOW its own final. Excluding prereleases on both
          #     sides means that disagreement can never decide anything;
          #     `--exclude-pre-releases` above covers the other side.
          # Reachable with an arbitrary tag via workflow_dispatch, so not
          # hypothetical.
          case "$mine" in
            *-*)
              echo "publishing $version WITHOUT --latest: it is a PRERELEASE."
              retry gh release edit "$version" --repo "${{ github.repository }}" --draft=false --latest=false
              exit 0
              ;;
          esac

          if [ -z "$highest" ] || [ "$(printf '%s\n%s\n' "$highest" "$mine" | sort -V | tail -1)" = "$mine" ]; then
            echo "publishing $version as latest (highest published: ${highest:-none})"
            retry gh release edit "$version" --repo "${{ github.repository }}" --draft=false --latest
          else
            echo "publishing $version WITHOUT --latest: $highest is already published and is newer."
            echo "This is the aegis-egqrv4 guard. The release is still published and its"
            echo "assets are still available; it simply does not demote a newer release."
            retry gh release edit "$version" --repo "${{ github.repository }}" --draft=false --latest=false
          fi
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

  # Open/refresh the release PR on every main push; merging it tags + releases.
  # release-please works from commit history and the manifest directly. Unlike
  # release-plz it does not run `cargo package`, which cannot represent Bobbin's
  # deliberately git-pinned Quipu dependency: Cargo strips the git source from
  # packaged manifests and then tries to resolve the unrelated crates.io crate.
  release-please:
    name: Release Please
    if: startsWith(github.ref, 'refs/heads/')
    runs-on: ubuntu-latest
    permissions:
      pull-requests: write
      contents: write
    # Exported so `build` can run IN THIS SAME RUN when the release PR merge
    # creates a release. See the trigger note on `build` for why a tag push
    # cannot be relied on to do it.
    outputs:
      releases_created: ${{ steps.release.outputs.releases_created }}
      tag_name: ${{ steps.release.outputs.tag_name }}
    steps:
      - uses: googleapis/release-please-action@5c625bfb5d1ff62eadeeb3772007f7f66fdcf071 # v4
        id: release
        with:
          token: ${{ secrets.GITHUB_TOKEN }}

  crates:
    name: Publish to crates.io
    needs: [release-please, build, checksums, release]
    if: >-
      always() &&
      needs.build.result == 'success' &&
      needs.checksums.result == 'success' &&
      needs.release.result == 'success'
    runs-on: ubuntu-latest
    timeout-minutes: 30
    permissions:
      contents: read
      id-token: write
    env:
      VERSION: ${{ inputs.version || (startsWith(github.ref, 'refs/tags/') && github.ref_name) || needs.release-please.outputs.tag_name }}
    steps:
      - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
        with:
          ref: ${{ env.VERSION }}
          fetch-depth: 0
      - name: Verify publishing guard
        run: bash scripts/ci/crates-publish-guard.sh --selftest
      - name: Resolve independently declared release version
        id: want
        run: |
          set -euo pipefail
          echo "version=${VERSION#v}" >> "$GITHUB_OUTPUT"
      - name: Install package build dependencies
        run: sudo apt-get update && sudo apt-get install -y protobuf-compiler cmake g++
      - uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de # master 2026-09-12
        with:
          toolchain: "1.98.1"
      - uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2
      - uses: ./.github/actions/crates-publish
        with:
          expected-version: ${{ steps.want.outputs.version }}
          dry-run: 'false'