- Rust (stable)
- PowerShell 7+ (
pwsh) — required for the pwsh integration tests; tests are skipped at runtime ifpwshis not found - bash 4+ — required for the bash integration tests; tests are skipped at runtime if bash < 4.0 is found. macOS ships bash 3.2; install a newer version via Homebrew (
brew install bash)
cargo build# Run all tests
cargo test --workspace
# (`runex` is the only crate in the workspace as of 0.1.14; the
# previous `runex-core` crate was absorbed into `runex/src/{domain,
# app, infra}/` and removed from the workspace member list.)
cargo test -p runexSome tests are skipped at runtime when their prerequisites are missing (e.g. pwsh for PowerShell tests, bash 4+ for bash tests).
A small number of tests exercise UNIX-only behaviour (named pipes, /dev/zero, mkfifo). These are compiled only on Unix and require a Linux environment. On Windows, run them via WSL:
wsl -e bash -c 'cd /mnt/path/to/runex && cargo test --workspace'Replace /mnt/path/to/runex with the WSL path to your checkout. The tests are gated with #[cfg(unix)] and are automatically skipped on Windows.
Starting with 0.1.16, runex ships a Linux CI container image with bash 4+, zsh, pwsh, nu, xclip, wl-paste, xsel, and the rust toolchain pinned at known versions. CI's test-linux job runs inside this image; you can run exactly the same environment locally:
# Pull the latest CI image and run the full Linux test suite
# against your working tree. The bind-mount on /workspace makes
# target/ and Cargo.lock changes show up in your checkout.
docker run --rm -it \
-v "$(pwd)":/workspace -w /workspace \
--user 1001 \
ghcr.io/shortarrow/runex-ci:latest \
cargo test --locked --workspaceOr build the image locally if you've changed the Dockerfile. Note
the build context is containers/ci, not the repo root — the
.dockerignore next to the Dockerfile only applies when the
context is scoped that way:
docker build -t runex-ci -f containers/ci/ubuntu.Dockerfile containers/ci
docker run --rm -it -v "$(pwd)":/workspace -w /workspace --user 1001 \
runex-ci cargo test --locked --workspaceThe container is the same one CI uses, so a green cargo test --workspace here is the strongest pre-push signal short of pushing to a feature branch. PTY-based E2E tests (bash_pty_integration.rs, zsh_pty_integration.rs, pwsh_pty_integration.rs, nu_pty_integration.rs) and the shell-integration cache tests (shell_integration.rs) all exercise the same toolchain inside the container as in CI.
The image is amd64-only at present. macOS / Windows hosts can still use it via Docker Desktop's built-in emulation but PTY tests may be slow under emulation; running on a native Linux host (or WSL2) is preferred for development.
CI consumes the image by @sha256:... digest, not by the :latest
tag. After changing anything under containers/ci/, the new digest
must land in .github/workflows/ci.yml for CI to pick the change up.
- Trigger a build. Push your
containers/ci/**change to a branch. TheBuild CI imageworkflow runs on push tomain/develop; for feature branches, open a PR (thepull_requesttrigger runs the build withpush: falseso you can verify the Dockerfile compiles without yet uploading to GHCR). Once the PR merges todevelop, the push trigger uploads the image. - Copy the digest. Open the green
Build CI imagerun ondevelop(or trigger one manually withgh workflow run build-ci-image.yml). The run's Step Summary prints a "Pin in.github/workflows/ci.yml:" block containing the line you need. - Update
ci.yml. Replace theimage:value undertest-linux.containerwith the newghcr.io/shortarrow/runex-ci@sha256:<new>. Commit ondevelopwith a message likeci(linux): bump pinned runex-ci digest to <reason>. - Push and verify. The next CI run should consume the new
image. Mismatch between Dockerfile changes and the pinned
digest will manifest as missing-tool failures in
Verify image toolingor behavioural drift incargo test --locked.
The two-step (build → pin) is deliberate. A single-step approach
where CI tracks :latest would let a re-built image silently
change every running PR's gate. The digest pin makes a re-build
visible as a one-line commit reviewable like any other change.
- Source code, comments, doc comments (
///), and commit messages are written in English. - Use
///doc comments for public-facing items and forfndeclarations inside#[cfg(test)]blocks when the why is non-obvious from the name alone. - Avoid
//inline comments inside function bodies. If an explanation is needed, move it to a///docstring, extract a named helper function, or restructure the code so the intent is clear without prose. - State the why, not the what — never restate what the code already says.
- Keep functions small and single-purpose. Prefer flat code over deep nesting.
- Do not add error handling, fallbacks, or validation for scenarios that cannot occur. Trust internal invariants; validate only at system boundaries (user input, external processes, file I/O).
-
Write a failing test first, confirm it is red, then write the minimal code to make it green.
-
Tests are organised into nested
modblocks inside#[cfg(test)], grouped by theme:mod parsing { use super::*; /* ... */ } mod sanitization { use super::*; /* ... */ }
-
Helper functions (
test_config,abbr, …) live at themod testslevel so all sub-mods can access them viause super::*. -
Each test function tests exactly one behaviour. Name it after what it asserts, not how it does it (
read_rc_content_returns_empty_for_oversized_file, nottest_size_limit). -
Do not mock subsystems that can be exercised cheaply (filesystem via
tempfile, subprocess via a fake binary). Integration-level tests that touch real syscalls are preferred over unit tests with mocks.
Keep business logic pure — no I/O, no global state, no side effects.
- Pure functions first. New logic should be pure by default: given the same inputs, always return the same output. If a function needs to query the environment (filesystem, PATH, processes), that dependency should be injected, not called directly.
- Push I/O to the boundary. Parsing, validation, expansion, and formatting are pure. I/O (file reads, subprocess calls, terminal output) belongs in the outermost layer. When adding a feature, write the logic as a pure function first, then wire up I/O in the caller.
- Inject dependencies as closures. Use
Fntrait bounds (e.g.command_exists: impl Fn(&str) -> bool) to pass in environment-querying behaviour. This keeps the function pure and makes it testable with a trivial closure. - Prefer iterators over mutation.
.map(),.filter(),.partition(),.flat_map(),.collect()are idiomatic. Avoid mutating values in-place. - Use
ResultandOptionidiomatically. Propagate errors with?. Convert between them with.ok(),.map_err(),and_then. Do not panic on recoverable conditions.
runex is a single bin-only crate (the former runex-core crate was
absorbed in 0.1.14) layered into modules with a deliberate boundary:
cmd → app → domain
↓ ↓
util infra → domain
domain/— pure data types and rule-evaluation logic. No I/O, no env, no time. Imports from sibling layers are forbidden.app/— orchestration / use-case wrappers. Composesdomaintypes withinfraadapters. Nostd::fs::*calls.infra/— file system, environment, registry adapters. Implements injection traits (HomeDirResolver) forapp/.infra → domainonly.cmd/— per-subcommand handlers. Reach behaviour throughapp/; reach leaf utilities throughutil/.util/— leaf helpers shared bycmd/*(shell detection, prompt confirmation, command-existence factory). No command-specific policy.
The rule: if new code does not need to spawn a process or write to stdout, it belongs in domain/ (or app/ when it composes adapters). Formatting helpers are an exception — they live at the crate root (format.rs) but remain pure (data in, string out, no printing). The full layering contract lives in the runex/src/main.rs crate-root docstring, and runex/tests/architecture.rs enforces it at test time (no infra → app imports, no domain → anything, no filesystem calls in app/).
Dependency injection at the boundary. Environment-querying closures (command existence checks, PATH resolution) are constructed once in the cmd/ layer from user-supplied flags, then passed down into app/ and domain/ functions. Those functions never reach into the environment themselves.
Testability follows from the architecture. A function that accepts an injected closure can be tested without touching the filesystem or PATH. Design for this — it is not an afterthought.
The runex hook migration moved every per-keystroke decision into Rust.
The shell-side templates in runex/src/domain/templates/*.{sh,zsh,ps1,lua,nu}
are now thin wrappers (244 lines total across five shells) that read
buffer state, call runex hook, and apply the eval-able output. When
deciding whether new logic belongs in Rust or in a template, ask
whether the work requires state only the live shell process holds:
- Stays in the shell — anything that touches the live readline
buffer (
READLINE_LINE,LBUFFER/RBUFFER,commandline, clink'srl_buffer, PSReadLineReplace/SetCursorPosition); anything that introspects the shell's internal state at runtime (PSReadLine_queuedKeysreflection for paste detection); pre-RPC sanitisers that exist specifically to avoid spawning a Rust subprocess (clink'srunex_is_safe_linerejects control characters before the cmd.exe roundtrip). - Belongs in Rust — everything else. Token extraction,
command-position detection, cursor placeholder substitution, shell
escaping, output formatting, command-existence checks. New rules
that don't depend on live buffer state should be added under
runex/src/domain/(orapp/, depending on whether they're pure) and exposed throughrunex hook's output.
The remaining shell code is small, stable, and self-justifying. Avoid
rewriting it for the sake of consistency — read the comment at the top
of each template (e.g. templates/pwsh.ps1 explains why paste
detection lives in pwsh; templates/clink.lua explains why the line
safety regex stays in lua) and trust the existing rationale unless
there's a concrete observation suggesting otherwise.
Any value that originates from user-controlled data (config fields, command names, file paths) and is later rendered to the terminal or embedded in a shell string must be sanitised before use.
Terminal output — strip unsafe characters (ASCII control characters, Unicode visual-deception characters such as RLO, BOM, and zero-width spaces) before including user-controlled values in any human-readable output. Use the sanitisation utilities in runex/src/domain/sanitize.rs.
Shell string embedding — use the quoting helpers in runex/src/domain/shell.rs. Never interpolate raw user data into a shell string literal.
Config validation — new config fields must follow the same rules as existing ones: reject control characters, deceptive Unicode, and enforce a byte-length limit. Field limits are documented in docs/config-reference.md.
Subprocess output — any new subprocess call must cap both the total output size and the wall-clock execution time. Use the existing helpers; do not call Command::output() directly.
A release ships runex through five channels — GitHub Releases (tag-driven
binaries), crates.io, AUR runex-bin, the Homebrew tap, and winget. The
big risk is that any of these going stale leaves users running the old
binary while everything else looks fine. Follow the checklist in order.
0.x.y— current phase; no stability guarantees.- Bump patch (
0.1.x) for bug fixes, docs, additive features. - Bump minor (
0.x.0) for breaking changes to the CLI surface or config schema. Therunexcrate ships only a binary ([[bin]]), not a library — every internal symbol ispub(crate), so there is no library API to break. External callers should embed the bin rather than depending on internal types.
Before touching any version number:
- CHANGELOG
[Unreleased]is complete. Every notable change since the last tag has an entry under Added / Changed / Deprecated / Removed / Fixed / Security. Skimgit log v<previous>..HEAD --onelineand reconcile. -
cargo test --workspaceis green on Windows. This is the baseline; nothing else matters if this fails. -
cargo test --workspaceis green on Linux. Run viawsl -d archlinux -e bash -lc 'cd /path/to/runex && cargo test --workspace'(a few#[cfg(unix)]tests don't compile on Windows). -
developis in sync withorigin/develop. No unpushed commits, no dangling working-tree changes. - A clean
runex doctorrun on a real machine. Catches integration drift that unit tests don't see (e.g. clink lua outdated). Especially important if the release touches shell templates. - crates.io Trusted Publisher is registered for
runex. Sanity check at https://crates.io/crates/runex/settings/ — the crate needs a GitHub Actions trusted publisher entry pointing at this repo +release.yml. One-time setup, but worth re-confirming if you haven't released in a while (crates.io occasionally invalidates unused tokens). The legacyrunex-coreTrusted Publisher entry can stay registered (harmless; the crate publishes nothing as of 0.1.14). See### crates.io (OIDC Trusted Publishing)below for the exact field values.
All commands from the repo root.
-
Merge develop → main:
git checkout main git pull git merge --no-ff develop
-
Bump the version. Edit
runex/Cargo.tomlversionto the new value. (As of 0.1.14 there is only one crate; the pre-0.1.14 three-place bump acrossrunex-core/Cargo.toml,runex/Cargo.toml, and therunex-corepath-dep line is no longer needed.) -
Promote the CHANGELOG
[Unreleased]heading to## [X.Y.Z] - YYYY-MM-DD, then add a fresh empty## [Unreleased]block above it for next time. -
Refresh
Cargo.lockwithcargo check. -
Single bump commit:
git add runex/Cargo.toml Cargo.lock CHANGELOG.md git commit -m "chore: bump version to X.Y.Z" git push origin main -
Tag and push to trigger the binary build workflow:
git tag -a vX.Y.Z -m "Release vX.Y.Z" git push origin vX.Y.Z.github/workflows/release.ymlruns and takes ~10 minutes to build every target platform (see "Binary release workflow" below) and attach archives to the auto-created GitHub release. -
Wait for the workflow to finish. The next steps need the release artifacts (sha256 inputs for AUR and Homebrew). Watch https://github.com/ShortArrow/runex/actions.
Run after the GitHub release artifacts are visible at https://github.com/ShortArrow/runex/releases/tag/vX.Y.Z.
-
crates.io. Automated. The
publish-cratesjob inrelease.ymlfires on the same tag push asbuild/releaseand publishesrunexvia OIDC Trusted Publishing. (Pre-0.1.14 it also publishedrunex-core, but that crate was absorbed intorunexin 0.1.14 and no longer exists in the workspace.) See### crates.io (OIDC Trusted Publishing)below for the one-time setup.- To skip publishing on a particular tag (e.g. when re-tagging
a previously-published version because of a release-process
glitch), include
[skip publish]in the bump commit's message. The job'sif:condition checks for it. - There is no
CARGO_REGISTRY_TOKENto manage — the workflow exchanges a short-lived GitHub OIDC token for an equally short-lived crates.io token at job start, scoped to the workflow + repository registered with crates.io.
- To skip publishing on a particular tag (e.g. when re-tagging
a previously-published version because of a release-process
glitch), include
-
AUR
runex-bin. Use the helper:packaging/aur-bin/release.sh X.Y.Z ~/aur/runex-binThe script fetches the Linux x86_64/aarch64 release tarball sha256s, rewrites the PKGBUILD, regenerates
.SRCINFO, and stages a commit in your AUR clone. Review withgit show, then push:cd ~/aur/runex-bin GIT_SSH_COMMAND='ssh -i ~/.ssh/aur' git push origin master
Forgetting this step is the canonical way users end up with a stale
runexbinary on theirPATH. The shell template safe-fails to "literal space" whenrunex hookerrors out as an unknown subcommand, and it's hard to debug from the user side. Don't skip.The helper edits
packaging/aur-bin/PKGBUILDand writespackaging/aur-bin/.SRCINFOin this repo as well (the AUR clone copy is taken from those). Commit the in-repo changes as part of the back-merge step so the template stays in sync with the published AUR version — otherwise the in-repo PKGBUILD drifts (it stayed pinned at 0.1.11 across the 0.1.12–0.1.14 cycle before this rule was added). -
AUR
runex(source). Sibling ofrunex-bin(binary); this package builds from therunexcrate published to crates.io byrelease.yml. It is not interchangeable withrunex-bin— users pick one, and the PKGBUILD declaresconflicts=('runex-bin')(andrunex-bindeclaresconflicts=('runex')) to enforce that. Use the helper:packaging/aur/release.sh X.Y.Z ~/aur/runexSame sha-fetch + PKGBUILD + .SRCINFO + local-commit flow as
runex-bin, but the source is the.cratefrom crates.io — so the script must run after the crates.io publish completes (= same gate as Homebrew). Push manually:cd ~/aur/runex GIT_SSH_COMMAND='ssh -i ~/.ssh/aur' git push origin master
The helper also edits
packaging/aur/PKGBUILDand writespackaging/aur/.SRCINFOin this repo. Commit those in the back-merge step alongside therunex-binupdates — same rule, same reason (the in-repo template stays the source of truth so it doesn't drift likerunex-bin's did across 0.1.12–0.1.14).Historical note:
runexwas a third-party AUR package (maintainer: Rafael Dominiquini) through 0.1.16. Maintainership was transferred to ShortArrow on 2026-05-25. Dominiquini is preserved asContributor:in the PKGBUILD header. -
Homebrew tap. Use the helper:
packaging/homebrew/release.sh X.Y.Z /v/homebrew-runex
The script fetches the macOS arm64/x86_64 and Linux arm64/x86_64 tarball sha256s, rewrites
Formula/runex.rb, and stages a commit in the tap clone. Push manually:cd /v/homebrew-runex git push origin main -
winget-pkgs PR. See
### winget submissionbelow — the Defender ML pipeline can reject runex on submission and the recovery procedure deserves its own subsection.
-
Merge main → develop with
--no-ffso the bump commit shows up in develop's history with a clear merge marker:git checkout develop git pull git merge --no-ff main git push origin develop
-
Polish the GitHub release body. The auto-generated body is bare. Fill it in using the template in
### GitHub release bodybelow — at minimum, a summary, install commands, and an upgrade-notice line for clink users when shell templates changed. -
Verify each install channel resolves the new version. Don't trust the publish steps to have succeeded — check:
cargo search runex # crates.io pacman -Si runex-bin 2>/dev/null # AUR brew info shortarrow/runex/runex # Homebrew tap winget show ShortArrow.runex # winget (after the PR merges)
runex is published to crates.io via OIDC Trusted Publishing — no
long-lived CARGO_REGISTRY_TOKEN is stored as a GitHub secret or in
any local environment. The publish-crates job in
.github/workflows/release.yml exchanges the workflow's GitHub OIDC
token for a short-lived crates.io token at job start
(rust-lang/crates-io-auth-action@v1.0.4), runs cargo publish --dry-run as a packaging sanity check, then publishes the real
crate.
(Pre-0.1.14 the same workflow also published runex-core first and
waited for the sparse index to propagate before runex. Phase C
absorbed runex-core into runex, so the workflow now publishes a
single crate. The legacy runex-core Trusted Publisher entry on
crates.io stays registered — harmless because the workflow no longer
calls cargo publish -p runex-core.)
Register this repository's release.yml as a Trusted Publisher on
crates.io for the runex crate:
- Sign in at https://crates.io as a crate owner.
- Go to https://crates.io/crates/runex/settings.
- Under Trusted Publishers, click Add and choose GitHub Actions.
- Fill in:
- Repository owner:
ShortArrow - Repository name:
runex - Workflow filename:
release.yml - Environment: leave blank (we don't gate publishing behind a GitHub Environment for runex).
- Repository owner:
- Save.
After saving, the next tag-pushed release will publish without any further manual action.
If the publish-crates job has been failing at the
Exchange OIDC token step or the Publish runex step, the usual
cause is one of:
- Trusted Publisher not registered for
runexyet. Re-run the setup above. - Workflow filename mismatch. If you renamed the workflow file the registered entry no longer matches and crates.io won't issue a token. Update the entry to the new filename.
- Repository was renamed/transferred. Update the
Repository owner/Repository namefields to the current ones.
If you ever need to publish manually as a fallback (e.g. crates.io
OIDC issuer outage), you can still run
RUNEX_GIT_COMMIT=$(git rev-parse --short=12 HEAD) cargo publish -p runex
from a checkout at the release tag, using a personal API token via
cargo login --registry crates-io. Don't commit the token
anywhere; revoke it from your crates.io account once the manual run
is complete.
winget validation runs the candidate manifest through Defender. A non-zero
fraction of past PRs have been blocked by ML detections like
Trojan:Win32/Sprisky.U!cl or Trojan:Script/Wacatac.H!ml. The pattern:
!cl(cloud ML) — usually clears within 24-72 hours as the model updates. Comment on the PR asking maintainers to retry validation.!ml(local ML) — needs a false-positive submission to https://www.microsoft.com/en-us/wdsi/filesubmission. Pick "Software developer" and explain the binary is dual-licensed open-source Rust code with reproducible builds via GitHub Actions. Include a link to the failing winget PR.
Pre-submission checks (the PR template asks for these — do them before opening the PR):
- wingetcreate is current —
wingetcreate --versionshould match a recent release (1.12+ at time of writing). Older wingetcreate defaults to schema 1.10, whichwinget-pkgsmaster has moved past; check withwinget upgrade Microsoft.WingetCreate. - Schema matches the previously-merged version's schema.
Inspect the existing manifest in
microsoft/winget-pkgs:manifests/s/ShortArrow/runex/<previous>/for itsManifestVersion, and confirm the regenerated manifest uses the same or newer. The 0.1.11 PR landed at 1.12.0 — a regression to 1.10.0 will get pushed back. - No other open PRs for the same manifest:
gh search prs --repo microsoft/winget-pkgs "is:pr is:open ShortArrow.runex". - Manifest validates locally:
winget validate --manifest <path>. - Local install attempt with
winget install --manifest <path> --accept-source-agreements --accept-package-agreements. Be aware: on Defender-active machines this may stall at the "applying motw" step due to the same false-positive that hits the official validation pipeline. A successful local install is nice-to-have but not required — the PR still gets the same Defender treatment regardless.
Submission steps:
- Generate the manifest update with
wingetcreate update ShortArrow.runex --version X.Y.Z --urls <release-zip-url>, passing--out <dir>so the files land somewhere obvious. The tool produces three YAMLs:ShortArrow.runex.{installer,locale.en-US,version}.yaml. - Open a PR against
microsoft/winget-pkgsby adding--submitto the samewingetcreate updateinvocation, or by manually committing on awinget-pkgsfork and opening the PR withgh pr create. Title format:New version: ShortArrow.runex version X.Y.Z. - Post the checklist confirmation as a PR comment so reviewers don't have to verify each box themselves. Mention which boxes are blocked by Defender (the local-install row).
- Watch the validation pipeline. Status is reported as PR
comments from
@microsoft-github-policy-serviceand tags likeValidation-Defender-Error. - If Defender rejects: post the WDSI submission ID on the PR, ask for revalidation after the analyst clears the file.
- If validation hangs: the validation pipeline sometimes uses stale Defender definitions; retry by closing/reopening the PR or pushing an empty commit to the branch.
Until the PR merges, point users at cargo install runex or
brew install shortarrow/runex/runex as the fastest install path.
Use this template for the release body, filled in from the CHANGELOG:
## Highlights
- [3-5 bullet points of user-visible changes from CHANGELOG]
## Install
| Channel | Command |
|---------|---------|
| crates.io | `cargo install runex` |
| AUR | `paru -S runex-bin` |
| Homebrew tap | `brew install shortarrow/runex/runex` |
| winget | `winget install ShortArrow.runex` |
(AUR / Homebrew / winget can lag the GitHub release by hours;
`cargo install runex` always picks up the new version immediately.)
## Upgrade notes
[Conditional: only when shell templates changed]
- **bash / zsh / pwsh / nu users:** the integration line in your rcfile
re-evaluates the export at every shell start; just open a fresh shell.
- **clink users:** the lua file at `%LOCALAPPDATA%\clink\runex.lua` is
a static copy and does not auto-refresh. Run
`runex export clink > %LOCALAPPDATA%\clink\runex.lua` (or
`runex doctor` to confirm whether refresh is needed) and open a new
cmd window.
[Conditional: when CLI surface or config schema changed]
- **Breaking changes:** [list]
## Full changelog
See [CHANGELOG.md](https://github.com/ShortArrow/runex/blob/vX.Y.Z/CHANGELOG.md#XYZ---YYYY-MM-DD).Skip sections that don't apply. The "AUR / Homebrew / winget can lag" line should stay even when those channels caught up — it sets expectations for users hitting the page on day-of.
The branching model assumed by the checklist above:
develop : feature work, bug fixes, docs
main : release-only; bump commits and merges from develop
develop : merge main back with --no-ff so version bumps show up
The --no-ff on the back-merge is deliberate: it preserves the merge
commit so the branch history stays clear about which commits came from
main (version bumps) vs develop (feature work).
Pushing a v* tag triggers .github/workflows/release.yml. The
workflow runs cargo test --workspace --locked on Ubuntu, Windows,
and macOS as a hard gate — every native target must pass before any
binary is built or published. This closes the timing hole where a
tag push would otherwise race the bump commit's CI workflow and
could ship binaries from an unverified commit.
Once the test gate passes, the workflow builds for every supported platform and attaches archives to the auto-created GitHub release.
| Target | OS runner | Archive |
|---|---|---|
| x86_64-pc-windows-msvc | windows-latest | zip |
| x86_64-unknown-linux-gnu | ubuntu-latest | tar.gz |
| aarch64-unknown-linux-gnu | ubuntu-latest | tar.gz |
| x86_64-apple-darwin | macos-latest | tar.gz |
| aarch64-apple-darwin | macos-latest | tar.gz |
| aarch64-linux-android (Termux) | ubuntu-latest | tar.gz |
Workflow hardening:
- Top-level
permissions: contents: read. Only thereleasejob getscontents: writeto publish the release; build jobs cannot mutate the repo. actions/checkouton build jobs usespersist-credentials: falseso the checkout token is not left on disk for malicious build code to exfiltrate.- All third-party actions are pinned to commit SHAs.
- Only
GITHUB_TOKENis used — no external secrets, no automaticcargo publish.