Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 101 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,8 @@ jobs:
id: rust
uses: ./
with:
components: rustfmt,clippy
targets: wasm32-unknown-unknown
working-directory: tests/fixtures/file-toolchain

- name: Verify setup contract
Expand All @@ -138,6 +140,105 @@ jobs:
INSTALLED_TOOLCHAIN_SOURCE: ${{ steps.rust.outputs.toolchain-source }}
run: node tests/setup-contract.js

file-toolchain-platforms:
name: file / ${{ matrix.os }}
runs-on: ${{ matrix.os }}
timeout-minutes: 10
strategy:
fail-fast: false
matrix:
os:
- macos-15
- windows-2025
steps:
- name: Check out repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false

- name: Set up Rust from a toolchain file
id: rust
uses: ./
with:
components: rustfmt,clippy
targets: wasm32-unknown-unknown
working-directory: tests/fixtures/file-toolchain

- name: Verify setup contract
env:
EXPECTED_RUST_VERSION: "1.88.0"
EXPECTED_TOOLCHAIN_SOURCE: tests/fixtures/file-toolchain/rust-toolchain.toml
INSTALLED_CARGO_VERSION: ${{ steps.rust.outputs.cargo-version }}
INSTALLED_HOST: ${{ steps.rust.outputs.host }}
INSTALLED_RUSTC_VERSION: ${{ steps.rust.outputs.rustc-version }}
INSTALLED_RUSTUP_VERSION: ${{ steps.rust.outputs.rustup-version }}
INSTALLED_TOOLCHAIN: ${{ steps.rust.outputs.toolchain }}
INSTALLED_TOOLCHAIN_SOURCE: ${{ steps.rust.outputs.toolchain-source }}
run: node tests/setup-contract.js

runner-state:
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- name: Check out repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false

- name: Seed a conflicting rustup directory override
run: rustup override set stable --path tests/fixtures/file-toolchain

- name: Set up file-selected Rust above the override
id: file-state
uses: ./
with:
components: rustfmt,clippy
targets: wasm32-unknown-unknown
working-directory: tests/fixtures/file-toolchain

- name: Verify repository selection won
env:
EXPECTED_RUST_VERSION: "1.88.0"
EXPECTED_TOOLCHAIN_SOURCE: tests/fixtures/file-toolchain/rust-toolchain.toml
INSTALLED_CARGO_VERSION: ${{ steps.file-state.outputs.cargo-version }}
INSTALLED_HOST: ${{ steps.file-state.outputs.host }}
INSTALLED_RUSTC_VERSION: ${{ steps.file-state.outputs.rustc-version }}
INSTALLED_RUSTUP_VERSION: ${{ steps.file-state.outputs.rustup-version }}
INSTALLED_TOOLCHAIN: ${{ steps.file-state.outputs.toolchain }}
INSTALLED_TOOLCHAIN_SOURCE: ${{ steps.file-state.outputs.toolchain-source }}
run: node tests/setup-contract.js

- name: Use an isolated rustup home with no default
run: echo "RUSTUP_HOME=$RUNNER_TEMP/setup-rust-home" >> "$GITHUB_ENV"

- name: Set up without updating or creating a default
id: isolated-state
uses: ./
with:
toolchain: "1.88.0"
components: rustfmt,clippy
targets: wasm32-unknown-unknown
update: "false"

- name: Verify no-update additions
env:
EXPECTED_RUST_VERSION: "1.88.0"
EXPECTED_TOOLCHAIN_SOURCE: input
INSTALLED_CARGO_VERSION: ${{ steps.isolated-state.outputs.cargo-version }}
INSTALLED_HOST: ${{ steps.isolated-state.outputs.host }}
INSTALLED_RUSTC_VERSION: ${{ steps.isolated-state.outputs.rustc-version }}
INSTALLED_RUSTUP_VERSION: ${{ steps.isolated-state.outputs.rustup-version }}
INSTALLED_TOOLCHAIN: ${{ steps.isolated-state.outputs.toolchain }}
INSTALLED_TOOLCHAIN_SOURCE: ${{ steps.isolated-state.outputs.toolchain-source }}
run: node tests/setup-contract.js

- name: Verify the global default remains absent
run: |
if rustup toolchain list | grep -E '\((active, )?default\)$'; then
echo "setup-rust created a global default" >&2
exit 1
fi

fail-closed:
runs-on: ubuntu-24.04
timeout-minutes: 5
Expand Down
1 change: 1 addition & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ git diff --check
Requirements:

- preserve the fail-closed toolchain selection contract;
- test rustup semantics against isolated persistent runner state, not only fake argv;
- pass external values to child processes as arguments, never shell text;
- add a failure-path test for every new input or process boundary;
- keep Cargo tools, native packages, caching, and compiler flags out of scope;
Expand Down
34 changes: 23 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,11 @@
Install and select one reviewed Rust toolchain in GitHub Actions without hidden
cache, compiler-flag, or native-package policy.

The action requires `rustup` on `PATH`, installs the selected toolchain with
argument-safe process execution, exports the fully resolved toolchain through
`RUSTUP_TOOLCHAIN`, and reports the resolved Rust, Cargo, rustup, commit, and
host identities as outputs.
The action requires rustup 1.28.0 or newer on `PATH`, installs the selected
toolchain with argument-safe process execution, exports the fully resolved
toolchain through `RUSTUP_TOOLCHAIN`, and reports the resolved Rust, Cargo,
rustup, commit, and host identities as outputs. Rustup self-update is always
disabled and an initially absent global default remains absent.

## Usage

Expand All @@ -21,7 +22,7 @@ components = ["clippy", "rustfmt"]

```yaml
- name: Set up Rust
uses: zactionsz/setup-rust@f284a629c36439306a68624f66759dc33288b950
uses: zactionsz/setup-rust@329a2736cd0ad2a71140f3fb6011f5abbd8fb5a1
```

An explicit input overrides any repository toolchain file. This is useful for
Expand All @@ -30,7 +31,7 @@ compatibility and latest-stable jobs:
```yaml
- name: Set up Rust 1.88
id: rust
uses: zactionsz/setup-rust@f284a629c36439306a68624f66759dc33288b950
uses: zactionsz/setup-rust@329a2736cd0ad2a71140f3fb6011f5abbd8fb5a1
with:
toolchain: "1.88.0"
components: rustfmt,clippy
Expand All @@ -45,15 +46,25 @@ also explicit moving behavior; use an exact Rust version for reproducible jobs.
| Input | Default | Contract |
| --- | --- | --- |
| `toolchain` | repository file | One rustup toolchain name. Without it, `rust-toolchain` or `rust-toolchain.toml` must exist. |
| `profile` | file setting or `minimal` | `minimal`, `default`, or `complete`. Explicit toolchain installs default to `minimal`. |
| `components` | none | Comma- or whitespace-separated additional rustup components. |
| `targets` | none | Comma- or whitespace-separated additional compilation targets. |
| `profile` | file setting or `minimal` | `minimal`, `default`, or `complete`. An action input overrides the file setting. |
| `components` | file settings | Comma- or whitespace-separated additional rustup components. |
| `targets` | file settings | Comma- or whitespace-separated additional compilation targets. |
| `working-directory` | `.` | Repository-relative directory from which rustup discovers the nearest toolchain file. |
| `update` | `true` | Update an installed moving toolchain. Set `false` to pass rustup's `--no-update`. |
| `allow-downgrade` | `false` | Allow rustup to select an older release when requested components are unavailable. |
| `update` | `true` | Update an installed moving toolchain. When `false`, keep the installed release while still adding missing components and targets. |
| `allow-downgrade` | `false` | Allow rustup to select an older release when requested components are unavailable. Requires `update: true`. |

List inputs are deduplicated without reordering. The action invokes rustup
directly with an argument array; input text is never evaluated by a shell.
Repository-file components and targets are installed before action-provided
additions, and the selected channel is forced above any persistent rustup
directory override.

### Toolchain file contract

`rust-toolchain.toml` supports the rustup `channel`, `profile`, `components`,
and `targets` fields. The legacy single-line `rust-toolchain` format is also
supported. Local `path` toolchains and linked custom toolchains are outside this
action's distribution-install boundary and fail before rustup is invoked.

## Outputs

Expand All @@ -76,6 +87,7 @@ runner's global default.
This action does not:

- install rustup;
- update the rustup executable;
- set `RUSTFLAGS` or other compiler policy;
- cache Cargo registries, Git repositories, or build outputs;
- install Cargo binaries such as `cargo-deny`, `zcheck`, or `zrail`;
Expand Down
12 changes: 9 additions & 3 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,15 @@ pin a reviewed full commit SHA and update that pin after a fix is released.
## Security boundary

Inputs and repository paths are treated as untrusted data. The action validates
rustup names, rejects working directories outside `GITHUB_WORKSPACE`, invokes
commands without a shell, and stops when no explicit input or repository
toolchain file selects Rust.
rustup names and its supported toolchain-file subset, rejects working
directories outside `GITHUB_WORKSPACE`, invokes commands without a shell, and
stops when no explicit input or repository toolchain file selects Rust. The
selected channel is forced through `RUSTUP_TOOLCHAIN`, so persistent rustup
directory overrides cannot replace repository policy.

Local path and linked custom toolchains are deliberately unsupported. The
action disables rustup self-update and restores an initially absent global
default after installation.

The action trusts the selected action commit, the runner, the installed rustup
executable, and rustup's configured distribution server. It does not verify the
Expand Down
6 changes: 3 additions & 3 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ author: zactionsz

inputs:
toolchain:
description: Rustup toolchain name; when omitted, a repository toolchain file is required
description: Installable rustup channel; when omitted, a supported repository toolchain file is required
required: false
profile:
description: Rustup profile (minimal, default, or complete); explicit installs default to minimal
Expand All @@ -20,11 +20,11 @@ inputs:
required: false
default: "."
update:
description: Update an already installed moving toolchain such as stable
description: Update an installed moving toolchain; false still adds missing components and targets
required: false
default: "true"
allow-downgrade:
description: Allow rustup to select an older toolchain when requested components are unavailable
description: With updates enabled, allow an older toolchain when requested components are unavailable
required: false
default: "false"

Expand Down
84 changes: 32 additions & 52 deletions dist/action.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,54 +37,48 @@ exports.runAction = runAction;
const contracts_1 = require("./contracts");
const github = __importStar(require("./github"));
const process_1 = require("./process");
const rustup_1 = require("./rustup");
const toolchain_file_1 = require("./toolchain-file");
const workspace_1 = require("./workspace");
async function runAction(environment = process.env, overrides = {}) {
const dependencies = { runCommand: process_1.runCommand, ...overrides };
const inputs = readInputs(environment);
requireCompatibleInputs(inputs);
const workspaceValue = environment.GITHUB_WORKSPACE || process.cwd();
const paths = await (0, workspace_1.resolveWorkspacePaths)(workspaceValue, inputs.workingDirectory);
if (!inputs.toolchain && !paths.toolchainFile) {
throw new Error('No Rust toolchain selected; provide the toolchain input or commit rust-toolchain.toml');
}
const file = inputs.toolchain
? undefined
: await (0, toolchain_file_1.readToolchainFile)(requireValue(paths.toolchainFile));
const toolchainSource = inputs.toolchain
? 'input'
: (0, workspace_1.relativeSource)(paths.workspace, requireValue(paths.toolchainFile));
const commandEnvironment = { ...environment };
delete commandEnvironment.RUSTUP_TOOLCHAIN;
if (inputs.toolchain)
commandEnvironment.RUSTUP_TOOLCHAIN = inputs.toolchain;
const installArguments = buildInstallArguments(inputs);
const selection = selectToolchain(inputs, file);
github.startGroup(`Install Rust toolchain from ${toolchainSource}`);
let installed;
try {
await dependencies.runCommand('rustup', installArguments, {
installed = await (0, rustup_1.installRustupToolchain)({
...selection,
allowDowngrade: inputs.allowDowngrade,
cwd: paths.workingDirectory,
environment: commandEnvironment
});
environment,
update: inputs.update
}, dependencies.runCommand);
}
finally {
github.endGroup();
}
const selected = await dependencies.runCommand('rustup', ['show', 'active-toolchain'], {
cwd: paths.workingDirectory,
environment: commandEnvironment,
quiet: true
});
const toolchain = parseActiveToolchain(selected.stdout);
const selectedEnvironment = { ...commandEnvironment, RUSTUP_TOOLCHAIN: toolchain };
const [rustc, cargo, rustup] = await Promise.all([
const [rustc, cargo] = await Promise.all([
dependencies.runCommand('rustc', ['--version', '--verbose'], {
cwd: paths.workingDirectory,
environment: selectedEnvironment,
environment: installed.environment,
quiet: true
}),
dependencies.runCommand('cargo', ['--version'], {
cwd: paths.workingDirectory,
environment: selectedEnvironment,
quiet: true
}),
dependencies.runCommand('rustup', ['--version'], {
cwd: paths.workingDirectory,
environment: selectedEnvironment,
environment: installed.environment,
quiet: true
})
]);
Expand All @@ -94,51 +88,37 @@ async function runAction(environment = process.env, overrides = {}) {
host: rustcDetails.host,
rustcCommit: rustcDetails.commit,
rustcVersion: rustcDetails.release,
rustupVersion: firstVersionLine(rustup, 'rustup'),
toolchain,
rustupVersion: installed.rustupVersion,
toolchain: installed.toolchain,
toolchainSource
};
await publishResult(result, environment);
github.info(`Selected Rust ${result.rustcVersion} (${result.host}) from ${result.toolchainSource}`);
return result;
}
function requireCompatibleInputs(inputs) {
if (!inputs.update && inputs.allowDowngrade) {
throw new Error('allow-downgrade requires update to be true');
}
}
function readInputs(environment) {
return {
allowDowngrade: (0, contracts_1.booleanInput)(github.input('allow-downgrade', environment) || 'false', 'allow-downgrade'),
components: (0, contracts_1.rustupList)(github.input('components', environment), 'components'),
profile: (0, contracts_1.optionalProfile)(github.input('profile', environment)),
targets: (0, contracts_1.rustupList)(github.input('targets', environment), 'targets'),
toolchain: (0, contracts_1.optionalToolchain)(github.input('toolchain', environment)),
toolchain: (0, contracts_1.optionalInstallableToolchain)(github.input('toolchain', environment)),
update: (0, contracts_1.booleanInput)(github.input('update', environment) || 'true', 'update'),
workingDirectory: (0, contracts_1.nonEmptyDirectory)(github.input('working-directory', environment) || '.')
};
}
function buildInstallArguments(inputs) {
const arguments_ = ['toolchain', 'install'];
if (inputs.toolchain)
arguments_.push(inputs.toolchain);
const profile = inputs.profile ?? (inputs.toolchain ? 'minimal' : undefined);
if (profile)
arguments_.push('--profile', profile);
if (inputs.components.length > 0) {
arguments_.push('--component', inputs.components.join(','));
}
if (inputs.targets.length > 0)
arguments_.push('--target', inputs.targets.join(','));
if (!inputs.update)
arguments_.push('--no-update');
if (inputs.allowDowngrade)
arguments_.push('--allow-downgrade');
return arguments_;
}
function parseActiveToolchain(stdout) {
const candidate = stdout.trim().split(/\s+/u)[0];
if (!candidate)
throw new Error('rustup did not report an active toolchain');
const toolchain = (0, contracts_1.optionalToolchain)(candidate);
if (!toolchain)
throw new Error('rustup reported an empty active toolchain');
return toolchain;
function selectToolchain(inputs, file) {
return {
components: (0, contracts_1.rustupItems)([...(file?.components ?? []), ...inputs.components], 'components'),
profile: inputs.profile ?? file?.profile ?? 'minimal',
targets: (0, contracts_1.rustupItems)([...(file?.targets ?? []), ...inputs.targets], 'targets'),
toolchain: inputs.toolchain ?? requireValue(file).toolchain
};
}
function parseRustc(stdout) {
const values = new Map();
Expand Down
Loading
Loading