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.
The bounded TLA+ model and executable code comparison cover the dispatcher and miner/refiner/monster Claim lifecycle. Queue policy, authorized producers and worker profiles are provisioned externally; these workflows do not install an automatic miner → refiner → monster DAG. Issue creation is not queue admission. Workers use authenticated version-3 assignments with immutable original membership; permitted homogeneous batches require each output to retain its originating Claim scope. Completion alone is neither verified delivery nor a Result, and output count contracts are per Claim rather than automatically run-wide.
The JS/TS mining goals above do not describe current scan coverage: configured rules apply to non-test CJS files. Most rules warn; the HTTP response-error-listener rule is an error. Monster's precheck marks a scan clean on ESLint exit zero, including warning-only diagnostics. Installation/build/tool failures are not evidence of defect-free source. Build/test/quality bars and the prompt's three total Copilot assignments are agent obligations, not guarantees proved by the finite model. The linked checker records assumptions, source/tool identities, negative controls and validated readable traces without live effects.
| Rule | Description |
|---|---|
no-async-foreach-callback |
Disallow async callbacks passed to Array.prototype.forEach(), which ignores returned promises |
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-empty-catch-block |
Disallow undocumented empty catch blocks |
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-json-stringify-equality |
Disallow comparing two JSON.stringify() results for equality |
no-json-stringify-set-or-map |
Disallow JSON.stringify() directly on Set or Map instances |
no-math-minmax-array-spread |
Disallow spreading a non-literal array into Math.min(...) / Math.max(...) |
no-misplaced-error-code-definition |
Require exported error-code constants to be defined in error_codes.cjs |
no-single-char-string-replace |
Disallow String.prototype.replace() with a single-character string pattern, which replaces only the first occurrence |
no-string-fallback-for-non-string-message |
Disallow stringifying a container object instead of its non-string .message value in a fallback |
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-decodeuricomponent-try-catch |
Require try/catch around decodeURIComponent(...) and decodeURI(...) on dynamic input |
require-error-cause-in-rethrow |
Require { cause: err } when rethrowing inside a catch block |
require-error-code-in-thrown-error |
Require standardized error codes in thrown errors when error_codes.cjs is imported |
require-error-code-for-github-api-throw |
Require standardized error codes for throw new Error(...) after GitHub API calls |
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-chmod-try-catch |
Require try/catch around fs.chmodSync and fs.fchmodSync |
require-fs-close-sync |
Require fs.openSync(...) file descriptors to be closed with fs.closeSync(fd) in the same function |
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-http-response-error-listener |
Require an 'error' listener on response objects in http / https request and get callbacks |
require-json-parse-try-catch |
Require try/catch around JSON.parse(...) calls |
require-mkdirsync-try-catch |
Require try/catch around fs.mkdirSync calls |
require-mkdtempsync-try-catch |
Require try/catch around fs.mkdtempSync calls |
require-realpathsync-try-catch |
Require try/catch around fs.realpathSync 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-nan-check-after-split-index-parse |
Require NaN validation after parsing a split(...)[index] value |
require-invalid-date-check-before-compare |
Require Invalid Date validation before relational comparisons |
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-sync-exec-timeout |
Require positive timeouts for synchronous child_process calls |
require-spawn-error-listener |
Require an 'error' event listener on async spawn(...) child processes |
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 |
require-lastindex-reset-before-global-exec-loop |
Require resetting stateful regexes before global exec() loops |
require-page-counter-increment-in-while-true-loop |
Require page counters to advance in manual while (true) pagination loops |
require-getexecoutput-exitcode-check |
Require exitCode / returned exit code to be read after getExecOutput() or exec() with { ignoreReturnCode: true } |
prefer-actions-exec-over-child-process |
Prefer @actions/exec over child_process to spawn processes that run to completion |
no-unguarded-async-timer-callback |
Require async timer callbacks to wrap their entire body in try/catch |
require-finite-check-before-date-toisostring |
Validate dynamic dates before calling toISOString() or toJSON() |
Disallow empty catch blocks, which silently swallow errors that otherwise remain invisible in CI logs.
Empty catch blocks are allowed only when their comment explicitly documents an intentional no-op with intentional, best-effort, or best effort (case-insensitive). Otherwise, log the error, assign a fallback value, or rethrow it.
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, boolean, and null 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.
Disallow spreading an array of unknown size into Math.min(...) / Math.max(...). Spreading an array into call arguments pushes every element onto the call stack, so a large array throws RangeError: Maximum call stack size exceeded (the limit is engine and version dependent, commonly in the tens of thousands of elements). Arrays built from workflow runs, API responses, or file scans have no static size bound, so the crash only appears on large inputs.
Detected forms:
Math.max(...values)/Math.min(...values)— identifier spread.Math.max(...stats.durations)— member expression spread.Math.min(...runs.map(run => run.duration))— call expression spread.Math["max"](...values)— computed access to the same methods.
Out of scope:
Math.max(0, ...values)andMath.min(a, b, ...values)— fixed arguments alongside the spread suggest an intentional, likely bounded call shape.Math.max(...[1, 2, 3])— inline array literals are statically bounded by the source.- Calls where
Mathis shadowed by a local declaration.
Safe alternative:
values.reduce((a, b) => Math.max(a, b), -Infinity)/values.reduce((a, b) => Math.min(a, b), Infinity)— folds the array without expanding it into arguments, using the same identity valueMath.max()/Math.min()return on an empty array so the empty-input result matches the spread form instead of throwing.
Require exported constants whose names end in _ERROR_CODE or _REASON_CODE to be defined in the centralized error_codes.cjs registry. Local-only constants are allowed because they do not establish a shared code outside the registry.
Flagged form:
const POLICY_FILE_PROTECTION_DENIED_REASON_CODE = "POLICY_FILE_PROTECTION_DENIED";
module.exports = { POLICY_FILE_PROTECTION_DENIED_REASON_CODE };Safe alternative:
Define and export the constant from error_codes.cjs, then import it where needed.
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.chmodSync and fs.fchmodSync calls to be wrapped in try/catch.
Why: these calls can throw for missing files or descriptors, permission errors, and unsupported filesystems. A call-site catch preserves useful error context.
Detected forms:
fs.chmodSync(path, mode)andfs["chmodSync"](path, mode).fs.fchmodSync(fd, mode).- Bindings imported or required from
fs/node:fs, including destructured bindings.
Out of scope:
- Objects that are not resolved to the Node
fs/node:fsmodule. try { ... } finally { ... }without acatchclause is still flagged.
Require file descriptors returned by fs.openSync(...) to be closed with fs.closeSync(fd) in the same enclosing function.
Why: unclosed descriptors leak file handles for the lifetime of the process and can eventually surface as unrelated EMFILE failures.
Detected forms:
const fd = fs.openSync(path, "w")with no matchingfs.closeSync(fd)in the same enclosing function.let fd; fd = fs.openSync(path, "w")with no matchingfs.closeSync(fd)in the same enclosing function.
Accepted close forms (in the same enclosing function):
fs.closeSync(fd), including insidetry/finally.- Property access and single-level aliases such as
fs.closeSync(handle.fd)afterconst handle = { fd }, orfs.closeSync(alias)afterconst alias = fd.
Out of scope:
- Destructured bindings and inline argument forms such as
consume(fs.openSync(...)). - Close calls placed in a nested function, including cleanup callbacks such as
const cleanup = () => fs.closeSync(fd), and cross-function close pairs (open in one function, close in another). Only closes in the same enclosing function count. - Strict control-flow proof. A
fs.closeSync(fd)anywhere in the enclosing function body is accepted.
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 fs.mkdtempSync calls to be wrapped in try/catch.
Why: mkdtempSync throws synchronously when the parent directory does not exist, permissions are denied, or disk space is exhausted. An unhandled throw crashes the action without surfacing a useful diagnostic.
Detected forms:
fs.mkdtempSync(prefix)— direct call on a knownrequire("fs")result.fs["mkdtempSync"](prefix)— computed string-literal property access.const { mkdtempSync } = require("fs"); mkdtempSync(prefix)— destructured binding fromrequire("fs")orrequire("node:fs").- ESM namespace imports:
import * as fs from "fs"; fs.mkdtempSync(prefix). - ESM named imports:
import { mkdtempSync } from "fs"; mkdtempSync(prefix).
Out of scope:
- Objects whose
requiresource is not the Nodefs/node:fsmodule (e.g.mockFs.mkdtempSync,storage.mkdtempSync, orconst fs = require("mock-fs"); fs.mkdtempSync). - Other
fsmethods such asmkdirSync— userequire-mkdirsync-try-catch; userequire-fs-io-try-catchforstatSync,readdirSync,copyFileSync,unlinkSync, andrenameSync. try { ... } finally { ... }without acatchclause is still flagged.
Known limitation — no autofix for VariableDeclaration: when the flagged fs.mkdtempSync(...) appears as the initializer of a variable declaration (const tmpDir = fs.mkdtempSync(prefix)), the rule reports the error but emits no autofix suggestion. Wrapping the declaration in try { ... } catch { ... } would move subsequent uses of tmpDir outside the try block, leaving them referencing an undeclared binding. Only ExpressionStatement and ReturnStatement positions receive an autofix suggestion.
Safe alternative:
try {
const tmpDir = fs.mkdtempSync(prefix);
// use tmpDir here
} catch (err) {
throw new Error("fs.mkdtempSync failed: " + (err instanceof Error ? err.message : String(err)), { cause: err });
}Require fs.realpathSync calls to be wrapped in try/catch.
Why: realpathSync throws synchronously when the target path is missing, permissions are denied, or a symlink cycle is encountered. Wrapping the call preserves call-site-specific error context and ensures path containment checks are not skipped on failure.
Detected forms:
fs.realpathSync(path)— direct call on a knownrequire("fs")result.fs["realpathSync"](path)— computed string-literal property access.const { realpathSync } = require("fs"); realpathSync(path)— destructured binding fromrequire("fs")orrequire("node:fs").- ESM namespace imports:
import * as fs from "fs"; fs.realpathSync(path). - ESM named imports:
import { realpathSync } from "fs"; realpathSync(path).
Out of scope:
- Objects whose
requiresource is not the Nodefs/node:fsmodule. - Calls already inside a
tryblock with acatchclause. try { ... } finally { ... }without acatchclause is still flagged.
Known limitation — no autofix for VariableDeclaration: when the flagged call appears as a variable initializer, the rule reports the error but emits no autofix suggestion. Only ExpressionStatement and ReturnStatement positions receive an autofix suggestion.
Safe alternative:
try {
const resolved = fs.realpathSync(path);
} catch (err) {
throw new Error("fs.realpathSync failed: " + (err instanceof Error ? err.message : String(err)), { cause: err });
}Require new URL(variable) calls to be wrapped in try/catch.
Require decodeURIComponent(...) and decodeURI(...) on dynamic input to be wrapped in try/catch.
Malformed percent-encoded input throws URIError and can crash the action if left unhandled.
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.
Why the exclusion does not extend to log / info / debug
The risk above comes from the stream change (stderr → stdout), not from workflow commands as such. console.log, console.info, and console.debug already write to process.stdout (console.debug is an alias of console.log in Node.js), and their replacements also write to process.stdout — core.info writes a plain message, core.debug writes a ::debug:: workflow command. Since both sides use the same stream, the substitution cannot move output onto a stdio protocol channel that was previously clean: a process that owns stdout for framing is already corrupting it by calling console.log at all. No stdio-owning file exclusion is therefore applied to these three methods.
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"). execSync(`git checkout ${branch}`.trim())— chained string-normalizing methods (trim,trimStart,trimEnd,toLowerCase,toUpperCase,toLocaleLowerCase,toLocaleUpperCase,replace,replaceAll,normalize) are unwrapped before the check.execSync("git checkout PLACEHOLDER".replace("PLACEHOLDER", () => branch))— a.replace()/.replaceAll()replacer callback's return value is also inspected.
Not flagged:
- Fully static command strings (
"git status",`git status`, and fully static+concatenations), including when a string-normalizing method is chained onto them. 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.
Require child processes created with async spawn() to register an 'error' event listener.
Why: when spawn() cannot launch the executable (for example ENOENT or EACCES), Node emits an 'error' event on the returned ChildProcess instead of throwing synchronously. Without an attached listener, that event is unhandled and crashes the action.
Detected forms (when bound to child_process / node:child_process):
const { spawn } = require("child_process"); const child = spawn(...)const cp = require("child_process"); const child = cp.spawn(...)import { spawn } from "child_process"; const child = spawn(...)
The rule then looks for child.on("error", ...) or child.once("error", ...) on that same variable anywhere it is referenced, including nested callbacks in the same file.
Out of scope:
- Assignment-expression forms such as
child = spawn(...) - Inline chains such as
spawn(...).on("error", ...) - Passing the child process to a helper function that registers the listener later
spawnidentifiers that are not bound to Node'schild_processmodule
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.exec.exec(`git checkout ${branchName}`.trim())— chained string-normalizing methods (trim,trimStart,trimEnd,toLowerCase,toUpperCase,toLocaleLowerCase,toLocaleUpperCase,replace,replaceAll,normalize) are unwrapped before the check.exec.exec("git checkout PLACEHOLDER".replace("PLACEHOLDER", () => branchName))— a.replace()/.replaceAll()replacer callback's return value is also inspected.
Not flagged:
- Static command strings, including string concatenation of only static expressions and chained string-normalizing methods on them.
- 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 typeof <x>.message === "string" ? <x>.message : String(<container>) when the fallback stringifies a different container object instead of the .message value itself.
Why: when .message exists but is non-string (for example an object), stringifying the container often yields "[object Object]" and loses the intended message value.
Flagged form:
return typeof err.message === "string" ? err.message : String(err);Safe alternative:
return typeof err.message === "string" ? err.message : String(err.message);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.
Disallow comparing two JSON.stringify() results with an equality operator (===, !==, ==, !=). JSON.stringify() output depends on object key insertion order, so two deeply-equal objects built with keys inserted in a different order serialize to different strings and are reported as unequal.
Flagged form:
return JSON.stringify(normalizedLeft) === JSON.stringify(normalizedRight);Safe alternative:
return deepEqual(normalizedLeft, normalizedRight);Not flagged:
JSON.stringify(value) === "{}"— only one operand is aJSON.stringify()call, so no key-order ambiguity exists.- Non-equality operators such as
<,>or+. - A locally shadowed
JSONbinding.
Disallow JSON.stringify() directly on Set and Map instances. Their entries are not own enumerable properties, so serialization silently produces {}.
Flagged form:
const serverNames = new Set(["api", "web"]);
JSON.stringify(serverNames);Safe alternatives:
JSON.stringify([...serverNames]);
JSON.stringify(Object.fromEntries(cache));Require NaN validation after parsing a value selected from split(...)[index]. A malformed delimited string can otherwise silently pass NaN to downstream API calls.
Flagged form:
const discussionNumber = parseInt(endpoint.split(":")[1], 10);
getDiscussionNodeId(owner, repo, discussionNumber);Safe alternative:
const discussionNumber = parseInt(endpoint.split(":")[1], 10);
if (Number.isNaN(discussionNumber)) throw new Error("invalid discussion number");
getDiscussionNodeId(owner, repo, discussionNumber);Require errors thrown in files that import error_codes.cjs to include an imported standardized ERR_* code. This keeps error-code coverage consistent for log and dashboard filtering.
Flagged form:
const { ERR_API } = require("./error_codes.cjs");
throw new Error("failed to fetch");Safe alternative:
const { ERR_API } = require("./error_codes.cjs");
throw new Error(`${ERR_API}: failed to fetch`);In files that already import ./error_codes.cjs, require throw new Error(...) messages to include a standardized code when an earlier call in the same function uses githubClient.rest.*, .paginate(...), or .graphql(...).
Flagged form:
const { ERR_API } = require("./error_codes.cjs");
await githubClient.rest.pulls.get({ owner, repo, pull_number });
throw new Error("failed to fetch pull request");Safe alternative:
const { ERR_API } = require("./error_codes.cjs");
await githubClient.rest.pulls.get({ owner, repo, pull_number });
throw new Error(`${ERR_API}: failed to fetch pull request`);Require validation of new Date(...) and Date.parse(...) results before relational comparisons. Invalid dates and NaN timestamps compare as neither greater nor less than other values, silently defeating time-window checks.
Flagged form:
const createdAt = new Date(run.created_at);
if (createdAt < cutoff) archive(run);Safe alternative:
const createdAt = new Date(run.created_at);
if (Number.isNaN(createdAt.getTime())) throw new Error("invalid created_at");
if (createdAt < cutoff) archive(run);Require execSync, execFileSync, and spawnSync calls from child_process to use a positive timeout. Without one, a hung child process can block the action until the job-level timeout kills it.
Flagged form:
const { execSync } = require("child_process");
execSync("git status");Safe alternative:
const { execSync } = require("child_process");
execSync("git status", { timeout: 5_000 });Require module-scoped global or sticky regexes to reset .lastIndex before a while ((match = RE.exec(text))) loop. Stateful regexes otherwise resume from a previous invocation and can skip matches.
Flagged form:
const RE = /foo/g;
while ((match = RE.exec(text)) !== null) process(match);Safe alternative:
const RE = /foo/g;
RE.lastIndex = 0;
while ((match = RE.exec(text)) !== null) process(match);Require a numeric page counter immediately preceding a terminating while (true) pagination loop to be incremented or reassigned when used by the loop.
Flagged form:
let page = 1;
while (true) {
const { data } = await github.rest.issues.listComments({ page });
if (data.length === 0) break;
}Safe alternative:
let page = 1;
while (true) {
const { data } = await github.rest.issues.listComments({ page });
if (data.length === 0) break;
page++;
}Require the response object passed to http.request() / http.get() / https.request() / https.get() callbacks to register an 'error' event listener.
Why: Node emits 'error' on the IncomingMessage (the response) — not on the request — for socket-level failures that occur while the body is streamed, such as reset connections, decompression failures, or aborted sockets. A req.on("error", ...) listener does not catch those, so the unhandled response 'error' event becomes an uncaught exception that crashes the action.
Flagged form:
const http = require("http");
const req = http.request(options, res => {
let data = "";
res.on("data", chunk => {
data += chunk;
});
res.on("end", () => resolve(data));
});
req.on("error", reject);Safe alternative:
const http = require("http");
const req = http.request(options, res => {
let data = "";
res.on("data", chunk => {
data += chunk;
});
res.on("end", () => resolve(data));
res.on("error", reject);
});
req.on("error", reject);Out of scope:
http/httpsidentifiers that are not statically bound throughrequire("http")/require("https")/require("node:http")/require("node:https"), including bindings created by a locally shadowedrequireor reassigned after initialization- Request calls without a response callback, or callbacks whose response parameter is destructured
fetch-based HTTP calls (covered byrequire-fetch-try-catchandrequire-fetch-timeout) and non-standard HTTP client libraries
Require the exitCode returned by @actions/exec's getExecOutput() or the exit code returned by exec() to be read (destructured, accessed, or captured) whenever the call passes { ignoreReturnCode: true }.
Why: getExecOutput() and exec() throw automatically on a non-zero exit code by default. Passing ignoreReturnCode: true suppresses that behavior, making the caller solely responsible for detecting failure. Discarding exitCode or the returned exit code (e.g. only destructuring { stdout } from getExecOutput(), or a bare await exec.exec(...) statement whose returned exit code is never captured) silently swallows command failures — the action proceeds with empty or stale output as if the command had succeeded.
Flagged form:
const { stdout } = await exec.getExecOutput("git", ["diff", "--name-only"], { ignoreReturnCode: true });
return stdout.split("\n");
await exec.exec("git", ["diff", "--exit-code", "."], { ignoreReturnCode: true });Safe alternative:
const { stdout, exitCode } = await exec.getExecOutput("git", ["diff", "--name-only"], { ignoreReturnCode: true });
if (exitCode !== 0) {
throw new Error(`git diff failed with exit code ${exitCode}`);
}
return stdout.split("\n");
const exitCode = await exec.exec("git", ["diff", "--exit-code", "."], { ignoreReturnCode: true });
if (exitCode !== 0) {
throw new Error(`git diff failed with exit code ${exitCode}`);
}Out of scope:
- Calls without
ignoreReturnCode: truein a statically-inspectable options object (the default throw-on-failure behavior already surfaces failures) - Options whose
ignoreReturnCodevalue can't be statically resolved: a non-object-literal identifier (options), an options literal built only from spreads ({ ...opts }), or one where a spread follows the flag ({ ignoreReturnCode: true, ...opts }). An explicitignoreReturnCode: truewritten after a spread ({ ...opts, ignoreReturnCode: true }) is resolvable and still checked - Results forwarded to a helper function that checks
exitCodeinternally, or destructured into an array pattern
Prefer @actions/exec's exec() / getExecOutput() over child_process's exec(), execSync(), execFile(), and execFileSync() to spawn processes.
Why: modules loaded by actions/github-script steps already provide the @actions/exec toolkit (bound to exec and passed through by setupGlobals) without needing an extra dependency. child_process.exec() / execSync() / execFile() / execFileSync() all run a command to completion and return or capture its output — exactly what @actions/exec's exec() and getExecOutput() already do, with consistent GitHub Actions logging, cross-platform argument handling, and (for getExecOutput()) throw-on-non-zero-exit behavior built in.
Flagged form:
const { execFileSync } = require("child_process");
const branch = execFileSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], { encoding: "utf8" }).trim();Safe alternative:
const { stdout } = await exec.getExecOutput("git", ["rev-parse", "--abbrev-ref", "HEAD"]);
const branch = stdout.trim();promisify()-wrapped bindings are resolved too, so const execAsync = promisify(exec); await execAsync("git status"); is flagged as well. Since a promisified exec() / execFile() resolves to the captured output rather than a ChildProcess handle, the handle-retention exemption below does not apply to those calls.
Scope: only files carrying the /// <reference types="@actions/github-script" /> triple-slash reference are checked. That marker is how actions/setup/js identifies modules loaded by actions/github-script steps, which are the only ones guaranteed to have the exec global. The directory also contains standalone Node entry points (for example the mcp-scripts MCP server) and the modules they load; those processes never get setupGlobals()-injected toolkit globals — shim.cjs only backfills core and context, not exec — so they carry no marker and the rule stays silent there.
Out of scope:
child_process.spawn()andchild_process.spawnSync()— used for long-running, detached, or interactively-streamed processes (background servers, sidecars, and similar) for which@actions/exechas no equivalent, sinceexec()/getExecOutput()always wait for the command to finish before resolvingexec()/execFile()calls that retain the returnedChildProcesshandle (used as a value: assigned, returned, member-accessed, passed to another call, ...) — those callers can write tochild.stdin, streamchild.stdout, or manage the process lifecycle, which@actions/execcannot express; only calls whose result is discarded (pure callback style) are flagged- Calls to
exec/execSync/execFile/execFileSyncfrom any module other thanchild_process(ornode:child_process)
Disallow async callbacks passed to Array.prototype.forEach(). forEach() ignores each callback's returned promise, so the work is not sequenced and rejections can go unhandled.
Flagged form:
items.forEach(async item => {
await processItem(item);
});Safe alternatives:
for (const item of items) {
await processItem(item);
}
await Promise.all(items.map(async item => processItem(item)));Disallow String.prototype.replace() with a single-character string pattern, which only replaces the first occurrence. Use replaceAll() or a global regular expression when every occurrence should be replaced.
Flagged form:
const normalized = value.replace("_", "-");Safe alternative:
const normalized = value.replaceAll("_", "-");Disallow async callbacks passed to setTimeout(), setInterval(), or setImmediate() unless the entire callback body is wrapped in try/catch. These timer functions discard the callback's returned promise, so uncaught errors become unhandled rejections.
Require validation of dynamic values passed to new Date(...) before calling toISOString() or toJSON(), since invalid dates throw a RangeError. Validate the input with Number.isFinite() / Number.isNaN() or wrap the serialization in try/catch.