Skip to content
Open
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
1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]

Expand Down
15 changes: 15 additions & 0 deletions docs/CI.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion rust-toolchain.toml
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
[toolchain]
channel = "stable"
channel = "1.85.0"
targets = ["wasm32v1-none"]
137 changes: 137 additions & 0 deletions scripts/check-rust-toolchain.mjs
Original file line number Diff line number Diff line change
@@ -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();
}
70 changes: 70 additions & 0 deletions scripts/check-rust-toolchain.test.mjs
Original file line number Diff line number Diff line change
@@ -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 });
}
});
});