UNPKG

eslint-plugin-github-action

Version:

Rules for consistent, readable and valid GitHub action files.

1,415 lines (1,384 loc) 39.5 kB
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 };