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>,
) -> 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) => r#"
- 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::*;
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));
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);
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));
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));
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),
);
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));
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));
assert!(
with.contains("sourcemap.json Packages ServerPackages"),
"{with}"
);
let without = ci_workflow(PackageWorkflow::Wally, false, Some(TestRunner::TestEz));
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))
.contains("sourcemap.json Packages")
);
}
}