grr-cli 2.1.3

Google tools from the terminal, at maximum performance: 401 methods across 14 Google APIs in one generated command tree, zero-config, over HTTP/3
Documentation
name: Release

on:
  push:
    tags: ["v*"]
  # Dispatched by tag-release.yml, which pushes the tag with GITHUB_TOKEN —
  # a token-authored push starts no workflows, so the native tag trigger
  # never fires for the automated path. workflow_dispatch is the exception;
  # its tag input names the release the run should serve (defaulting to the
  # workflow ref, which for a `v*` ref is that tag).
  workflow_dispatch:
    inputs:
      tag:
        description: "Release tag this run serves (defaults to the workflow ref)"
        required: false
        type: string

permissions:
  contents: read

concurrency:
  group: release-${{ github.ref }}
  cancel-in-progress: false

jobs:
  build:
    name: build (${{ matrix.target.name }})
    runs-on: ${{ matrix.target.os }}
    timeout-minutes: 90
    # Scoped to this job on purpose: the archive and release jobs run from
    # the same workflow and have no business seeing the OAuth client. build.rs
    # bakes these into the binaries (see .env.example for the local
    # equivalent), so a downloaded grr can `auth login` with no setup. The
    # device pair is a second client of type "TVs and Limited Input devices":
    # Google's device endpoint rejects the Desktop client, so `auth login
    # --device` needs its own credentials.
    env:
      GRR_CLIENT_ID: ${{ secrets.GRR_CLIENT_ID }}
      GRR_CLIENT_SECRET: ${{ secrets.GRR_CLIENT_SECRET }}
      GRR_DEVICE_CLIENT_ID: ${{ secrets.GRR_DEVICE_CLIENT_ID }}
      GRR_DEVICE_CLIENT_SECRET: ${{ secrets.GRR_DEVICE_CLIENT_SECRET }}
      UPX_VERSION: "5.2.1"
    strategy:
      fail-fast: false
      matrix:
        target:
          - name: windows-x86_64
            os: windows-latest
            artifact: grr.exe
            triple: x86_64-pc-windows-msvc
          - name: windows-aarch64
            # Native ARM runner: no MSVC cross-linker needed, so the
            # aarch64 binary cannot silently come out x86_64.
            os: windows-11-arm
            artifact: grr.exe
            triple: aarch64-pc-windows-msvc
          - name: linux-x86_64
            os: ubuntu-24.04
            artifact: grr
            triple: x86_64-unknown-linux-gnu
          - name: linux-aarch64
            # Native ARM runner (GA): no cross toolchain needed, so the
            # aarch64 binary cannot silently come out x86_64. Same safety
            # property as the Windows-ARM target above.
            os: ubuntu-24.04-arm
            artifact: grr
            triple: aarch64-unknown-linux-gnu
          - name: macos-aarch64
            os: macos-latest
            artifact: grr
            triple: aarch64-apple-darwin
    steps:
      - uses: actions/checkout@v7
        timeout-minutes: 2
      - uses: dtolnay/rust-toolchain@nightly
        timeout-minutes: 10
        with:
          components: rust-src
      - uses: Swatinem/rust-cache@v2
        timeout-minutes: 10
        with:
          key: ${{ matrix.target.triple }}

      - name: Build release binary
        timeout-minutes: 45
        run: cargo build --release --target ${{ matrix.target.triple }}

      - name: Install UPX (linux)
        if: runner.os == 'Linux'
        timeout-minutes: 5
        # The asset name depends on the runner's ARCH: the linux-aarch64
        # target runs natively on an ARM64 runner, so it needs the arm64
        # UPX build, not the amd64 one.
        run: |
          case "$(uname -m)" in
            x86_64)  UPX_ARCH="amd64_linux" ;;
            aarch64) UPX_ARCH="arm64_linux" ;;
            *) echo "::error::unsupported linux arch $(uname -m)"; exit 1 ;;
          esac
          curl -fsSL -o upx.tar.xz \
            "https://github.com/upx/upx/releases/download/v${UPX_VERSION}/upx-${UPX_VERSION}-${UPX_ARCH}.tar.xz"
          tar -xJf upx.tar.xz
          sudo install -m 0755 "upx-${UPX_VERSION}-${UPX_ARCH}/upx" /usr/local/bin/upx
          upx --version | head -1

      - name: Install UPX (macos)
        if: runner.os == 'macOS'
        timeout-minutes: 10
        # UPX publishes no macOS binary (codesigning), so brew builds it.
        run: |
          brew install upx
          upx --version | head -1

      - name: Install UPX (windows)
        if: runner.os == 'Windows'
        timeout-minutes: 5
        shell: pwsh
        run: |
          $zip = "upx-$env:UPX_VERSION-win64.zip"
          curl.exe -fsSL -o $zip "https://github.com/upx/upx/releases/download/v$env:UPX_VERSION/$zip"
          Expand-Archive -Path $zip -DestinationPath upx-dist -Force
          $upx = (Get-ChildItem -Recurse upx-dist -Filter upx.exe | Select-Object -First 1).FullName
          # Appending to GITHUB_PATH is the supported way to extend PATH; it
          # takes effect for LATER steps, so the Pack step below is where
          # `upx` resolves (and fails loudly if it does not). The deprecated
          # `::add-path::` command is disabled outright, and GITHUB_ENV
          # entries must be KEY=VALUE (a bare path is rejected).
          "$((Split-Path $upx))" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append

      - name: Pack binary (UPX --best --lzma)
        # Every target packs, Windows included: the small archives are a
        # deliberate product property, and winget's ValidationFailureGuide
        # attributes Validation-Executable-Error to a missing dependency or
        # an unexpected exit code — not to packing. The provenance of the
        # label here is being handled on the PR itself, not by weakening
        # the artifact.
        shell: bash
        timeout-minutes: 10
        run: |
          set -o pipefail
          BIN="target/${{ matrix.target.triple }}/release/${{ matrix.target.artifact }}"
          size() { stat -c %s "$1" 2>/dev/null || stat -f %z "$1"; }
          BEFORE="$(size "$BIN")"
          # Never pack in place: UPX's win64 packer segfaults (exit 139) on
          # the ARM64 PE and can leave a corrupted binary behind. Pack a
          # copy, require exit 0 AND that the result still executes, and
          # only then replace the original. Anything else ships unpacked.
          WORK="$BIN.packed"
          cp "$BIN" "$WORK"
          # --lzma measured 20% smaller than --best alone on the real binary
          # (4,495,508 -> 3,584,844 bytes, upx 5.2.1); --brute packs to the
          # identical size and is not worth its minutes. Formats that cannot
          # do LZMA fall back to plain --best from a fresh copy, since a
          # failed pack attempt must not be repacked on top of itself.
          #
          # No `| tee`: a pipeline reports the LAST command's status, so
          # `upx ... | tee log` without pipefail made this condition test
          # tee — every failed --lzma pack looked like success, the --best
          # fallback below was dead code, and the build reported "packed"
          # with equal before/after bytes. upx's output already lands in
          # the step log.
          if ! upx --best --lzma "$WORK"; then
            cp "$BIN" "$WORK"
            if upx --best "$WORK"; then
              echo "::notice::--lzma unsupported for ${{ matrix.target.name }}; packed with --best"
            else
              echo "::warning::UPX failed on ${{ matrix.target.name }}; shipping unpacked"
              rm -f "$WORK"
            fi
          fi
          if [ -f "$WORK" ]; then
            if "$WORK" --version >/dev/null 2>&1; then
              AFTER="$(size "$WORK")"
              mv "$WORK" "$BIN"
              echo "packed ${{ matrix.target.name }}: $BEFORE -> $AFTER bytes"
            else
              echo "::warning::UPX packed ${{ matrix.target.name }} but the result does not run; shipping unpacked"
              rm -f "$WORK"
            fi
          fi
          "$BIN" --version

      - uses: actions/upload-artifact@v7
        timeout-minutes: 5
        with:
          name: grr-${{ github.ref_name }}-${{ matrix.target.name }}
          path: target/${{ matrix.target.triple }}/release/${{ matrix.target.artifact }}
          if-no-files-found: error

  archive:
    name: archive
    needs: build
    runs-on: ubuntu-latest
    timeout-minutes: 20
    steps:
      - uses: actions/download-artifact@v8
        timeout-minutes: 5
        with:
          path: dist
          merge-multiple: false

      - name: Stage binaries
        timeout-minutes: 5
        run: |
          set -euo pipefail
          mkdir -p stage
          # merge-multiple=false keeps one directory per artifact, named
          # `grr-<ref>-<target>`. The target list is spelled out here instead of
          # being recovered by stripping the version out of that directory name:
          # a prerelease tag (`v0.7.0-rc.1`) makes the strip ambiguous and
          # produced names like `rc.1-linux-x86_64`, so every archive step below
          # then looked for a stage file that did not exist and the release
          # failed. Matching on the known target suffix is tag-independent.
          for name in linux-x86_64 linux-aarch64 macos-aarch64 windows-x86_64 windows-aarch64; do
            bin=$(find dist -type f \( -name grr -o -name grr.exe \) -path "*/grr-*-$name/*" -print -quit)
            if [ -z "$bin" ]; then
              echo "::error::no downloaded artifact for $name"
              exit 1
            fi
            # Stage under the PRODUCT name, one directory per target so the
            # four unix binaries cannot clobber each other. Archives shipping
            # `<target>` members broke real consumers: winget's
            # NestedInstallerFiles promised grr.exe, found nothing, and the
            # first submission looped on "issue with installing the
            # application correctly" (live example: winget-pkgs PR #441361);
            # every human extracting a tarball had to rename the file by hand.
            mkdir -p "stage/$name"
            case "$name" in
              windows-*) cp "$bin" "stage/$name/grr.exe" ;;
              *)         cp "$bin" "stage/$name/grr" ;;
            esac
          done
          ls -l stage/*

      - name: Build archives
        timeout-minutes: 10
        env:
          TAG: ${{ inputs.tag || github.ref_name }}
        run: |
          set -euo pipefail
          ref="$TAG"
          # zstd -22 is the maximum level; -T0 uses all cores. Decompression
          # stays fast, which matters more than the last few percent.
          for name in linux-x86_64 linux-aarch64 macos-aarch64; do
            tar -cf - -C "stage/$name" grr | zstd -22 -T0 -q -o "grr-$ref-$name.tar.zst"
          done
          # Windows keeps .zip: Explorer's built-in extractor only reads
          # deflate, so lzma2-in-zip would be unopenable without 7-Zip.
          # -mx=9 is max deflate.
          for name in windows-x86_64 windows-aarch64; do
            7z a -m0=deflate -mx=9 -bso0 -bsp0 "grr-$ref-$name.zip" "./stage/$name/grr.exe" >/dev/null
          done
          sha256sum grr-* > SHA256SUMS
          ls -l

      - uses: actions/upload-artifact@v7
        timeout-minutes: 5
        with:
          name: release-assets
          path: |
            grr-${{ inputs.tag || github.ref_name }}-*.tar.zst
            grr-${{ inputs.tag || github.ref_name }}-*.zip
            SHA256SUMS

  release:
    name: create release
    needs: archive
    runs-on: ubuntu-latest
    timeout-minutes: 15
    permissions:
      contents: write
      # gh workflow run below — dispatching the release watchers.
      actions: write
    env:
      TAG: ${{ inputs.tag || github.ref_name }}
    steps:
      - uses: actions/download-artifact@v8
        timeout-minutes: 5
        with:
          name: release-assets
          path: assets
      - name: Create GitHub release
        uses: softprops/action-gh-release@v3
        timeout-minutes: 10
        with:
          tag_name: ${{ inputs.tag || github.ref_name }}
          generate_release_notes: true
          files: |
            assets/grr-${{ inputs.tag || github.ref_name }}-windows-x86_64.zip
            assets/grr-${{ inputs.tag || github.ref_name }}-windows-aarch64.zip
            assets/grr-${{ inputs.tag || github.ref_name }}-linux-x86_64.tar.zst
            assets/grr-${{ inputs.tag || github.ref_name }}-linux-aarch64.tar.zst
            assets/grr-${{ inputs.tag || github.ref_name }}-macos-aarch64.tar.zst
            assets/SHA256SUMS

      - name: Dispatch the release watchers
        # The release is created with GITHUB_TOKEN, and a release event from
        # the token starts no workflows — the same anti-recursion rule that
        # swallowed the tag (see tag-release.yml). workflow_dispatch is the
        # exception, so the watchers are fired by hand:
        #   demo re-records against the new version, benchmark re-measures
        #   when the version gate says so, stats refresh the download tiles,
        #   verify-delivery checks the delivered artifacts (crate, docs.rs,
        #   tap, live site), and changelog's release-only verify job finally
        #   runs — it is dispatched with verify=true because the token-authored
        #   release event never reaches its `release` trigger.
        env:
          GH_TOKEN: ${{ github.token }}
          GH_REPO: ${{ github.repository }}
          TAG: ${{ inputs.tag || github.ref_name }}
        timeout-minutes: 5
        run: |
          for wf in demo.yml benchmark.yml stats.yml verify-delivery.yml; do
            echo "dispatching $wf"
            # A swallowed failure would leave this release unverified and
            # unmeasured — tag-release.yml's own dispatches fail loud for the
            # same reason. Fail here too.
            if [ "$wf" = demo.yml ]; then
              gh workflow run "$wf" --ref main -f tag="$TAG"
            else
              gh workflow run "$wf" --ref main
            fi
          done
          echo "dispatching changelog.yml (verify=true)"
          gh workflow run changelog.yml --ref main -f verify=true

  winget:
    name: generate the winget manifests
    # `release` already depends on `archive`, so listing archive here was
    # redundant; listing only the tail is the same ordering with less to keep
    # in sync.
    needs: release
    runs-on: ubuntu-latest
    timeout-minutes: 10
    # The winget-publishing environment is the only place the submission
    # credential lives: not visible to any other job, workflow, or
    # environment. Job-level, not step-level — GitHub rejects
    # `environment` on a step.
    environment: winget-publishing
    permissions:
      contents: write  # gh release upload
    env:
      TAG: ${{ inputs.tag || github.ref_name }}
    steps:
      - uses: actions/checkout@v7
        timeout-minutes: 2

      - uses: actions/setup-node@v7
        timeout-minutes: 5
        with:
          node-version: "26"

      # The manifests must be generated, never retyped: the installer entry
      # has to name the executable that is actually inside the zip. The first
      # hand-written submission promised grr.exe while the archive contained
      # windows-x86_64.exe — validation looped on "issue with installing the
      # application correctly" (winget-pkgs PR #441361). The script reads the
      # member list out of the released archive itself.
      - name: Generate and attach the winget submission
        timeout-minutes: 8
        env:
          GH_TOKEN: ${{ github.token }}
          GH_REPO: ${{ github.repository }}
        run: |
          node scripts/generate-winget.ts "$TAG" --out winget-manifests
          tar -czf "grr-$TAG-winget-manifests.tar.gz" -C winget-manifests .
          gh release upload "$TAG" "grr-$TAG-winget-manifests.tar.gz" --clobber
          echo "attached grr-$TAG-winget-manifests.tar.gz"

      # Submission needs a classic PAT with public_repo scope on the account
      # that owns the winget-pkgs fork — the workflow token cannot open PRs
      # against microsoft/winget-pkgs, and fine-grained tokens cannot either
      # (https://github.com/russellbanks/Komac/issues/310). Same contract as
      # repo-metadata.yml: unset secret → notice + green (no token must not
      # turn the release red); set but refused → fail loudly.
      - name: Submit the manifests to winget-pkgs
        timeout-minutes: 8
        env:
          WINGET_TOKEN: ${{ secrets.REPO_WINGET_TOKEN }}
        run: |
          if [ -z "$WINGET_TOKEN" ]; then
            echo "::notice::winget submission skipped: REPO_WINGET_TOKEN is unset. To publish, create a classic PAT with the public_repo scope, add it as the REPO_WINGET_TOKEN secret, and re-run this workflow. The bundle is attached to the release as grr-$TAG-winget-manifests.tar.gz; the manual path is: unpack it, then run komac submit <dir> --yes --token <PAT> (https://github.com/russellbanks/Komac)."
            exit 0
          fi
          KOMAC_VERSION=2.17.1
          # The SHA256SUMS file is GitHub's *checksums.txt* style: it writes
          # `hash *file` (asterisk, binary-mode marker), so it must be fed to
          # `sha256sum -c` with the real filename beside it — grepping the
          # name into a file and running `-c` on it makes sha256sum treat the
          # tarball as its *input list* and fail with "No such file".
          curl -sSLo komac.tar.gz "https://github.com/russellbanks/Komac/releases/download/v${KOMAC_VERSION}/komac-${KOMAC_VERSION}-x86_64-unknown-linux-gnu.tar.gz"
          curl -sSLo SHA256SUMS "https://github.com/russellbanks/Komac/releases/download/v${KOMAC_VERSION}/SHA256SUMS"
          # without pipefail a checksum mismatch would only print and still submit
          set -euo pipefail
          # checksums.txt lists `hash *<original name>`, and `sha256sum -c -`
          # looks the name up on disk — so the tarball must carry the name the
          # list expects, not our `komac.tar.gz` shorthand.
          mv komac.tar.gz "komac-${KOMAC_VERSION}-x86_64-unknown-linux-gnu.tar.gz"
          grep "komac-${KOMAC_VERSION}-x86_64-unknown-linux-gnu.tar.gz" SHA256SUMS | sha256sum -c -
          tar -xzf "komac-${KOMAC_VERSION}-x86_64-unknown-linux-gnu.tar.gz" komac
          export GITHUB_TOKEN="$WINGET_TOKEN"  # env, not argv, so the token never lands in the process list
          ./komac submit winget-manifests --yes