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
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: ${{ 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: 1.94.1
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:
# 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
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
run: rustup component add clippy rustfmt
- 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
- 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:
# 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
-
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
-
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
-
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 }}