diff --git a/README.md b/README.md index b0ac59a..d2faadb 100644 --- a/README.md +++ b/README.md @@ -13,3 +13,4 @@ ### Setup Actions - [**Setup Node**](actions/setup-node) - Setup a Node.js environment with the right package manager and package caching. +- [**Setup Rust**](actions/setup-rust) - Setup a Rust environment with the right toolchain and build caching. diff --git a/actions/setup-rust/README.md b/actions/setup-rust/README.md new file mode 100644 index 0000000..3a2aba8 --- /dev/null +++ b/actions/setup-rust/README.md @@ -0,0 +1,115 @@ +# Setup Rust Action + +**Setup a Rust environment with the right toolchain and build caching. 🧰** + +```yaml +- name: Set up Rust + uses: Systemcluster/actions@setup-rust-v0 + with: + channel: stable + components: rustfmt,clippy + targets: wasm32-unknown-unknown,x86_64-unknown-linux-gnu + binaries: + - cargo-nextest + - cargo-insta + sccache: true +``` + +## Features + +- **Installs Rust** detecting the desired toolchain by reading the `RUSTUP_TOOLCHAIN` environment variable, `rust-toolchain.toml` and `rust-toolchain` files, or by manual specification +- **Caches the Cargo registry and build artifacts** to speed up build and dependency installation +- **Installs sccache** and sets up GitHub Actions integration +- **Adds problem matchers** for `rustc`, `rustfmt` and runtime panics + +## Usage + +### Inputs + +| Name | Type | Description | Default | Required | +| --- | --- | --- | --- | --- | +| `channel` | String | Version of Rust to install. Can be a channel like `stable`, `beta`, `nightly`, or a specific version like `1.54.0` or `nightly-2021-08-01`. | Read from the `RUSTUP_TOOLCHAIN` environment variable, `rust-toolchain` or `rust-toolchain.toml` file, and falling back to `stable`. | ✗ | +| `components` | String[] | List of components to install. Can be a list of component names like `rustfmt`, `clippy`, or `rust-src`. | Read from the `rust-toolchain.toml` file. | ✗ | +| `targets` | String[] | List of targets to install. Can be a list of target names like `wasm32-unknown-unknown`, `x86_64-unknown-linux-gnu`, or `x86_64-apple-darwin`. | Read from the `rust-toolchain.toml` file. | ✗ | +| `profile` | String | Profile to install. Can be `minimal`, `default`, or `complete`. | Read from the `rust-toolchain.toml` file and falling back to `minimal`. | ✗ | +| `binaries` | String[] | List of binaries to install. Can be a list of binary names like `cargo-nextest` or specific versions like `cargo-insta@1.32.0`. | | ✗ | +| `directory` | String | Directory containing the Rust project. Used for detecting Rust version and configuration and for caching build artifacts. | `.` | ✗ | +| `sccache` | Boolean | Whether to set up [`sccache`](github.com/mozilla/sccache/) with GitHub Actions integration. | `true` | ✗ | +| `cache` | Boolean | Whether to cache and restore the Cargo registry and the build artifacts in the target directory. | `true` | ✗ | +| `cache-profile` | String | The build profile to cache. Can be `debug`, `release`, or any custom profile. If not specified, all profiles are cached. Requires `cache` to be `true`. | | ✗ | +| `cache-key-job` | Boolean | Whether to consider the Job ID when generating the cache key. Set to `true` to prevent sharing the cache across jobs. Requires `cache` to be `true`. | `false` | ✗ | +| `cache-key-env` | String[] | List of environment variables to consider when generating the cache key. Requires `cache` to be `true`. | | ✗ | + +### Outputs + +| Name | Type | Description | +| --- | --- | --- | +| `rust-version` | String | Version of Rust that was installed. | +| `rust-version-hash` | String | Commit hash of the Rust version that was installed. | +| `cache-hit` | Boolean | Whether the Cargo registry and build artifact cache was restored. | + +## Details + +### Rust toolchain + +#### Toolchain channel + +The desired Rust toolchain channel is detected in the following order: + +1. Using the `channel` input if specified +2. Read from the `RUSTUP_TOOLCHAIN` environment variable if present +3. Read from a `rust-toolchain.toml` if present in `directory` +4. Read from a `rust-toolchain` file if present in `directory` +5. Falling back to `stable` + +The chosen toolchain is installed with `rustup` and set as the default toolchain. + +#### Toolchain components, targets, and profile + +The desired toolchain components, targets, and profile are detected in the following order: + +1. Using the `components`, `targets`, and `profile` inputs if specified +2. Read from the `rust-toolchain.toml` file if present in `directory` +3. Falling back to the `minimal` profile and no components or targets + +Both `targets` and `components` can alternatively be specified as a single string with values separated by `,` or `;`, for example `x86_64-unknown-linux-gnu;wasm32-unknown-unknown` or `rustfmt,clippy`. + +If `targets` and `components` are specified in multiple places, the lists are merged. + +### Binaries + +Binaries are installed with [`cargo-binstall`](https://crates.io/crates/cargo-binstall). `cargo-cache` and `cargo-sweep` are installed by default when `cache` is `true`. + +If `binaries` is empty and both `cache` and `sccache` are `false`, the installation of `cargo-binstall` is skipped. + +### Caching + +When the `cache` input is `true`, the Cargo store and build artifacts are cached and restored using [actions/cache](https://github.com/actions/cache). + +The cache key is generated based on the following inputs: + +- The `Cargo.lock` files in `directory` and all subdirectories +- The `dependencies` and `workspace.dependencies` fields in `Cargo.toml` files in `directory` and all subdirectories +- The `GITHUB_JOB` environment variable if `cache-key-job` is `true` +- The environment variables specified in `cache-key-env` +- The `os.platform()` and `os.arch()` of the runner + +In case of a cache key miss, a cache matching the `os.platform()` and `os.arch()` of the runner is restored. + +The following directories are cached: + +- The Cargo registry (`~/.cargo/registry`) + - Before caching, the registry is cleaned with [`cargo cache --autoclean`](https://crates.io/crates/cargo-cache) and any `.cache` directories are removed +- The build artifacts (`target`) + - Before caching, the target directory is cleaned with [`cargo sweep --installed`](https://crates.io/crates/cargo-sweep), and the `examples` and `incremental` directories as well as all dep-info files (`*.d`) are removed + - If `cache-profile` is specified, all other profile directories are removed + +### Sccache + +When the `sccache` input is `true`, [`sccache`](https://crates.io/crates/sccache) is installed and set up with [GitHub Actions integration](https://github.com/mozilla/sccache/blob/HEAD/docs/GHA.md) by setting the following environment variables: + +- `ACTIONS_CACHE_URL` is set to the cache URL provided to the action +- `ACTIONS_RUNTIME_TOKEN` is set to the token provided to the action +- `SCCACHE_PATH` is set to the path to the `sccache` binary +- `SCCACHE_GHA_ENABLED` is set to `true` +- `RUSTC_WRAPPER` is set to `sccache` diff --git a/actions/setup-rust/action.yml b/actions/setup-rust/action.yml new file mode 100644 index 0000000..a81e263 --- /dev/null +++ b/actions/setup-rust/action.yml @@ -0,0 +1,55 @@ +name: Setup Rust +description: Setup a Rust environment with the right toolchain and build caching. +author: Systemcluster + +runs: + using: node20 + main: dist/action.js + post: dist/action-post.js + +branding: + color: white + icon: box + +inputs: + channel: + required: false + description: Version of Rust to install. Can be a channel like `stable`, `beta`, `nightly`, or a specific version like `1.54.0` or `nightly-2021-08-01`. Read from the `RUSTUP_TOOLCHAIN` environment variable, `rust-toolchain` or `rust-toolchain.toml` file, and falling back to `stable`. + components: + required: false + description: List of components to install. Can be a list of component names like `rustfmt`, `clippy`, or `rust-src`. Read from the `rust-toolchain.toml` file. + targets: + required: false + description: List of targets to install. Can be a list of target names like `wasm32-unknown-unknown`, `x86_64-unknown-linux-gnu`, or `x86_64-apple-darwin`. Read from the `rust-toolchain.toml` file. + profile: + required: false + description: Profile to install. Can be `minimal`, `default`, or `complete`. Read from the `rust-toolchain.toml` file and falling back to `minimal`. + binaries: + required: false + description: List of binaries to install. Can be a list of binary names like `cargo-nextest` or specific versions like `cargo-insta@1.32.0`. + directory: + required: false + description: Directory containing the Rust project. Used for detecting Rust version and configuration and for caching build artifacts. Defaults to `.`. + sccache: + required: false + description: Whether to set up sccache with GitHub Actions integration. Defaults to `true`. + cache: + required: false + description: Whether to cache and restore the Cargo store and the build artifacts in the target directory. Defaults to `true`. + cache-profile: + required: false + description: The build profile to cache. Can be `debug`, `release`, or any custom profile. If not specified, all profiles are cached. Requires `cache` to be `true`. + cache-key-job: + required: false + description: Whether to consider the Job ID when generating the cache key. Set to `true` to prevent sharing the cache across jobs. Requires `cache` to be `true`. + cache-key-env: + required: false + description: List of environment variables to consider when generating the cache key. Requires `cache` to be `true`. + +outputs: + rust-version: + description: Version of Rust that was installed. + rust-version-hash: + description: Commit hash of the Rust version that was installed. + cache-hit: + description: Whether the Cargo store and build artifact cache was restored. diff --git a/actions/setup-rust/jest.config.js b/actions/setup-rust/jest.config.js new file mode 100644 index 0000000..69cdd8d --- /dev/null +++ b/actions/setup-rust/jest.config.js @@ -0,0 +1,5 @@ +import config from 'jest-config-workspace' + +config.setupFiles = ['dotenv/config'] +config.setupFilesAfterEnv = ['/jest.setup.js'] +export default config diff --git a/actions/setup-rust/jest.setup.js b/actions/setup-rust/jest.setup.js new file mode 100644 index 0000000..8142d87 --- /dev/null +++ b/actions/setup-rust/jest.setup.js @@ -0,0 +1,13 @@ +process.env['GITHUB_EVENT_NAME'] = 'push' +process.env['GITHUB_SHA'] = 'HEAD' +process.env['GITHUB_REF'] = 'refs/heads/main' +process.env['GITHUB_WORKFLOW'] = 'test' +process.env['GITHUB_ACTION'] = 'test' +process.env['GITHUB_ACTOR'] = 'test' +process.env['GITHUB_JOB'] = 'test' +process.env['GITHUB_RUN_NUMBER'] = '1' +process.env['GITHUB_RUN_ID'] = '1' +process.env['GITHUB_REPOSITORY'] ||= process.env['INPUT_REPOSITORY'] || 'github/.github' + +process.env['INPUT_DIRECTORY'] ||= '' +process.env['INPUT_CACHE'] ||= 'true' diff --git a/actions/setup-rust/matcher.json b/actions/setup-rust/matcher.json new file mode 100644 index 0000000..358464c --- /dev/null +++ b/actions/setup-rust/matcher.json @@ -0,0 +1,44 @@ +{ + "problemMatcher": [ + { + "owner": "rust-compiler", + "pattern": [ + { + "regexp": "^(?:\\x1B\\[[0-9;]*[a-zA-Z])*(warning|warn|error)(\\[(\\S*)\\])?(?:\\x1B\\[[0-9;]*[a-zA-Z])*: (.*?)(?:\\x1B\\[[0-9;]*[a-zA-Z])*$", + "severity": 1, + "message": 4, + "code": 3 + }, + { + "regexp": "^(?:\\x1B\\[[0-9;]*[a-zA-Z])*\\s+(?:\\x1B\\[[0-9;]*[a-zA-Z])*-->\\s(?:\\x1B\\[[0-9;]*[a-zA-Z])*(\\S+):(\\d+):(\\d+)(?:\\x1B\\[[0-9;]*[a-zA-Z])*$", + "file": 1, + "line": 2, + "column": 3 + } + ] + }, + { + "owner": "rust-formatter", + "pattern": [ + { + "regexp": "^(Diff in (\\S+)) at line (\\d+):", + "message": 1, + "file": 2, + "line": 3 + } + ] + }, + { + "owner": "rust-panic", + "pattern": [ + { + "regexp": "^.*panicked\\s+at\\s+'(.*)',\\s+(.*):(\\d+):(\\d+)$", + "message": 1, + "file": 2, + "line": 3, + "column": 4 + } + ] + } + ] +} diff --git a/actions/setup-rust/package.json b/actions/setup-rust/package.json new file mode 100644 index 0000000..ce1670d --- /dev/null +++ b/actions/setup-rust/package.json @@ -0,0 +1,51 @@ +{ + "name": "setup-rust", + "displayName": "Setup Rust Action", + "version": "0.1.0", + "description": "Setup a Rust environment with the right toolchain and build caching.", + "author": { + "name": "Christian Sdunek", + "email": "me@systemcluster.me" + }, + "repository": { + "type": "git", + "url": "github:Systemcluster/actions", + "directory": "actions/setup-rust" + }, + "license": "BSD-2-Clause", + "private": true, + "keywords": [ + "github", + "actions" + ], + "type": "module", + "sideEffects": false, + "main": "dist/main.js", + "types": "dist/main.d.ts", + "scripts": { + "build": "tsc && rollup -c", + "check": "tsc --noEmit --emitDeclarationOnly false && eslint \"**/*.ts*\"", + "test": "jest" + }, + "devDependencies": { + "@types/jest": "^29.5.8", + "@types/node": "^20.9.1", + "dotenv": "^16.3.1", + "eslint": "^8.54.0", + "eslint-config-workspace": "workspace:*", + "jest": "^29.7.0", + "jest-config-workspace": "workspace:*", + "jest-mock": "^29.7.0", + "rollup": "^4.4.1", + "rollup-config-workspace": "workspace:*", + "typescript": "^5.2.2", + "typescript-config-workspace": "workspace:*" + }, + "dependencies": { + "@ltd/j-toml": "^1.38.0", + "actions-utils": "workspace:*", + "detect-libc": "^2.0.2", + "glob": "^10.3.10", + "slash": "^5.1.0" + } +} diff --git a/actions/setup-rust/rollup.config.js b/actions/setup-rust/rollup.config.js new file mode 100644 index 0000000..ddd5010 --- /dev/null +++ b/actions/setup-rust/rollup.config.js @@ -0,0 +1,4 @@ +import config from 'rollup-config-workspace' + +config.input = ['src/action.ts', 'src/action-post.ts', 'src/main.ts'] +export default config diff --git a/actions/setup-rust/src/action-post.ts b/actions/setup-rust/src/action-post.ts new file mode 100644 index 0000000..a68bfc4 --- /dev/null +++ b/actions/setup-rust/src/action-post.ts @@ -0,0 +1,9 @@ +import { setFailed } from 'actions-utils/context' + +import { post } from './main.js' + +try { + await post() +} catch (error: any) { + setFailed(`${(error as { message?: string }).message || (error as string)}`) +} diff --git a/actions/setup-rust/src/action.ts b/actions/setup-rust/src/action.ts new file mode 100644 index 0000000..0c473f4 --- /dev/null +++ b/actions/setup-rust/src/action.ts @@ -0,0 +1,9 @@ +import { setFailed } from 'actions-utils/context' + +import { main } from './main.js' + +try { + await main() +} catch (error: any) { + setFailed(`${(error as { message?: string }).message || (error as string)}`) +} diff --git a/actions/setup-rust/src/main.test.ts b/actions/setup-rust/src/main.test.ts new file mode 100644 index 0000000..4cf372f --- /dev/null +++ b/actions/setup-rust/src/main.test.ts @@ -0,0 +1,7 @@ +import { main } from './main' + +describe('main', () => { + it('should complete successfully', async () => { + await expect(main(undefined, false)).resolves.toBeUndefined() + }) +}) diff --git a/actions/setup-rust/src/main.ts b/actions/setup-rust/src/main.ts new file mode 100644 index 0000000..24bf158 --- /dev/null +++ b/actions/setup-rust/src/main.ts @@ -0,0 +1,596 @@ +import crypto from 'node:crypto' +import fs from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import url from 'node:url' + +import toml from '@ltd/j-toml' + +import { downloadTool, extractTar, extractZip, isCacheAvailable, restoreCache, saveCache } from 'actions-utils/cache' +import { execCommand, spawnCommand } from 'actions-utils/commands' +import { addPath, context, exportVariable, getState, saveState, setOutput } from 'actions-utils/context' +import { isDirectory, isFile } from 'actions-utils/files' +import { getBooleanInput, getStringArrayInput, getStringInput } from 'actions-utils/inputs' +import { debug, endGroup, info, startGroup, warning } from 'actions-utils/outputs' + +import libc from 'detect-libc' +import { glob } from 'glob' +import slash from 'slash' + +export interface Inputs { + channel: string + components: string[] + targets: string[] + profile: string + binaries: string[] + directory: string + sccache: boolean + cache: boolean + cacheProfile: string + cacheKeyJob: boolean + cacheKeyEnv: string[] +} + +export const getInputs = (): Inputs => { + const inputs = { + channel: getStringInput('channel', false), + components: getStringArrayInput('components', false), + targets: getStringArrayInput('targets', false), + profile: getStringInput('profile', false), + binaries: getStringArrayInput('binaries', false), + directory: getStringInput('directory', false, '.'), + sccache: getBooleanInput('cache-sccache', false, true), + cache: getBooleanInput('cache', false, true), + cacheProfile: getStringInput('cache-profile', false), + cacheKeyJob: getBooleanInput('cache-key-job', false), + cacheKeyEnv: getStringArrayInput('cache-key-env', false), + } + inputs.components = inputs.components.flatMap((component) => component.split(/[,;]+/u).map((component) => component.trim())) + inputs.targets = inputs.targets.flatMap((target) => target.split(/[,;]+/u).map((target) => target.trim())) + inputs.binaries = inputs.binaries.flatMap((binary) => binary.split(/[,;]+/u).map((binary) => binary.trim())) + inputs.directory = slash(path.join(process.cwd(), inputs.directory)) + return inputs +} + +export const stringify = (value: unknown, indent: number = 0): string => { + return JSON.stringify( + value, + (key, value) => { + if (value instanceof Map) { + return [...value] + } + if (value instanceof Set) { + return [...value] + } + return value + }, + indent + ) +} + +export interface Toolchain { + channel: string + components: string[] + targets: string[] + profile: string +} + +export const parseToolchainFile = async (filePath: string): Promise> => { + const toolchainToml = await fs.readFile(filePath, 'utf8') + const toolchain = toml.parse(toolchainToml) as { toolchain: Partial } + return toolchain.toolchain +} + +export const getToolchain = async (inputs: Inputs): Promise => { + const toolchain: Toolchain = { + channel: 'stable', + components: [], + targets: [], + profile: 'minimal', + } + if (process.env.RUSTUP_TOOLCHAIN) { + debug(`Using toolchain channel from environment variable: "${process.env.RUSTUP_TOOLCHAIN}"`) + toolchain.channel = process.env.RUSTUP_TOOLCHAIN + } else { + const toolchainTomlPath = path.join(inputs.directory, 'rust-toolchain.toml') + const toolchainNamePath = path.join(inputs.directory, 'rust-toolchain') + if (await isFile(toolchainTomlPath)) { + debug(`Using toolchain from file: "${toolchainTomlPath}"`) + const toolchainToml = await parseToolchainFile(toolchainTomlPath) + if (toolchainToml.channel) { + toolchain.channel = toolchainToml.channel + } + if (toolchainToml.components && toolchainToml.components.length > 0) { + toolchain.components = [...toolchainToml.components] + } + if (toolchainToml.targets && toolchainToml.targets.length > 0) { + toolchain.targets = [...toolchainToml.targets] + } + if (toolchainToml.profile) { + toolchain.profile = toolchainToml.profile + } + } else if (await isFile(toolchainNamePath)) { + debug(`Using toolchain from file: "${toolchainNamePath}"`) + toolchain.channel = (await fs.readFile(toolchainNamePath, 'utf8')).trim() + } + } + if (inputs.channel) { + debug(`Using toolchain channel from input: "${inputs.channel}"`) + toolchain.channel = inputs.channel + } + if (inputs.components.length > 0) { + debug(`Using toolchain components from input: "${inputs.components.join(', ')}"`) + toolchain.components.push(...inputs.components) + } + if (inputs.targets.length > 0) { + debug(`Using toolchain targets from input: "${inputs.targets.join(', ')}"`) + toolchain.targets.push(...inputs.targets) + } + if (inputs.profile) { + debug(`Using toolchain profile from input: "${inputs.profile}"`) + toolchain.profile = inputs.profile + } + if (!toolchain.channel) { + debug(`Using default toolchain channel: "stable"`) + toolchain.channel = 'stable' + } + if (!toolchain.profile) { + debug(`Using default toolchain profile: "minimal"`) + toolchain.profile = 'minimal' + } + return toolchain +} + +export const installToolchain = async (toolchain: Toolchain): Promise => { + startGroup(`Installing toolchain "${toolchain.channel}"`) + const args = ['toolchain', 'install', toolchain.channel, '--profile', toolchain.profile] + for (const component of toolchain.components) { + args.push('--component', component) + } + for (const target of toolchain.targets) { + args.push('--target', target) + } + if (toolchain.channel === 'nightly') { + args.push('--allow-downgrade') + } + try { + await spawnCommand('rustup', args) + await spawnCommand('rustup', ['default', toolchain.channel]) + } finally { + endGroup() + } +} + +export const installBinstall = async (target: string): Promise => { + let arch = '' + switch (os.arch()) { + case 'x64': { + arch = 'x86_64' + break + } + case 'arm': { + arch = 'armv7' + break + } + case 'arm64': { + arch = 'aarch64' + break + } + default: { + throw new Error(`Unknown architecture ${os.arch()}`) + } + } + let downloadFileName = '' + switch (os.platform()) { + case 'linux': { + let lib = 'gnu' + if ((await libc.family()) === 'musl') { + lib = 'musl' + } + if (os.arch() === 'arm') { + lib += 'eabihf' + } + downloadFileName = `${arch}-unknown-linux-${lib}.tgz` + break + } + case 'darwin': { + downloadFileName = `${arch}-apple-darwin.zip` + break + } + case 'win32': { + downloadFileName = `${arch}-pc-windows-msvc.zip` + break + } + default: { + throw new Error(`Unknown platform ${process.platform}`) + } + } + const url = `https://github.com/cargo-bins/cargo-binstall/releases/latest/download/cargo-binstall-${downloadFileName}` + const tempDirectory = process.env.RUNNER_TEMP || path.join(os.tmpdir(), `setup-binstall`) + const downloadPath = path.join(tempDirectory, downloadFileName) + if (!(await isFile(downloadPath))) { + debug(`Downloading cargo-binstall from "${url}" to "${downloadPath}"`) + await downloadTool(url, downloadPath) + } else { + debug(`cargo-binstall already downloaded to "${downloadPath}"`) + } + if (downloadPath.endsWith('.zip')) { + await extractZip(downloadPath, target) + } else if (downloadPath.endsWith('.tgz')) { + await extractTar(downloadPath, target) + } else { + throw new Error(`Unknown archive type ${downloadPath}`) + } +} + +export interface RustVersion { + version: string + hash: string +} +export const getRustVersion = async (): Promise => { + const rustcVersion = await execCommand('rustc', ['--version', '--verbose']) + const rustcVersionMatch = rustcVersion.match(/^rustc ([^ ]+) \(([^)]+)\)$/mu) + if (!rustcVersionMatch || rustcVersionMatch.length !== 3) { + throw new Error(`Unknown rustc version format: "${rustcVersion}"`) + } + return { + version: rustcVersionMatch[1]!, + hash: rustcVersionMatch[2]!, + } +} + +export const installBinaries = async (binaries: string[]): Promise => { + startGroup(`Installing binaries [${binaries.join(', ')}]`) + try { + await spawnCommand('cargo', ['binstall', '--no-confirm', '--log-level', 'info', ...binaries]) + } finally { + endGroup() + } +} + +export interface CacheKey { + exact: string + partial: string[] +} +export const getCacheKey = async ( + inputs: Inputs, + projectDirectory: string, + toolchain: Toolchain, + version: RustVersion +): Promise => { + const hasher = crypto.createHash('sha256') + hasher.update(toolchain.channel) + hasher.update(version.version) + hasher.update(version.hash) + const lockfilePaths = await glob('**/Cargo.lock', { + cwd: projectDirectory, + absolute: true, + nodir: true, + posix: true, + ignore: ['**/target/**'], + }) + for (const cachePath of lockfilePaths) { + if (await isFile(cachePath)) { + const cargoLock = await fs.readFile(cachePath, 'utf8') + hasher.update(cargoLock) + debug(`Hashed file "${cachePath}"`) + } + } + const cargoTomlPaths = await glob('**/Cargo.toml', { + cwd: projectDirectory, + absolute: true, + nodir: true, + posix: true, + ignore: ['**/target/**'], + }) + for (const cachePath of cargoTomlPaths) { + if (await isFile(cachePath)) { + const cargoToml = await fs.readFile(cachePath, 'utf8') + const cargo = toml.parse(cargoToml) as { + dependencies?: Record + workspace?: { dependencies?: Record } + } + const dependencies = cargo.dependencies ?? [] + const workspaceDependencies = cargo.workspace?.dependencies ?? [] + hasher.update(stringify(dependencies)) + hasher.update(stringify(workspaceDependencies)) + debug(`Hashed dependencies from "${cachePath}"`) + } + } + if (inputs.cacheKeyJob) { + hasher.update(process.env.GITHUB_JOB || '') + debug(`Hashed job id "${process.env.GITHUB_JOB || ''}"`) + } + if (inputs.cacheKeyEnv.length > 0) { + for (const env of inputs.cacheKeyEnv) { + const value = process.env[env] + if (value) { + hasher.update(value) + debug(`Hashed env "${env}"`) + } + } + } + const hash = hasher.digest('hex') + return { + exact: `setup-rust-${os.platform()}-${os.arch()}-${hash}`, + partial: [`setup-rust-${os.platform()}-${os.arch()}-`, 'setup-rust-'], + } +} + +export const restoreCargoCache = async ( + projectDirectory: string, + cargoDirectory: string, + cacheKey: CacheKey +): Promise => { + if (!isCacheAvailable()) { + info('Cache feature is not available on this runner, not saving cache') + return + } + const directories = [] + const registryPath = path.normalize(path.join(cargoDirectory, 'registry')) + const targetPath = path.normalize(path.join(projectDirectory, 'target')) + const cwdRoot = path.parse(process.cwd()).root + if (path.parse(registryPath).root !== cwdRoot) { + info('Cache path is not in the current drive, restoring registry to temporary directory') + const tempDir = path.normalize(path.join(process.cwd(), '..', 'setup-rust-cargo-registry')) + await fs.mkdir(tempDir, { recursive: true }) + directories.push(tempDir) + } else { + directories.push(registryPath) + } + directories.push(targetPath) + debug(`Restoring directories [${directories.join(', ')}]`) + const cacheHit = await restoreCache(directories, cacheKey.exact, cacheKey.partial) + if (cacheHit && path.parse(registryPath).root !== cwdRoot) { + info('Copying registry from temporary directory') + const tempDir = path.normalize(path.join(process.cwd(), '..', 'setup-rust-cargo-registry')) + await fs.cp(tempDir, registryPath, { recursive: true }) + } + return cacheHit +} + +export const saveCargoCache = async (projectDirectory: string, cargoDirectory: string, cacheKey: string): Promise => { + const directories = [] + const registryPath = path.normalize(path.join(cargoDirectory, 'registry')) + const targetPath = path.normalize(path.join(projectDirectory, 'target')) + const cwdRoot = path.parse(process.cwd()).root + if (await isDirectory(registryPath)) { + if (path.parse(registryPath).root !== cwdRoot) { + info('Cache path is not in the current drive, copying registry to temporary directory') + const tempDir = path.normalize(path.join(process.cwd(), '..', 'setup-rust-cargo-registry')) + await fs.mkdir(tempDir, { recursive: true }) + await fs.cp(registryPath, tempDir, { recursive: true }) + directories.push(tempDir) + } else { + directories.push(registryPath) + } + } + if (await isDirectory(targetPath)) { + directories.push(targetPath) + } + if (directories.length === 0) { + info('No directories to cache, not saving cache') + return -1 + } + debug(`Caching directories [${directories.join(', ')}]`) + if (!isCacheAvailable()) { + info('Cache feature is not available on this runner, not saving cache') + return -1 + } + const cacheId = await saveCache(directories, cacheKey) + return cacheId +} + +export const pruneCargoCache = async (cargoDirectory: string): Promise => { + if (!isCacheAvailable()) { + info('Cache feature is not available on this runner, not pruning cache') + return + } + startGroup(`Pruning cargo cache`) + try { + const args = ['cache', '--autoclean'] + await spawnCommand('cargo', args) + const indexDir = path.join(cargoDirectory, 'registry', 'index') + if (await isDirectory(indexDir)) { + await Promise.all( + (await fs.readdir(indexDir)).map(async (dir) => { + if (await isDirectory(path.join(indexDir, dir, '.git'))) { + debug(`Removing directory "${path.join(indexDir, dir, '.git')}"`) + await fs.rm(path.join(indexDir, dir, '.cache'), { recursive: true }) + } + }) + ) + } + } finally { + endGroup() + } +} + +export const pruneTargetDirectory = async (inputs: Inputs, projectDirectory: string): Promise => { + if (!isCacheAvailable()) { + info('Cache feature is not available on this runner, not pruning target') + return + } + startGroup(`Pruning target directory "${projectDirectory}/target"`) + try { + debug(`Pruning outdated artifacts`) + const args = ['sweep', '--installed', '--verbose'] + await spawnCommand('cargo', args, { cwd: projectDirectory }) + if (inputs.cacheProfile) { + const dirs = await glob('**/', { + cwd: path.join(projectDirectory, 'target'), + absolute: true, + nodir: false, + posix: true, + maxDepth: 1, + ignore: [`**/${inputs.cacheProfile}`], + }) + debug(`Removing all targets except "${inputs.cacheProfile}"`) + await Promise.all( + dirs.map(async (dir) => { + if ((await isDirectory(dir)) && dir !== path.join(projectDirectory, 'target')) { + debug(`Removing directory "${dir}"`) + await fs.rm(dir, { recursive: true }) + } + }) + ) + } + debug(`Removing example and incremental directories`) + const targets = await glob('**/', { + cwd: path.join(projectDirectory, 'target'), + absolute: true, + nodir: false, + posix: true, + maxDepth: 1, + }) + await Promise.all( + targets.map(async (target) => + Promise.all( + ['examples', 'incremental'].map(async (dir) => { + const dirPath = path.join(target, dir) + if (await isDirectory(dirPath)) { + debug(`Removing directory "${dirPath}"`) + await fs.rm(dirPath, { recursive: true }) + } + }) + ) + ) + ) + const dfiles = await glob('**/*.d', { + cwd: path.join(projectDirectory, 'target'), + absolute: true, + nodir: true, + posix: true, + }) + debug(`Removing dep-info files`) + await Promise.all( + dfiles.map(async (dfile) => { + if (await isFile(dfile)) { + debug(`Removing file "${dfile}"`) + await fs.rm(dfile) + } + }) + ) + } finally { + endGroup() + } +} + +export const main = async (inputsOverride?: Inputs, install = true) => { + debug(`Context: ${JSON.stringify(context, null, 0)}`) + + const inputs = inputsOverride ?? getInputs() + const toolchain = await getToolchain(inputs) + + info(`Using toolchain "${toolchain.channel}" with profile "${toolchain.profile}"`) + + if (install) { + await installToolchain(toolchain) + } + + const cargoDirectory = install ? process.env.CARGO_HOME || path.join(os.homedir(), '.cargo') : path.join(os.tmpdir(), 'cargo') + const cargoBinDirectory = path.join(cargoDirectory, 'bin') + const projectDirectory = inputs.directory + const rustVersion = await getRustVersion() + + debug(`Cargo directory: "${cargoDirectory}"`) + debug(`Project directory: "${projectDirectory}"`) + + saveState('project-directory', projectDirectory) + saveState('cargo-directory', cargoDirectory) + + addPath(cargoBinDirectory) + + const binaries = [...inputs.binaries] + if (inputs.cache) { + binaries.push('cargo-cache', 'cargo-sweep') + } + if (inputs.sccache) { + binaries.push('sccache') + } + + if (binaries.length > 0) { + if (!(await isFile(path.join(cargoBinDirectory, 'cargo-binstall')))) { + await installBinstall(cargoBinDirectory) + } + if (install) { + await installBinaries(binaries) + } + } + + if (inputs.cache) { + const cacheKey = await getCacheKey(inputs, projectDirectory, toolchain, rustVersion) + + debug(`Cache key: "${cacheKey.exact}"`) + saveState('cache-key', cacheKey.exact) + + const cacheHit = install ? await restoreCargoCache(projectDirectory, cargoDirectory, cacheKey) : 'skip' + if (cacheHit === cacheKey.exact) { + info(`Cache hit for key "${cacheHit}"`) + setOutput('cache-hit', 'true') + saveState('cache-hit', cacheHit) + } else { + info(`Cache miss for key "${cacheKey.exact}": "${cacheHit}"`) + } + } + + if (inputs.sccache) { + exportVariable('ACTIONS_CACHE_URL', process.env.ACTIONS_CACHE_URL || '') + exportVariable('ACTIONS_RUNTIME_TOKEN', process.env.ACTIONS_RUNTIME_TOKEN || '') + exportVariable('SCCACHE_PATH', slash(path.join(cargoDirectory, 'sccache'))) + exportVariable('SCCACHE_GHA_ENABLED', 'true') + exportVariable('RUSTC_WRAPPER', 'sccache') + } + + info(`::add-matcher::${slash(path.join(path.dirname(url.fileURLToPath(import.meta.url)), '..', 'matcher.json'))}`) +} + +export const post = async () => { + const inputs = getInputs() + + if (inputs.sccache) { + startGroup(`Showing sccache stats`) + try { + await spawnCommand('sccache', ['--show-stats']) + } finally { + endGroup() + } + } + + if (!inputs.cache) { + return + } + + const cacheKey = getState('cache-key') + const cacheHit = getState('cache-hit') + debug(`Cache key: "${cacheKey}"`) + debug(`Cache hit: "${cacheHit}"`) + + if (!cacheKey) { + info(`Cache key not found, not saving cache`) + return + } + if (cacheKey === cacheHit) { + info(`Cache hit on "${cacheHit}", not saving cache`) + return + } + + const projectDirectory = getState('project-directory') + const cargoDirectory = getState('cargo-directory') + debug(`Project directory: "${projectDirectory}"`) + debug(`Cargo directory: "${cargoDirectory}"`) + + if (await isDirectory(cargoDirectory)) { + await pruneCargoCache(cargoDirectory) + } + if (await isDirectory(path.join(projectDirectory, 'target'))) { + await pruneTargetDirectory(inputs, projectDirectory) + } + + const cacheId = await saveCargoCache(projectDirectory, cargoDirectory, cacheKey) + if (cacheId === -1) { + warning(`Cache could not be saved for key "${cacheKey}"`) + return + } + info(`Cache saved for key "${cacheKey}" with id "${cacheId}"`) +} diff --git a/actions/setup-rust/tsconfig.json b/actions/setup-rust/tsconfig.json new file mode 100644 index 0000000..4e8f600 --- /dev/null +++ b/actions/setup-rust/tsconfig.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "typescript-config-workspace/index.json", + "compilerOptions": { + "outDir": "dist", + "noEmit": false, + "emitDeclarationOnly": true + }, + "include": [ + "src/*.ts" + ], + "exclude": [ + "dist", + "node_modules", + "coverage" + ] +}