This project hosts custom ESLint linters for /actions/setup/js.
- Mine recurring JavaScript/TypeScript defects in
actions/setup/js. - Implement custom ESLint rules in TypeScript.
- Compile rules to
dist/and run them againstactions/setup/jsscripts.
npm run build— compile rule sources.npm run lint:setup-js— build and lint all../actions/setup/js/**/*.cjsfiles.npm run lint:setup-js:changed— build and lint../actions/setup/js/*.cjsfiles.
| Rule | Description |
|---|---|
no-core-exportvariable-non-string |
Require explicit string values for core.exportVariable calls |
no-core-setoutput-non-string |
Require explicit string values for core.setOutput calls |
no-duplicate-constant-values |
Report constants with duplicate static primitive values in the same file |
no-child-process-interpolated-command |
Disallow interpolated command strings in shell-evaluated child_process calls |
no-github-request-interpolated-route |
Disallow interpolated route arguments in Octokit .request() calls |
no-json-stringify-error |
Disallow JSON.stringify() on caught error variables |
no-throw-plain-object |
Disallow throwing plain object literals |
no-unsafe-catch-error-property |
Disallow unsafe property access on catch error bindings |
no-unsafe-promise-catch-error-property |
Disallow unsafe property access in promise rejection handlers |
prefer-get-error-message |
Prefer getErrorMessage(err) over the inline ternary pattern |
prefer-core-logging |
Prefer @actions/core logging over console.log / console.info / console.debug |
prefer-number-isnan |
Prefer Number.isNaN() over global isNaN() |
prefer-structured-clone |
Prefer structuredClone(...) over JSON.parse(JSON.stringify(...)) for deep-cloning data |
require-async-entrypoint-catch |
Require .catch(...) on bare async entrypoint calls |
require-await-core-summary-write |
Require await on core.summary.write() calls |
require-error-cause-in-rethrow |
Require { cause: err } when rethrowing inside a catch block |
require-fetch-response-body-try-catch |
Require try/catch around .json() or .text() on Responses from fetch(...) |
require-fetch-timeout |
Require fetch(...) calls to include a non-nullish abort signal option |
require-fetch-try-catch |
Require try/catch around awaited fetch(...) calls, including chained promise forms without rejection handlers |
require-fs-io-try-catch |
Require try/catch around fs.statSync, readdirSync, copyFileSync, unlinkSync, and renameSync |
require-fs-sync-try-catch |
Require try/catch around fs.readFileSync, writeFileSync, and appendFileSync |
require-json-parse-try-catch |
Require try/catch around JSON.parse(...) calls |
require-mkdirsync-try-catch |
Require try/catch around fs.mkdirSync calls |
require-new-url-try-catch |
Require try/catch around new URL(variable) calls |
require-parseInt-radix |
Require an explicit radix argument to parseInt() |
require-nan-check-after-env-numeric-parse |
Require NaN validation after parsing numeric values from process.env |
require-return-after-core-setfailed |
Require a control-transfer statement after core.setFailed() |
require-execsync-try-catch |
Require try/catch around execSync(...) calls from child_process |
require-execfilesync-try-catch |
Require try/catch around execFileSync(...) calls from child_process |
require-spawnsync-error-check |
Require checking result.error after spawnSync calls |
prefer-get-error-message-over-string |
Prefer getErrorMessage(err) over String(err) when interpolating a caught error |
require-rmsync-try-catch |
Require try/catch around fs.rmSync calls |
no-core-error-then-process-exit |
Disallow core.error() immediately followed by process.exit(nonzero) |
no-core-error-then-process-exitcode |
Disallow core.error() immediately followed by process.exitCode = nonzero |
no-exec-interpolated-command |
Disallow interpolated command strings passed to @actions/exec |
no-setfailed-then-exit-zero |
Disallow resetting the exit code to success after core.setFailed() |
no-err-stack-then-string-fallback |
Disallow the err.stack || String(err) fallback pattern |
no-caught-error-interpolation |
Disallow directly interpolating a caught error in a template literal |
no-core-error-then-setfailed |
Disallow a redundant core.error() call immediately before core.setFailed() with the same message |
require-escaped-regexp-interpolation |
Require regex-escaping of interpolated values in new RegExp() template literals |
Inventory module-level const declarations with static primitive initializers and report each declaration after the first one that uses the same value in a file. The diagnostic names both constants and shows the duplicated value.
The rule compares string, number, boolean, null, bigint, regular-expression, static template-literal, and signed numeric initializers. To avoid collisions in their small value spaces, it reports duplicate numeric and boolean values only when at least three module-level constants share them. Dynamic expressions, object and array literals, destructuring declarations, function-local declarations, and let or var declarations are ignored.
Disallow template literals with interpolations or string concatenation expressions as the route argument of Octokit .request() calls.
Using an interpolated route bypasses Octokit's typed route dispatch, can silently produce malformed paths when values contain special characters, and prevents static analysis of the route string.
Detected Octokit clients:
- Well-known names:
github,octokit,githubClient,octokitClient. context.github— the GitHub context object's client property.- Identifiers initialized by calling
getOctokit(...)directly or via known module objects (github.getOctokit(...),actions.getOctokit(...)). (Known module object names currently:github,actions.) - Simple
constaliases of any of the above:const gh = github,const client = getOctokit(token),const myClient = context.github.
Flagged forms:
github.request(`GET /repos/${owner}/${repo}`, ...)— template literal with interpolations.github.request("GET /repos/" + owner + "/" + repo, ...)— string concatenation.github.request(`POST ${endpoint}`, ...)— opaque whole-route helper; thread a typed route from the caller instead of interpolating the entire path.context.github.request(`GET /repos/${owner}/${repo}`, ...)—context.githubclient.const gh = github; gh.request(`GET /repos/${owner}/${repo}`, ...)— aliased client.const client = getOctokit(token); client.request(`GET /repos/${owner}/${repo}`, ...)—getOctokitresult alias.
Out of scope:
this.github.request(...)—this-based member expressions are not resolved.github.request(route, ...)— variable indirection for the route argument is not resolved.github.request("GET /repos/".concat(owner), ...)—.concat()-built routes are not inspected.github.request("GET /repos" + "/{owner}/{repo}", ...)— compile-time constant concatenations are accepted.
Safe alternative:
github.request("GET /repos/{owner}/{repo}", { owner, repo });For helpers that receive the entire route as a parameter, there is no mechanical {owner} / {repo} rewrite. Pass a typed route string from the caller instead of interpolating POST ${endpoint} or "POST " + endpoint at the helper call site.
Require core.exportVariable(name, value) calls to pass an explicit string value for the targeted low-false-positive cases: numeric literals, boolean literals, null, undefined, and .length member accesses.
Why: GitHub Actions environment variables exported by core.exportVariable are always strings. Relying on implicit coercion can silently emit "null", "undefined", "true", or other unintended values into downstream expressions that read the variable.
Detected forms:
core.exportVariable("MY_VAR", 42)— numeric literal value.core.exportVariable("MY_FLAG", true)/...false— boolean literal value.core.exportVariable("MY_VAR", null)/...undefined— null or undefined value.core.exportVariable("MY_COUNT", items.length)—.lengthmember access.core["exportVariable"]("MY_VAR", 42)— computed string-literal property access.coreObj.exportVariable("MY_VAR", 42)— thecoreObjalias for@actions/core.
Out of scope:
- Variable references (e.g.
core.exportVariable("MY_VAR", someVariable)) — the rule does not resolve variable types. - Methods other than
exportVariable— useno-core-setoutput-non-stringforsetOutput. - Objects whose name is not in the known
@actions/corealias list (core,coreObj).
Safe alternatives:
core.exportVariable("MY_COUNT", String(items.length))— explicit coercion.core.exportVariable("MY_VAR", "")— empty-string semantics whennull/undefinedis intended to mean "not set".
Disallow JSON.stringify() on caught error variables. Error properties (message, stack, etc.) are non-enumerable, so JSON.stringify(err) silently produces {}.
Detected scopes:
try { } catch (err) { }— catch-clause bindings.p.catch(err => ...)— inline arrow or function callbacks passed as the first argument to.catch().p.then(onFulfilled, err => ...)— inline rejection handlers passed as the second argument to.then(), which are semantically equivalent to.catch().
Out of scope: named-reference handlers such as p.catch(handler) or p.then(ok, handler) — the rule does not follow references across files or scopes.
Flagged forms:
JSON.stringify(err)whereerris a catch-clause or inline rejection-handler parameter.JSON.stringify(err, null, 2)(with replacer/space arguments).
Safe alternatives:
getErrorMessage(err)fromerror_helpers.cjs(auto-suggested fix).JSON.stringify({ message: err.message, stack: err.stack })— explicitly serializing safe string properties.
Prefer Number.isNaN() over global isNaN() to avoid silent coercion of non-numeric inputs.
Global isNaN() coerces its argument before testing, so isNaN("123") returns false because "123" coerces to the number 123 — masking that the input was a string. Number.isNaN() is strict and does not coerce, making numeric validation reliable when handling raw inputs such as environment variables or API strings.
Flagged forms:
isNaN(x)globalThis.isNaN(x)/globalThis["isNaN"](x)window.isNaN(x)/window["isNaN"](x)global.isNaN(x)/global["isNaN"](x)
Locally shadowed bindings (e.g. const isNaN = Number.isNaN) are intentionally excluded.
Prefer structuredClone(...) over JSON.parse(JSON.stringify(...)) for deep-cloning data. The JSON round trip is slower, drops values such as undefined and functions, converts Date instances to strings, and throws on circular references.
Detected forms:
JSON.parse(JSON.stringify(value))JSON["parse"](JSON["stringify"](value))
The rule only reports a JSON.stringify(...) call with exactly one argument, because replacer or indentation arguments change the round-trip semantics.
The rule suggests replacing the round trip with structuredClone(value) unless value is an identifier that is assigned a function-valued property, initialized with a function-valued object property, or checked with typeof value.property === "function" anywhere in the file. Those identifiers are still reported, but without a suggestion, because structuredClone throws on functions while JSON serialization silently drops them.
Disallow throwing plain object literals (throw { ... }). Plain objects lack a .stack trace and a meaningful .message string, making errors hard to debug and incompatible with catch-clause error utilities such as getErrorMessage.
Detected forms:
throw { message: "not found" }— object literal with amessageproperty.throw { code: 500, message: "internal" }— object literal with extra fields.throw {}— empty object literal.throw { ...base, code: 1 }— spread elements or computed keys (no autofix suggestion, only an error).
Out of scope:
throw err— identifier references are not checked.throw new Error(...)—Errorconstructor calls are always accepted.throw Object.assign(new Error(...), { ... })— already in the recommended form.
JSON-RPC exemption: Objects that match the JSON-RPC error shape { code: <negative integer literal>, message: <any>, data?: <any> } are intentionally exempt. These are deliberately thrown at protocol boundaries where the receiver reads code, message, and data directly rather than a stack trace. Only keys from { code, message, data } are allowed; extra keys, a positive code, a fractional code, or a missing message disqualify the exemption.
Safe alternatives:
throw new Error(message)— minimal form.throw Object.assign(new Error(message), { code, ... })— attaches extra context while preserving the stack trace.
The rule provides an autofix suggestion for plain-key objects: it extracts the message property as the Error argument and collects remaining properties into Object.assign(...).
Require core.setOutput(name, value) calls to pass an explicit string value for the targeted low-false-positive cases: numeric literals, boolean literals, null, undefined, and .length member accesses.
Why: GitHub Actions step outputs are strings. Relying on implicit coercion can silently emit "null", "undefined", "true", or other unintended values into downstream expressions.
Typical fixes:
core.setOutput("count", String(count))core.setOutput("optional", "")when empty-string semantics are intended fornull/undefined
Disallow direct access to .message, .stack, .code, .status, .cause, or .name on a catch (err) binding unless the code first proves the thrown value is safe to inspect.
Accepted guards:
getErrorMessage(err)err instanceof Errortypeof err === "object" && err !== null
Why: JavaScript can throw non-Error values, so err.message is not always safe.
Disallow the same unsafe error-property accesses inside inline promise rejection handlers such as .catch(err => ...).
This rule mirrors no-unsafe-catch-error-property, but for promise rejection values rather than catch clauses. Truthiness checks such as err && err.message are recognized for the accessed property.
Prefer getErrorMessage(err) over the repeated pattern err instanceof Error ? err.message : String(err).
Why: getErrorMessage(err) centralizes safe error extraction and also sanitizes HTML error-page responses in the gh-aw runtime helpers.
Require bare calls to module-scope async entrypoints such as main() to be chained with .catch(...) when they are invoked outside an async context.
Flagged form:
main();
Safe alternatives:
main().catch(err => { ... });await main();when already inside an async function
Require core.summary.write() (including known aliases and fluent core.summary.*().write() chains) to be awaited when used as a bare expression.
Why: core.summary.write() returns a promise. Dropping it can truncate or lose the step summary if the process exits first.
Intentional exception:
void core.summary.write()is treated as an explicit deliberate discard marker.
Require rethrown new Error(...) values inside a catch block to preserve the original failure with { cause: err } when the new message already references the caught error or a direct alias of it.
Flagged form:
throw new Error(\failed: ${getErrorMessage(err)}`);`
Safe alternative:
throw new Error(\failed: ${getErrorMessage(err)}`, { cause: err });`
Require awaited fetch(...) calls to be wrapped in try/catch, including member-chained promise forms rooted in fetch(...).
Why: fetch rejects with TypeError on network failures (DNS errors, connection refused, timeouts surfaced as aborts, etc.). Without either an enclosing try/catch or an explicit promise rejection handler, the action crashes with an unhelpful uncaught exception.
Flagged forms:
await fetch(url);await fetch(url).then(res => res.json());await fetch(url).then(ok).finally(cleanup);
Not flagged:
try { await fetch(url).then(res => res.json()); } catch (err) {}await fetch(url).catch(handleFetchError);await fetch(url).then(onFulfilled, onRejected);
Out of scope:
- locally shadowed
fetchbindings such asasync function f(fetch) { await fetch(url); } - named-reference rejection handlers are not inspected for correctness; the rule only checks that
.catch(handler)or.then(ok, onErr)is present on the awaited fetch chain
Require awaited .json() or .text() calls on a fetch(...) response to be wrapped in try/catch. Reading a response body can reject when the stream errors, and .json() can also reject for invalid JSON; an unhandled rejection crashes the action with an unhelpful exception.
Flagged forms:
await fetch(url).json();const response = await fetch(url); await response.text();
The rule recognizes direct global fetch(...) chains and identifiers assigned from a bare await fetch(...). It supports direct or string-literal computed body methods, such as response["json"]().
Not flagged:
try { await fetch(url).json(); } catch (err) {}try { await response.text(); } catch (err) {}whenresponsewas assigned fromawait fetch(...)
Out of scope:
- body methods other than
.json()and.text() - response values not resolved to a bare
await fetch(...) - calls in deferred callbacks nested within a
tryblock, because thattrycannot catch their asynchronous failures
Require fetch(...) calls to include a signal option so requests can be aborted instead of hanging indefinitely.
Why: without an abort signal, a stalled network call can block the action until the workflow/job timeout ends it.
Flagged forms:
fetch(url);fetch(url, null);fetch(url, undefined);fetch(url, { method: "GET" });fetch(url, { signal: null });globalThis.fetch(url, { method: "GET" });
Not flagged:
fetch(url, { signal: AbortSignal.timeout(10_000) });fetch(url, { signal: controller.signal });fetch(url, options);(options object is not statically resolved)fetch(url, { ...options });(spread may already includesignal)obj.fetch(url);(only globalfetchcalls are in scope)
Require fs.statSync, fs.readdirSync, fs.copyFileSync, fs.unlinkSync, and fs.renameSync calls to be wrapped in try/catch.
Why: these synchronous filesystem methods throw on missing files, permission errors (EACCES), busy resources (EBUSY), and other I/O failures. An unhandled throw crashes the action without surfacing a useful diagnostic message.
Detected forms:
fs.statSync(path)— direct call on a knownrequire("fs")result.fs["readdirSync"](dir)— computed string-literal property access.const { unlinkSync } = require("fs"); unlinkSync(path)— destructured binding fromrequire("fs")orrequire("node:fs").- ESM namespace imports:
import * as fs from "fs"; fs.copyFileSync(src, dest). - ESM named imports:
import { renameSync } from "fs"; renameSync(src, dest). - Bare unbound identifiers:
statSync(path)whenstatSyncis not a locally bound variable.
Out of scope:
- Objects whose
requiresource is not the Nodefs/node:fsmodule. try { ... } finally { ... }without acatchclause is still flagged.
Safe alternative:
try {
fs.statSync(filePath);
} catch (err) {
throw new Error("fs.statSync failed: " + (err instanceof Error ? err.message : String(err)), { cause: err });
}Require fs.readFileSync, fs.writeFileSync, and fs.appendFileSync calls to be wrapped in try/catch.
Why: these synchronous filesystem calls throw on missing files, permission errors, and disk failures, which otherwise crash the action without useful context.
Current scope:
- direct
fs.readFileSync(...) - known
require("fs")aliases - destructured aliases such as
const { readFileSync } = require("fs")
Require JSON.parse(...) calls to be wrapped in try/catch.
Why: malformed JSON should produce a controlled failure path in runtime scripts rather than an uncaught exception.
Out of scope:
- aliased or destructured
JSON.parsereferences such asconst parse = JSON.parse
Require parseInt() to include an explicit radix argument.
Flagged forms:
parseInt(value)Number.parseInt(value)globalThis.parseInt(value)
Why: omitting the radix allows implicit base detection, which can silently accept prefixes such as 0x.
Require NaN validation after parsing numeric values from process.env.
Why: parseInt, parseFloat, Number.parseInt, Number.parseFloat, and Number() silently return NaN for malformed environment input (empty string, typo, unexpected value). An unvalidated NaN can propagate silently into comparisons (e.g. rate-limit thresholds, size limits, timeouts), loop bounds, or GitHub API payloads without any error surfacing.
Detected parse forms (first argument must trace back to process.env):
parseInt(process.env.FOO, 10)— globalparseIntparseFloat(process.env.FOO)— globalparseFloatNumber.parseInt(process.env.FOO, 10)—Number.parseIntNumber.parseFloat(process.env.FOO)—Number.parseFloatNumber(process.env.FOO)—Numberconversion function
Detected env-access patterns in the first argument:
- Direct:
process.env.FOO - Logical fallbacks:
process.env.FOO || "default",process.env.FOO ?? "default" - Optional chaining:
process.env.FOO?.trim() - Ternary:
process.env.FOO ? process.env.FOO : "default"
Considered validated when the declared variable is passed as the sole argument to Number.isNaN(...) or isNaN(...) anywhere in the enclosing file scope.
Safe pattern:
const maxRuns = parseInt(process.env.MAX_RUNS, 10);
if (Number.isNaN(maxRuns)) throw new Error("MAX_RUNS must be a valid integer");Require fs.mkdirSync calls to be wrapped in try/catch.
Why: mkdirSync throws synchronously on permission errors, invalid paths, or unexpected filesystem state. An unhandled throw crashes the action without surfacing a useful diagnostic.
Detected forms:
fs.mkdirSync(dir)/fs.mkdirSync(dir, { recursive: true })— direct calls on a knownrequire("fs")result.fs["mkdirSync"](dir, ...)— computed string-literal property access.const { mkdirSync } = require("fs"); mkdirSync(dir)— destructured binding fromrequire("fs")orrequire("node:fs").- ESM namespace imports:
import * as fs from "fs"; fs.mkdirSync(dir). - ESM named imports:
import { mkdirSync } from "fs"; mkdirSync(dir).
Out of scope:
- Objects whose
requiresource is not the Nodefs/node:fsmodule (e.g.mockFs.mkdirSync,storage.mkdirSync, orconst fs = require("mock-fs"); fs.mkdirSync). - Other
fsmethods such asexistsSync— userequire-fs-sync-try-catchforreadFileSync,writeFileSync, andappendFileSync; userequire-fs-io-try-catchforstatSync,readdirSync,copyFileSync,unlinkSync, andrenameSync. try { ... } finally { ... }without acatchclause is still flagged.
Safe alternative:
try {
fs.mkdirSync(dir, { recursive: true });
} catch (err) {
throw new Error("fs.mkdirSync failed: " + (err instanceof Error ? err.message : String(err)), { cause: err });
}Require new URL(variable) calls to be wrapped in try/catch.
Why: the URL constructor throws a TypeError when given an invalid or relative URL string, which crashes the action with an unhelpful uncaught exception.
Detected forms:
new URL(urlStr)— first argument is a runtime-dynamic expression.new URL(process.env.GITHUB_SERVER_URL)— environment variable reference.new URL(`https://${host}/path`)— template literal with expressions.new URL(host + "/x")— string concatenation containing a variable.new URL("/path", base)— dynamic second (base) argument.new URL()— zero arguments (always throwsTypeErrorat runtime).
Out of scope (not flagged):
new URL("https://github.com")— compile-time constant string literal or static concatenation.new URL(`https://github.com/static`)— template literal with no expressions.new URL("https://github.com" + "/owner/repo")— concatenation of string literals only.new URL(import.meta.url)—import.meta.urlis always a valid absolute URL in ES modules.new URL("./relative/path", import.meta.url)—import.meta.urlas the base is safe.function parse(URL, value) { return new URL(value); }—URLshadowed by a local binding is not the global constructor.- Calls already inside a
tryblock with acatchclause.
Known limitation — no autofix for VariableDeclaration: when the flagged new URL(...) appears as the initializer of a variable declaration (const u = new URL(urlStr)), the rule reports the error but emits no autofix suggestion. Wrapping that statement in try { ... } catch { ... } would move subsequent uses of u outside the try block, leaving them referencing an undeclared binding. Only ExpressionStatement and ReturnStatement positions receive an autofix suggestion.
Safe alternative:
try {
const u = new URL(urlStr);
// use u here
} catch (err) {
throw new Error("URL constructor call failed: " + (err instanceof Error ? err.message : String(err)), { cause: err });
}Require a return, throw, break, continue, or process.exit() statement immediately after core.setFailed() to prevent execution from continuing after a failure is declared.
Why: core.setFailed() only marks the action as failed at the end of the run — it does not stop execution. Any code that follows continues to run in a failed state, which can produce misleading output or unexpected side effects.
Detected forms:
core.setFailed(...)— direct non-computed call on a known@actions/corealias (core,coreObj).core["setFailed"](...)— computed string-literal property access.const c = core; c.setFailed(...)— single-assignment alias for a core-like object.const { setFailed } = core; setFailed(...)— destructured binding from a core-like object (including renamed destructuring such asconst { setFailed: sf } = core).
Accepted control-transfer statements:
return/return valuethrow new Error(...)process.exit(...)break(inside a loop or switch body)continue(inside a loop body)
Out of scope:
- Calls on unrecognized objects:
other.setFailed("bad")is not flagged. setFailed("bad")as a bare identifier call (not destructured from a core alias) is not flagged.
Known limitation: break and continue are accepted as control-transfer statements within loop or switch bodies, but they do not prevent post-loop or post-switch statements from running in a failed state. Detecting that kind of continuation is out of scope.
Cross-block fall-through: the rule also flags core.setFailed(...) that is the last statement of a nested block when the enclosing block has a subsequent statement that would execute unconditionally after the nested block exits:
// Flagged — doMore() runs after setFailed even though they are in different blocks
function f() {
if (!ok) {
core.setFailed("msg");
}
doMore();
}Safe alternative:
if (!ok) {
core.setFailed("msg");
return;
}
doMore(); // only reached when ok is trueRequire spawnSync result variables to check result.error in addition to result.status.
Why: when spawnSync cannot spawn the child process (e.g. ENOENT, ETIMEDOUT), result.status is null and result.error holds the actual Error. Checking only result.status silently swallows spawn-level failures or reports a misleading "exit null" diagnostic.
Detected forms:
const result = spawnSync(cmd, args)— unqualifiedspawnSyncidentifier.const result = childProcess.spawnSync(...)—childProcessnamespace alias.const result = child_process.spawnSync(...)—child_processnamespace alias.- Object-destructuring:
const { status, error } = spawnSync(...)— the destructurederrorbinding must appear in a guard position. - Renamed destructuring:
const { error: spawnError } = spawnSync(...)— the renamed binding must appear in a guard position. - String-literal key:
const { "error": spawnError } = spawnSync(...).
Accepted guard positions for .error:
if (result.error) throw result.error;— direct truthiness check as aniftest.if (result.error !== undefined) throw result.error;— binary comparison as aniftest.if (result.status !== 0 || result.error) throw result.error;—.erroron the right side of||where the full expression is theiftest.throw result.error;/return result.error;— direct throw or return.const e = result.error; if (e) throw e;— single-assignment immutable alias that is later guarded.
Out of scope (not recognized as valid guards):
result.error && result.error.message— right-hand side of&&is not an independent guard.result.error || new Error("fallback")—||right-hand operand is not a guard.result.error ?? null— nullish coalescing is not a guard.core.info(String(result.error))— logging without a conditional check does not count.AssignmentExpressionforms (result = spawnSync(...)) and inline chains (spawnSync(...).status) are not analyzed.- Passing the result object to a helper function that internally checks
.erroris not recognized. - Mutable aliases (
let e = result.error; e = undefined; if (e) throw e) are rejected because the original value may have been discarded before the guard.
Prefer @actions/core logging methods (core.info, core.debug) over console.log, console.info, and console.debug.
core.* logging methods integrate with the GitHub Actions annotation system (errors and warnings appear as file annotations in the UI) and produce structured log output. global.core is always available via shim.cjs in the Node.js context and via github-script in the Actions context.
Covered methods and their replacements:
console.* method |
Suggested replacement |
|---|---|
console.log |
core.info |
console.info |
core.info |
console.debug |
core.debug |
Intentionally excluded: console.error and console.warn
console.error and console.warn write to process.stderr, while core.error and core.warning emit GitHub Actions workflow commands to process.stdout. For processes that own stdout as a data/protocol channel — such as stdio MCP servers and transports — replacing stderr logging with stdout logging would corrupt the JSON-RPC stream. Because the stream change is not behavior-preserving, the rule never reports console.error or console.warn and offers no suggestion to replace them.
Disallow interpolated template literals and dynamic string concatenation as command arguments to shell-evaluated child_process calls.
Why: command strings evaluated by a shell (exec, execSync, spawn / spawnSync with shell: true, and execFile / execFileSync with shell: true) can become shell-injection vectors when command content is assembled dynamically.
Detected forms (when bound to child_process / node:child_process):
const { execSync } = require("child_process"); execSync(\git checkout ${branch}`)`const cp = require("child_process"); cp.exec("git checkout " + branch)const run = cp.execSync; run("git checkout " + branch)— member alias call.spawn(\git checkout ${branch}`, { shell: true })` — shell-enabled spawn.execFileSync("git " + branch, ["status"], { shell: true })— shell-enabled execFileSync.spawn("git checkout " + branch, ...opts)— spread options are treated conservatively as potentially shell-enabled.- ESM imports are recognized (
import { execSync } from "node:child_process").
Not flagged:
- Fully static command strings (
"git status",`git status`, and fully static+concatenations). spawn(cmd, [args])/spawnSync(cmd, [args])withoutshell: true.execFile/execFileSyncwithoutshell: true.
Out of scope: github-script's injected exec.exec(...) / exec.getExecOutput(...). Those are covered by no-exec-interpolated-command, which targets @actions/exec argument-splitting correctness rather than shell injection.
Safer alternatives:
- Use a static executable and pass arguments as an array (
execFileSync("git", ["checkout", branch])). - Avoid
shell: trueunless strictly required.
Require execSync calls sourced from child_process to be wrapped in try/catch.
Why: execSync throws an Error containing child-process result fields (for example status, signal, stdout, stderr) when the child process exits with a non-zero status code or is killed by a signal. An unhandled throw crashes the action without surfacing a useful diagnostic.
Detected forms:
const { execSync } = require("child_process"); execSync(...)— destructured.const cp = require("child_process"); cp.execSync(...)— namespace access.const run = cp.execSync; run(...)— aliased via member expression.import { execSync } from "child_process"; execSync(...)— ESM named import.- Both
"child_process"and"node:child_process"specifiers are recognized.
Not flagged:
execSyncfrom any module other thanchild_process/node:child_process.- Calls already inside an enclosing
try { ... } catch { ... }block.
Require execFileSync calls sourced from child_process to be wrapped in try/catch.
Why: execFileSync has identical throw-on-failure semantics to execSync — it throws an Error containing child-process result fields (for example status, signal, stdout, stderr) when the child process exits with a non-zero status code or is killed by a signal. An unhandled throw crashes the action without surfacing a useful diagnostic.
Detected forms:
const { execFileSync } = require("child_process"); execFileSync(...)— destructured.const cp = require("child_process"); cp.execFileSync(...)— namespace access.const run = cp.execFileSync; run(...)— aliased via member expression.import { execFileSync } from "child_process"; execFileSync(...)— ESM named import.- Both
"child_process"and"node:child_process"specifiers are recognized.
Not flagged:
execFileSyncfrom any module other thanchild_process/node:child_process.- Calls already inside an enclosing
try { ... } catch { ... }block.
Out of scope: execFile (the async, callback-based sibling) is intentionally excluded. The async form accepts a callback and does not throw synchronously; errors are delivered through the callback or the returned ChildProcess event emitter, so a synchronous try/catch provides no protection.
Prefer getErrorMessage(err) over String(err) when interpolating a caught error into a template literal, when getErrorMessage is already resolvable in scope.
String(err) on an Error produces the redundant "Error: message" prefix and does not sanitize GitHub's HTML error-page responses, while getErrorMessage(err) handles both correctly. Several actions/setup/js files already import getErrorMessage elsewhere yet still call String(err) at other call sites in the same file — this rule catches that inconsistency.
Detected forms:
`Failed: ${String(err)}`—String(...)wrapping a caught-error identifier inside a template literal expression, whengetErrorMessageis resolvable in the enclosing scope (import, function declaration, or earlier declaration).
Not flagged:
String(err)outside of a template literal.String(value)wherevalueis not a caught error (not bound by acatchclause or the first parameter of an inline.catch()/.then()rejection handler).String(err)whengetErrorMessageis not resolvable in scope.- Tagged template literals — values are passed to the tag function as-is, not string-coerced.
The rule provides an autofix suggestion that replaces String(err) with getErrorMessage(err).
Require fs.rmSync calls in actions/setup/js scripts to be wrapped in try/catch.
rmSync throws synchronously on permission errors, invalid paths, or unexpected filesystem state; an unhandled throw crashes the action without surfacing a useful diagnostic.
Not flagged: Calls already inside an enclosing try { ... } catch { ... } block.
Disallow core.error() immediately followed by process.exit(nonzero).
Prefer core.setFailed(msg) to signal action failure; it marks the action failed and allows post-action cleanup hooks to run. In standalone node scripts, process.exit(nonzero) does fail the step, but core.setFailed is more portable.
The rule provides an autofix suggestion that replaces core.error(msg); process.exit(...); with core.setFailed(msg); return;.
Disallow core.error() immediately followed by process.exitCode = nonzero.
Prefer core.setFailed(msg) to signal action failure; it marks the action failed and allows post-action cleanup hooks to run. Unlike process.exit(1), process.exitCode = 1 does not halt execution immediately.
The rule provides an autofix suggestion that replaces core.error(msg); process.exitCode = nonzero; with core.setFailed(msg); (at module top level) or core.setFailed(msg); return; (inside main()).
Disallow passing an interpolated template literal or dynamic string concatenation as the command argument to @actions/exec's exec.exec(...) / exec.getExecOutput(...) calls.
@actions/exec splits the command string by spaces, so values containing spaces silently break argument boundaries. Use a static command string and pass all arguments in the args array instead, for example exec.exec("git", ["checkout", branchName]).
Detected forms:
exec.exec(`git checkout ${branchName}`)— interpolated template literal.exec.exec("git " + branchName)— dynamic string concatenation.
Not flagged:
- Static command strings, including string concatenation of only static expressions.
- Arguments passed correctly via the
argsarray.
Disallow resetting the exit code to success (process.exit(0) or process.exitCode = 0) after core.setFailed().
Doing so silently resets the exit code to success, hiding the failure that core.setFailed() already recorded.
Detected forms:
core.setFailed(msg); process.exit(0);core.setFailed(msg); process.exitCode = 0;
The rule provides an autofix suggestion: replace process.exit(0) with return;, or remove the process.exitCode = 0; assignment.
Disallow the err.stack || String(err) (and equivalent) fallback pattern for formatting caught errors.
Prefer getErrorMessage(err) from error_helpers.cjs. The err.stack ternary/logical-OR pattern surfaces noisy stack frames; getErrorMessage() returns a clean, consistent message.
Detected forms:
err && err.stack ? err.stack : String(err)err instanceof Error ? err.stack : String(err)err.stack || String(err)
The rule provides an autofix suggestion that replaces the pattern with getErrorMessage(err) (ensure getErrorMessage is imported from error_helpers.cjs before applying).
Disallow directly interpolating a caught error variable in a template literal (for example `Failed: ${err}`).
Directly interpolating a caught error is unsafe — for Error objects it produces "Error: message" (a redundant prefix); for non-Error throws it produces "[object Object]". Use ${getErrorMessage(err)} if it is available, or ${String(err)} as an import-free alternative.
Not flagged:
- Error variables passed through
getErrorMessage(...)orString(...)before interpolation. - Identifiers that are not caught-error bindings (not bound by a
catchclause, an inline.catch()/.then()rejection handler, or an inline'error'event listener).
The rule provides autofix suggestions to wrap the interpolated expression in getErrorMessage(err) (when resolvable in scope) or String(err) (import-free fallback).
Disallow a redundant core.error() call immediately before core.setFailed() with the same message.
core.error() immediately before core.setFailed() with the same message is redundant: core.setFailed() already logs an error annotation and marks the action failed.
The rule provides an autofix suggestion that removes the redundant core.error() call.
Require interpolated values inside a new RegExp() template literal to be passed through a regex-escaping helper (or already marked as escaped) before use.
Interpolating an unescaped, user- or runtime-controlled value directly into a new RegExp(...) template literal allows regex metacharacters (. * + ? ^ $ { } ( ) | [ ] \) in that value to change the meaning of the pattern, which can cause unintended matches or a ReDoS-prone expression.
Detected forms:
new RegExp(`^${value}$`)— interpolated identifier not passed through an escaping helper and not obviously pre-escaped.
Not flagged:
new RegExp(`^${escapeRegExp(value)}$`)— interpolated value passed through a call whose name matches an escaping-helper pattern (contains both "escape" and "reg", e.g.escapeRegExp,utils.escapeRegex).new RegExp(`^${escapedValue}$`)— interpolated identifier whose name starts withescaped(e.g.escapedValue,ESCAPED_NAME).- Static (non-interpolated) template literals.