Add mapbox doctor: a read-only snapshot of the environment #127
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: CI | |
| # Builds, tests and lints this crate on every push and pull request, and | |
| # checks the two installers beside it. Release, publish and spec-sync | |
| # automation live elsewhere, on infrastructure this repository does not have — | |
| # nothing here needs it. | |
| # | |
| # Everything runs on every PR: this is one crate on three runners, and a | |
| # check nobody remembers to ask for is a check that does not run. | |
| on: | |
| push: | |
| branches: [main] | |
| pull_request: | |
| workflow_dispatch: | |
| # One run per ref. Without this, three pushes to a branch inside a few minutes | |
| # pay for three full runs of everything and only the last answer is read. | |
| concurrency: | |
| group: ${{ github.workflow }}-${{ github.ref }} | |
| cancel-in-progress: true | |
| env: | |
| CARGO_TERM_COLOR: always | |
| jobs: | |
| build: | |
| name: build (${{ matrix.os }}) | |
| strategy: | |
| fail-fast: false | |
| # All three: the crate has `#[cfg(not(unix))]` arms no Unix leg | |
| # compiles, so Windows is the only thing that checks them. | |
| matrix: | |
| os: [ubuntu-latest, macos-14, windows-2022] | |
| runs-on: ${{ matrix.os }} | |
| # These legs take one to two minutes. Twenty is not a budget, it is a | |
| # tripwire: without one, a job that stops making progress runs to GitHub's | |
| # six-hour default before anyone hears about it. That is not hypothetical — | |
| # a test that blocked on `accept()` with no deadline wedged the Windows leg | |
| # for its full six hours, and the only reason it went unnoticed for a day | |
| # is that every run on `main` was canceled by the next push first. | |
| # | |
| # On all three rather than Windows alone: nobody here runs Windows as a | |
| # daily driver, so CI is the only signal it has — but a Unix leg that | |
| # suddenly needs twenty minutes has something wrong with it worth hearing | |
| # about too. | |
| timeout-minutes: 20 | |
| permissions: | |
| contents: read | |
| steps: | |
| # One checkout and nothing else to fetch: the OpenAPI specs the commands | |
| # are generated from are vendored in `openapi/`, so `cargo build` needs | |
| # no second repository and no credential. | |
| - uses: actions/checkout@v7 | |
| # rustup applies `rust-toolchain.toml` on its own, and it outranks the | |
| # action below — that action ends in `rustup default`, which rustup | |
| # ranks under a toolchain file. What the action would still do is fetch | |
| # whatever stable is today and install the components onto *that* | |
| # toolchain rather than the pinned one: two toolchains downloaded, and | |
| # `cargo clippy` then run on the one that has no clippy. So it is told | |
| # the version outright, read out of the file rather than written down | |
| # here a second time. | |
| # | |
| # The sed is anchored to the line as written; a reformat that hides it | |
| # empties this output and fails the install step, which is the loud half | |
| # of the two ways this could go wrong. | |
| - name: Read the pinned toolchain | |
| id: toolchain | |
| shell: bash | |
| run: | | |
| echo "toolchain=$(sed -n 's/^channel = "\(.*\)"/\1/p' rust-toolchain.toml | head -1)" >>"$GITHUB_OUTPUT" | |
| # Both actions below have to be admitted by this repository's own | |
| # Actions settings before the first push. A repository restricted to | |
| # selected actions (Settings > Actions > General) that does not name | |
| # them fails the entire run at startup: no jobs, no logs, and an error | |
| # the REST API does not expose. Worth settling once, on the day this | |
| # becomes a repository, rather than rediscovering it from a run that | |
| # says nothing. | |
| - name: Install Rust | |
| uses: dtolnay/rust-toolchain@stable | |
| with: | |
| toolchain: ${{ steps.toolchain.outputs.toolchain }} | |
| components: rustfmt, clippy | |
| # No cargo cache action here: this repo's Actions allowlist doesn't yet | |
| # include one, so builds run uncached until that's settled. Slower, not | |
| # broken. | |
| # Platform-independent, so one leg is enough. | |
| - name: Check formatting | |
| if: matrix.os == 'ubuntu-latest' | |
| run: cargo fmt --check | |
| # `--locked` on all three: without it cargo is free to re-resolve the | |
| # graph, so CI can go green on a dependency set that is not the one | |
| # `Cargo.lock` names. | |
| - name: Clippy | |
| run: cargo clippy --locked --all-targets -- -D warnings | |
| - name: Build | |
| run: cargo build --locked --verbose | |
| - name: Test | |
| run: cargo test --locked --verbose | |
| # The half of `mapbox completion`'s verification `cargo test` cannot | |
| # reach: whether the shell the script names will actually load it. A | |
| # shell the runner image does not have is skipped and named in the | |
| # summary — the images carry bash, zsh and pwsh but not fish, so the | |
| # fish leg is the one a laptop with fish installed covers. | |
| - name: Source the completion scripts in real shells | |
| shell: bash | |
| run: sh scripts/test-completion.sh | |
| # No Rust: this is where the repo's shell gets checked, install.sh above all | |
| # — it is served to machines we do not control, and this is the only thing | |
| # that checks it. Both runners, because the portability it has to get right | |
| # — shasum vs sha256sum, BSD vs GNU tar and script(1) — differs between | |
| # them. | |
| installer: | |
| name: install.sh (${{ matrix.os }}) | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| os: [ubuntu-latest, macos-14] | |
| runs-on: ${{ matrix.os }} | |
| timeout-minutes: 20 | |
| permissions: | |
| contents: read | |
| steps: | |
| - uses: actions/checkout@v7 | |
| - name: Parse as POSIX sh | |
| run: | | |
| sh -n scripts/install.sh | |
| sh -n scripts/test-install.sh | |
| sh -n scripts/test-completion.sh | |
| # -s sh, not the default: bash would accept things `curl | sh` will not. | |
| # Once is enough — the findings do not depend on the runner. The version | |
| # is whatever the image ships and it does drift: 0.9 and 0.10 report the | |
| # trap-invoked cleanup as unreachable where 0.11 does not, so both codes | |
| # are suppressed at the function rather than pinned to one analyzer. | |
| # Kept clean against 0.9, 0.10 and 0.11. | |
| - name: Shellcheck | |
| if: runner.os == 'Linux' | |
| run: shellcheck -s sh scripts/install.sh scripts/test-install.sh scripts/test-completion.sh | |
| - name: Install, verify and prompt against a local channel | |
| run: sh scripts/test-install.sh | |
| # The other half of the same job, for the installer install.sh stops short | |
| # of. Both shells, because both are on a stock Windows 11 and they differ | |
| # exactly where this script lives: the default TLS versions, what `2>` does | |
| # to a native command's stderr, and the parser itself. | |
| installer-windows: | |
| name: install.ps1 (${{ matrix.shell }}) | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| shell: [powershell, pwsh] | |
| runs-on: windows-2022 | |
| timeout-minutes: 20 | |
| permissions: | |
| contents: read | |
| steps: | |
| - uses: actions/checkout@v7 | |
| # `sh -n` in PowerShell: parse it, run none of it. Under pwsh in both | |
| # legs - `shell:` is one of the few keys that takes no expression, so it | |
| # cannot follow the matrix - which costs nothing: PSUseCompatibleSyntax | |
| # below is what says 5.1 can parse this, and the suite parses both files | |
| # with the real 5.1 parser when that is the leg running. | |
| - name: Parse | |
| if: matrix.shell == 'pwsh' | |
| shell: pwsh | |
| run: | | |
| $ErrorActionPreference = 'Stop' | |
| $bad = 0 | |
| foreach ($file in 'scripts/install.ps1', 'scripts/test-install.ps1') { | |
| $errors = $null | |
| [void][System.Management.Automation.Language.Parser]::ParseFile( | |
| (Resolve-Path $file).Path, [ref]$null, [ref]$errors) | |
| foreach ($e in $errors) { | |
| Write-Host "::error file=$file,line=$($e.Extent.StartLineNumber)::$($e.Message)" | |
| $bad++ | |
| } | |
| } | |
| if ($bad) { exit 1 } | |
| # Shellcheck's counterpart, and once is enough for the same reason: the | |
| # findings do not depend on the shell running the analyzer. The module | |
| # is installed rather than assumed - the runner image does not carry it. | |
| - name: PSScriptAnalyzer | |
| if: matrix.shell == 'pwsh' | |
| shell: pwsh | |
| run: | | |
| $ErrorActionPreference = 'Stop' | |
| Install-Module PSScriptAnalyzer -Scope CurrentUser -Force | |
| # Write-Host is the point in an installer: the output is a message to | |
| # a person, not data for a pipeline. The empty catches are | |
| # best-effort cleanups, each with a comment saying what it is giving | |
| # up on. The two naming rules are for modules that export cmdlets; | |
| # these are scripts, and the harness's Expect-* names deliberately | |
| # mirror test-install.sh's expect_*. | |
| $exclude = @( | |
| 'PSAvoidUsingWriteHost' | |
| 'PSAvoidUsingEmptyCatchBlock' | |
| 'PSUseApprovedVerbs' | |
| 'PSUseShouldProcessForStateChangingFunctions' | |
| ) | |
| # The rule that carries the weight here: it is what says Windows | |
| # PowerShell 5.1 can parse what was written against 7. Not in the | |
| # default set, so it has to be asked for. | |
| $compat = @{ | |
| IncludeRules = @('PSUseCompatibleSyntax') | |
| Rules = @{ | |
| PSUseCompatibleSyntax = @{ Enable = $true; TargetVersions = @('5.1', '7.0') } | |
| } | |
| } | |
| $bad = 0 | |
| foreach ($file in 'scripts/install.ps1', 'scripts/test-install.ps1') { | |
| $findings = @(Invoke-ScriptAnalyzer -Path $file -Severity Error, Warning -ExcludeRule $exclude) | |
| $findings += @(Invoke-ScriptAnalyzer -Path $file -Settings $compat) | |
| foreach ($f in $findings) { | |
| Write-Host "::error file=$file,line=$($f.Line)::$($f.RuleName): $($f.Message)" | |
| $bad++ | |
| } | |
| } | |
| if ($bad) { exit 1 } | |
| # PSUseBOMForUnicodeEncodedFile above says a non-ASCII byte needs a byte | |
| # order mark; this says there may be no non-ASCII byte to need one. The | |
| # reason is in install.ps1's header: 5.1 reads a BOM-less .ps1 as the | |
| # machine's ANSI code page, and a BOM breaks `irm | iex`. | |
| - name: ASCII only | |
| if: matrix.shell == 'pwsh' | |
| shell: pwsh | |
| run: | | |
| $bad = 0 | |
| foreach ($file in 'scripts/install.ps1', 'scripts/test-install.ps1') { | |
| $bytes = [IO.File]::ReadAllBytes((Resolve-Path $file).Path) | |
| for ($i = 0; $i -lt $bytes.Length; $i++) { | |
| if ($bytes[$i] -gt 127) { | |
| Write-Host "::error file=$file::byte $i is 0x$('{0:x2}' -f $bytes[$i]); this file has to stay ASCII" | |
| $bad++ | |
| break | |
| } | |
| } | |
| } | |
| if ($bad) { exit 1 } | |
| # The step the matrix exists for. The shell is named in the command | |
| # rather than in `shell:`, which takes no expression; -ExecutionPolicy | |
| # is a Windows-only switch both of them accept, and is what lets 5.1 run | |
| # an unsigned script out of a checkout. | |
| - name: Install, verify and PATH against a local channel | |
| shell: pwsh | |
| run: ${{ matrix.shell }} -NoProfile -ExecutionPolicy Bypass -File scripts/test-install.ps1 | |
| # The one job here that can block on something other than this repo's own | |
| # code, and the split is the whole design: a vulnerability fails it, an | |
| # unmaintained or unsound crate does not. | |
| # | |
| # `Cargo.lock` pins the whole graph behind the handful of crates | |
| # `Cargo.toml` names, and a release compiles the binaries users download | |
| # from exactly that graph — so the gap this closes was never "we might be | |
| # shipping something old", it was that nothing here would have said so. A | |
| # vulnerability names a crate, a version and a fixed version, so there is | |
| # one action and it fits in a lockfile line; merging past it ships a known | |
| # hole to people who ran a one-line installer. An unmaintained crate names | |
| # no version to move to: the action is a migration, and making that the | |
| # price of an unrelated PR buys an ignore entry written under time pressure, | |
| # which is the kind nobody ever removes. The two answers are cargo-audit's | |
| # own exit codes rather than anything configured here, which is a further | |
| # reason to pin its version below — a change to those defaults then arrives | |
| # in a diff somebody wrote. | |
| audit: | |
| name: advisories (blocks on a vulnerability) | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 20 | |
| permissions: | |
| contents: read | |
| steps: | |
| # No toolchain: nothing here compiles. This job reads a text file | |
| # against a git repository of advisories. | |
| - uses: actions/checkout@v7 | |
| # Not `rustsec/audit-check`, for the reason the note in `build` gives: | |
| # an action has to be admitted by this repository's Actions settings, | |
| # and a disallowed one fails the whole run at startup with no logs. A | |
| # pinned tarball needs no such permission. Pinned by version *and* by | |
| # hash, because upstream publishes no checksum of its own and this | |
| # should run the bytes we looked at rather than whatever the URL serves | |
| # later. Bumping the version means replacing both lines. | |
| - name: Install cargo-audit | |
| env: | |
| AUDIT_VERSION: "0.22.2" | |
| AUDIT_SHA256: ab28a1bdb54db4d5d8ad5981cf1f959410370b3d28250dbd35f6a44248620e39 | |
| run: | | |
| name="cargo-audit-x86_64-unknown-linux-gnu-v${AUDIT_VERSION}" | |
| curl -fsSL -o /tmp/cargo-audit.tgz \ | |
| "https://github.com/rustsec/rustsec/releases/download/cargo-audit%2Fv${AUDIT_VERSION}/${name}.tgz" | |
| echo "${AUDIT_SHA256} /tmp/cargo-audit.tgz" | sha256sum -c - | |
| # The archive has a versioned top-level directory and carries a | |
| # README and two licenses beside the binary; name the one file that | |
| # belongs on PATH. | |
| tar xzf /tmp/cargo-audit.tgz -C "$HOME/.cargo/bin" \ | |
| --strip-components=1 "${name}/cargo-audit" | |
| cargo-audit --version | |
| # `cargo-audit`, not `cargo audit`. The `cargo` on the runner is a rustup | |
| # shim and `rust-toolchain.toml` in this checkout outranks whatever the | |
| # image installed, so going through it downloads the pinned toolchain in | |
| # order to run a program that reads a text file. | |
| # | |
| # `--file` is how this keeps the property every cargo call in `build` | |
| # gets from `--locked`. Left to its default, `cargo audit` with no | |
| # lockfile in sight runs `cargo generate-lockfile` and audits the result | |
| # — a graph nobody committed and nothing ships, reported green. Naming | |
| # the file makes that an error instead: exit 2, "Couldn't load | |
| # Cargo.lock". | |
| # | |
| # The advisory database is fetched every run and deliberately not cached. | |
| # It is the one input here that is worthless the moment it is a day old. | |
| # | |
| # The output reaches the job summary whatever it said: an informational | |
| # advisory exits 0, and a warning behind a green check is one nobody | |
| # reads. | |
| - name: Audit Cargo.lock | |
| run: | | |
| status=0 | |
| cargo-audit audit --color never --file Cargo.lock >audit.txt 2>&1 || status=$? | |
| cat audit.txt | |
| { | |
| printf '```\n' | |
| cat audit.txt | |
| printf '```\n\n' | |
| printf "A vulnerability fails this job; an unmaintained, unsound or " | |
| printf "yanked crate is reported here and does not. " | |
| printf "\`cargo audit\` says the same thing locally, before the push " | |
| printf "rather than after it.\n" | |
| } >>"$GITHUB_STEP_SUMMARY" | |
| exit "$status" |