eslint-plugin-github-action
Version:
Rules for consistent, readable and valid GitHub action files.
1,415 lines (1,384 loc) • 39.5 kB
JavaScript
import * as parserYAML from 'yaml-eslint-parser';
import { isString, isNonEmptyString, isNumber, isInteger } from '@ntnyq/utils';
import { snakeCase, trainCase, capitalCase, pascalCase, kebabCase, camelCase } from 'uncase';
const recommended = [
{
name: "github-action/recommended",
files: ["**/.github/workflows/*.y?(a)ml"],
ignores: ["!**/.github/workflows/*.y?(a)ml"],
plugins: {
/* v8 ignore start */
get "github-action"() {
return plugin;
}
/* v8 ignore stop */
},
languageOptions: {
parser: parserYAML
},
rules: {
"github-action/no-invalid-key": "error",
"github-action/prefer-file-extension": "error",
"github-action/require-action-name": "error",
"github-action/valid-trigger-events": "error",
"github-action/valid-timeout-minutes": "error"
}
}
];
const configs = {
recommended
};
const name = "eslint-plugin-github-action";
const version = "0.0.16";
const meta = {
name,
version
};
function isYAMLScalar(value) {
return value?.type === "YAMLScalar";
}
function isYAMLMapping(value) {
return value?.type === "YAMLMapping";
}
function isObjectNotArray(obj) {
return typeof obj === "object" && obj != null && !Array.isArray(obj);
}
function deepMerge(first = {}, second = {}) {
const keys = new Set(Object.keys(first).concat(Object.keys(second)));
return Array.from(keys).reduce((acc, key) => {
const firstHasKey = key in first;
const secondHasKey = key in second;
const firstValue = first[key];
const secondValue = second[key];
if (firstHasKey && secondHasKey) {
if (isObjectNotArray(firstValue) && isObjectNotArray(secondValue)) {
acc[key] = deepMerge(firstValue, secondValue);
} else {
acc[key] = secondValue;
}
} else if (firstHasKey) {
acc[key] = firstValue;
} else {
acc[key] = secondValue;
}
return acc;
}, {});
}
const CASING = {
camelCase: "camelCase",
kebabCase: "kebab-case",
pascalCase: "PascalCase",
snakeCase: "snake_case",
titleCase: "Title Case",
trainCase: "Train-Case",
screamingSnakeCase: "SCREAMING_SNAKE_CASE"
};
const convertersMap = {
camelCase,
"kebab-case": kebabCase,
PascalCase: pascalCase,
snake_case: snakeCase,
"Title Case": capitalCase,
"Train-Case": trainCase,
SCREAMING_SNAKE_CASE: (str) => snakeCase(str).toUpperCase()
};
function getExactConverter(caseType) {
const convert = convertersMap[caseType];
return (source) => {
const value = convert(source);
const changed = value !== source;
return {
value,
changed
};
};
}
function createRule({
create,
defaultOptions,
meta
}) {
return {
create: (context) => {
const optionsCount = Math.max(
context.options.length,
defaultOptions.length
);
const optionsWithDefault = Array.from(
{ length: optionsCount },
(_, i) => {
if (isObjectNotArray(context.options[i]) && isObjectNotArray(defaultOptions[i])) {
return deepMerge(defaultOptions[i], context.options[i]);
}
return context.options[i] ?? defaultOptions[i];
}
);
return create(context, optionsWithDefault);
},
defaultOptions,
meta: {
...meta,
defaultOptions
}
};
}
function RuleCreator(urlCreator) {
return function createNamedRule({
name,
meta,
...rule
}) {
return createRule({
meta: {
...meta,
docs: {
...meta.docs,
url: urlCreator(name)
}
},
...rule
});
};
}
const createESLintRule = RuleCreator(
(ruleName) => `https://eslint-plugin-github-action.ntnyq.com/rules/${ruleName}.html`
);
function resolveOptions(options, defaultOptions) {
return options?.[0] || defaultOptions;
}
const RULE_NAME$e = "action-name-casing";
const allowedCaseOptions$1 = [
CASING.camelCase,
CASING.kebabCase,
CASING.pascalCase,
CASING.snakeCase,
CASING.titleCase,
CASING.trainCase,
CASING.screamingSnakeCase
];
const defaultOptions$5 = CASING.titleCase;
const actionNameCasing = createESLintRule({
name: RULE_NAME$e,
meta: {
type: "suggestion",
docs: {
recommended: false,
description: "enforce naming convention to action name."
},
fixable: "code",
schema: [
{
description: "Casing type for action name.",
anyOf: [
{
type: "string",
description: "Casing type for action name.",
enum: allowedCaseOptions$1
},
{
type: "object",
description: "Casing type for job id.",
properties: {
"kebab-case": {
type: "boolean"
},
camelCase: {
type: "boolean"
},
PascalCase: {
type: "boolean"
},
snake_case: {
type: "boolean"
},
"Title Case": {
type: "boolean"
},
"Train-Case": {
type: "boolean"
},
SCREAMING_SNAKE_CASE: {
type: "boolean"
},
ignores: {
type: "array",
description: "action name patterns to ignore.",
items: {
type: "string"
},
uniqueItems: true,
additionalItems: false
}
},
additionalProperties: false
}
]
}
],
messages: {
actionNameNotMatch: `Action name '{{name}}' is not in {{caseTypes}}.`
}
},
defaultOptions: [defaultOptions$5],
create(context) {
const rawOptions = resolveOptions(context.options, defaultOptions$5);
const caseTypes = [];
const ignores = isString(rawOptions) ? [] : (rawOptions.ignores || []).map((ignore) => new RegExp(ignore));
if (isString(rawOptions)) {
caseTypes.push(
allowedCaseOptions$1.includes(rawOptions) ? rawOptions : defaultOptions$5
);
} else {
caseTypes.push(
...Object.keys(rawOptions).filter(
(key) => allowedCaseOptions$1.includes(key)
).filter((key) => rawOptions[key])
);
if (caseTypes.length === 0) {
caseTypes.push(defaultOptions$5);
}
}
const converters = caseTypes.map((caseType) => getExactConverter(caseType));
function isActionNameValid(name) {
if (ignores.some((regex) => regex.test(name))) return true;
return converters.some((converter) => !converter(name).changed);
}
return {
"Program > YAMLDocument > YAMLMapping > YAMLPair[key.value=name]": (node) => {
if (isYAMLScalar(node.value) && isNonEmptyString(node.value.value)) {
const name = node.value.value;
const range = node.value.range;
if (!isActionNameValid(name)) {
context.report({
node: node.value,
messageId: "actionNameNotMatch",
loc: node.value.loc,
data: {
name,
caseTypes: caseTypes.join(", ")
},
fix: caseTypes.length === 1 ? (fixer) => {
const result = getExactConverter(caseTypes[0])(name);
return fixer.replaceTextRange(range, result.value);
} : void 0
});
}
}
}
};
}
});
const RULE_NAME$d = "job-id-casing";
const allowedCaseOptions = [
CASING.camelCase,
CASING.kebabCase,
CASING.pascalCase,
CASING.snakeCase,
CASING.trainCase,
CASING.screamingSnakeCase
];
const defaultOptions$4 = CASING.kebabCase;
const jobIdCasing = createESLintRule({
name: RULE_NAME$d,
meta: {
type: "suggestion",
docs: {
recommended: false,
description: "enforce naming convention to job id."
},
schema: [
{
description: "Casing type for job id.",
anyOf: [
{
type: "string",
description: "Casing type for job id.",
enum: allowedCaseOptions
},
{
type: "object",
description: "Casing type for job id.",
properties: {
"kebab-case": {
type: "boolean"
},
camelCase: {
type: "boolean"
},
PascalCase: {
type: "boolean"
},
snake_case: {
type: "boolean"
},
"Train-Case": {
type: "boolean"
},
SCREAMING_SNAKE_CASE: {
type: "boolean"
},
ignores: {
type: "array",
description: "Job id patterns to ignore.",
items: {
type: "string"
},
uniqueItems: true,
additionalItems: false
}
},
additionalProperties: false
}
]
}
],
messages: {
jobIdNotMatch: `Job id '{{id}}' is not in {{caseTypes}}.`
}
},
defaultOptions: [defaultOptions$4],
create(context) {
const rawOptions = resolveOptions(context.options, defaultOptions$4);
const caseTypes = [];
const ignores = isString(rawOptions) ? [] : (rawOptions.ignores || []).map((ignore) => new RegExp(ignore));
if (isString(rawOptions)) {
caseTypes.push(
allowedCaseOptions.includes(rawOptions) ? rawOptions : defaultOptions$4
);
} else {
caseTypes.push(
...Object.keys(rawOptions).filter(
(key) => allowedCaseOptions.includes(key)
).filter((key) => rawOptions[key])
);
if (caseTypes.length === 0) {
caseTypes.push(defaultOptions$4);
}
}
const converters = caseTypes.map((caseType) => getExactConverter(caseType));
function isJobIdValid(id) {
if (ignores.some((regex) => regex.test(id))) return true;
return converters.some((converter) => !converter(id).changed);
}
return {
"Program > YAMLDocument > YAMLMapping > YAMLPair[key.value=jobs] > YAMLMapping > YAMLPair": (node) => {
if (isYAMLScalar(node.key) && isNonEmptyString(node.key.value)) {
const id = node.key.value;
if (!isJobIdValid(id)) {
context.report({
node: node.key,
messageId: "jobIdNotMatch",
loc: node.key.loc,
data: {
id,
caseTypes: caseTypes.join(", ")
}
});
}
}
}
};
}
});
const RULE_NAME$c = "max-jobs-per-action";
const defaultOptions$3 = 3;
const maxJobsPerAction = createESLintRule({
name: RULE_NAME$c,
meta: {
type: "suggestion",
docs: {
recommended: false,
description: "enforce maximum jobs per action file."
},
schema: [
{
type: "integer",
description: "Max jobs per file.",
minimum: 1
}
],
messages: {
toManyJobs: "There are {{count}} jobs, maximum allowed is {{limit}}."
}
},
defaultOptions: [defaultOptions$3],
create(context) {
const optionLimit = resolveOptions(context.options, defaultOptions$3);
const limit = optionLimit >= 1 ? optionLimit : defaultOptions$3;
return {
"Program > YAMLDocument > YAMLMapping > YAMLPair[key.value=jobs] > YAMLMapping": (node) => {
const count = node.pairs.length;
if (count > limit) {
context.report({
node,
messageId: "toManyJobs",
data: {
count,
limit
}
});
}
}
};
}
});
const RULE_NAME$b = "no-external-job";
const noExternalJob = createESLintRule({
name: RULE_NAME$b,
meta: {
type: "suggestion",
docs: {
recommended: false,
description: "disallow using external job."
},
schema: [],
messages: {
disallowExternalJob: "Disallow using external job."
}
},
defaultOptions: [],
create(context) {
return {
"Program > YAMLDocument > YAMLMapping > YAMLPair[key.value=jobs] > YAMLMapping > YAMLPair > YAMLMapping > YAMLPair[key.value=uses]": (node) => {
context.report({
node,
loc: node.loc,
messageId: "disallowExternalJob"
});
}
};
}
});
const validTopLevelKeys = [
"name",
"run-name",
"on",
"permissions",
"env",
"defaults",
"concurrency",
"jobs"
];
const validJobKeys = [
"name",
"permissions",
"needs",
"if",
"runs-on",
"environment",
"concurrency",
"outputs",
"env",
"defaults",
"steps",
"timeout-minutes",
"strategy",
"continue-on-error",
"container",
"services",
"uses",
"with",
"secrets"
];
const validStepKeys = [
"id",
"if",
"name",
"uses",
"run",
"working-directory",
"shell",
"with",
"env",
"continue-on-error",
"timeout-minutes"
];
const validServiceKeys = [
"image",
"credentials",
"env",
"ports",
"volumes",
"options"
];
const validContainerKeys = [...validServiceKeys];
const validStrategyKeys = ["matrix", "fail-fast", "max-parallel"];
function getPairKeyValue(pair) {
if (!isYAMLScalar(pair.key)) return;
if (!isNonEmptyString(pair.key.value)) return;
return pair.key.value;
}
const RULE_NAME$a = "no-invalid-key";
const noInvalidKey = createESLintRule({
name: RULE_NAME$a,
meta: {
type: "suggestion",
docs: {
recommended: true,
description: "disallow using invalid key."
},
schema: [],
messages: {
invalidTopLevelKey: "invalid top-level key `{{key}}`.",
invalidJobKey: "invalid job key `{{key}}`.",
invalidStepKey: "invalid step key `{{key}}`.",
invalidStrategyKey: "invalid strategy key `{{key}}`.",
invalidContainerKey: "invalid container key `{{key}}`.",
invalidServiceKey: "invalid service key `{{key}}`."
}
},
defaultOptions: [],
create(context) {
return {
"Program > YAMLDocument > YAMLMapping": (node) => {
validateMappingKeys({
context,
node,
validKeys: validTopLevelKeys,
messageId: "invalidTopLevelKey"
});
},
// checks all jobs keys
"Program > YAMLDocument > YAMLMapping > YAMLPair[key.value=jobs] > YAMLMapping > YAMLPair > YAMLMapping": (node) => {
validateMappingKeys({
context,
node,
validKeys: validJobKeys,
messageId: "invalidJobKey"
});
},
// checks all jobs strategy keys
"Program > YAMLDocument > YAMLMapping > YAMLPair[key.value=jobs] > YAMLMapping > YAMLPair > YAMLMapping > YAMLPair[key.value=strategy] > YAMLMapping": (node) => {
validateMappingKeys({
context,
node,
validKeys: validStrategyKeys,
messageId: "invalidStrategyKey"
});
},
// checks all jobs container keys
"Program > YAMLDocument > YAMLMapping > YAMLPair[key.value=jobs] > YAMLMapping > YAMLPair > YAMLMapping > YAMLPair[key.value=container] > YAMLMapping": (node) => {
validateMappingKeys({
context,
node,
validKeys: validContainerKeys,
messageId: "invalidContainerKey"
});
},
// checks all steps services keys
"Program > YAMLDocument > YAMLMapping > YAMLPair[key.value=jobs] > YAMLMapping > YAMLPair > YAMLMapping > YAMLPair[key.value=services] > YAMLMapping > YAMLPair > YAMLMapping": (node) => {
validateMappingKeys({
context,
node,
validKeys: validServiceKeys,
messageId: "invalidServiceKey"
});
},
// checks all steps keys
"Program > YAMLDocument > YAMLMapping > YAMLPair[key.value=jobs] > YAMLMapping > YAMLPair > YAMLMapping > YAMLPair[key.value=steps] > YAMLSequence > YAMLMapping": (node) => {
validateMappingKeys({
context,
node,
validKeys: validStepKeys,
messageId: "invalidStepKey"
});
}
};
}
});
function validateMappingKeys({
context,
node,
validKeys,
messageId
}) {
for (const pair of node.pairs) {
const pairKeyValue = getPairKeyValue(pair);
if (
// key.value is not a string
!pairKeyValue || validKeys.includes(pairKeyValue)
) {
continue;
}
context.report({
node,
loc: pair.key?.loc,
messageId,
data: {
key: pairKeyValue
}
});
}
}
const RULE_NAME$9 = "no-top-level-env";
const noTopLevelEnv = createESLintRule({
name: RULE_NAME$9,
meta: {
type: "suggestion",
docs: {
recommended: false,
description: "disallow using top level env."
},
schema: [],
messages: {
disallowTopLevelEnv: "Disallow using top level env."
}
},
defaultOptions: [],
create(context) {
return {
"Program > YAMLDocument > YAMLMapping > YAMLPair[key.value=env]": (node) => {
context.report({
node,
loc: node.loc,
messageId: "disallowTopLevelEnv"
});
}
};
}
});
const RULE_NAME$8 = "no-top-level-permissions";
const noTopLevelPermissions = createESLintRule({
name: RULE_NAME$8,
meta: {
type: "suggestion",
docs: {
recommended: false,
description: "disallow using top level permissions."
},
schema: [],
messages: {
disallowTopLevelPermissions: "Disallow using top level permissions."
}
},
defaultOptions: [],
create(context) {
return {
"Program > YAMLDocument > YAMLMapping > YAMLPair[key.value=permissions]": (node) => {
context.report({
node,
loc: node.loc,
messageId: "disallowTopLevelPermissions"
});
}
};
}
});
/**
* @copyright https://github.com/ota-meshi/eslint-plugin-yml
* @license MIT {@link https://github.com/ota-meshi/eslint-plugin-yml/blob/master/LICENSE}
*/
const actionExtension = {
yml: "yml",
yaml: "yaml"
};
const RULE_NAME$7 = "prefer-file-extension";
const allowedExtensionOptions = [
actionExtension.yml,
actionExtension.yaml
];
const defaultOptions$2 = actionExtension.yml;
const preferFileExtension = createESLintRule({
name: RULE_NAME$7,
meta: {
type: "suggestion",
docs: {
recommended: true,
description: "enforce action file extension."
},
schema: [
{
description: "Action file extension.",
anyOf: [
{
type: "string",
description: "Action file extension.",
enum: allowedExtensionOptions
},
{
type: "object",
description: "Action file extension.",
properties: {
extension: {
type: "string",
enum: allowedExtensionOptions
},
caseSensitive: {
type: "boolean"
}
},
additionalProperties: false
}
]
}
],
messages: {
unexpected: "Expected extension `{{expected}}`, but got `{{actual}}`."
}
},
defaultOptions: [defaultOptions$2],
create(context) {
const rawOptions = resolveOptions(context.options, defaultOptions$2);
const extension = isString(rawOptions) ? rawOptions : rawOptions.extension ?? defaultOptions$2;
const caseSensitive = isString(rawOptions) ? true : rawOptions.caseSensitive;
return {
Program: (node) => {
const filename = context.getFilename();
const fileExtension = filename.split(".").pop();
if (!filename.includes(".") || !fileExtension) return;
if ((caseSensitive ? fileExtension : fileExtension.toLowerCase()) === extension) {
return;
}
context.report({
node,
loc: node.loc,
messageId: "unexpected",
data: {
expected: extension,
actual: fileExtension
}
});
}
};
}
});
const UsesStyle = {
commit: "commit",
release: "release",
branch: "branch"
};
const RULE_NAME$6 = "prefer-step-uses-style";
const allowedStyleOptions = [
UsesStyle.release,
UsesStyle.commit,
UsesStyle.branch
];
const defaultOptions$1 = UsesStyle.release;
const preferStepUsesStyle = createESLintRule({
name: RULE_NAME$6,
meta: {
type: "suggestion",
docs: {
recommended: false,
description: "enforce the style of job step uses."
},
schema: [
{
description: "Style for job step uses.",
anyOf: [
{
type: "string",
description: "Style for job step uses.",
enum: allowedStyleOptions
},
{
type: "object",
description: "Style for job step uses.",
properties: {
commit: {
type: "boolean"
},
release: {
type: "boolean"
},
branch: {
type: "boolean"
},
allowRepository: {
type: "boolean"
},
allowDocker: {
type: "boolean"
},
ignores: {
type: "array",
description: "Job id patterns to ignore.",
items: {
type: "string"
},
uniqueItems: true,
additionalItems: false
}
},
additionalProperties: false
}
]
}
],
messages: {
invalidStyle: "Invalid style for job step uses.",
disallowStyle: "Disallow style `{{style}}` for job step uses.",
disallowRepository: "Disallow using same repository action.",
disallowDocker: "Disallow using docker action."
}
},
defaultOptions: [defaultOptions$1],
create(context) {
const rawOptions = resolveOptions(context.options, defaultOptions$1);
const usesStyles = [];
const ignores = isString(rawOptions) ? [] : (rawOptions.ignores || []).map((ignore) => new RegExp(ignore));
const allowRepository = isString(rawOptions) ? false : rawOptions.allowRepository;
const allowDocker = isString(rawOptions) ? false : rawOptions.allowDocker;
if (isString(rawOptions)) {
usesStyles.push(
allowedStyleOptions.includes(rawOptions) ? rawOptions : defaultOptions$1
);
} else {
usesStyles.push(
...Object.keys(rawOptions).filter(
(key) => allowedStyleOptions.includes(key)
).filter((key) => rawOptions[key])
);
if (usesStyles.length === 0) {
usesStyles.push(defaultOptions$1);
}
}
return {
"Program > YAMLDocument > YAMLMapping > YAMLPair[key.value=jobs] > YAMLMapping > YAMLPair > YAMLMapping > YAMLPair[key.value=steps] > YAMLSequence > YAMLMapping > YAMLPair[key.value=uses]": (node) => {
if (!isYAMLScalar(node.value) || !isNonEmptyString(node.value.value)) {
context.report({
node: node.value || node,
messageId: "invalidStyle",
loc: node.value?.loc || node.loc
});
} else {
const usesValue = node.value.value;
if (ignores.some((regex) => regex.test(usesValue))) {
return;
}
const { style, isRepository, isDocker } = parseJobStepUses(usesValue);
if (isRepository) {
if (!allowRepository) {
context.report({
node: node.value,
messageId: "disallowRepository",
loc: node.value.loc
});
}
} else if (isDocker) {
if (!allowDocker) {
context.report({
node: node.value,
messageId: "disallowDocker",
loc: node.value.loc
});
}
} else {
if (!usesStyles.includes(style)) {
context.report({
node: node.value,
messageId: "disallowStyle",
loc: node.value.loc,
data: {
style
}
});
}
}
}
}
};
}
});
function parseJobStepUses(uses) {
const result = {
style: UsesStyle.branch,
isRepository: false,
isDocker: false
};
if (uses.startsWith("./")) {
result.isRepository = true;
} else if (uses.startsWith("docker://")) {
result.isDocker = true;
} else {
const [_, style] = uses.split("@");
if (style.length === 40) {
result.style = UsesStyle.commit;
} else if (style.startsWith("v")) {
result.style = UsesStyle.release;
}
}
return result;
}
const RULE_NAME$5 = "require-action-name";
const requireActionName = createESLintRule({
name: RULE_NAME$5,
meta: {
type: "suggestion",
docs: {
recommended: true,
description: "require a string action name."
},
schema: [],
messages: {
noName: "Require action name to be set.",
invalidName: "Action name must be a non-empty string."
}
},
defaultOptions: [],
create(context) {
return {
YAMLDocument(node) {
if (node.content) return;
context.report({
node,
messageId: "noName"
});
},
"Program > YAMLDocument > YAMLMapping": (node) => {
const namePair = node.pairs.find(
(pair) => isYAMLScalar(pair.key) && pair.key.value === "name"
);
if (!namePair) {
return context.report({
node,
messageId: "noName"
});
}
if (!isYAMLScalar(namePair.value) || !isNonEmptyString(namePair.value.value)) {
return context.report({
node: namePair.value ?? namePair,
loc: namePair.value?.loc ?? namePair.loc,
messageId: "invalidName"
});
}
}
};
}
});
const RULE_NAME$4 = "require-action-run-name";
const requireActionRunName = createESLintRule({
name: RULE_NAME$4,
meta: {
type: "suggestion",
docs: {
recommended: false,
description: "require a string action run-name."
},
schema: [],
messages: {
noName: "Require action run-name to be set.",
invalidName: "Action run-name must be a non-empty string."
}
},
defaultOptions: [],
create(context) {
return {
YAMLDocument(node) {
if (node.content) return;
context.report({
node,
messageId: "noName"
});
},
"Program > YAMLDocument > YAMLMapping": (node) => {
const namePair = node.pairs.find(
(pair) => isYAMLScalar(pair.key) && pair.key.value === "run-name"
);
if (!namePair) {
return context.report({
node,
messageId: "noName"
});
}
if (!isYAMLScalar(namePair.value) || !isNonEmptyString(namePair.value.value)) {
return context.report({
node: namePair.value ?? namePair,
loc: namePair.value?.loc ?? namePair.loc,
messageId: "invalidName"
});
}
}
};
}
});
const RULE_NAME$3 = "require-job-name";
const requireJobName = createESLintRule({
name: RULE_NAME$3,
meta: {
type: "suggestion",
docs: {
recommended: false,
description: "require a string job name."
},
schema: [],
messages: {
noName: "Require job name to be set.",
invalidName: "Job name must be a non-empty string."
}
},
defaultOptions: [],
create(context) {
return {
"Program > YAMLDocument > YAMLMapping > YAMLPair[key.value=jobs] > YAMLMapping > YAMLPair": (node) => {
if (!isYAMLMapping(node.value)) {
return context.report({
node: node.value ?? node,
loc: node.value?.loc ?? node.loc,
messageId: "noName"
});
}
const namePair = node.value.pairs.find(
(pair) => isYAMLScalar(pair.key) && pair.key.value === "name"
);
if (!namePair) {
return context.report({
node,
loc: node.loc,
messageId: "noName"
});
}
if (!isYAMLScalar(namePair.value) || !isNonEmptyString(namePair.value.value)) {
return context.report({
node: namePair.value || namePair,
loc: namePair.value?.loc || namePair.loc,
messageId: "invalidName"
});
}
}
};
}
});
const RULE_NAME$2 = "require-job-step-name";
const requireJobStepName = createESLintRule({
name: RULE_NAME$2,
meta: {
type: "suggestion",
docs: {
recommended: false,
description: "require a string job step name."
},
schema: [],
messages: {
noName: "Require job step name to be set.",
invalidName: "Job step name must be a non-empty string."
}
},
defaultOptions: [],
create(context) {
return {
"Program > YAMLDocument > YAMLMapping > YAMLPair[key.value=jobs] > YAMLMapping > YAMLPair > YAMLMapping > YAMLPair[key.value=steps] > YAMLSequence > YAMLMapping": (node) => {
const namePair = node.pairs.find(
(pair) => isYAMLScalar(pair.key) && pair.key.value === "name"
);
if (!namePair) {
return context.report({
node,
loc: node.loc,
messageId: "noName"
});
}
if (!isYAMLScalar(namePair.value) || !isNonEmptyString(namePair.value.value)) {
return context.report({
node: namePair.value ?? namePair,
loc: namePair.value?.loc || namePair.loc,
messageId: "invalidName"
});
}
}
};
}
});
const TIMEOUT_MINUTES = {
default: 6 * 60,
max: 24 * 60,
min: 1
};
const RULE_NAME$1 = "valid-timeout-minutes";
const defaultOptions = TIMEOUT_MINUTES.default;
const validTimeoutMinutes = createESLintRule({
name: RULE_NAME$1,
meta: {
type: "suggestion",
docs: {
recommended: true,
description: "disallow invalid timeout-minutes."
},
schema: [
{
description: "Options",
anyOf: [
{
type: "integer",
minimum: TIMEOUT_MINUTES.min,
maximum: TIMEOUT_MINUTES.max,
description: "Max value for timeout-minutes"
},
{
type: "object",
description: "Range value for timeout-minutes",
properties: {
min: {
type: "integer",
minimum: TIMEOUT_MINUTES.min,
maximum: TIMEOUT_MINUTES.max,
description: "Min value for timeout-minutes"
},
max: {
type: "integer",
minimum: TIMEOUT_MINUTES.min,
maximum: TIMEOUT_MINUTES.max,
description: "Max value for timeout-minutes"
}
},
additionalProperties: false
},
{
type: "object",
description: "Range value for timeout-minutes",
properties: {
job: {
anyOf: [
{
type: "integer",
minimum: TIMEOUT_MINUTES.min,
maximum: TIMEOUT_MINUTES.max,
description: "Max value for job timeout-minutes"
},
{
type: "object",
description: "Range value for job timeout-minutes",
properties: {
min: {
type: "integer",
minimum: TIMEOUT_MINUTES.min,
maximum: TIMEOUT_MINUTES.max,
description: "Min value for job timeout-minutes"
},
max: {
type: "integer",
minimum: TIMEOUT_MINUTES.min,
maximum: TIMEOUT_MINUTES.max,
description: "Max value for job timeout-minutes"
}
},
additionalProperties: false
}
]
},
step: {
anyOf: [
{
type: "integer",
minimum: TIMEOUT_MINUTES.min,
maximum: TIMEOUT_MINUTES.max,
description: "Max value for step timeout-minutes"
},
{
type: "object",
description: "Range value for step timeout-minutes",
properties: {
min: {
type: "integer",
minimum: TIMEOUT_MINUTES.min,
maximum: TIMEOUT_MINUTES.max,
description: "Min value for step timeout-minutes"
},
max: {
type: "integer",
minimum: TIMEOUT_MINUTES.min,
maximum: TIMEOUT_MINUTES.max,
description: "Max value for step timeout-minutes"
}
},
additionalProperties: false
}
]
}
},
additionalProperties: false
}
]
}
],
messages: {
notInteger: "Timeout-minutes should be a positive integer.",
invalidRange: "Timeout-minutes range should be {{min}}-{{max}}."
}
},
defaultOptions: [defaultOptions],
create(context) {
const rawOptions = resolveOptions(context.options, defaultOptions);
const jobTimeoutMinutes = {
min: TIMEOUT_MINUTES.min,
max: TIMEOUT_MINUTES.max
};
const stepTimeoutMinutes = {
min: TIMEOUT_MINUTES.min,
max: TIMEOUT_MINUTES.max
};
if (isNumber(rawOptions)) {
jobTimeoutMinutes.max = rawOptions;
stepTimeoutMinutes.max = rawOptions;
} else {
if ("min" in rawOptions && rawOptions.min) {
jobTimeoutMinutes.min = rawOptions.min;
stepTimeoutMinutes.min = rawOptions.min;
}
if ("max" in rawOptions && rawOptions.max) {
jobTimeoutMinutes.max = rawOptions.max;
stepTimeoutMinutes.max = rawOptions.max;
}
if ("job" in rawOptions && rawOptions.job) {
if (isNumber(rawOptions.job)) {
jobTimeoutMinutes.max = rawOptions.job;
} else {
jobTimeoutMinutes.min = rawOptions.job.min || jobTimeoutMinutes.min;
jobTimeoutMinutes.max = rawOptions.job.max || jobTimeoutMinutes.max;
}
}
if ("step" in rawOptions && rawOptions.step) {
if (isNumber(rawOptions.step)) {
stepTimeoutMinutes.max = rawOptions.step;
} else {
stepTimeoutMinutes.min = rawOptions.step.min || stepTimeoutMinutes.min;
stepTimeoutMinutes.max = rawOptions.step.max || stepTimeoutMinutes.max;
}
}
}
function validateTimeoutMinutes(node, range) {
if (!isYAMLScalar(node.value)) {
return;
}
if (!isNumber(node.value.value) || !isInteger(node.value.value) || node.value.value <= 0) {
return context.report({
messageId: "notInteger",
node,
loc: node.loc
});
}
if (node.value.value < range.min || node.value.value > range.max) {
return context.report({
messageId: "invalidRange",
node,
loc: node.loc,
data: {
min: range.min,
max: range.max
}
});
}
}
return {
"Program > YAMLDocument > YAMLMapping > YAMLPair[key.value=jobs] > YAMLMapping > YAMLPair > YAMLMapping > YAMLPair[key.value=timeout-minutes]": (node) => {
return validateTimeoutMinutes(node, jobTimeoutMinutes);
},
"Program > YAMLDocument > YAMLMapping > YAMLPair[key.value=jobs] > YAMLMapping > YAMLPair > YAMLMapping > YAMLPair[key.value=steps] > YAMLSequence > YAMLMapping > YAMLPair[key.value=timeout-minutes]": (node) => {
return validateTimeoutMinutes(node, stepTimeoutMinutes);
}
};
}
});
const VALID_TRIGGER_EVENTS = [
"branch_protection_rule",
"check_run",
"check_suite",
"create",
"delete",
"deployment",
"deployment_status",
"discussion",
"discussion_comment",
"fork",
"gollum",
"issue_comment",
"issues",
"label",
"merge_group",
"milestone",
"page_build",
"public",
"pull_request",
"pull_request_comment",
"pull_request_review",
"pull_request_review_comment",
"pull_request_target",
"push",
"registry_package",
"release",
"repository_dispatch",
"schedule",
"status",
"watch",
"workflow_call",
"workflow_dispatch",
"workflow_run"
];
const RULE_NAME = "valid-trigger-events";
const validTriggerEvents = createESLintRule({
name: RULE_NAME,
meta: {
type: "problem",
docs: {
recommended: true,
description: "disallow invalid trigger events."
},
fixable: "code",
schema: [],
messages: {
invalidEvent: "Disallow invalid trigger events {{event}}.",
invalidPair: "Disallow invalid on.event_name"
}
},
defaultOptions: [],
create(context) {
return {
"Program > YAMLDocument > YAMLMapping > YAMLPair[key.value=on]": (node) => {
if (!isYAMLMapping(node.value)) {
return;
}
const onPairs = node.value.pairs;
if (!onPairs.length) {
return;
}
onPairs.forEach((pair) => {
if (isYAMLScalar(pair.key) && isString(pair.key.value)) {
const event = pair.key.value;
if (VALID_TRIGGER_EVENTS.includes(event)) {
return;
}
context.report({
node: pair,
loc: pair.loc,
data: {
event
},
messageId: "invalidEvent",
fix: (fixer) => fixer.removeRange(pair.range)
});
} else {
context.report({
node: pair,
loc: pair.loc,
messageId: "invalidPair",
fix: (fixer) => fixer.removeRange(pair.range)
});
}
});
}
};
}
});
const rules = {
"action-name-casing": actionNameCasing,
"job-id-casing": jobIdCasing,
"max-jobs-per-action": maxJobsPerAction,
"no-external-job": noExternalJob,
"no-invalid-key": noInvalidKey,
"no-top-level-env": noTopLevelEnv,
"no-top-level-permissions": noTopLevelPermissions,
"prefer-file-extension": preferFileExtension,
"prefer-step-uses-style": preferStepUsesStyle,
"require-action-name": requireActionName,
"require-action-run-name": requireActionRunName,
"require-job-name": requireJobName,
"require-job-step-name": requireJobStepName,
"valid-trigger-events": validTriggerEvents,
"valid-timeout-minutes": validTimeoutMinutes
};
const plugin = {
meta,
rules,
configs
};
export { configs, plugin as default, meta, plugin, recommended, rules };