///|
fn normalize_setup_node_registry_url(value : String) -> String {
let trimmed = value.trim(chars=" \t\r\n").to_owned()
if trimmed.length() == 0 {
""
} else if trimmed.has_suffix("/") {
trimmed
} else {
trimmed + "/"
}
}
///|
fn normalize_setup_node_version(
value : String,
current_version : String,
) -> String {
let trimmed = value.trim(chars=" \t\r\n").to_owned()
if trimmed.length() == 0 {
return current_version
}
let bare = if trimmed.has_prefix("v") {
exec_text_slice(trimmed, 1, trimmed.length())
} else {
trimmed
}
let first = bare.split(".").next().unwrap_or("").to_owned()
let mut digits = ""
for c in first {
if c >= '0' && c <= '9' {
digits += c.to_string()
} else {
break
}
}
if digits.length() == 0 {
return current_version
}
let parts : Array[String] = []
for part_view in bare.split(".") {
let part = part_view.to_owned()
if part.length() == 0 {
continue
}
if part == "x" || part == "*" {
break
}
let mut numeric = ""
for c in part {
if c >= '0' && c <= '9' {
numeric += c.to_string()
} else {
break
}
}
if numeric.length() == 0 {
break
}
parts.push(numeric)
}
let major = parts.get(0).unwrap_or(digits)
let minor = parts.get(1).unwrap_or("0")
let patch = parts.get(2).unwrap_or("0")
"v" + major + "." + minor + "." + patch
}
///|
fn read_node_version_from_file(file_path : String) -> String {
let content = try @xfs.read_file_to_string(file_path) catch {
_ => return ""
} noraise {
value => value
}
let trimmed = content.trim(chars=" \t\r\n").to_owned()
if trimmed.length() == 0 {
return ""
}
// .nvmrc / .node-version: single line with version
if file_path.has_suffix(".nvmrc") ||
file_path.has_suffix(".node-version") ||
file_path.has_suffix(".tool-versions") {
// .tool-versions: find "nodejs " line
if file_path.has_suffix(".tool-versions") {
for line_view in trimmed.split("\n") {
let line = line_view.trim(chars=" \t\r").to_owned()
if line.has_prefix("nodejs ") || line.has_prefix("nodejs\t") {
return exec_text_slice(line, 7, line.length())
.trim(chars=" \t\r\n")
.to_owned()
}
}
return ""
}
// First non-empty line
for line_view in trimmed.split("\n") {
let line = line_view.trim(chars=" \t\r").to_owned()
if line.length() > 0 && !line.has_prefix("#") {
return line
}
}
return ""
}
// package.json: extract engines.node
if file_path.has_suffix("package.json") {
let parsed = try @json.parse(trimmed) catch {
_ => return ""
} noraise {
value => value
}
match parsed {
Object(fields) =>
match fields.get("engines") {
Some(Object(engines)) =>
match engines.get("node") {
Some(String(version)) => return version
_ => return ""
}
_ => return ""
}
_ => return ""
}
}
// Default: treat entire content as version
trimmed
}
///|
fn setup_node_store_root(workspace_root : String) -> String {
ignore(ensure_exec_dir_recursive("_build/actrun/setup_node"))
let workspace_key = sanitize_task_id(
absolute_exec_path(resolve_task_cwd(workspace_root, "")),
)
let root = absolute_exec_path("_build/actrun/setup_node/" + workspace_key)
ignore(ensure_exec_dir_recursive(root))
root
}
///|
async fn exec_make_executable(path : String) -> Bool {
let (code, _, _) = run_command("chmod", ["+x", path], cwd=".")
code == 0
}
///|
async fn write_setup_node_shim(
workspace_root : String,
task_id : String,
node_bin : String,
node_version : String,
) -> String? {
let shim_root = setup_node_store_root(workspace_root) +
"/" +
sanitize_task_id(task_id)
let shim_dir = shim_root + "/bin"
if !ensure_exec_dir_recursive(shim_dir) {
return None
}
let shim_path = shim_dir + "/node"
let d = "$"
let script = "#!/bin/sh\n" +
"set -eu\n" +
"if [ \"" +
d +
"{1:-}\" = \"--version\" ] || [ \"" +
d +
"{1:-}\" = \"-v\" ]; then\n" +
" printf '%s\\n' '" +
node_version +
"'\n" +
" exit 0\n" +
"fi\n" +
"exec '" +
node_bin.replace_all(old="'", new="'\"'\"'") +
"' \"" +
d +
"@\"\n"
if !write_exec_text(shim_path, script) {
return None
}
if !exec_make_executable(shim_path) {
return None
}
Some(shim_dir)
}
///|
fn write_setup_node_npmrc(
path : String,
registry_url : String,
always_auth? : Bool = false,
scope? : String = "",
) -> Bool {
let stripped = if registry_url.has_prefix("https://") {
"//" + exec_text_slice(registry_url, 8, registry_url.length())
} else if registry_url.has_prefix("http://") {
"//" + exec_text_slice(registry_url, 7, registry_url.length())
} else {
registry_url
}
let mut content = stripped + ":_authToken=${NODE_AUTH_TOKEN}\n"
if scope.length() > 0 {
let scoped = if scope.has_prefix("@") { scope } else { "@" + scope }
content = content + scoped + ":registry=" + registry_url + "\n"
}
content = content + "registry=" + registry_url + "\n"
if always_auth {
content = content + "always-auth=true\n"
}
write_exec_text(path, content)
}
///|
fn setup_node_cache_relative_path(cache_name : String) -> String {
".setup-node/" + cache_name + "-cache"
}
///|
fn setup_node_default_dependency_paths(cache_name : String) -> Array[String] {
if cache_name == "npm" {
["package-lock.json", "npm-shrinkwrap.json"]
} else if cache_name == "yarn" {
["yarn.lock"]
} else if cache_name == "pnpm" {
["pnpm-lock.yaml"]
} else {
[]
}
}
///|
priv enum SetupNodeDependencyResolution {
Ok(Array[String])
Err(String)
}
///|
fn resolve_setup_node_dependency_paths(
workspace_root : String,
cache_name : String,
dependency_input : String,
) -> SetupNodeDependencyResolution {
let candidates = if dependency_input.trim(chars=" \t\r\n").length() > 0 {
split_nonempty_lines(dependency_input)
} else {
setup_node_default_dependency_paths(cache_name)
}
if candidates.length() == 0 {
return Err(
"setup-node cache '\{cache_name}' requires cache-dependency-path or a default lockfile",
)
}
let resolved : Array[String] = []
let explicit = dependency_input.trim(chars=" \t\r\n").length() > 0
for candidate in candidates {
let resolved_path = resolve_task_cwd(workspace_root, candidate)
if explicit && !@xfs.path_exists(resolved_path) {
return Err(
"setup-node cache dependency path '\{candidate}' does not exist",
)
}
if !@xfs.path_exists(resolved_path) {
continue
}
guard workspace_relative_store_path(workspace_root, resolved_path)
is Some(relative_path) else {
return Err(
"setup-node cache dependency path '\{candidate}' must stay within the workspace",
)
}
resolved.push(relative_path)
}
if resolved.length() == 0 {
Err("setup-node cache '\{cache_name}' found no dependency files")
} else {
Ok(resolved)
}
}
///|
fn setup_node_cache_key_text(
workspace_root : String,
cache_name : String,
dependency_paths : Array[String],
) -> String? {
let parts : Array[String] = []
parts.push("cache=" + cache_name)
for relative_path in dependency_paths {
let resolved_path = resolve_task_cwd(workspace_root, relative_path)
guard read_exec_text(resolved_path) is Some(content) else { return None }
parts.push("--")
parts.push(relative_path)
parts.push(content)
}
Some(parts.join("\n"))
}
///|
fn stable_setup_node_hash(text : String) -> String {
let mut hash = 7
let mut idx = 0
while idx < text.length() {
hash = (hash * 131 + text.unsafe_get(idx).to_int()) % 2147483647
idx += 1
}
hash.to_string()
}
///|
fn compute_setup_node_cache_key(
workspace_root : String,
cache_name : String,
dependency_paths : Array[String],
) -> String? {
guard setup_node_cache_key_text(workspace_root, cache_name, dependency_paths)
is Some(key_text) else {
return None
}
Some("setup-node-" + cache_name + "-" + stable_setup_node_hash(key_text))
}
///|
async fn execute_setup_node_native(
plan : TaskPlan,
workspace_root : String,
resolved_plan_env : Map[String, String],
) -> TaskExecutionResult {
let cache_input = resolved_plan_env
.get("INPUT_CACHE")
.unwrap_or(
resolved_plan_env.get("INPUT_PACKAGE_MANAGER_CACHE").unwrap_or(""),
)
let normalized_cache_input = cache_input.trim(chars=" \t\r\n").to_owned()
let requested_node_file = resolved_plan_env
.get("INPUT_NODE_VERSION_FILE")
.unwrap_or("")
let node_version_from_file = if requested_node_file
.trim(chars=" \t\r\n")
.length() >
0 {
let file_path = resolve_task_cwd(
workspace_root,
requested_node_file.trim(chars=" \t\r\n").to_owned(),
)
read_node_version_from_file(file_path)
} else {
""
}
let configured_node_fallback = resolved_plan_env
.get("ACTRUN_NODE_BIN")
.unwrap_or(@xsys.get_env_var("ACTRUN_NODE_BIN").unwrap_or("node"))
let fallback_node_bin = resolve_exec_bin_for_workspace(
configured_node_fallback, workspace_root,
)
let configured_setup_node_bin = resolved_plan_env
.get("ACTRUN_SETUP_NODE_BIN")
.unwrap_or(
@xsys.get_env_var("ACTRUN_SETUP_NODE_BIN").unwrap_or(fallback_node_bin),
)
let node_bin = resolve_exec_bin_for_workspace(
configured_setup_node_bin, workspace_root,
)
let (version_code, version_stdout, version_stderr) = run_command(
node_bin,
["--version"],
cwd=resolve_task_cwd(workspace_root, ""),
)
if version_code != 0 {
return failure_execution_result(
plan,
workspace_root,
exec_command_message(
version_stdout, version_stderr, "failed to inspect node version",
),
)
}
let actual_version = version_stdout.trim(chars=" \t\r\n").to_owned()
let explicit_version = resolved_plan_env
.get("INPUT_NODE_VERSION")
.unwrap_or("")
let version_input = if explicit_version.trim(chars=" \t\r\n").length() > 0 {
explicit_version
} else {
node_version_from_file
}
let node_version = normalize_setup_node_version(version_input, actual_version)
guard write_setup_node_shim(workspace_root, plan.id, node_bin, node_version)
is Some(shim_dir) else {
return failure_execution_result(
plan, workspace_root, "failed to prepare setup-node shim",
)
}
let architecture_input = resolved_plan_env
.get("INPUT_ARCHITECTURE")
.unwrap_or("")
.trim(chars=" \t\r\n")
.to_owned()
let env_updates : Map[String, String] = {}
let output_values : Map[String, String] = { "node-version": node_version }
if architecture_input.length() > 0 {
output_values["node-architecture"] = architecture_input
}
let state_updates : Map[String, String] = {}
if normalized_cache_input.length() > 0 {
if normalized_cache_input != "npm" {
return failure_execution_result(
plan,
workspace_root,
"setup-node cache '\{normalized_cache_input}' is not supported yet",
)
}
let dependency_input = resolved_plan_env
.get("INPUT_CACHE_DEPENDENCY_PATH")
.unwrap_or("")
let dependency_paths = match
resolve_setup_node_dependency_paths(
workspace_root, normalized_cache_input, dependency_input,
) {
Ok(paths) => paths
Err(message) =>
return failure_execution_result(plan, workspace_root, message)
}
guard compute_setup_node_cache_key(
workspace_root, normalized_cache_input, dependency_paths,
)
is Some(cache_key) else {
return failure_execution_result(
plan, workspace_root, "failed to compute setup-node cache key",
)
}
let cache_relative_path = setup_node_cache_relative_path(
normalized_cache_input,
)
let cache_abs_path = resolve_task_cwd(workspace_root, cache_relative_path)
if !ensure_exec_dir_recursive(cache_abs_path) {
return failure_execution_result(
plan, workspace_root, "failed to prepare setup-node cache directory",
)
}
let cache_result = execute_restore_cache_native(plan, workspace_root, {
"INPUT_KEY": cache_key,
"INPUT_PATH": cache_relative_path,
})
if cache_result.report.status != "success" {
return cache_result
}
let cache_hit = cache_result.output_values
.get("cache-hit")
.unwrap_or("false")
env_updates["NPM_CONFIG_CACHE"] = cache_abs_path
env_updates["npm_config_cache"] = cache_abs_path
output_values["cache-hit"] = cache_hit
state_updates["SETUP_NODE_CACHE_NAME"] = normalized_cache_input
state_updates["SETUP_NODE_CACHE_KEY"] = cache_key
state_updates["SETUP_NODE_CACHE_PATH"] = cache_relative_path
state_updates["SETUP_NODE_CACHE_HIT"] = cache_hit
}
let registry_url = normalize_setup_node_registry_url(
resolved_plan_env.get("INPUT_REGISTRY_URL").unwrap_or(""),
)
if registry_url.length() > 0 {
let always_auth = parse_bool_false_default(
resolved_plan_env.get("INPUT_ALWAYS_AUTH").unwrap_or("false"),
)
let scope = resolved_plan_env
.get("INPUT_SCOPE")
.unwrap_or("")
.trim(chars=" \t\r\n")
.to_owned()
let npmrc_path = prepare_runner_temp_dir(plan.job_id, workspace_root) +
"/.npmrc"
if !write_setup_node_npmrc(npmrc_path, registry_url, always_auth~, scope~) {
return failure_execution_result(
plan, workspace_root, "failed to write setup-node npm auth config",
)
}
let node_auth_token = resolved_plan_env
.get("NODE_AUTH_TOKEN")
.unwrap_or(
@xsys.get_env_var("NODE_AUTH_TOKEN").unwrap_or(
"XXXXX-XXXXX-XXXXX-XXXXX",
),
)
env_updates["NPM_CONFIG_USERCONFIG"] = npmrc_path
env_updates["NODE_AUTH_TOKEN"] = node_auth_token
}
{
report: task_report_success(plan, workspace_root),
env_updates,
path_entries: [shim_dir],
output_values,
state_updates,
}
}
///|
fn execute_setup_node_cache_post_native(
plan : TaskPlan,
workspace_root : String,
resolved_plan_env : Map[String, String],
state : JobRuntimeState,
) -> TaskRunReport {
let saved_state = action_scope_state(state, plan.action_scope)
if saved_state.length() == 0 {
return task_report_success(plan, workspace_root)
}
if saved_state.get("SETUP_NODE_CACHE_HIT").unwrap_or("") == "true" {
return task_report_success(plan, workspace_root)
}
let cache_name = saved_state
.get("SETUP_NODE_CACHE_NAME")
.unwrap_or(resolved_plan_env.get("INPUT_CACHE").unwrap_or(""))
let path_input = saved_state
.get("SETUP_NODE_CACHE_PATH")
.unwrap_or(
setup_node_cache_relative_path(
cache_name.trim(chars=" \t\r\n").to_owned(),
),
)
let key = saved_state.get("SETUP_NODE_CACHE_KEY").unwrap_or("")
if key.length() == 0 {
return task_report_success(plan, workspace_root)
}
let resolved_path = resolve_task_cwd(workspace_root, path_input)
if !@xfs.path_exists(resolved_path) {
return task_report_success(plan, workspace_root)
}
return execute_save_cache_native(
plan,
workspace_root,
merge_runner_env(resolved_plan_env, {
"INPUT_KEY": key,
"INPUT_PATH": path_input,
}),
)
}