diff --git a/Cargo.toml b/Cargo.toml index cc5f12b..370e08f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,7 @@ members = ["contracts/round"] [workspace.package] version = "0.1.0" edition = "2021" +rust-version = "1.85.0" license = "MIT" authors = ["Sub Rosa"] diff --git a/docs/CI.md b/docs/CI.md index 4a3af75..8f5918a 100644 --- a/docs/CI.md +++ b/docs/CI.md @@ -83,3 +83,18 @@ print order are asserted by: ```bash node --test scripts/check-bindings-cli.test.mjs ``` + +## Pinned Rust toolchain + +The workspace pins Rust **1.85.0** with target `wasm32v1-none` in +`rust-toolchain.toml` and `Cargo.toml` (`[workspace.package] rust-version = "1.85.0"`), +replacing a moving `stable` channel reference so contract builds and +`stellar-cli` bindings checks stay reproducible across local runs and both +contract workflows (`.github/workflows/contract.yml` and +`.github/workflows/bindings-check.yml`). Validate the pin and alignment locally +with: + +```bash +pnpm rust:toolchain:test +pnpm rust:toolchain:check +``` diff --git a/package.json b/package.json index 66e5b7c..7fca775 100644 --- a/package.json +++ b/package.json @@ -76,7 +76,9 @@ "snapshot:check": "node scripts/check-snapshots.mjs", "docs:check": "node scripts/check-deploy-docs.mjs", "docs:check-links": "node scripts/check-links.js", - "coverage:test": "node --test scripts/run-ts-coverage.test.mjs && node scripts/run-ts-coverage.mjs", + "coverage:test": "node --test scripts/check-rust-toolchain.test.mjs && node scripts/check-rust-toolchain.mjs && node --test scripts/run-ts-coverage.test.mjs && node scripts/run-ts-coverage.mjs", + "rust:toolchain:check": "node scripts/check-rust-toolchain.mjs", + "rust:toolchain:test": "node --test scripts/check-rust-toolchain.test.mjs", "drand:test": "pnpm --filter @sub-rosa/drand-tools test", "drand:typecheck": "pnpm --filter @sub-rosa/drand-tools typecheck", "receipt:test": "pnpm --filter @sub-rosa/receipt-cli test", diff --git a/rust-toolchain.toml b/rust-toolchain.toml index e25fb76..25b3f02 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,3 +1,3 @@ [toolchain] -channel = "stable" +channel = "1.85.0" targets = ["wasm32v1-none"] diff --git a/scripts/check-rust-toolchain.mjs b/scripts/check-rust-toolchain.mjs new file mode 100644 index 0000000..8c6d96f --- /dev/null +++ b/scripts/check-rust-toolchain.mjs @@ -0,0 +1,137 @@ +#!/usr/bin/env node +// Copyright (c) 2026 Sub Rosa contributors +import { existsSync, readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { createLogger } from "../packages/logging/src/index.cjs"; + +const diagnostics = createLogger("scripts.check-rust-toolchain"); + +export const PINNED_RUST_VERSION = "1.85.0"; +export const REQUIRED_TARGET = "wasm32v1-none"; + +const DEFAULT_ROOT = fileURLToPath(new URL("..", import.meta.url)); + +/** + * Validate `rust-toolchain.toml` content. + * @param {string} tomlText + */ +export function inspectRustToolchainToml(tomlText) { + const errors = []; + const channelMatch = tomlText.match(/^\s*channel\s*=\s*"([^"]+)"/m); + if (!channelMatch) { + errors.push('rust-toolchain.toml is missing a channel = "..." entry'); + } else { + const channel = channelMatch[1]; + if (/^(stable|beta|nightly)$/i.test(channel)) { + errors.push( + `rust-toolchain.toml uses moving channel "${channel}"; pin to an exactMAJOR.MINOR.PATCH version`, + ); + } else if (!/^\d+\.\d+\.\d+$/.test(channel)) { + errors.push( + `rust-toolchain.toml channel "${channel}" must be an exact MAJOR.MINOR.PATCH version`, + ); + } else if (channel !== PINNED_RUST_VERSION) { + errors.push( + `rust-toolchain.toml channel "${channel}" does not match reviewed version ${PINNED_RUST_VERSION}`, + ); + } + } + + if (!tomlText.includes(`"${REQUIRED_TARGET}"`)) { + errors.push( + `rust-toolchain.toml must include "${REQUIRED_TARGET}" in targets`, + ); + } + + return { channel: channelMatch?.[1] ?? null, errors }; +} + +/** + * Validate workspace Rust toolchain alignment across `rust-toolchain.toml`, + * `Cargo.toml`, contract workflows, and `docs/CI.md`. + * @param {string} [rootDir] + */ +export function validateRustToolchain(rootDir = DEFAULT_ROOT) { + const errors = []; + + const toolchainPath = resolve(rootDir, "rust-toolchain.toml"); + const cargoTomlPath = resolve(rootDir, "Cargo.toml"); + const contractWfPath = resolve(rootDir, ".github/workflows/contract.yml"); + const bindingsWfPath = resolve(rootDir, ".github/workflows/bindings-check.yml"); + const ciDocsPath = resolve(rootDir, "docs/CI.md"); + + let pinnedChannel = null; + if (!existsSync(toolchainPath)) { + errors.push("Missing rust-toolchain.toml at repository root"); + } else { + const res = inspectRustToolchainToml(readFileSync(toolchainPath, "utf8")); + pinnedChannel = res.channel; + errors.push(...res.errors); + } + + if (!existsSync(cargoTomlPath)) { + errors.push("Missing Cargo.toml at repository root"); + } else { + const cargoText = readFileSync(cargoTomlPath, "utf8"); + const rvMatch = cargoText.match(/^\s*rust-version\s*=\s*"([^"]+)"/m); + if (!rvMatch) { + errors.push('Cargo.toml [workspace.package] must declare rust-version = "1.85.0"'); + } else if (pinnedChannel && rvMatch[1] !== pinnedChannel) { + errors.push( + `Cargo.toml rust-version "${rvMatch[1]}" does not match rust-toolchain.toml "${pinnedChannel}"`, + ); + } + } + + if (existsSync(contractWfPath) && existsSync(bindingsWfPath)) { + const contractWf = readFileSync(contractWfPath, "utf8"); + const bindingsWf = readFileSync(bindingsWfPath, "utf8"); + const contractRef = + contractWf.match(/uses:\s*(dtolnay\/rust-toolchain@\S+)/)?.[1] ?? null; + const bindingsRef = + bindingsWf.match(/uses:\s*(dtolnay\/rust-toolchain@\S+)/)?.[1] ?? null; + + if (!contractRef || !bindingsRef) { + errors.push( + "Both contract.yml and bindings-check.yml must use dtolnay/rust-toolchain", + ); + } else if (contractRef !== bindingsRef) { + errors.push( + `Rust toolchain action mismatch between contract.yml (${contractRef}) and bindings-check.yml (${bindingsRef})`, + ); + } + } + + if (!existsSync(ciDocsPath)) { + errors.push("Missing docs/CI.md"); + } else { + const docs = readFileSync(ciDocsPath, "utf8"); + if (!docs.includes(PINNED_RUST_VERSION)) { + errors.push( + `docs/CI.md must document the pinned Rust toolchain version ${PINNED_RUST_VERSION}`, + ); + } + } + + return { ok: errors.length === 0, errors }; +} + +function main() { + const res = validateRustToolchain(); + if (!res.ok) { + for (const err of res.errors) { + diagnostics.error("rust-toolchain-check-failed", `FAIL: ${err}`); + } + process.exit(1); + } + + diagnostics.info( + "rust-toolchain-check-passed", + `PASS: Rust toolchain is pinned to ${PINNED_RUST_VERSION} (${REQUIRED_TARGET}) and aligned across Cargo.toml, workflows, and docs/CI.md.`, + ); +} + +if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { + main(); +} diff --git a/scripts/check-rust-toolchain.test.mjs b/scripts/check-rust-toolchain.test.mjs new file mode 100644 index 0000000..7f09c2a --- /dev/null +++ b/scripts/check-rust-toolchain.test.mjs @@ -0,0 +1,70 @@ +// Copyright (c) 2026 Sub Rosa contributors +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, mkdirSync, copyFileSync, writeFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { + PINNED_RUST_VERSION, + inspectRustToolchainToml, + validateRustToolchain, +} from "./check-rust-toolchain.mjs"; + +const REPO_ROOT = fileURLToPath(new URL("..", import.meta.url)); + +describe("check-rust-toolchain", () => { + it("passes on the repository configuration", () => { + const res = validateRustToolchain(REPO_ROOT); + assert.equal(res.ok, true, `Expected pass, got errors: ${res.errors.join("; ")}`); + }); + + it("rejects moving stable/nightly channels in rust-toolchain.toml", () => { + const stableRes = inspectRustToolchainToml( + '[toolchain]\nchannel = "stable"\ntargets = ["wasm32v1-none"]\n', + ); + assert.ok(stableRes.errors.some((e) => e.includes('moving channel "stable"'))); + + const partialRes = inspectRustToolchainToml( + '[toolchain]\nchannel = "1.85"\ntargets = ["wasm32v1-none"]\n', + ); + assert.ok(partialRes.errors.some((e) => e.includes("MAJOR.MINOR.PATCH"))); + }); + + it("rejects missing wasm32v1-none target", () => { + const res = inspectRustToolchainToml( + `[toolchain]\nchannel = "${PINNED_RUST_VERSION}"\ntargets = []\n`, + ); + assert.ok(res.errors.some((e) => e.includes("wasm32v1-none"))); + }); + + it("rejects drift between rust-toolchain.toml, Cargo.toml, and contract workflows", () => { + const tmp = mkdtempSync(join(tmpdir(), "rust-toolchain-test-")); + try { + mkdirSync(join(tmp, ".github/workflows"), { recursive: true }); + mkdirSync(join(tmp, "docs"), { recursive: true }); + copyFileSync(join(REPO_ROOT, "rust-toolchain.toml"), join(tmp, "rust-toolchain.toml")); + copyFileSync(join(REPO_ROOT, "docs/CI.md"), join(tmp, "docs/CI.md")); + writeFileSync( + join(tmp, "Cargo.toml"), + '[workspace]\n[workspace.package]\nrust-version = "1.80.0"\n', + ); + writeFileSync( + join(tmp, ".github/workflows/contract.yml"), + "steps:\n - uses: dtolnay/rust-toolchain@stable\n", + ); + writeFileSync( + join(tmp, ".github/workflows/bindings-check.yml"), + "steps:\n - uses: dtolnay/rust-toolchain@nightly\n", + ); + + const res = validateRustToolchain(tmp); + assert.equal(res.ok, false); + assert.ok(res.errors.some((e) => e.includes("Cargo.toml rust-version"))); + assert.ok(res.errors.some((e) => e.includes("Rust toolchain action mismatch"))); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } + }); +});