-
Notifications
You must be signed in to change notification settings - Fork 0
526 lines (501 loc) · 24.1 KB
/
Copy pathci.yml
File metadata and controls
526 lines (501 loc) · 24.1 KB
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
name: CI
on:
push:
branches: [main]
# Pure-docs / site / packaging-template changes don't affect Rust
# behavior, and skipping them here avoids a full 3-OS test matrix on
# a typo fix in a README. The site has its own pages.yml workflow
# that handles its own changes; packaging templates are validated
# at release time by publish.yml when they're actually used.
paths-ignore:
- '**/*.md'
- 'docs/**'
- 'site/**'
- 'packaging/**'
- 'LICENSE'
- '.gitignore'
# NOTE: deliberately no `paths-ignore` here, unlike the push trigger
# above. `CI Gate` at the bottom of this file is a required status
# check, and a required check that never *reports* leaves a PR stuck on
# "Expected — waiting for status" forever. Filtering at the workflow
# level would do exactly that for any PR touching only site/docs/
# packaging. So the workflow always runs, and the expensive jobs skip
# themselves via the `changes` job below. A skipped job satisfies a
# required check; an absent one does not.
pull_request:
branches: [main]
# Let me run CI manually from the Actions tab, useful for re-running a
# cache-warm build after tweaking the workflow without having to push.
workflow_dispatch:
# A new push to the same PR cancels any still-running job from that PR.
# Saves runner minutes and gives fresher feedback on force-pushes.
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
# Least-privilege by default; individual jobs can opt into more.
permissions:
contents: read
jobs:
# Every job below sets `timeout-minutes`. GitHub's default is 360, and on
# 2026-08-19 a stalled `apt-get` in "Install system dependencies" sat there
# for the full six hours on five branches at once, then failed the gate --
# burning a day of runner time to report a problem visible in the first
# minute.
#
# The values are sized against measured runs, not guessed: with a warm
# cache the slowest job here is ~3 minutes, and a fully cold build of the
# workspace (GTK/WebKit + Tauri, release profile with LTO) is well under
# 20. Each limit leaves roughly an order of magnitude of headroom over
# what the job actually needs, so a timeout means something is wrong
# rather than merely slow.
# Classify the diff so the expensive jobs can skip themselves without
# the workflow disappearing from the PR's check list. Done with plain
# git rather than a paths-filter action to avoid adding another
# third-party action to a repo that signs and publishes binaries.
changes:
name: Detect changes
runs-on: ubuntu-latest
timeout-minutes: 5
outputs:
code: ${{ steps.filter.outputs.code }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
- id: filter
shell: bash
# `github.event.pull_request.*` is attacker-reachable on a fork PR.
# These two fields are commit SHAs, so hex, so not exploitable today —
# but interpolating an event field straight into a `run:` script is
# the shape of the bug, not the field's contents, and this was the
# last place in the repo still doing it. Through `env:` the shell
# receives them as data that no quoting mistake can turn into code.
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
set -euo pipefail
if [[ "${{ github.event_name }}" != "pull_request" ]]; then
# push / manual runs always do the full build.
echo "code=true" >> "$GITHUB_OUTPUT"
exit 0
fi
files=$(git diff --name-only "$BASE_SHA" "$HEAD_SHA")
echo "Changed files:"
echo "$files" | sed 's/^/ /'
# Anything that is NOT purely docs/site/packaging counts as code.
# Mirrors the push trigger's paths-ignore list above; keep the two
# in step.
code=$(echo "$files" | grep -vE '(\.md$|^docs/|^site/|^packaging/|^LICENSE$|^\.gitignore$)' || true)
if [[ -n "$code" ]]; then
echo "code=true" >> "$GITHUB_OUTPUT"
else
echo "code=false" >> "$GITHUB_OUTPUT"
echo "No code changes — Rust/GUI jobs will skip."
fi
maintainability:
name: Maintainability
needs: changes
if: needs.changes.outputs.code == 'true'
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
# 22, matching pages.yml / release.yml / site.yml / site-preview.yml.
# Node 20 left standard support in April 2026, and the split meant
# the GUI was tested on one runtime and shipped from another.
node-version-file: .nvmrc
- name: File size guard
run: node scripts/check-file-size.mjs
# Contrast is invisible until someone cannot read something, and the
# light theme gets far less use than the dark one, so a regression there
# can sit for months. This also fails if design-tokens.json has drifted
# from tokens.css, which is the only thing keeping the generated file
# honest.
- name: Design token contrast
run: node scripts/design-tokens.mjs
# The app links at the docs site, and neither project knows what the
# other publishes. Runs in site.yml too, since either side can break it.
- name: App doc links
run: node scripts/check-app-doc-links.mjs
# Installer scripts aren't exercised by any other job, so lint them
# directly. shellcheck and PSScriptAnalyzer are both preinstalled on
# ubuntu-latest/GitHub-hosted runners.
script-lint:
name: Installer Script Lint
needs: changes
if: needs.changes.outputs.code == 'true'
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# install.sh is user-facing; the release/ scripts hold GH_TOKEN and
# push to three package registries. Both are worth linting, and
# neither is exercised by any other job.
- name: shellcheck install.sh
run: shellcheck scripts/install.sh
- name: shellcheck release scripts
run: shellcheck scripts/release/*.sh
# The publish path only executes during a release, so a regression in
# it stays invisible until the worst possible moment. lib_test.sh
# covers the two things shellcheck cannot see: that verified_sha's
# stdout carries the digest and nothing else, and that validate_tag
# rejects the shell metacharacters that reach sed.
- name: test release script helpers
run: bash scripts/release/lib_test.sh
- name: PSScriptAnalyzer install.ps1
shell: pwsh
# Write-Host is intentional here (colored installer UX output, which
# Write-Output can't do); the empty catch blocks are documented
# best-effort fallbacks (e.g. TLS 1.2 opt-in on older PowerShell);
# and BOM-less UTF-8 is the existing, working file encoding. None of
# these are correctness bugs, so they're excluded rather than
# rewriting a working installer for style alone.
run: |
Install-Module -Name PSScriptAnalyzer -Force -Scope CurrentUser
$results = Invoke-ScriptAnalyzer -Path scripts/install.ps1 -Severity Warning,Error `
-ExcludeRule PSAvoidUsingWriteHost, PSAvoidUsingEmptyCatchBlock, PSUseBOMForUnicodeEncodedFile
$results | Format-Table -AutoSize
if ($results.Count -gt 0) { exit 1 }
# Fast lint + format gate. Runs first because if `cargo fmt --check` is
# going to fail there's no point firing up the matrix below.
lint:
name: Format + Clippy
needs: [changes, maintainability]
if: needs.changes.outputs.code == 'true'
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# libpcap-dev: netscli-core pcap feature. GTK/WebKit: netscli-gui
# (Tauri 2) — clippy --all-targets compiles the Tauri shell, which
# won't link without these even when we only intend to lint Rust.
- name: Install system dependencies
run: |
# `|| true`, deliberately. GitHub's Ubuntu image ships third-party
# apt sources (Google Chrome among them) that this job does not use,
# and a broken index on any one of them makes `apt-get update` exit
# 100 and kill the job before Rust is even invoked. That is not a
# hypothetical: on 2026-09-09 a `Hash Sum mismatch` on
# dl.google.com failed this step across three re-runs.
#
# The install below is the step whose failure means something, and
# it still fails hard: if libpcap genuinely cannot be fetched,
# `apt-get install` exits non-zero and the job stops there.
sudo apt-get update || true
sudo apt-get install -y \
libpcap-dev pkg-config \
libwebkit2gtk-4.1-dev libgtk-3-dev libayatana-appindicator3-dev \
librsvg2-dev libsoup-3.0-dev libjavascriptcoregtk-4.1-dev
- name: Install Rust
# Pinned by commit SHA; the trailing `# stable` records which
# action-side ref that SHA came from, and is deliberately not a
# version number. dtolnay/rust-toolchain's numeric refs (@1.96.0)
# are Rust *toolchain* versions rather than action releases, so a
# version-shaped comment here invites Dependabot to bump the
# compiler while pretending to bump an action. The toolchain we
# actually want comes from the explicit `toolchain` input below,
# and .github/dependabot.yml ignores this action as a second guard.
uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable
with:
toolchain: "1.96.0"
components: rustfmt, clippy
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
shared-key: lint
- name: cargo fmt --check
run: cargo fmt --check
- name: cargo clippy (default features)
run: cargo clippy --all-targets -- -D warnings
- name: cargo clippy (pcap feature)
run: cargo clippy --all-targets --features pcap -- -D warnings
test:
name: Test (${{ matrix.os }})
needs: [changes, lint]
if: needs.changes.outputs.code == 'true'
runs-on: ${{ matrix.os }}
timeout-minutes: 45
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# Linux needs libpcap for netscli-core's pcap feature and GTK/WebKit
# for netscli-gui (Tauri 2). `cargo test --all` compiles the whole
# workspace including the GUI, so both are required to link.
- name: Install system dependencies (Linux)
if: runner.os == 'Linux'
run: |
# `|| true` so a broken third-party apt index cannot fail this job;
# the install below still fails hard. See the first occurrence in
# ci.yml for the full reasoning.
sudo apt-get update || true
sudo apt-get install -y \
libpcap-dev pkg-config \
libwebkit2gtk-4.1-dev libgtk-3-dev libayatana-appindicator3-dev \
librsvg2-dev libsoup-3.0-dev libjavascriptcoregtk-4.1-dev
- name: Install Rust
# See the pin note on the lint job above.
uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable
with:
toolchain: "1.96.0"
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
shared-key: test-${{ matrix.os }}
- name: cargo test --all
run: cargo test --all --no-fail-fast
- name: cargo test netscli-core with pcap
if: runner.os == 'Linux'
run: cargo test -p netscli-core --features pcap --no-fail-fast
- name: cargo test netscli-mcp with pcap
if: runner.os == 'Linux'
run: cargo test -p netscli-mcp --features pcap --no-fail-fast
# The Windows pcap paths used to be covered by nothing, anywhere.
#
# Both steps above are Linux-only, and the feature cannot be built on a
# Windows runner without the Npcap SDK -- the link step fails with
# `LNK1181: cannot open input file 'wpcap.lib'`. So the Npcap code, which
# is the entire reason the feature exists on Windows and is what the
# `-pcap` Windows release asset ships, was exercised on no platform.
# SECURITY.md said so; this is the half of it that CI can close.
#
# release.yml already installs this SDK to build that asset, so the
# step below is its twin -- same pinned URL, same pinned digest, same
# reason for verifying before extracting. Re-pin both together.
- name: Install Npcap SDK (Windows, pcap)
if: runner.os == 'Windows'
shell: pwsh
run: |
$sdkUrl = "https://npcap.com/dist/npcap-sdk-1.13.zip"
$sdkSha256 = "dad1f2bf1b02b787be08ca4862f99e39a876c1f274bac4ac0cedc9bbc58f94fd"
$zipPath = Join-Path $env:RUNNER_TEMP "npcap-sdk.zip"
$destPath = Join-Path $env:RUNNER_TEMP "npcap-sdk"
Invoke-WebRequest -Uri $sdkUrl -OutFile $zipPath
$actual = (Get-FileHash -Algorithm SHA256 -Path $zipPath).Hash.ToLowerInvariant()
if ($actual -ne $sdkSha256) {
Write-Error "Npcap SDK checksum mismatch. Expected $sdkSha256, got $actual"
exit 1
}
Write-Host "Npcap SDK checksum verified."
Expand-Archive -Path $zipPath -DestinationPath $destPath -Force
"LIB=$(Join-Path $destPath 'Lib\x64');$env:LIB" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
"INCLUDE=$(Join-Path $destPath 'Include');$env:INCLUDE" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
# Build, not test, and the difference is forced rather than chosen.
#
# The SDK above provides headers and `wpcap.lib`, which is everything
# the *link* needs. Running is a different matter: the test binary
# imports `wpcap.dll`, which ships with the Npcap runtime driver, not
# the SDK. Windows resolves imports at load time, so without that DLL
# the executable does not start and every test in it fails at once --
# measured, not predicted: `cargo test` here exited 0xc0000135,
# STATUS_DLL_NOT_FOUND, after compiling cleanly in 1m35s.
#
# Installing the Npcap runtime is not the fix. Its silent installer is
# licensed, and a CI job that installs a packet-capture driver to run
# unit tests is a poor trade.
#
# `--tests` still compiles the test targets, so a Windows-only break in
# the pcap code -- a cfg that stops matching, an API that moved, a type
# that only differs on this platform -- fails here. That is the part
# that was covered by nothing. Actually capturing a packet needs a live
# adapter and stays a manual check.
- name: cargo build netscli-core with pcap (Windows)
if: runner.os == 'Windows'
run: cargo build -p netscli-core --features pcap --tests
- name: cargo build netscli-mcp with pcap (Windows)
if: runner.os == 'Windows'
run: cargo build -p netscli-mcp --features pcap --tests
gui-build:
name: GUI Build (TypeScript + Vite)
needs: [changes, lint]
if: needs.changes.outputs.code == 'true'
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
# 22 — see the note on the maintainability job. This one is also
# load-bearing for jsdom: jsdom 30 pulls an undici that calls
# `worker_threads.markAsUncloneable`, added in Node 22.10.0 and
# never backported to 20, so the whole test suite failed to start
# its workers on the old runtime.
node-version-file: .nvmrc
cache: npm
cache-dependency-path: apps/netscli-gui/package-lock.json
- name: npm ci
run: npm ci
working-directory: apps/netscli-gui
- name: GUI lint
# C-22: there was no lint config or script here at all, which
# AGENTS.md acknowledged. `react-hooks` is the rule set that catches
# stale closures and wrong dependency arrays -- the shape of A-13,
# B-16 and B-18.
run: npm run lint
working-directory: apps/netscli-gui
- name: GUI unit tests
run: npm run test:unit
working-directory: apps/netscli-gui
- name: Source size guard
run: npm run test:maintainability
working-directory: apps/netscli-gui
- name: tsc + vite build
run: npm run build
working-directory: apps/netscli-gui
# Release-config binary build. This catches regressions that only show
# up under optimizations / lto that debug builds miss, and the artifact
# upload makes it possible to grab a "tip of main" binary straight from
# the Actions run for quick smoke testing without cutting a release.
#
# Matrix is conditional on event:
# - pull_request → ubuntu-latest only. The `test` job already runs on
# all 3 OS, so cross-platform compile errors are caught there.
# Release-profile-specific bugs (LTO, opt-level=3, strip) are rare,
# and the actual release.yml rebuilds across 5 OS/arch on every tag.
# - push to main → full ubuntu+windows+macos. macOS is a 10x billing
# multiplier vs. ubuntu, so paying it once per main-merge instead
# of once per PR cuts release-build cost by ~95% with effectively
# equivalent coverage.
release-build:
name: Release build (${{ matrix.os }})
needs: [changes, test]
if: needs.changes.outputs.code == 'true'
runs-on: ${{ matrix.os }}
timeout-minutes: 45
strategy:
fail-fast: false
matrix:
os: ${{ github.event_name == 'pull_request' && fromJSON('["ubuntu-latest"]') || fromJSON('["ubuntu-latest", "windows-latest", "macos-latest"]') }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install system dependencies (Linux)
if: runner.os == 'Linux'
run: |
# `|| true` so a broken third-party apt index cannot fail this job;
# the install below still fails hard. See the first occurrence in
# ci.yml for the full reasoning.
sudo apt-get update || true
sudo apt-get install -y libpcap-dev pkg-config
- name: Install Rust
# See the pin note on the lint job above.
uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable
with:
toolchain: "1.96.0"
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
shared-key: release-${{ matrix.os }}
- name: cargo build --release -p netscli
run: cargo build --release -p netscli
- name: Upload artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: netscli-${{ matrix.os }}
path: |
target/release/netscli
target/release/netscli.exe
if-no-files-found: ignore
retention-days: 14
# Prove the crates still package, long before release day.
#
# `cargo publish` is the one step of a release that cannot be undone:
# crates.io has no unpublish, only yank, and a yanked version still holds
# its number forever. Until now nothing exercised packaging until the
# release was already public and the upload was running.
#
# Dry-running all three together is what makes this possible at all. The
# dependent crates require `netscli-core = "^0.3.0"`, which does not exist
# on the index before release, so packaging them individually fails on
# resolution -- with or without `--no-verify`. Passing the whole set lets
# cargo resolve them from the workspace instead, and it builds each
# packaged copy to confirm it compiles standing alone.
#
# Push and dispatch only, not pull_request. It compiles the packaged copy
# of the workspace from scratch, which is minutes, and a packaging break
# caught at merge is still caught a long way from a release.
publish-dry-run:
name: Crate packaging (publish dry run)
needs: [changes, lint]
if: needs.changes.outputs.code == 'true' && github.event_name != 'pull_request'
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install system dependencies
run: |
# `|| true` so a broken third-party apt index cannot fail this job;
# the install below still fails hard. See the first occurrence in
# ci.yml for the full reasoning.
sudo apt-get update || true
sudo apt-get install -y libpcap-dev pkg-config
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
with:
toolchain: "1.96.0"
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
shared-key: publish-dry-run
# Mirrors the real publish in publish.yml exactly, minus the upload.
# If these two ever diverge, this check stops meaning anything.
- name: cargo publish --dry-run (all three crates)
run: cargo publish --dry-run -p netscli-core -p netscli-mcp -p netscli
# ─── Required status check ────────────────────────────────────────────
#
# This is the ONLY job from this workflow that should be marked required
# in branch protection. It runs unconditionally (`if: always()`, no path
# filter), so it always reports — which is the whole point. Marking the
# individual jobs required instead would deadlock any PR that legitimately
# skips them.
#
# `needs` result values: success | failure | cancelled | skipped.
# Skipped is a pass here: it means the `changes` job decided this PR does
# not touch code, which is a real answer, not a missing one.
ci-gate:
name: CI Gate
if: always()
needs:
- changes
- maintainability
- script-lint
- lint
- test
- gui-build
- release-build
- publish-dry-run
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Verify no upstream job failed
shell: bash
env:
NEEDS: ${{ toJSON(needs) }}
run: |
set -euo pipefail
echo "$NEEDS" | jq -r 'to_entries[] | " \(.key): \(.value.result)"'
# Allowlist the two good outcomes rather than denylisting the bad
# ones. This used to fail only on "failure" or "cancelled", and
# GitHub has more result states than that.
#
# Observed on PR #187: codeload started returning 429 for action
# downloads, the ubuntu leg of the test matrix died before running
# anything, and its result came back as "abandoned" — which matched
# neither arm, so this gate reported success over a test job that
# had visibly failed. A required status check that can go green
# while a test job is red is worse than no gate, because branch
# protection is trusting it.
#
# Anything that is not success or skipped now fails the gate,
# including result states GitHub may add later.
bad=$(echo "$NEEDS" | jq -r '
to_entries
| map(select(.value.result != "success" and .value.result != "skipped"))
| map("\(.key)=\(.value.result)")
| join(", ")
')
if [[ -n "$bad" ]]; then
echo "::error::CI Gate failed — these jobs did not succeed: ${bad}"
exit 1
fi
echo "CI Gate passed (all jobs succeeded or were legitimately skipped)."