name: rust

on:
  push:
    branches:
      - main
    # NOTE: tag pushes (`v*`) deliberately do NOT trigger this workflow. Releases push the commit
    # (which runs the real lanes) immediately followed by the tag; triggering on BOTH produced two
    # runs per release and was the dominant source of CI notification spam on this fork. The commit
    # push already verifies the exact tree the tag points at, so the tag run added nothing.
  pull_request:
  # No nightly `schedule` cron: on a fork with no self-hosted runners it was pure notification noise
  # (a daily green run that verifies nothing new). Re-add if a scheduled audit is ever wanted.

  workflow_dispatch:
    inputs:
      # It doesn't currently make sense to publish to staging.crates.io because this
      # requires all our dependencies to be present for the push to succeed, so this is
      # disabled for now.
      crates_repo:
        description: "crates.io repo to publish to"
        default: prod
        required: true
        options:
          # - staging
          - prod

# Collapse redundant runs of the same ref (e.g. rapid pushes to a PR branch). `cancel-in-progress`
# is deliberately gated OFF for tag refs so an in-flight publish run is never cancelled mid-release.
concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: ${{ !startsWith(github.ref, 'refs/tags/') }}

env:
  CARGO_TERM_COLOR: always
  CARGO_TERM_VERBOSE: true
  RUST_BACKTRACE: full

  crates_environment: &crates_environment ${{ case(inputs.crates_repo == 'prod', 'crates.io', startsWith(github.ref, 'refs/tags/'), 'crates.io', 'staging.crates.io') }}

  is_tag_push: ${{ startsWith(github.ref, 'refs/tags/') }}
  is_dispatch: ${{ github.event_name == 'workflow_dispatch' }}

  prev_rust: &prev_rust 1.94.1
  latest_rust: &latest_rust 1.95.0


defaults:
  run:
    shell: bash

jobs:
  # Self-hosted Linux/X64 build/test/lint lane (runs on the maintainer's own runner, not paid
  # GitHub-hosted minutes). The inherited `build_test`/`arch_independent` matrices target OTHER
  # self-hosted labels (`linux-x86_64-16cpu`, etc.) that don't exist here and stay owner-gated, so
  # this lane is the real green signal for the `rust` workflow. ring-only, default features
  # (no `ssh`/`acme`/`tun`); the musl + feature-specific paths are covered by `musl_static` + local.
  hosted_test:
    name: hosted test (self-hosted Linux)
    # Self-hosted Linux/X64 runner (Docker container on the maintainer's infra) — keeps CI off paid
    # GitHub-hosted minutes. The inherited `build_test`/`arch_independent` matrices target other
    # self-hosted labels (`linux-x86_64-16cpu`, …) that don't exist here and stay owner-gated.
    runs-on: [self-hosted, Linux, X64]
    # Skip on tag pushes: this lane verifies code, and `publish` (which fires on tags) deliberately
    # does NOT depend on it, so re-running the full build+test on every release tag gates nothing.
    # SECURITY: this runs on a self-hosted runner with Docker-socket access. Never let a PR from a
    # FORK execute here (arbitrary code on the maintainer's infra = host-compromise path) — only same
    # -repo branches (which a maintainer already controls) and direct pushes run on the self-hosted
    # box. Fork PRs simply skip this lane.
    if: >-
      ${{ !startsWith(github.ref, 'refs/tags/')
        && (github.event_name != 'pull_request'
            || github.event.pull_request.head.repo.full_name == github.repository) }}
    # A hung cargo step shouldn't pin the self-hosted runner indefinitely; cap it.
    timeout-minutes: 60
    env:
      # This fork gates compilation behind an explicit acknowledgement env var.
      TS_RS_EXPERIMENT: this_is_unstable_software
      COMMON_FLAGS: --workspace
      # Incremental artifacts roughly double `target/` size for no benefit in a cold CI build.
      CARGO_INCREMENTAL: 0
      # Cap codegen parallelism on the memory-constrained self-hosted runner. With unbounded jobs,
      # several heavy rustc invocations link concurrently and the heaviest — `ts_python`'s pyo3
      # cdylib — gets OOM-killed (SIGKILL / signal 9) mid-link, flaking the build for reasons
      # unrelated to the change under test. 4 keeps enough parallelism for throughput while bounding
      # peak memory below the runner's ceiling. (RUST_TEST_THREADS similarly bounds the test phase so
      # 20 cores × unbounded test threads don't OOM rustc-spawned test binaries.)
      CARGO_BUILD_JOBS: 4
      RUST_TEST_THREADS: 4
    steps:
      - name: Checkout
        uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
        with:
          # Don't leave the GITHUB_TOKEN persisted in .git/config on the persistent self-hosted
          # runner (the checkout default) — these jobs never push, so the token is pure exposure.
          persist-credentials: false
      # The self-hosted runner is a stock bare-Ubuntu container; self-heal its build prereqs
      # (rustup, lld, libpython3.12-dev) so a freshly (re)created runner Just Works.
      - name: Provision self-hosted runner
        uses: ./.github/actions/provision-self-hosted
      - name: Setup rust
        id: setup-rust
        uses: ./.github/actions/setup-rust
        with:
          toolchain-version: *latest_rust
          builder-triple: x86_64-unknown-linux-gnu
          # The shared setup-rust action bakes `components` into an `actions/cache` key, which
          # rejects commas. Multiple components (`clippy,rustfmt`) would fail that key validation,
          # so install them in a separate step below and pass none here (like `musl_static`).
          components: ""
          # Distinct target/ cache from the musl lanes: this lane builds a native host workspace,
          # they build cross containers with a different glibc. Sharing one cache risks the same
          # cross-container build-script GLIBC mismatch (see the note on `musl_static`).
          cache-key: hosted-test
      - name: Install clippy + rustfmt
        # Pin the component add to the EXACT toolchain `cargo fmt`/`cargo clippy` will resolve (the
        # repo's `rust-toolchain.toml` channel), not rustup's ambient default. The persistent
        # self-hosted runner restores a `components=none` toolchain from the setup-rust cache (its
        # cache key carries `components=none` for this lane), so on a cache hit setup-rust SKIPS the
        # component install and a bare `rustup component add` against the default toolchain left
        # `cargo fmt` reporting "the 'cargo-fmt' binary ... is not applicable to the '<ver>'
        # toolchain". We must re-fetch a component a partial cache recorded as present but whose
        # binary is missing; `rustup component add` alone is a no-op ("already installed") in that
        # state and would NOT repair it. `rustup component add --force` did this, but rustup 1.95.0
        # removed the `--force` flag (`error: unexpected argument '--force'`), which silently red the
        # whole lane once the runner's rustup self-updated. So remove-then-add instead: the remove
        # clears any stale/partial record (|| true — absent is fine) and the add re-fetches cleanly.
        # Version-agnostic (no removed flags). The verify lines then fail the job loudly rather than
        # letting a broken-rustfmt runner silently red the lane.
        run: |
          ver="$(rustup show active-toolchain | awk '{print $1}')"
          echo "active toolchain: ${ver}"
          rustup component remove --toolchain "${ver}" clippy rustfmt || true
          rustup component add --toolchain "${ver}" clippy rustfmt
          cargo +"${ver}" fmt --version
          cargo +"${ver}" clippy --version
      - name: Check formatting (cargo fmt)
        run: cargo fmt --all -- --check
      - name: Lint lib targets (cargo clippy)
        run: cargo clippy $COMMON_FLAGS --no-deps --lib -- -D warnings
      - name: Lint other targets (cargo clippy)
        run: cargo clippy $COMMON_FLAGS --no-deps --bins --tests --benches --examples -- -D warnings -A missing_docs
      - name: Build (cargo build)
        run: cargo build $COMMON_FLAGS --all-targets
      - name: Test (cargo test)
        run: cargo test $COMMON_FLAGS
      # Compile + lint the optional `tun` feature on the lib it gates (ts_runtime's TUN-mode
      # datapath). `COMMON_FLAGS` is default-features-only, so `mod tun_actor`
      # (`#[cfg(feature = "tun")]`) is otherwise NEVER built on a fork — a shape change to a type it
      # shares with the default graph (e.g. a `Decision::Forward` field) would compile-break only
      # here and ship false-green. The `tun` feature pulls no aws-lc (ts_transport_tun/ts_host_net/
      # etherparse are ring-only), so this stays on the ring-only supply-chain graph this lane guards.
      - name: Build + lint the `tun` feature (cargo check/clippy --features tun)
        run: |
          cargo check -p geiserx_ts_runtime --features tun --all-targets
          cargo clippy -p geiserx_ts_runtime --features tun --no-deps --all-targets -- -D warnings -A missing_docs
      - name: Anti-leak firewall checks (cargo run -p checks)
        run: cargo run -p checks
      - name: binstall
        uses: cargo-bins/cargo-binstall@aaa84a43aec4955a42c5ffc65d258961e39f276e #v1.19.1
      - name: Install cargo-deny
        run: command -v cargo-deny || cargo binstall --no-confirm cargo-deny
      # Machine-enforce the ring-only / no-aws-lc supply-chain invariant on every push, on the
      # ring-only runtime graph this lane builds (default features, no `ssh`). `--no-default-features`
      # turns `ssh` off so `russh -> aws-lc-rs` is gone, and `--exclude-dev` drops the `reqwest`
      # default-tls dev-dependency of ts_derp/ts_netcheck. The aws-lc ban in `deny.toml` must hold
      # here. Do NOT add `--all-features`: that re-introduces aws-lc via russh+reqwest and is the
      # upstream `arch_independent` job's concern (left intact). `TS_RS_EXPERIMENT` is set at job-level.
      - name: Supply-chain audit (cargo deny, ring-only graph)
        run: cargo deny --no-default-features --exclude-dev check all

  build_test:
    # This matrix targets self-hosted runner labels (linux-x86_64-16cpu, macos-26, windows-8vcpu,
    # …) that only exist in the upstream tailscale/tailscale-rs org. On this fork they would queue
    # forever and never report (spamming the Actions tab / notifications), so skip the whole job
    # off-upstream. The `hosted_test` lane above covers the equivalent verification on a
    # GitHub-hosted runner. Delete this guard if/when the fork provisions its own runners.
    if: ${{ github.repository_owner == 'tailscale' }}
    strategy:
      fail-fast: false
      matrix:
        is_pr_or_push:
          - ${{github.event_name == 'pull_request' || github.event_name == 'push'}}
        target:
          - triple: x86_64-unknown-linux-gnu
            runner: linux-x86_64-16cpu
          - triple: aarch64-unknown-linux-gnu
            runner: linux-arm64-16cpu
          - triple: aarch64-apple-darwin
            runner: macos-26
          - triple: x86_64-pc-windows-gnu
            runner: windows-8vcpu
          - triple: x86_64-pc-windows-msvc
            runner: windows-8vcpu
        toolchain:
          - version: *prev_rust
            label: prev
          - version: *latest_rust
            label: latest
        exclude:
          # Don't run macOS builds on PR/push workflows; only on the nightly workflow.
          - is_pr_or_push: true
            target:
              triple: aarch64-apple-darwin
    # The name needs to remain stable for the "Require status checks to pass" feature of our branch
    # protection rules to work, thus the "toolchain.label" field in the matrix. Unfortunately the
    # status check matching on job name doesn't support regexes, just an exact job name match.
    name: test (rust ${{ matrix.toolchain.label }}, ${{ matrix.target.triple }})
    runs-on: ${{ matrix.target.runner }}

    env:
      COMMON_FLAGS: --all-features --workspace
      CARGO_BUILD_TARGET: ${{ matrix.target.triple }}
      is_windows_gnu: ${{ endsWith(matrix.target.triple, '-windows-gnu') }}

    steps:
      - name: Checkout
        uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
      - name: Setup rust
        id: setup-rust
        uses: ./.github/actions/setup-rust
        with:
          toolchain-version: ${{ matrix.toolchain.version }}
          builder-triple: ${{ matrix.target.triple }}
          components: clippy
      - name: Lint lib targets (cargo clippy)
        run: |-
          cargo clippy \
            $COMMON_FLAGS \
            --no-deps \
            --lib \
            -- -D warnings
      # These are separated from `--lib` to avoid enforcing missing docs in targets that
      # can't become part of public API
      - name: Lint other targets (cargo clippy)
        run: |-
          cargo clippy \
            $COMMON_FLAGS \
            --no-deps \
            --bins --tests --benches --examples \
            -- -D warnings -A missing_docs
      - name: Build (cargo build)
        run: cargo build $COMMON_FLAGS --all-targets

      # `ts_python` has the same lib name as the `tailscale` root crate, which again appears
      # to just be an issue on `*-windows-gnu` targets, so don't test it here (depend on
      # python tests instead).
      - name: Test (cargo test), --all-features
        run: |-
          cargo test \
              $COMMON_FLAGS \
              ${{ case(env.is_windows_gnu == 'true', '--exclude ts_python', '') }}

      # Release builds on Windows specifically take forever: disable them on PRs to speed
      # the check (the dev build is sufficient to establish that the code compiles)
      - name: Release build (cargo build --release)
        if: ${{ runner.os != 'Windows' || github.event_name != 'pull_request' }}
        run: cargo build $COMMON_FLAGS --release --all-targets

      - name: Docs (cargo doc)
        run: cargo doc $COMMON_FLAGS --no-deps

  # Static MUSL build of the root `tailscale` crate with `ssh`/`russh`/`aws-lc-rs` OFF (ring-only),
  # proving the egress/proxy daemon path is musl-clean for minimal (scratch/distroless) pod images.
  #
  # NOTE: this lane runs on the maintainer's self-hosted Linux/X64 runner (Docker container with the
  # host docker socket, so `cross`'s container-based builds work). The inherited upstream matrix
  # targets other self-hosted labels (e.g. `linux-arm64-16cpu`) that do not exist here.
  #
  # We use `cross` (docker-based) because `ring`'s build script needs a matching `*-linux-musl-gcc`
  # cross C toolchain to assemble its C/asm; the default cross images already ship it. We must NOT
  # use `--all-features` here: that would enable `ssh`, pulling `russh -> aws-lc-rs`, which fights
  # static musl and is the entire thing this lane exists to avoid.
  musl_static:
    name: musl static (${{ matrix.target.triple }})
    runs-on: [self-hosted, Linux, X64]
    # SECURITY: same as `hosted_test` — never run a FORK PR's code on the self-hosted (Docker-socket)
    # runner. Same-repo branches and direct pushes only; fork PRs skip this lane.
    if: >-
      ${{ github.event_name != 'pull_request'
        || github.event.pull_request.head.repo.full_name == github.repository }}
    strategy:
      fail-fast: false
      matrix:
        target:
          # Primary: ARM64 is the main deployment target.
          - triple: aarch64-unknown-linux-musl
          # Nice-to-have secondary target.
          - triple: x86_64-unknown-linux-musl
    env:
      # This fork gates compilation behind an explicit acknowledgement env var. It is forwarded into
      # the `cross` container via the `passthrough` list in `Cross.toml`.
      TS_RS_EXPERIMENT: this_is_unstable_software
      # ring-only feature set: `tailscale` has no `default` feature, so `--no-default-features`
      # leaves `ssh`/`axum` off while keeping the full tailnet/TLS (ring) daemon path.
      MUSL_FLAGS: -p geiserx_tailscale --no-default-features
    steps:
      - name: Checkout
        uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
        with:
          # See hosted_test: don't persist the token on the self-hosted runner (this lane won't push).
          persist-credentials: false
      # Self-heal the bare-Ubuntu self-hosted runner's build prereqs (see hosted_test).
      - name: Provision self-hosted runner
        uses: ./.github/actions/provision-self-hosted
      - name: Setup rust
        id: setup-rust
        uses: ./.github/actions/setup-rust
        with:
          toolchain-version: *latest_rust
          builder-triple: x86_64-unknown-linux-gnu
          targets: ${{ matrix.target.triple }}
          components: ""
          # Isolate the target/ cache PER musl target. The action keys its target cache on
          # `builder-triple` (x86_64 here for both matrix entries) + Cargo.lock only -- NOT on the
          # cross target -- so without this both musl jobs (and hosted_test) would share one cache.
          # `cross` runs each target in its own container image (different glibc); a build-script
          # binary (e.g. libc's) compiled for one image then restored into the other fails with
          # `GLIBC_2.XX not found` (cargo doesn't invalidate build-script fingerprints across
          # targets -- cross-rs FAQ "Glibc Version Error"). A per-triple cache-key keeps caching
          # while preventing the cross-container artifact collision.
          cache-key: musl-${{ matrix.target.triple }}
      - name: Install cross
        uses: cargo-bins/cargo-binstall@aaa84a43aec4955a42c5ffc65d258961e39f276e #v1.19.1
      - name: Install cross (binary)
        run: command -v cross || cargo binstall --no-confirm cross
      - name: Build static musl (cross build --no-default-features)
        run: |-
          cross build \
            --target ${{ matrix.target.triple }} \
            $MUSL_FLAGS
      - name: Verify aws-lc-rs is absent from the musl graph
        run: |-
          if cross tree --target ${{ matrix.target.triple }} $MUSL_FLAGS -i aws-lc-rs 2>/dev/null; then
            echo "::error::aws-lc-rs leaked into the musl build graph (ssh feature on?)"
            exit 1
          fi
          echo "OK: aws-lc-rs is not in the musl dependency graph"

  arch_independent:
    name: arch-independent checks
    # Self-hosted runner (linux-x86_64-16cpu) that only exists upstream; skip off-upstream so it
    # doesn't queue forever on this fork. cargo-deny/machete/fmt/checks are run locally + the
    # `checks` step is also covered by `hosted_test`. See the note on `build_test`.
    if: ${{ github.repository_owner == 'tailscale' }}
    runs-on: linux-x86_64-16cpu
    steps:
      - name: Checkout
        uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
      - name: Setup rust
        id: setup-rust
        uses: ./.github/actions/setup-rust
        with:
          toolchain-version: nightly
          builder-triple: x86_64-unknown-linux-gnu
          components: rustfmt
      - &binstall
        name: binstall
        uses: cargo-bins/cargo-binstall@aaa84a43aec4955a42c5ffc65d258961e39f276e #v1.19.1
      - name: Install cargo-deny
        run: command -v cargo-deny || cargo binstall --no-confirm cargo-deny
      - &cargo_ws
        name: Install cargo-workspaces
        run: command -v cargo-workspaces || cargo binstall --no-confirm cargo-workspaces
      - name: Check for unused dependencies (cargo machete)
        uses: bnjbvr/cargo-machete@main
        with:
          args: "--with-metadata"
      - name: Dependency audit (cargo deny)
        run: cargo deny --workspace --all-features check all
      - name: Check formatting (cargo fmt)
        run: cargo fmt --check
      - name: Custom workspace checks
        run: cargo run -p checks
      - &dry_publish
        name: Publish (Dry run)
        run: cargo ws publish --dry-run --publish-as-is

  publish:
    name: publish
    runs-on: linux-x86_64-16cpu

    # Upstream-only: self-hosted runner + crates.io publish. This fork releases via git tags, not
    # crates.io, and `needs` two upstream-only jobs anyway. The owner guard makes the skip explicit.
    if: ${{ github.repository_owner == 'tailscale' && (startsWith(github.ref, 'refs/tags/') || github.event_name == 'workflow_dispatch') }}
    needs:
      - build_test
      - arch_independent

    environment: *crates_environment

    # `id-token` is used to grab the cargo registry token.
    permissions:
      id-token: write

    steps:
      - name: Checkout
        uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3

      - uses: rust-lang/crates-io-auth-action@bbd81622f20ce9e2dd9622e3218b975523e45bbe # v1.0.4
        id: auth
        with:
          url: ${{ case(inputs.crates_repo == 'prod', 'https://crates.io', startsWith(github.ref, 'refs/tags/'), 'https://crates.io', 'https://staging.crates.io') }}

      - name: Setup rust
        id: setup-rust
        uses: ./.github/actions/setup-rust
        with:
          toolchain-version: *latest_rust
          builder-triple: x86_64-unknown-linux-gnu

      - *binstall
      - *cargo_ws

      - name: Configure staging registry
        if: inputs.crates_repo == 'staging'
        run: |-
          echo CARGO_REGISTRIES_STAGING_INDEX=https://staging.crates.io/index >> $GITHUB_ENV
          echo CARGO_REGISTRY_DEFAULT=staging >> $GITHUB_ENV

      - *dry_publish

      - name: Publish
        run: cargo ws publish --publish-as-is
        env:
          TS_FFI_BUILDRS_STRICT: 1
          CARGO_REGISTRY_TOKEN: ${{ steps.auth.outputs.token }}
