all-smi 0.26.2

Command-line utility for monitoring GPU hardware. It provides a real-time view of GPU utilization, memory usage, temperature, power consumption, and other metrics.
Documentation
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
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
name: Release

# SECURITY: this repository is public and this workflow schedules jobs on
# self-hosted runners (the Windows signing box and the Intel Mac builder).
# Both triggers below are maintainer-only: `release: published` requires push
# access to publish a release, and `workflow_dispatch` requires write access.
# Neither can be fired by a pull request, so a fork PR can never run code on a
# self-hosted machine. Do NOT add `pull_request`, `pull_request_target`, or
# `issue_comment` triggers here, and keep self-hosted runners out of ci.yml
# (which is PR-triggered and runs on GitHub-hosted runners only).
on:
  release:
    types: [published]
  workflow_dispatch:
    inputs:
      update_homebrew:
        description: 'Update Homebrew formula after build'
        required: false
        default: 'false'
        type: choice
        options: ['true','false']
      release_tag:
        description: 'Tag to build from AND upload artifacts to (e.g. v1.2.3). Empty = build the dispatched ref.'
        required: false
      targets:
        description: 'Platform families to build (comma-separated: windows, linux, macos, or all). Empty = all. "macos" covers both aarch64 and x86_64.'
        required: false
        default: ''
        type: string

permissions:
  contents: write

jobs:
  # Resolve which platforms to build into a matrix. A real release always
  # builds every target; the `targets` input only filters manual dispatch
  # runs (empty => all). The build job's matrix is generated from this so
  # there is a single source of truth for the platform list.
  setup:
    name: Resolve build matrix
    runs-on: ubuntu-latest
    outputs:
      includes: ${{ steps.resolve.outputs.includes }}
      all_assets: ${{ steps.resolve.outputs.all_assets }}
    steps:
      - name: Resolve target platforms
        id: resolve
        env:
          # release event => build everything; the input only applies to dispatch.
          TARGETS: ${{ github.event_name == 'release' && 'all' || github.event.inputs.targets }}
        run: |
          set -euo pipefail

          # Full build matrix; each entry is tagged with os_family for filtering.
          # JSON uses only double quotes, so a single-quoted bash literal is safe
          # and avoids heredoc indentation pitfalls inside this YAML block.
          #
          # `os` becomes the job's runs-on value. It is a label string for every
          # GitHub-hosted runner and for the label-targeted Windows box, and a
          # {group, labels} object for the self-hosted Intel Mac, which has to be
          # selected out of a specific runner group. runs-on is evaluated per
          # matrix job instance, so the two shapes coexist without affecting each
          # other.
          ALL='[
            {"os_family":"linux",   "target":"x86_64-unknown-linux-gnu",    "os":"ubuntu-22.04",             "artifact_name":"all-smi",     "asset_name":"all-smi-linux-x86_64",       "archive_ext":".tar.gz", "protoc_platform":"linux-x86_64"},
            {"os_family":"linux",   "target":"x86_64-unknown-linux-musl",   "os":"ubuntu-latest",           "artifact_name":"all-smi",     "asset_name":"all-smi-linux-x86_64-musl",  "archive_ext":".tar.gz", "protoc_platform":"linux-x86_64"},
            {"os_family":"linux",   "target":"aarch64-unknown-linux-gnu",   "os":"ubuntu-22.04-arm",        "artifact_name":"all-smi",     "asset_name":"all-smi-linux-aarch64",      "archive_ext":".tar.gz", "protoc_platform":"linux-aarch_64"},
            {"os_family":"linux",   "target":"aarch64-unknown-linux-musl",  "os":"ubuntu-24.04-arm",        "artifact_name":"all-smi",     "asset_name":"all-smi-linux-aarch64-musl", "archive_ext":".tar.gz", "protoc_platform":"linux-aarch_64"},
            {"os_family":"macos",   "target":"aarch64-apple-darwin",        "os":"macos-14",                "artifact_name":"all-smi",     "asset_name":"all-smi-macos-aarch64",      "archive_ext":".zip",    "protoc_platform":"osx-aarch_64"},
            {"os_family":"macos",   "target":"x86_64-apple-darwin",         "os":{"group":"macOS x64","labels":"self-hosted-macos-15-x64"}, "artifact_name":"all-smi", "asset_name":"all-smi-macos-x86_64", "archive_ext":".zip", "protoc_platform":"osx-x86_64"},
            {"os_family":"windows", "target":"x86_64-pc-windows-msvc",      "os":"windows-on-macmini02-x64","artifact_name":"all-smi.exe", "asset_name":"all-smi-windows-x86_64",     "archive_ext":".zip",    "protoc_platform":""}
          ]'

          # Normalize selection: lowercase, strip spaces; empty => all.
          sel="$(printf '%s' "${TARGETS:-}" | tr '[:upper:]' '[:lower:]' | tr -d '[:space:]')"
          if [ -z "$sel" ]; then sel="all"; fi

          # Validate tokens and expand `all` into concrete families.
          fams=""
          IFS=','
          for tok in $sel; do
            case "$tok" in
              all) fams="windows linux macos" ;;
              windows|linux|macos) fams="$fams $tok" ;;
              "") ;;
              *) echo "::error::Unknown build target '$tok'. Allowed: windows, linux, macos, all."; exit 1 ;;
            esac
          done
          unset IFS

          fam_json="$(printf '%s\n' $fams | awk 'NF' | sort -u | jq -R . | jq -cs .)"
          includes="$(printf '%s' "$ALL" | jq -c --argjson fams "$fam_json" \
            '[ .[] | select(.os_family as $f | $fams | index($f)) ]')"

          if [ "$(printf '%s' "$includes" | jq 'length')" -eq 0 ]; then
            echo "::error::No build targets selected."; exit 1
          fi

          echo "Building families: $fam_json"
          printf '%s' "$includes" | jq -r '.[] | "  - " + .target'
          echo "includes=$includes" >> "$GITHUB_OUTPUT"

          # Every asset a complete release carries, from the UNFILTERED matrix.
          # `promote-release` needs this: a dispatch may build one family, and
          # promoting on the strength of that alone would publish a release
          # missing the targets this run never touched.
          all_assets="$(printf '%s' "$ALL" | jq -c '[ .[] | .asset_name + .archive_ext ]')"
          echo "all_assets=$all_assets" >> "$GITHUB_OUTPUT"

  build:
    name: Build ${{ matrix.target }}
    needs: setup
    runs-on: ${{ matrix.os }}
    environment: packaging

    strategy:
      fail-fast: false
      matrix:
        include: ${{ fromJSON(needs.setup.outputs.includes) }}

    env:
      BIN_NAME: all-smi
      BUNDLE_ID: ${{ vars.BUNDLE_ID }}
      # Must match the release asset filename version in protoc releases (e.g. "30.2" or "32.0-rc-2")
      PROTOC_VERSION: "31.1"

    steps:
      # 1) Checkout repository.
      # Self-healing release: build the *source of the target tag* while running
      # the *current* workflow definition. GitHub always executes the release.yml
      # baked into the triggering ref, so a bug in an old tag's workflow (e.g. a
      # broken notarization step) cannot be fixed by re-running that tag. To
      # recover, dispatch this workflow from a branch carrying the fixed
      # release.yml and pass the old tag as release_tag: the fixed workflow runs,
      # but the old tag's source is what gets built and uploaded.
      #   - release event      -> the published release's tag
      #   - workflow_dispatch  -> the release_tag input, when provided
      #   - otherwise          -> the exact commit that triggered the run
      - name: Checkout code
        uses: actions/checkout@v6
        with:
          ref: ${{ github.event.release.tag_name || github.event.inputs.release_tag || github.sha }}

      # 1.2) The composite actions this workflow calls.
      #
      # Step 1 deliberately checks out the *tag's* source. But `uses: ./...`
      # resolves against the workspace, not against the ref this workflow was
      # loaded from, so a tag older than an action the workflow calls fails
      # with "Can't find 'action.yml'". Recovering v0.26.0 hit exactly that:
      # release.yml came from main and called an action main had just gained,
      # while the workspace held v0.26.0, which did not.
      #
      # `github.sha` is the ref that supplied release.yml under both triggers
      # (the dispatched branch, or the release's tag), so this keeps the
      # workflow and the actions it calls from drifting apart, which is what
      # the self-healing design was after in the first place.
      - name: Checkout workflow actions
        uses: actions/checkout@v6
        with:
          ref: ${{ github.sha }}
          sparse-checkout: .github/actions
          sparse-checkout-cone-mode: false
          path: .workflow-actions

      # 1.5) Pin Cargo/rustup to persistent paths on the Windows self-hosted
      # runner so registry/git/target survive workspace cleanup between jobs.
      # Must run before any cargo or rustup invocation so they use these paths.
      - name: Setup persistent cache paths (Windows self-hosted)
        if: matrix.target == 'x86_64-pc-windows-msvc'
        shell: pwsh
        run: |
          $cargoHome = "C:\.cargo"
          if (!(Test-Path $cargoHome)) { New-Item -ItemType Directory -Path $cargoHome -Force | Out-Null }
          echo "CARGO_HOME=$cargoHome" >> $env:GITHUB_ENV
          echo "RUSTUP_HOME=C:\.rustup" >> $env:GITHUB_ENV

      # 2) Cache Cargo build artifacts
      # Skipped on the self-hosted runners, for different reasons on each.
      # The Windows box has a persistent CARGO_HOME and RUSTUP_HOME (configured
      # above), so the Actions cache would only mirror state already on disk.
      # On the Intel Mac only ~/.cargo survives between jobs: actions/checkout
      # defaults to clean: true, whose `git clean -ffdx` removes the ignored
      # target/ directory, so each release compiles the dependency tree cold.
      # That is accepted here because releases are infrequent and a release
      # target/ is large enough that caching it would crowd the repository's
      # 10 GB cache budget shared with the other five targets. Revisit with
      # real build times if the Intel job becomes the critical path.
      - name: Cache cargo
        if: matrix.target != 'x86_64-pc-windows-msvc' && matrix.target != 'x86_64-apple-darwin'
        uses: actions/cache@v5
        with:
          path: |
            ~/.cargo/registry
            ~/.cargo/git
            target
          key: ${{ runner.os }}-cargo-${{ matrix.target }}-${{ hashFiles('**/Cargo.lock') }}
          restore-keys: |
            ${{ runner.os }}-cargo-${{ matrix.target }}-

      # 3) Install Rust toolchain
      - name: Install Rust toolchain
        uses: dtolnay/rust-toolchain@stable

      # 4) Add target architecture
      - name: Add target architecture
        run: rustup target add ${{ matrix.target }}

      # 5) Install musl tools only for musl builds
      - name: Install musl tools (Linux musl only)
        if: contains(matrix.target, 'musl')
        run: |
          sudo apt update
          sudo apt install -y musl-tools

      # 5.5) Install AMD GPU dependencies for Linux builds
      - name: Install AMD GPU dependencies (Linux only)
        if: runner.os == 'Linux'
        run: |
          sudo apt update
          sudo apt install -y libdrm-dev libdrm-amdgpu1

      # 6) Setup protoc (cached) - skip on Windows as TPU support is Linux-only
      - name: Setup protoc
        if: runner.os != 'Windows'
        uses: ./.workflow-actions/.github/actions/setup-protoc
        with:
          version: ${{ env.PROTOC_VERSION }}
          platform: ${{ matrix.protoc_platform }}

      # 7) Build the release binary
      - name: Build release binary
        run: cargo build --release --target ${{ matrix.target }} --locked -p all-smi

      - name: Build and verify AMD runtime plugin (Linux glibc only)
        if: runner.os == 'Linux' && !contains(matrix.target, 'musl')
        run: |
          set -euo pipefail
          echo "AMD_PLUGIN_BUILT=false" >> "$GITHUB_ENV"
          if [ ! -f crates/all-smi-amd-plugin/Cargo.toml ]; then
            echo "::notice::The selected source tag predates the AMD companion crate; preserving the self-healing old-tag release path."
            exit 0
          fi
          cargo build --release --target ${{ matrix.target }} --locked -p all-smi-amd-plugin
          BIN="target/${{ matrix.target }}/release/${{ matrix.artifact_name }}"
          PLUGIN="target/${{ matrix.target }}/release/liball_smi_amd.so"
          test -f "$PLUGIN"
          if objdump -p "$BIN" | grep -q 'NEEDED.*libdrm'; then
            echo "::error::$BIN inherits libdrm even though AMD is runtime-loaded"
            exit 1
          fi
          if ! objdump -p "$PLUGIN" | grep -q 'NEEDED.*libdrm_amdgpu'; then
            echo "::error::$PLUGIN does not own the expected libdrm_amdgpu linkage"
            exit 1
          fi
          report="${RUNNER_TEMP}/amd-plugin-doctor.json"
          ALL_SMI_AMD_PLUGIN="$PWD/$PLUGIN" "$BIN" doctor --only amd --json > "$report"
          cat "$report"
          test "$(jq -r '.checks[] | select(.id=="amd.libamdgpu_top.abi") | .status' "$report")" = pass
          echo "AMD_PLUGIN_BUILT=true" >> "$GITHUB_ENV"

      # 8) macOS code signing
      #
      # rcodesign, not Apple's codesign, and therefore no keychain at all.
      #
      # The keychain route this replaces worked on GitHub-hosted macOS and
      # failed on the self-hosted Intel Mac, with the same certificate and the
      # same command:
      #
      #   Warning: unable to build chain to self-signed root for signer
      #            "Developer ID Application: Lablup Inc. (NVWJ7XZ6BY)"
      #   all-smi: errSecInternalComponent
      #
      # That is codesign asking the Security framework to complete a trust
      # chain out of host keychain state, on a machine with no logged-in GUI
      # session. rcodesign reads the certificate from a PEM and builds the
      # signature itself, so the whole failure class is gone rather than
      # worked around, and it no longer matters which identities happen to be
      # in the runner's keychain.
      #
      # Mirrored from lablup/bssh. The certificate-type gate inside the action
      # is the reason that pipeline exists: bssh shipped releases signed by an
      # "Apple Distribution" certificate, an App Store submission identity
      # that Gatekeeper rejects for downloads, and when it was revoked macOS
      # began deleting installed binaries as malware. codesign had verified
      # those signatures happily, because nothing ever checked the authority.
      - name: Prepare signing certificate and tools
        if: runner.os == 'macOS'
        uses: ./.workflow-actions/.github/actions/macos-signing-setup
        with:
          certificate: ${{ secrets.DEV_ID_CERT_P12 }}
          certificate-password: ${{ secrets.DEV_ID_CERT_PASSWORD }}
          # Release artifacts must never ship unsigned, so a missing or wrong
          # certificate fails the job rather than degrading quietly.
          required: "true"

      # Sign into `package/` rather than in place. `target/` is restored from
      # actions/cache across runs, so signing the build output there would put
      # a signed binary into the cache; every later step reads the staged copy
      # instead, and the packaging step below zips this same directory.
      - name: Sign macOS binary
        if: runner.os == 'macOS'
        run: |
          set -euo pipefail

          BIN="target/${{ matrix.target }}/release/${{ matrix.artifact_name }}"
          if [ ! -f "$BIN" ]; then
            echo "::error::binary to sign not found: $BIN"
            exit 1
          fi

          mkdir -p package
          STAGED="package/${{ matrix.artifact_name }}"
          cp "$BIN" "$STAGED"
          chmod +x "$STAGED"

          # A composite action cannot enforce `required` on its inputs at
          # runtime, and an empty identifier would make rcodesign fall back to
          # the bare file name. Pin it, or the sealed identifier drifts with
          # the artifact name.
          if [ -z "${BUNDLE_ID:-}" ]; then
            echo "::error::BUNDLE_ID is not set on the packaging environment; set it to a reverse-DNS identifier such as com.lablup.all-smi"
            exit 1
          fi

          # rcodesign requests a secure timestamp from Apple by default, which
          # notarization requires. No entitlements: all-smi is a self-contained
          # Rust CLI that links no third-party dylib, so the hardened runtime
          # applies with Apple's defaults.
          rcodesign sign \
            --pem-file "$PEM_FILE" \
            --code-signature-flags runtime \
            --binary-identifier "$BUNDLE_ID" \
            "$STAGED"

          echo "=== Verifying signature ==="
          # `|| true` so a codesign failure surfaces as the named assertions
          # below rather than as a bare non-zero exit under `set -e`.
          CODESIGN_OUTPUT="$(codesign -dv --verbose=4 "$STAGED" 2>&1 || true)"
          echo "$CODESIGN_OUTPUT"

          # Matched from here-strings, not `echo | grep`: this runs under
          # pipefail, where `grep -q` closing the pipe early can surface as a
          # failed pipeline whether or not the pattern matched.
          #
          # This is the assertion that would have caught bssh's defect.
          if ! grep -q "Authority=Developer ID Application" <<<"$CODESIGN_OUTPUT"; then
            echo "::error::the binary is not signed by a Developer ID Application authority, so Gatekeeper will refuse it on download"
            exit 1
          fi
          # By flag name, not by a literal hex value: other code signature
          # flags combine into the same field.
          if ! grep -Eq 'flags=0x[0-9a-f]+\(.*runtime.*\)' <<<"$CODESIGN_OUTPUT"; then
            echo "::error::the binary is missing the hardened runtime flag, which notarization requires"
            exit 1
          fi
          if ! grep -qxF "Identifier=$BUNDLE_ID" <<<"$CODESIGN_OUTPUT"; then
            echo "::error::the binary was signed with a code signature identifier other than the requested $BUNDLE_ID"
            exit 1
          fi
          echo "Signed as $BUNDLE_ID with a Developer ID Application authority and the hardened runtime"

      # 8.5) macOS notarization via App Store Connect API key
      # We submit a temporary zip wrapping the already-codesigned binary
      # (notarytool only accepts .zip/.dmg/.pkg). Stapling is intentionally
      # skipped: `xcrun stapler` only operates on .app/.dmg/.pkg containers,
      # not on bare Mach-O executables. Gatekeeper verifies the notarization
      # ticket online at first launch instead.
      - name: Notarize macOS binary
        if: runner.os == 'macOS'
        env:
          AC_API_KEY_ID: ${{ secrets.AC_API_KEY_ID }}
          AC_API_ISSUER_ID: ${{ secrets.AC_API_ISSUER_ID }}
          AC_API_PRIVATE_KEY_P8: ${{ secrets.AC_API_PRIVATE_KEY_P8 }}
        run: |
          set -euo pipefail

          # The staged copy the signing step produced. Notarizing the build
          # output in target/ would submit an unsigned binary, which
          # notarytool rejects.
          BIN="package/${{ matrix.artifact_name }}"
          NOTARIZE_ZIP="${RUNNER_TEMP}/notarize.zip"
          KEY_FILE="${RUNNER_TEMP}/AuthKey.p8"

          # Always wipe the API key file, even on early failure.
          cleanup() {
            rm -f "$KEY_FILE" "$NOTARIZE_ZIP"
          }
          trap cleanup EXIT

          # The AC_API_PRIVATE_KEY_P8 secret stores the App Store Connect .p8
          # key base64-encoded, the same convention DEV_ID_CERT_P12 follows for
          # the signing certificate. Decode it back to PEM here. Also tolerate a raw .p8 pasted verbatim, and strip CR so a
          # CRLF-mangled paste still parses. Writing the secret verbatim without
          # decoding makes notarytool fail with the opaque "Error: invalidAsn1".
          if [ -z "${AC_API_PRIVATE_KEY_P8:-}" ]; then
            echo "::error::AC_API_PRIVATE_KEY_P8 secret is not set or empty"
            exit 1
          fi
          if printf '%s' "$AC_API_PRIVATE_KEY_P8" | grep -q 'BEGIN PRIVATE KEY'; then
            printf '%s\n' "$AC_API_PRIVATE_KEY_P8" | tr -d '\r' > "$KEY_FILE"
          else
            printf '%s' "$AC_API_PRIVATE_KEY_P8" | tr -d '[:space:]' | base64 --decode > "$KEY_FILE"
          fi
          chmod 600 "$KEY_FILE"

          # Surface a clear message if the materialized key is not a valid
          # PKCS#8 key, instead of the opaque notarytool "invalidAsn1" later.
          if command -v openssl >/dev/null 2>&1 && ! openssl pkey -in "$KEY_FILE" -noout 2>/dev/null; then
            echo "::warning::AC_API_PRIVATE_KEY_P8 did not parse as a PKCS#8 private key after decoding; notarization will likely fail. Re-store it as base64 of the .p8 file (base64 -i AuthKey_XXXX.p8 | pbcopy)."
          fi

          /usr/bin/ditto -c -k --keepParent "$BIN" "$NOTARIZE_ZIP"

          echo "Submitting notarization request..."
          SUBMIT_OUTPUT=$(xcrun notarytool submit "$NOTARIZE_ZIP" \
            --key "$KEY_FILE" \
            --key-id "$AC_API_KEY_ID" \
            --issuer "$AC_API_ISSUER_ID" \
            --wait --timeout 30m 2>&1) || true
          echo "$SUBMIT_OUTPUT"

          if echo "$SUBMIT_OUTPUT" | grep -qE 'status: (Invalid|Rejected)'; then
            SUBMISSION_ID=$(echo "$SUBMIT_OUTPUT" | awk '/^[[:space:]]*id:/ {print $2; exit}')
            if [ -n "${SUBMISSION_ID:-}" ]; then
              echo "Notarization failed (submission $SUBMISSION_ID). Fetching log..."
              xcrun notarytool log "$SUBMISSION_ID" \
                --key "$KEY_FILE" \
                --key-id "$AC_API_KEY_ID" \
                --issuer "$AC_API_ISSUER_ID" || true
            fi
            exit 1
          fi

          if ! echo "$SUBMIT_OUTPUT" | grep -q 'status: Accepted'; then
            echo "::error::notarytool did not report an Accepted status"
            exit 1
          fi

          # Gatekeeper verification. The ticket may take a moment to propagate;
          # treat a transient failure as a warning rather than a hard error.
          if /usr/sbin/spctl --assess --type execute --verbose "$BIN"; then
            echo "spctl assessment passed"
          else
            echo "::warning::spctl assessment did not pass yet; ticket may still be propagating"
          fi

      # 9) Package binaries
      - name: Package Linux binary (tar.gz)
        if: runner.os == 'Linux'
        run: |
          set -euo pipefail
          BIN_DIR=target/${{ matrix.target }}/release
          mkdir -p package
          cp "$BIN_DIR/${{ matrix.artifact_name }}" package/
          if [ "${AMD_PLUGIN_BUILT:-false}" = true ]; then
            cp "$BIN_DIR/liball_smi_amd.so" package/
          fi
          cp docs/man/all-smi.1 package/
          tar -C package -czf ${{ matrix.asset_name }}.tar.gz .

      # `package/` already holds the signed binary, staged and sealed by the
      # signing step above. Copying it again from target/ here would replace it
      # with the unsigned build output and ship an unsigned, notarization-less
      # artifact whose zip still looked correct.
      - name: Package macOS binary (zip)
        if: runner.os == 'macOS'
        run: |
          set -euo pipefail

          ASSET="${{ matrix.asset_name }}.zip"
          STAGED="package/${{ matrix.artifact_name }}"

          if [ ! -f "$STAGED" ]; then
            echo "::error::signed binary not found at $STAGED; the signing step must run before packaging"
            exit 1
          fi
          # Cheap guard against a future edit reintroducing the overwrite.
          #
          # `--verbose=4` is required, not cosmetic: plain `codesign -dv`
          # prints the identifier, format, and hashes but no `Authority=`
          # lines at all, so the grep below could never match and the guard
          # failed every run regardless of the signature. Same invocation as
          # the assertions in the signing step, for the same reason.
          #
          # Read into a variable rather than piping into `grep -q`. This step
          # runs under pipefail, where grep closing the pipe early can surface
          # as a failed pipeline whether or not the pattern matched.
          SIGNATURE="$(codesign -dv --verbose=4 "$STAGED" 2>&1 || true)"
          if ! grep -q "Authority=Developer ID Application" <<<"$SIGNATURE"; then
            echo "::error::$STAGED lost its Developer ID signature between signing and packaging"
            echo "$SIGNATURE"
            exit 1
          fi

          cp docs/man/all-smi.1 package/
          # ditto preserves the exec bit and the embedded signature.
          ditto -c -k --sequesterRsrc package "$ASSET"

      # Sign the Windows binary before packaging so the .zip artifact contains
      # the signed .exe. Signing runs on the self-hosted runner whose private
      # key lives in Google Cloud KMS (Sectigo EV cert). This is a PUBLIC repo,
      # so the certificate paths and the KMS key identifier are NOT committed:
      # scripts/sign-windows.ps1 reads them from the env vars below, which are
      # injected from repository secrets (GitHub masks secret values in logs).
      - name: Sign Windows binary with signtool
        if: runner.os == 'Windows'
        shell: pwsh
        env:
          # Required (configure as repository secrets):
          WINDOWS_SIGN_CERT_PATH: ${{ secrets.WINDOWS_SIGN_CERT_PATH }}
          WINDOWS_SIGN_CA_CERT_PATH: ${{ secrets.WINDOWS_SIGN_CA_CERT_PATH }}
          WINDOWS_SIGN_KEY_CONTAINER: ${{ secrets.WINDOWS_SIGN_KEY_CONTAINER }}
          # Optional overrides (unset -> non-sensitive script defaults are used):
          WINDOWS_SIGNTOOL_PATH: ${{ secrets.WINDOWS_SIGNTOOL_PATH }}
          WINDOWS_SIGN_CSP: ${{ secrets.WINDOWS_SIGN_CSP }}
          WINDOWS_SIGN_TIMESTAMP_URL: ${{ secrets.WINDOWS_SIGN_TIMESTAMP_URL }}
        run: |
          ./scripts/sign-windows.ps1 "target/${{ matrix.target }}/release/${{ matrix.artifact_name }}"

      - name: Package Windows binary (zip)
        if: runner.os == 'Windows'
        shell: pwsh
        run: |
          $BIN = "target/${{ matrix.target }}/release/${{ matrix.artifact_name }}"
          $ASSET = "${{ matrix.asset_name }}.zip"
          New-Item -ItemType Directory -Force -Path package
          Copy-Item $BIN -Destination package/
          Compress-Archive -Path package/* -DestinationPath $ASSET

      # 10) Generate checksum
      - name: Generate checksum (Linux/macOS)
        if: runner.os != 'Windows'
        run: |
          FILE="${{ matrix.asset_name }}${{ matrix.archive_ext }}"
          if [[ "$RUNNER_OS" == "Linux" ]]; then
            sha256sum "$FILE" > "$FILE.sha256"
          else
            shasum -a 256 "$FILE" > "$FILE.sha256"
          fi

      - name: Generate checksum (Windows)
        if: runner.os == 'Windows'
        shell: pwsh
        run: |
          $FILE = "${{ matrix.asset_name }}${{ matrix.archive_ext }}"
          $hash = (Get-FileHash -Path $FILE -Algorithm SHA256).Hash.ToLower()
          "$hash  $FILE" | Out-File -FilePath "$FILE.sha256" -Encoding ASCII -NoNewline

      # 11) Upload release artifacts and checksum
      - name: Upload release artifacts
        if: github.event_name == 'release' || github.event_name == 'workflow_dispatch'
        uses: softprops/action-gh-release@v2
        with:
          tag_name: ${{ github.event.release.tag_name || github.event.inputs.release_tag }}
          files: |
            ${{ matrix.asset_name }}${{ matrix.archive_ext }}
            ${{ matrix.asset_name }}${{ matrix.archive_ext }}.sha256

  # ============================================================================
  # Microsoft Teams release notification (Power Automate Workflows webhook)
  # ============================================================================
  notify-teams:
    name: Notify Teams on release
    needs: build
    if: github.event_name == 'release'
    runs-on: ubuntu-latest
    permissions: {}

    steps:
      - name: Build Adaptive Card payload
        env:
          TAG:  ${{ github.event.release.tag_name }}
          NAME: ${{ github.event.release.name }}
          URL:  ${{ github.event.release.html_url }}
          BODY: ${{ github.event.release.body }}
          REPO: ${{ github.repository }}
        run: |
          TRIMMED=$(printf '%s' "$BODY" | head -c 2000)
          jq -n \
            --arg tag "$TAG" --arg name "$NAME" \
            --arg url "$URL" --arg body "$TRIMMED" --arg repo "$REPO" '
          {
            type: "message",
            attachments: [{
              contentType: "application/vnd.microsoft.card.adaptive",
              content: {
                "$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
                type: "AdaptiveCard",
                version: "1.5",
                body: [
                  { type: "TextBlock", size: "Large", weight: "Bolder",
                    text: ("🚀 " + $repo + " " + $tag + " released") },
                  { type: "TextBlock", text: $name, wrap: true, isSubtle: true },
                  { type: "TextBlock", text: $body, wrap: true }
                ],
                actions: [
                  { type: "Action.OpenUrl", title: "View release", url: $url }
                ]
              }
            }]
          }' > card.json

      - name: POST to Teams workflow
        if: env.WEBHOOK_URL != ''
        env:
          WEBHOOK_URL: ${{ secrets.TEAMS_RELEASE_NOTIFICATION_WORKFLOW_URL }}
        run: |
          curl -sSf -X POST \
            -H "Content-Type: application/json" \
            --data-binary @card.json \
            "$WEBHOOK_URL"

  promote-release:
    name: Promote pre-release to release
    runs-on: ubuntu-latest
    needs: [setup, build]
    # A dispatch has to be able to finish a release too, not only build one.
    # The recovery path this workflow documents (dispatch a fixed release.yml
    # at an old tag) previously stopped at "artifacts uploaded": promotion ran
    # only for `release` events, so a rescued release stayed a pre-release
    # forever. That also silently stalls Homebrew, which resolves the version
    # through `releases/latest` and therefore never sees a pre-release.
    if: >-
      (github.event_name == 'release' && github.event.release.prerelease) ||
      (github.event_name == 'workflow_dispatch' && github.event.inputs.release_tag != '')
    steps:
      - name: Promote to full release
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          # This job checks out nothing, so `gh` has no git remote to infer the
          # repository from and every call below fails with "not a git
          # repository". That failure used to be indistinguishable from a
          # promoted release: the command substitution returned empty, the
          # `!= "true"` test passed, and the job exited 0 having done nothing
          # while reporting success. GH_REPO is what actually fixes it; the
          # explicit emptiness check below is what stops it being silent again.
          GH_REPO: ${{ github.repository }}
          TAG: ${{ github.event.release.tag_name || github.event.inputs.release_tag }}
          ALL_ASSETS: ${{ needs.setup.outputs.all_assets }}
        run: |
          set -euo pipefail

          if [ -z "$TAG" ]; then
            echo "::error::no release tag to promote"
            exit 1
          fi

          IS_PRERELEASE="$(gh release view "$TAG" --json isPrerelease -q .isPrerelease)"
          if [ -z "$IS_PRERELEASE" ]; then
            echo "::error::could not read the release state for $TAG; refusing to guess whether it needs promoting"
            exit 1
          fi

          # Idempotent: re-running a dispatch against an already-promoted tag
          # is a normal thing to do while recovering, and must not fail.
          if [ "$IS_PRERELEASE" != "true" ]; then
            echo "$TAG is already a full release; nothing to promote"
            exit 0
          fi

          # A dispatch can build one family. Promoting on the strength of that
          # would publish a release missing every target this run did not
          # touch, and Homebrew would then resolve a version whose artifacts
          # are not all there. Check the whole expected set, not just what was
          # built here.
          PRESENT="$(gh release view "$TAG" --json assets -q '.assets[].name')"
          if [ -z "$PRESENT" ]; then
            echo "::error::$TAG reports no assets at all; refusing to promote"
            exit 1
          fi
          MISSING=""
          while IFS= read -r want; do
            [ -n "$want" ] || continue
            if ! grep -qxF "$want" <<<"$PRESENT"; then
              MISSING="${MISSING:+$MISSING, }$want"
            fi
          done < <(jq -r '.[]' <<<"$ALL_ASSETS")

          if [ -n "$MISSING" ]; then
            echo "::error::$TAG is missing release artifacts, so it stays a pre-release: $MISSING"
            exit 1
          fi

          echo "All expected artifacts present on $TAG; promoting"
          gh release edit "$TAG" --prerelease=false --latest