use crate::config::PackageWorkflow;
use crate::graph::TestRunner;
pub struct CheckStep {
pub tool_key: &'static str,
pub imports: &'static [&'static str],
pub result_var: &'static str,
pub comment: &'static str,
pub body: &'static str,
}
fn targets(has_tests: bool) -> &'static str {
if has_tests {
r#""src", "tests""#
} else {
r#""src""#
}
}
const ANALYZE_BODY: &str = r#"fs.writeStringToFile(
"roblox.d.luau",
net.client.request("https://luau-lsp.pages.dev/globalTypes.None.d.luau", { method = "GET" }).body
)
local analyze = process.run({
"luau-lsp",
"analyze",
"--sourcemap=sourcemap.json",
-- Vendored dependencies are third-party code; their type errors are
-- not this project's to fix, and submodules in particular are pinned
-- checkouts we don't control.
"--ignore=**/submodules/**",
"--ignore=**/Packages/**",
"--ignore=**/ServerPackages/**",
"--ignore=**/DevPackages/**",
"--base-luaurc=.luaurc",
"--definitions=roblox.d.luau",
"--flag:LuauSolverV2=true",
{targets},
}, { stdio = "inherit" })
fs.remove("roblox.d.luau")"#;
pub const CHECK_STEPS: &[CheckStep] = &[
CheckStep {
tool_key: "rojo",
imports: &["process"],
result_var: "",
comment: "Regenerate the sourcemap so the type checker resolves requires\n-- against the current file tree rather than a stale snapshot.",
body: r#"process.run({ "rojo", "sourcemap", "--output=sourcemap.json" }, { stdio = "inherit" })"#,
},
CheckStep {
tool_key: "luau-lsp-cli",
imports: &["fs", "net", "process"],
result_var: "analyze",
comment: "Type-check the project with Roblox's API types and the new solver.",
body: ANALYZE_BODY,
},
CheckStep {
tool_key: "selene",
imports: &["process"],
result_var: "selene",
comment: "Lint for suspicious constructs (selene.toml decides severity).",
body: r#"local selene = process.run({ "selene", {targets} }, { stdio = "inherit" })"#,
},
CheckStep {
tool_key: "stylua",
imports: &["process"],
result_var: "stylua",
comment: "Fail if anything isn't formatted, rather than reformatting it here -\n-- CI must not rewrite the tree it was asked to check.",
body: r#"local stylua = process.run({ "stylua", "--check", {targets} }, { stdio = "inherit" })"#,
},
];
fn import_path(name: &str) -> &'static str {
match name {
"fs" => "@std/fs",
"net" => "@lute/net",
"process" => "@lute/process",
other => panic!("unknown lute import `{other}` in CHECK_STEPS"),
}
}
pub fn render_check(selected_tools: &[String], has_tests: bool) -> Option<String> {
let steps: Vec<&CheckStep> = CHECK_STEPS
.iter()
.filter(|s| selected_tools.iter().any(|t| t == s.tool_key))
.collect();
if steps.is_empty() {
return None;
}
let mut imports: Vec<&str> = Vec::new();
for step in &steps {
for name in step.imports {
if !imports.contains(name) {
imports.push(name);
}
}
}
imports.sort_unstable();
let mut out = String::from(
"--!strict\n\
-- Generated by `rproj new`. The project's quality gate.\n\
-- Run locally with `lute run check`; CI runs the same script.\n\n",
);
for name in &imports {
out.push_str(&format!(
"local {name} = require(\"{}\")\n",
import_path(name)
));
}
let targets = targets(has_tests);
for step in &steps {
out.push_str(&format!(
"\n-- {}\n{}\n",
step.comment,
step.body.replace("{targets}", targets)
));
}
let vars: Vec<&str> = steps
.iter()
.map(|s| s.result_var)
.filter(|v| !v.is_empty())
.collect();
if !vars.is_empty() {
let condition = vars
.iter()
.map(|v| format!("{v}.ok"))
.collect::<Vec<_>>()
.join(" and ");
out.push_str(&format!(
"\n-- Every check runs before exiting, so one run reports all problems\n\
-- rather than stopping at the first.\n\
if not ({condition}) then\n\tprocess.exit(1)\nend\n"
));
}
Some(out)
}
const WPT_FIXED_REV: &str = "daf5c97bf451e9fed47080769cfaa75d419eb768";
fn wally_ci_steps(has_server_packages: bool, runner: Option<TestRunner>) -> String {
let mut dirs = vec!["Packages"];
if has_server_packages {
dirs.push("ServerPackages");
}
if runner == Some(TestRunner::JestRoblox) {
dirs.push("DevPackages");
}
let dirs = dirs.join(" ");
let project_file = if runner == Some(TestRunner::JestRoblox) {
"jest.project.json"
} else {
"default.project.json"
};
format!(
r#"
# wally-package-types 1.6.2 - the newest release, and what rokit.toml
# pins - emits Luau that doesn't parse for packages whose generics mix
# defaults and non-defaults. Fixed upstream but not yet released, so
# the fix has to be built from a pinned commit. Delete this step and
# the cache above it once a release past 1.6.2 exists, and call the
# rokit-installed binary below instead.
- name: Cache wally-package-types
id: cache-wpt
uses: actions/cache@v6
with:
path: ~/.cargo/bin/wally-package-types
key: wally-package-types-{WPT_FIXED_REV}
- name: Build wally-package-types
if: steps.cache-wpt.outputs.cache-hit != 'true'
run: |
cargo install --locked --git https://github.com/JohnnyMorganz/wally-package-types --rev {WPT_FIXED_REV}
# Packages/ is gitignored, so it has to be installed here. The
# retyping step matters as much as the install: wally rewrites every
# link file without type information, so skipping it would have the
# type checker see `any` for every package.
#
# wally-package-types is called by absolute path on purpose. rokit's
# shim directory is also on PATH, and it holds the broken 1.6.2 - a
# bare `wally-package-types` would silently resolve to whichever comes
# first.
- name: Install packages
run: |
wally install
mkdir -p Packages
rojo sourcemap {project_file} --output sourcemap.json
~/.cargo/bin/wally-package-types --sourcemap sourcemap.json {dirs}
"#
)
}
pub fn ci_workflow(
workflow: PackageWorkflow,
has_server_packages: bool,
runner: Option<TestRunner>,
backend: crate::graph::JestBackend,
) -> String {
let install = match workflow {
PackageWorkflow::Wally => wally_ci_steps(has_server_packages, runner),
PackageWorkflow::GitSubmodules | PackageWorkflow::None => String::new(),
};
let tests = match runner {
None => String::new(),
Some(TestRunner::TestEz) => {
"\n - name: Run tests\n run: lute test\n".to_string()
}
Some(TestRunner::JestRoblox) if backend == crate::graph::JestBackend::Studio => String::new(),
Some(TestRunner::JestRoblox) => r#"
# In repository Settings > Secrets and variables > Actions, set secret
# ROBLOX_OPEN_CLOUD_API_KEY and variables ROBLOX_UNIVERSE_ID/ROBLOX_PLACE_ID.
# Use a dedicated test place: the runner uploads its test build there.
# Key scopes: universe-places:write, universe.place.luau-execution-session:write,
# memory-store.sorted-map:read and memory-store.sorted-map:write.
- name: Check Jest Roblox credentials
env:
ROBLOX_OPEN_CLOUD_API_KEY: ${{ secrets.ROBLOX_OPEN_CLOUD_API_KEY }}
ROBLOX_UNIVERSE_ID: ${{ vars.ROBLOX_UNIVERSE_ID }}
ROBLOX_PLACE_ID: ${{ vars.ROBLOX_PLACE_ID }}
run: |
if [ -z "$ROBLOX_OPEN_CLOUD_API_KEY" ] || [ -z "$ROBLOX_UNIVERSE_ID" ] || [ -z "$ROBLOX_PLACE_ID" ]; then
echo "Jest Roblox requires secret ROBLOX_OPEN_CLOUD_API_KEY and variables ROBLOX_UNIVERSE_ID and ROBLOX_PLACE_ID."
exit 1
fi
- name: Run tests
env:
ROBLOX_OPEN_CLOUD_API_KEY: ${{ secrets.ROBLOX_OPEN_CLOUD_API_KEY }}
ROBLOX_UNIVERSE_ID: ${{ vars.ROBLOX_UNIVERSE_ID }}
ROBLOX_PLACE_ID: ${{ vars.ROBLOX_PLACE_ID }}
run: jest-roblox-cli --backend open-cloud --formatters github-actions --passWithNoTests
"#
.to_string(),
};
format!(
r#"name: CI
on: [push, pull_request]
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
# Packages vendored as git submodules are part of the build.
submodules: true
# Installs every tool pinned in rokit.toml (rojo, luau-lsp, selene,
# stylua, lute...) at the exact versions this project uses.
- uses: CompeyDev/setup-rokit@v0.2.1
{install}
# Writes the ~/.lute typedef aliases into .luaurc. Merges into the
# committed file rather than replacing it, so languageMode survives.
- name: Set up Lute
run: lute setup --with-luaurc
- name: Check code quality
run: lute run check
{tests}
"#
)
}
#[cfg(test)]
mod tests {
use super::*;
use std::{path::PathBuf, process::Command};
fn bash() -> PathBuf {
if cfg!(windows) {
let git = Command::new("git")
.arg("--exec-path")
.output()
.expect("Git for Windows is required for the generated CI shell test");
assert!(git.status.success(), "git --exec-path failed");
let exec_path = PathBuf::from(String::from_utf8(git.stdout).unwrap().trim());
exec_path
.ancestors()
.flat_map(|root| [root.join("bin/bash.exe"), root.join("usr/bin/bash.exe")])
.find(|path| path.is_file())
.expect("Git for Windows must include Bash")
} else {
PathBuf::from("bash")
}
}
#[test]
fn cloud_ci_refuses_missing_credentials_before_the_runner() {
let ci = ci_workflow(
PackageWorkflow::Wally,
false,
Some(TestRunner::JestRoblox),
crate::graph::JestBackend::OpenCloud,
);
let (_, credential_step) = ci
.split_once(" - name: Check Jest Roblox credentials\n")
.expect("credential check precedes the test runner");
let (credential_step, _) = credential_step
.split_once("\n - name: Run tests")
.expect("test runner follows the credential check");
let (_, body) = credential_step.split_once(" run: |\n").unwrap();
let script = body
.lines()
.map(|line| line.strip_prefix(" ").unwrap())
.collect::<Vec<_>>()
.join("\n");
let script = format!("{script}\nprintf 'credential-check-passed\\n'\n");
let credentials = [
("ROBLOX_OPEN_CLOUD_API_KEY", "rproj-fixture-key"),
("ROBLOX_UNIVERSE_ID", "1"),
("ROBLOX_PLACE_ID", "2"),
];
let shell = bash();
let project = tempfile::tempdir().unwrap();
for empty in [false, true] {
for present in 0_u8..8 {
let mut command = Command::new(&shell);
command
.args(["--noprofile", "--norc", "-e", "-c", &script])
.current_dir(project.path())
.env_remove("BASH_ENV")
.env_remove("ENV");
for (index, (name, value)) in credentials.iter().enumerate() {
if present & (1 << index) != 0 {
command.env(name, value);
} else if empty {
command.env(name, "");
} else {
command.env_remove(name);
}
}
let output = command.output().unwrap();
let text = format!(
"{}{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
if present == 7 {
assert!(output.status.success(), "{text}");
assert_eq!(text.trim(), "credential-check-passed");
} else {
assert_eq!(output.status.code(), Some(1), "{text}");
assert!(!text.contains("credential-check-passed"), "{text}");
for (name, _) in credentials {
assert!(text.contains(name), "missing guidance for {name}: {text}");
}
}
assert!(!text.contains("rproj-fixture-key"), "{text}");
}
}
}
#[test]
fn local_jest_ci_has_no_test_steps_or_cloud_configuration() {
let ci = ci_workflow(
PackageWorkflow::Wally,
false,
Some(TestRunner::JestRoblox),
crate::graph::JestBackend::Studio,
);
assert!(ci.contains("lute run check"));
assert!(ci.contains("jest.project.json"));
assert!(!ci.contains("Run tests"));
assert!(!ci.contains("ROBLOX_OPEN_CLOUD"));
assert!(!ci.contains("JEST_OPEN_CLOUD"));
let cloud = ci_workflow(
PackageWorkflow::Wally,
false,
Some(TestRunner::JestRoblox),
crate::graph::JestBackend::OpenCloud,
);
assert!(cloud.contains("Run tests"));
assert!(cloud.contains("exit 1"));
assert!(!cloud.contains("JEST_OPEN_CLOUD"));
}
fn tools(keys: &[&str]) -> Vec<String> {
keys.iter().map(|k| (*k).to_string()).collect()
}
#[test]
fn only_emits_steps_for_selected_tools() {
let script = render_check(&tools(&["rojo", "selene"]), false).unwrap();
assert!(script.contains("\"rojo\""));
assert!(script.contains("\"selene\""));
assert!(
!script.contains("\"stylua\""),
"stylua not selected:\n{script}"
);
assert!(
!script.contains("luau-lsp"),
"luau-lsp not selected:\n{script}"
);
}
#[test]
fn imports_only_what_the_emitted_steps_use() {
let script = render_check(&tools(&["rojo", "stylua"]), false).unwrap();
assert!(script.contains(r#"local process = require("@lute/process")"#));
assert!(!script.contains("@std/fs"), "fs is unused here:\n{script}");
assert!(
!script.contains("@lute/net"),
"net is unused here:\n{script}"
);
let with_analyze = render_check(&tools(&["luau-lsp-cli"]), false).unwrap();
assert!(with_analyze.contains("@std/fs"));
assert!(with_analyze.contains("@lute/net"));
}
#[test]
fn aggregates_exactly_the_declared_result_vars() {
let script =
render_check(&tools(&["rojo", "luau-lsp-cli", "selene", "stylua"]), false).unwrap();
for var in ["analyze", "selene", "stylua"] {
assert!(
script.contains(&format!("local {var} = process.run")),
"{var} not declared"
);
assert!(
script.contains(&format!("{var}.ok")),
"{var} not aggregated"
);
}
assert!(!script.contains("rojo.ok"));
assert!(script.contains("process.exit(1)"));
}
#[test]
fn testez_projects_check_their_tests_too() {
let tools = tools(&["rojo", "luau-lsp-cli", "selene", "stylua"]);
let with_tests = render_check(&tools, true).unwrap();
assert!(
with_tests.contains(r#"{ "selene", "src", "tests" }"#),
"{with_tests}"
);
assert!(
with_tests.contains(r#"{ "stylua", "--check", "src", "tests" }"#),
"{with_tests}"
);
assert!(
with_tests.contains("\t\"src\", \"tests\",\n"),
"analyze:\n{with_tests}"
);
let without = render_check(&tools, false).unwrap();
assert!(!without.contains("tests"), "{without}");
assert!(without.contains(r#"{ "selene", "src" }"#), "{without}");
for script in [&with_tests, &without] {
assert!(
!script.contains("{targets}"),
"unsubstituted placeholder:\n{script}"
);
}
}
#[test]
fn omits_exit_check_when_no_step_produces_a_result() {
let script = render_check(&tools(&["rojo"]), false).unwrap();
assert!(
!script.contains("process.exit"),
"nothing to gate on:\n{script}"
);
}
#[test]
fn renders_nothing_when_no_step_applies() {
assert!(render_check(&tools(&["wally", "asphalt"]), false).is_none());
assert!(render_check(&[], false).is_none());
}
#[test]
fn every_declared_import_resolves() {
for step in CHECK_STEPS {
for name in step.imports {
let path = import_path(name);
assert!(path.starts_with('@'), "{name} -> {path}");
}
}
}
#[test]
fn ci_workflow_runs_the_gate_and_tolerates_no_tests() {
for workflow in [PackageWorkflow::Wally, PackageWorkflow::GitSubmodules] {
let ci = ci_workflow(
workflow,
false,
Some(TestRunner::TestEz),
crate::graph::JestBackend::OpenCloud,
);
assert!(ci.contains("lute run check"));
assert!(ci.contains("lute test"));
assert!(!ci.contains("lute run tests"));
assert!(ci.contains("submodules: true"));
}
}
#[test]
fn ci_omits_tests_when_testing_is_disabled() {
let ci = ci_workflow(
PackageWorkflow::None,
false,
None,
crate::graph::JestBackend::OpenCloud,
);
assert!(ci.contains("lute run check"));
assert!(!ci.contains("Run tests"), "{ci}");
}
#[test]
fn jest_ci_uses_dev_packages_and_fails_when_credentials_are_missing() {
let ci = ci_workflow(
PackageWorkflow::Wally,
false,
Some(TestRunner::JestRoblox),
crate::graph::JestBackend::OpenCloud,
);
assert!(ci.contains("jest.project.json"), "{ci}");
assert!(ci.contains("Packages DevPackages"), "{ci}");
assert!(ci.contains("ROBLOX_OPEN_CLOUD_API_KEY"), "{ci}");
assert!(ci.contains("ROBLOX_UNIVERSE_ID"), "{ci}");
assert!(ci.contains("ROBLOX_PLACE_ID"), "{ci}");
assert!(ci.contains("exit 1"), "{ci}");
assert!(ci.contains("--backend open-cloud"), "{ci}");
assert!(ci.contains("--formatters github-actions"), "{ci}");
}
#[test]
fn wally_ci_installs_packages_and_restores_their_types() {
let ci = ci_workflow(
PackageWorkflow::Wally,
false,
Some(TestRunner::TestEz),
crate::graph::JestBackend::OpenCloud,
);
assert!(ci.contains("wally install"), "{ci}");
assert!(ci.contains("mkdir -p Packages"), "{ci}");
assert!(ci.contains("wally-package-types"), "{ci}");
assert!(
ci.find("wally install") < ci.find("lute run check"),
"packages must be installed before the gate runs:\n{ci}"
);
let submodules = ci_workflow(
PackageWorkflow::GitSubmodules,
false,
Some(TestRunner::TestEz),
crate::graph::JestBackend::OpenCloud,
);
assert!(!submodules.contains("wally"), "{submodules}");
}
#[test]
fn wally_ci_builds_the_fixed_wally_package_types() {
let ci = ci_workflow(
PackageWorkflow::Wally,
false,
Some(TestRunner::TestEz),
crate::graph::JestBackend::OpenCloud,
);
assert_eq!(
WPT_FIXED_REV.len(),
40,
"pin a full 40-char sha, not {WPT_FIXED_REV}"
);
assert!(WPT_FIXED_REV.chars().all(|c| c.is_ascii_hexdigit()));
assert!(ci.contains(&format!("--rev {WPT_FIXED_REV}")), "{ci}");
assert!(
ci.contains("cargo install --locked"),
"unpinned deps aren't reproducible:\n{ci}"
);
assert!(
ci.find("cargo install") < ci.find("--sourcemap sourcemap.json"),
"the build has to precede the invocation:\n{ci}"
);
assert!(
ci.contains("~/.cargo/bin/wally-package-types --sourcemap"),
"must call the built binary by path, not by name:\n{ci}"
);
}
#[test]
fn ci_retypes_server_packages_only_when_there_are_any() {
let with = ci_workflow(
PackageWorkflow::Wally,
true,
Some(TestRunner::TestEz),
crate::graph::JestBackend::OpenCloud,
);
assert!(
with.contains("sourcemap.json Packages ServerPackages"),
"{with}"
);
let without = ci_workflow(
PackageWorkflow::Wally,
false,
Some(TestRunner::TestEz),
crate::graph::JestBackend::OpenCloud,
);
assert!(without.contains("sourcemap.json Packages\n"), "{without}");
assert!(!without.contains("ServerPackages"), "{without}");
}
#[test]
fn vendored_paths_use_wallys_real_capitalisation() {
assert!(ANALYZE_BODY.contains("**/Packages/**"), "{ANALYZE_BODY}");
assert!(
ci_workflow(
PackageWorkflow::Wally,
false,
Some(TestRunner::TestEz),
crate::graph::JestBackend::OpenCloud
)
.contains("sourcemap.json Packages")
);
}
}