timebomb-cli 0.9.0

Scan source code for deadline-tagged fuses and fail when they detonate
Documentation
name: CI

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

# Least-privilege default: all jobs get read-only access.
# Individual jobs that need nothing more inherit this.
permissions: read-all

env:
  CARGO_TERM_COLOR: always
  RUST_BACKTRACE: 1

jobs:
  fmt:
    name: fmt
    runs-on: ubuntu-24.04
    timeout-minutes: 10
    steps:
      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
      - name: Install stable toolchain
        run: |
          set -euo pipefail
          rustup update stable
          rustup default stable
          rustup component add rustfmt
      - name: Check formatting
        run: cargo fmt --all -- --check

  clippy:
    name: clippy
    runs-on: ubuntu-24.04
    timeout-minutes: 15
    steps:
      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
      - name: Install stable toolchain
        run: |
          set -euo pipefail
          rustup update stable
          rustup default stable
          rustup component add clippy
      - uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
        with:
          path: |
            ~/.cargo/registry/index/
            ~/.cargo/registry/cache/
            ~/.cargo/git/db/
            target/
          key: ${{ runner.os }}-cargo-clippy-${{ hashFiles('**/Cargo.lock') }}
          restore-keys: |
            ${{ runner.os }}-cargo-clippy-
            ${{ runner.os }}-cargo-
      - name: Run clippy
        run: cargo clippy --locked --all-targets --all-features -- -D warnings

  unit-tests:
    name: unit tests
    runs-on: ubuntu-24.04
    timeout-minutes: 15
    steps:
      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
      - name: Install stable toolchain
        run: rustup update stable && rustup default stable
      - uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
        with:
          path: |
            ~/.cargo/registry/index/
            ~/.cargo/registry/cache/
            ~/.cargo/git/db/
            target/
          key: ${{ runner.os }}-cargo-unit-${{ hashFiles('**/Cargo.lock') }}
          restore-keys: |
            ${{ runner.os }}-cargo-unit-
            ${{ runner.os }}-cargo-
      - name: Run unit tests (lib + bins only)
        # --lib --bins runs only the inline #[cfg(test)] modules, not tests/
        run: cargo test --locked --lib --bins --verbose

  integration-tests:
    name: integration tests
    runs-on: ubuntu-24.04
    timeout-minutes: 30
    steps:
      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
      - name: Install stable toolchain
        run: rustup update stable && rustup default stable
      - uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
        with:
          path: |
            ~/.cargo/registry/index/
            ~/.cargo/registry/cache/
            ~/.cargo/git/db/
            target/
          key: ${{ runner.os }}-cargo-integration-${{ hashFiles('**/Cargo.lock') }}
          restore-keys: |
            ${{ runner.os }}-cargo-integration-
            ${{ runner.os }}-cargo-
      - name: Run integration tests (tests/ directory)
        # --tests runs only the files under tests/ (scanner_tests, config_tests)
        run: cargo test --locked --tests --verbose

  smoke-tests:
    name: smoke tests
    runs-on: ubuntu-24.04
    timeout-minutes: 20
    needs: [unit-tests, integration-tests, clippy]
    steps:
      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
      - name: Install stable toolchain
        run: rustup update stable && rustup default stable
      - uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
        with:
          path: |
            ~/.cargo/registry/index/
            ~/.cargo/registry/cache/
            ~/.cargo/git/db/
            target/
          key: ${{ runner.os }}-cargo-smoke-${{ hashFiles('**/Cargo.lock') }}
          restore-keys: |
            ${{ runner.os }}-cargo-smoke-
            ${{ runner.os }}-cargo-
      - name: Build release binary
        run: cargo build --release --locked
      - name: Smoke — sweep exits 0 on empty directory
        run: |
          set -euo pipefail
          mkdir -p "${{ runner.temp }}/smoke-empty"
          ./target/release/timebomb sweep "${{ runner.temp }}/smoke-empty"
      - name: Smoke — manifest always exits 0 even with detonated fuse
        run: |
          set -euo pipefail
          mkdir -p "${{ runner.temp }}/smoke-list"
          echo "// TODO[2020-01-01]: expired annotation" > "${{ runner.temp }}/smoke-list/test.rs"
          ./target/release/timebomb manifest "${{ runner.temp }}/smoke-list"
      - name: Smoke — sweep exits 1 when detonated fuse found
        run: |
          set -euo pipefail
          mkdir -p "${{ runner.temp }}/smoke-expired"
          echo "// TODO[2020-01-01]: this is expired" > "${{ runner.temp }}/smoke-expired/main.rs"
          if ./target/release/timebomb sweep "${{ runner.temp }}/smoke-expired"; then
            echo "ERROR: expected exit 1 for detonated fuse" && exit 1
          fi
      - name: Smoke — JSON output is valid JSON
        run: |
          set -euo pipefail
          mkdir -p "${{ runner.temp }}/smoke-json"
          echo "// FIXME[2020-01-01]: old" > "${{ runner.temp }}/smoke-json/lib.rs"
          (./target/release/timebomb sweep "${{ runner.temp }}/smoke-json" --format json || true) | python3 -m json.tool > /dev/null
      - name: Smoke — GitHub Actions format emits workflow commands
        run: |
          set -euo pipefail
          mkdir -p "${{ runner.temp }}/smoke-gh"
          echo "// TODO[2020-01-01]: expired annotation" > "${{ runner.temp }}/smoke-gh/main.rs"
          output=$(./target/release/timebomb sweep "${{ runner.temp }}/smoke-gh" --format github || true)
          echo "$output" | grep -q "::error" || (echo "Expected ::error annotation" && exit 1)
      - name: Checksum release binary
        run: sha256sum target/release/timebomb > target/release/timebomb.sha256

      - name: Upload release binary
        uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
        with:
          name: timebomb-release-binary
          path: |
            target/release/timebomb
            target/release/timebomb.sha256
          retention-days: 1

  self-check:
    name: self check
    runs-on: ubuntu-24.04
    timeout-minutes: 10
    needs: [smoke-tests]
    # Informational only — fixture files contain deliberately past-dated annotations.
    continue-on-error: true
    steps:
      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
      - name: Download release binary
        uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
        with:
          name: timebomb-release-binary
          path: target/release/
      - name: Verify binary checksum
        run: sha256sum --check target/release/timebomb.sha256

      - name: Make binary executable
        run: chmod +x target/release/timebomb
      - name: Scan src/ with GitHub Actions format
        run: ./target/release/timebomb sweep ./src --format github || true
      - name: List all fuses sorted by date
        run: ./target/release/timebomb manifest ./src || true

  release:
    name: release
    runs-on: ubuntu-24.04
    # Only create releases on pushes to main, not on PRs.
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'
    needs: [smoke-tests, self-check]
    permissions:
      contents: write       # create tags, GitHub releases, and push Cargo.toml version bumps
      pull-requests: write  # open and update the release PR
    env:
      FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
    outputs:
      release_created: ${{ steps.release.outputs.release_created }}
      tag_name: ${{ steps.release.outputs.tag_name }}
    steps:
      - id: release
        uses: googleapis/release-please-action@a02a34c4d625f9be7cb89156071d8567266a2445 # v4.2.0
        with:
          config-file: release-please-config.json
          manifest-file: .release-please-manifest.json

  publish-crate:
    name: publish crate
    runs-on: ubuntu-24.04
    needs: [release, publish]
    if: needs.release.outputs.release_created == 'true' && needs.publish.result == 'success'
    timeout-minutes: 15
    steps:
      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
        with:
          ref: ${{ needs.release.outputs.tag_name }}
      - name: Install stable toolchain
        run: rustup update stable && rustup default stable
      - uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
        with:
          path: |
            ~/.cargo/registry/index/
            ~/.cargo/registry/cache/
            ~/.cargo/git/db/
            target/
          key: ${{ runner.os }}-cargo-publish-crate-${{ hashFiles('**/Cargo.lock') }}
          restore-keys: |
            ${{ runner.os }}-cargo-publish-crate-
            ${{ runner.os }}-cargo-
      - name: Publish to crates.io
        shell: bash
        run: |
          set -euo pipefail
          set +e
          output="$(cargo publish --locked 2>&1)"
          status=$?
          set -e

          printf '%s\n' "$output"

          if [ "$status" -eq 0 ]; then
            exit 0
          fi

          if printf '%s\n' "$output" | grep -Eiq 'already (been )?(uploaded|published)|version .* already (exists|uploaded|published)|crate version .* already'; then
            echo "crate version is already published; treating cargo publish as a no-op"
            exit 0
          fi

          exit "$status"
        env:
          CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}

  publish-image:
    name: publish Docker image
    runs-on: ubuntu-24.04
    needs: release
    if: needs.release.outputs.release_created == 'true'
    timeout-minutes: 45
    permissions:
      contents: read
    steps:
      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
        with:
          ref: ${{ needs.release.outputs.tag_name }}

      - name: Set up QEMU
        uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
        with:
          platforms: arm64

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0

      - name: Login to Docker Hub
        uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
        with:
          username: ${{ secrets.DOCKERHUB_USERNAME }}
          password: ${{ secrets.DOCKERHUB_TOKEN }}

      - name: Build and push Docker image
        shell: bash
        run: |
          set -euo pipefail
          tag="${{ needs.release.outputs.tag_name }}"
          version="${tag#v}"
          make docker-push IMAGE=pwbsladek/timebomb IMAGE_TAG="$version"

  publish:
    name: publish (${{ matrix.asset_name }})
    needs: release
    if: needs.release.outputs.release_created == 'true'
    timeout-minutes: 30
    permissions:
      contents: write  # upload release assets
    strategy:
      fail-fast: true
      matrix:
        include:
          - target: x86_64-unknown-linux-gnu
            os: ubuntu-24.04
            asset_name: timebomb-linux-x86_64
          - target: aarch64-unknown-linux-gnu
            os: ubuntu-24.04
            asset_name: timebomb-linux-aarch64
          - target: x86_64-apple-darwin
            os: macos-14
            asset_name: timebomb-macos-x86_64
          - target: aarch64-apple-darwin
            os: macos-14
            asset_name: timebomb-macos-aarch64
          - target: x86_64-pc-windows-msvc
            os: windows-2022
            asset_name: timebomb-windows-x86_64.exe
    runs-on: ${{ matrix.os }}
    steps:
      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd  # v6.0.2
        with:
          ref: ${{ needs.release.outputs.tag_name }}

      - name: Install stable toolchain
        shell: bash
        run: |
          set -euo pipefail
          rustup update stable
          rustup default stable
          rustup target add ${{ matrix.target }}

      - name: Install aarch64 Linux cross-compiler
        if: matrix.target == 'aarch64-unknown-linux-gnu'
        run: |
          set -euo pipefail
          sudo apt-get update -qq
          sudo apt-get install -y gcc-aarch64-linux-gnu

      - uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684  # v4.2.3
        with:
          path: |
            ~/.cargo/registry/index/
            ~/.cargo/registry/cache/
            ~/.cargo/git/db/
            target/
          key: ${{ runner.os }}-cargo-publish-${{ matrix.target }}-${{ hashFiles('**/Cargo.lock') }}
          restore-keys: |
            ${{ runner.os }}-cargo-publish-${{ matrix.target }}-
            ${{ runner.os }}-cargo-

      - name: Build release binary
        shell: bash
        run: cargo build --release --locked --target "$TARGET"
        env:
          TARGET: ${{ matrix.target }}
          CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER: aarch64-linux-gnu-gcc

      - name: Upload binary to GitHub release (Unix)
        if: matrix.os != 'windows-2022'
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          TAG: ${{ needs.release.outputs.tag_name }}
          TARGET: ${{ matrix.target }}
          ASSET_NAME: ${{ matrix.asset_name }}
        shell: bash
        run: |
          set -euo pipefail
          cp "target/${TARGET}/release/timebomb" "${RUNNER_TEMP}/${ASSET_NAME}"
          gh release upload "${TAG}" "${RUNNER_TEMP}/${ASSET_NAME}" --clobber

      - name: Upload binary to GitHub release (Windows)
        if: matrix.os == 'windows-2022'
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          TAG: ${{ needs.release.outputs.tag_name }}
          TARGET: ${{ matrix.target }}
          ASSET_NAME: ${{ matrix.asset_name }}
        run: |
          Copy-Item "target/$env:TARGET/release/timebomb.exe" "$env:RUNNER_TEMP/$env:ASSET_NAME"
          gh release upload "$env:TAG" "$env:RUNNER_TEMP/$env:ASSET_NAME" --clobber