Add missing validation for action.yml (parity with workflow files) (#311)
* Add missing validation for action.yml (parity with workflow files)
- Add uses format validation for composite action steps
- Validates owner/repo@ref format
- Supports docker:// and ./ local references
- Warns about shortened SHA refs (security concern)
- Detects reusable workflow references in wrong context
- Add if literal text detection for composite action steps
- Detects literal text outside ${{ }} that makes conditions always truthy
- Works for both plain string and mixed expression formats
- Uses shared hasFormatWithLiteralText() utility
- Add pre-if/post-if validation for node and docker actions
- Errors on explicit ${{ }} syntax (runner only supports implicit expressions)
- Literal text detection for implicit expressions
- New runs-if schema type with proper context (runner, github, job, env, inputs, status functions)
- Validates only in strict schema used by language services
- Add format() function validation for all expressions
- Validates format string syntax in all expression contexts
- Checks argument count matches placeholders
- Fix env and matrix context providers to return complete=false
- Prevents false positive 'unknown context' errors
- Matches behavior of other dynamic contexts (secrets, vars, etc.)
- Refactor validation utilities into utils/validate-uses.ts and utils/validate-if.ts
- Shared between workflow and action validation
- Consistent error messages and codes
* Add strategy and matrix contexts to runs-if definition
Based on runner source code analysis (actions/runner):
- ExecutionContext.InitializeJob() populates ExpressionValues from message.ContextData
- strategy and matrix are part of message.ContextData, available before any steps run
- StepsRunner evaluates all steps (pre, main, post) using the same code path
Did NOT add:
- steps: empty at pre-if time (no steps completed yet)
- hashFiles: workspace files don't exist at pre-step time
This commit is contained in:
@@ -198,9 +198,13 @@ function getDefaultActionContext(
|
|||||||
case "runner":
|
case "runner":
|
||||||
return getRunnerContext();
|
return getRunnerContext();
|
||||||
|
|
||||||
case "env":
|
case "env": {
|
||||||
// Actions can access env but we don't have runtime values
|
// Actions can access env but we don't know what env vars the calling workflow defines
|
||||||
return new DescriptionDictionary();
|
// Mark as incomplete to avoid false positive "Context access might be invalid" warnings
|
||||||
|
const envContext = new DescriptionDictionary();
|
||||||
|
envContext.complete = false;
|
||||||
|
return envContext;
|
||||||
|
}
|
||||||
|
|
||||||
case "job": {
|
case "job": {
|
||||||
// https://docs.github.com/en/actions/learn-github-actions/contexts#job-context
|
// https://docs.github.com/en/actions/learn-github-actions/contexts#job-context
|
||||||
@@ -218,9 +222,13 @@ function getDefaultActionContext(
|
|||||||
case "strategy":
|
case "strategy":
|
||||||
return getStrategyContext();
|
return getStrategyContext();
|
||||||
|
|
||||||
case "matrix":
|
case "matrix": {
|
||||||
// Actions can access matrix context at runtime
|
// Actions can access matrix context at runtime but we don't know the calling workflow's matrix
|
||||||
return new DescriptionDictionary();
|
// Mark as incomplete to avoid false positive "Context access might be invalid" warnings
|
||||||
|
const matrixContext = new DescriptionDictionary();
|
||||||
|
matrixContext.complete = false;
|
||||||
|
return matrixContext;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return undefined;
|
return undefined;
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
/**
|
||||||
|
* Shared validation utilities for `if` condition literal text detection.
|
||||||
|
* Used by both workflow and action validation.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {data} from "@actions/expressions";
|
||||||
|
import {Expr, FunctionCall, Literal, Logical} from "@actions/expressions/ast";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a format function contains literal text in its format string.
|
||||||
|
* This indicates user confusion about how expressions work.
|
||||||
|
*
|
||||||
|
* Example: format('push == {0}', github.event_name)
|
||||||
|
* The literal text "push == " will always evaluate to truthy.
|
||||||
|
*
|
||||||
|
* @param expr The expression to check
|
||||||
|
* @returns true if the expression is a format() call with literal text
|
||||||
|
*/
|
||||||
|
export function hasFormatWithLiteralText(expr: Expr): boolean {
|
||||||
|
// If this is a logical AND expression (from ensureStatusFunction wrapping)
|
||||||
|
// check the right side for the format call
|
||||||
|
if (expr instanceof Logical && expr.operator.lexeme === "&&" && expr.args.length === 2) {
|
||||||
|
return hasFormatWithLiteralText(expr.args[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(expr instanceof FunctionCall)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this is a format function
|
||||||
|
if (expr.functionName.lexeme.toLowerCase() !== "format") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the first argument is a string literal
|
||||||
|
if (expr.args.length < 1) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstArg = expr.args[0];
|
||||||
|
if (!(firstArg instanceof Literal) || firstArg.literal.kind !== data.Kind.String) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the format string and trim whitespace
|
||||||
|
const formatString = firstArg.literal.coerceString();
|
||||||
|
const trimmed = formatString.trim();
|
||||||
|
|
||||||
|
// Check if there's literal text (non-replacement tokens) after trimming
|
||||||
|
let inToken = false;
|
||||||
|
for (let i = 0; i < trimmed.length; i++) {
|
||||||
|
if (!inToken && trimmed[i] === "{") {
|
||||||
|
inToken = true;
|
||||||
|
} else if (inToken && trimmed[i] === "}") {
|
||||||
|
inToken = false;
|
||||||
|
} else if (inToken && trimmed[i] >= "0" && trimmed[i] <= "9") {
|
||||||
|
// OK - this is a replacement token like {0}, {1}, etc.
|
||||||
|
} else {
|
||||||
|
// Found literal text
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
/**
|
||||||
|
* Shared validation utilities for step `uses` field format.
|
||||||
|
* Used by both workflow and action validation.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {Diagnostic, DiagnosticSeverity} from "vscode-languageserver-types";
|
||||||
|
import {StringToken} from "@actions/workflow-parser/templates/tokens/string-token";
|
||||||
|
import {mapRange} from "./range.js";
|
||||||
|
|
||||||
|
// Matches a short SHA (7-8 hex characters) that looks like it should be a full SHA
|
||||||
|
const SHORT_SHA_PATTERN = /^[0-9a-f]{7,8}$/i;
|
||||||
|
const SHORT_SHA_DOCS_URL =
|
||||||
|
"https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions#using-third-party-actions";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a ref looks like a short SHA and adds a warning if so.
|
||||||
|
* Returns true if a warning was added.
|
||||||
|
*/
|
||||||
|
export function warnIfShortSha(diagnostics: Diagnostic[], token: StringToken, ref: string): boolean {
|
||||||
|
if (SHORT_SHA_PATTERN.test(ref)) {
|
||||||
|
diagnostics.push({
|
||||||
|
message: `The provided ref '${ref}' may be a shortened commit SHA. If so, please use the full 40-character commit SHA instead, as short SHAs are not supported.`,
|
||||||
|
severity: DiagnosticSeverity.Warning,
|
||||||
|
range: mapRange(token.range),
|
||||||
|
code: "short-sha-ref",
|
||||||
|
codeDescription: {
|
||||||
|
href: SHORT_SHA_DOCS_URL
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates the format of a step's `uses` field.
|
||||||
|
*
|
||||||
|
* Valid formats:
|
||||||
|
* - docker://image:tag
|
||||||
|
* - ./local/path
|
||||||
|
* - .\local\path (Windows)
|
||||||
|
* - {owner}/{repo}@{ref}
|
||||||
|
* - {owner}/{repo}/{path}@{ref}
|
||||||
|
*/
|
||||||
|
export function validateStepUsesFormat(diagnostics: Diagnostic[], token: StringToken): void {
|
||||||
|
const uses = token.value;
|
||||||
|
|
||||||
|
// Empty uses value
|
||||||
|
if (!uses) {
|
||||||
|
diagnostics.push({
|
||||||
|
message: "'uses' value in action cannot be blank",
|
||||||
|
severity: DiagnosticSeverity.Error,
|
||||||
|
range: mapRange(token.range),
|
||||||
|
code: "invalid-uses-format"
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Docker image reference - always valid format
|
||||||
|
if (uses.startsWith("docker://")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Local action path - always valid format
|
||||||
|
if (uses.startsWith("./") || uses.startsWith(".\\")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remote action: must be {owner}/{repo}[/path]@{ref}
|
||||||
|
const atSegments = uses.split("@");
|
||||||
|
|
||||||
|
// Must have exactly one @
|
||||||
|
if (atSegments.length !== 2) {
|
||||||
|
addStepUsesFormatError(diagnostics, token);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [repoPath, gitRef] = atSegments;
|
||||||
|
|
||||||
|
// Ref cannot be empty
|
||||||
|
if (!gitRef) {
|
||||||
|
addStepUsesFormatError(diagnostics, token);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Split by / or \ to get path segments
|
||||||
|
const pathSegments = repoPath.split(/[\\/]/);
|
||||||
|
|
||||||
|
// Must have at least owner and repo (both non-empty)
|
||||||
|
if (pathSegments.length < 2 || !pathSegments[0] || !pathSegments[1]) {
|
||||||
|
addStepUsesFormatError(diagnostics, token);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this is a reusable workflow reference (should be at job level, not step)
|
||||||
|
// Path would be like: owner/repo/.github/workflows/file.yml
|
||||||
|
if (pathSegments.length >= 4 && pathSegments[2] === ".github" && pathSegments[3] === "workflows") {
|
||||||
|
diagnostics.push({
|
||||||
|
message: "Reusable workflows should be referenced at the top-level `jobs.<job_id>.uses` key, not within steps",
|
||||||
|
severity: DiagnosticSeverity.Error,
|
||||||
|
range: mapRange(token.range),
|
||||||
|
code: "invalid-uses-format"
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Warn if ref looks like a short SHA
|
||||||
|
warnIfShortSha(diagnostics, token, gitRef);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addStepUsesFormatError(diagnostics: Diagnostic[], token: StringToken): void {
|
||||||
|
diagnostics.push({
|
||||||
|
message: `Expected format {owner}/{repo}[/path]@{ref}. Actual '${token.value}'`,
|
||||||
|
severity: DiagnosticSeverity.Error,
|
||||||
|
range: mapRange(token.range),
|
||||||
|
code: "invalid-uses-format"
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -527,4 +527,488 @@ runs:
|
|||||||
expect(diagnostics.some(d => d.message.includes("is not valid for"))).toBe(false);
|
expect(diagnostics.some(d => d.message.includes("is not valid for"))).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("composite step uses format validation", () => {
|
||||||
|
it("validates valid uses format with version", async () => {
|
||||||
|
const doc = createActionDocument(`
|
||||||
|
name: My Action
|
||||||
|
description: Uses another action
|
||||||
|
runs:
|
||||||
|
using: composite
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
`);
|
||||||
|
const diagnostics = await validate(doc);
|
||||||
|
expect(diagnostics.some(d => d.code === "invalid-uses-format")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("validates docker:// uses format", async () => {
|
||||||
|
const doc = createActionDocument(`
|
||||||
|
name: My Action
|
||||||
|
description: Uses docker image
|
||||||
|
runs:
|
||||||
|
using: composite
|
||||||
|
steps:
|
||||||
|
- uses: docker://alpine:3.14
|
||||||
|
`);
|
||||||
|
const diagnostics = await validate(doc);
|
||||||
|
expect(diagnostics.some(d => d.code === "invalid-uses-format")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("validates local ./ uses format", async () => {
|
||||||
|
const doc = createActionDocument(`
|
||||||
|
name: My Action
|
||||||
|
description: Uses local action
|
||||||
|
runs:
|
||||||
|
using: composite
|
||||||
|
steps:
|
||||||
|
- uses: ./local-action
|
||||||
|
`);
|
||||||
|
const diagnostics = await validate(doc);
|
||||||
|
expect(diagnostics.some(d => d.code === "invalid-uses-format")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("errors on missing @ref", async () => {
|
||||||
|
const doc = createActionDocument(`
|
||||||
|
name: My Action
|
||||||
|
description: Missing version
|
||||||
|
runs:
|
||||||
|
using: composite
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout
|
||||||
|
`);
|
||||||
|
const diagnostics = await validate(doc);
|
||||||
|
expect(diagnostics.some(d => d.code === "invalid-uses-format")).toBe(true);
|
||||||
|
expect(diagnostics.some(d => d.message.includes("Expected format"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("errors on invalid format", async () => {
|
||||||
|
const doc = createActionDocument(`
|
||||||
|
name: My Action
|
||||||
|
description: Invalid format
|
||||||
|
runs:
|
||||||
|
using: composite
|
||||||
|
steps:
|
||||||
|
- uses: invalid-format
|
||||||
|
`);
|
||||||
|
const diagnostics = await validate(doc);
|
||||||
|
expect(diagnostics.some(d => d.code === "invalid-uses-format")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("warns on short SHA", async () => {
|
||||||
|
const doc = createActionDocument(`
|
||||||
|
name: My Action
|
||||||
|
description: Short SHA
|
||||||
|
runs:
|
||||||
|
using: composite
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@a1b2c3d
|
||||||
|
`);
|
||||||
|
const diagnostics = await validate(doc);
|
||||||
|
expect(diagnostics.some(d => d.code === "short-sha-ref")).toBe(true);
|
||||||
|
expect(diagnostics.some(d => d.message.includes("shortened commit SHA"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows full SHA", async () => {
|
||||||
|
const doc = createActionDocument(`
|
||||||
|
name: My Action
|
||||||
|
description: Full SHA
|
||||||
|
runs:
|
||||||
|
using: composite
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2
|
||||||
|
`);
|
||||||
|
const diagnostics = await validate(doc);
|
||||||
|
expect(diagnostics.some(d => d.code === "short-sha-ref")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("errors on reusable workflow in step uses", async () => {
|
||||||
|
const doc = createActionDocument(`
|
||||||
|
name: My Action
|
||||||
|
description: Wrong workflow reference
|
||||||
|
runs:
|
||||||
|
using: composite
|
||||||
|
steps:
|
||||||
|
- uses: owner/repo/.github/workflows/build.yml@main
|
||||||
|
`);
|
||||||
|
const diagnostics = await validate(doc);
|
||||||
|
expect(diagnostics.some(d => d.message.includes("Reusable workflows should be referenced"))).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("composite step if literal text validation", () => {
|
||||||
|
it("errors when literal text mixed with embedded expression", async () => {
|
||||||
|
const doc = createActionDocument(`
|
||||||
|
name: My Action
|
||||||
|
description: Literal text in if
|
||||||
|
runs:
|
||||||
|
using: composite
|
||||||
|
steps:
|
||||||
|
- if: push == \${{ github.event_name }}
|
||||||
|
run: echo hi
|
||||||
|
shell: bash
|
||||||
|
`);
|
||||||
|
const diagnostics = await validate(doc);
|
||||||
|
expect(diagnostics.some(d => d.code === "expression-literal-text-in-condition")).toBe(true);
|
||||||
|
expect(diagnostics.some(d => d.message.includes("literal text outside replacement tokens"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows valid expression in if", async () => {
|
||||||
|
const doc = createActionDocument(`
|
||||||
|
name: My Action
|
||||||
|
description: Valid if expression
|
||||||
|
runs:
|
||||||
|
using: composite
|
||||||
|
steps:
|
||||||
|
- if: \${{ github.event_name == 'push' }}
|
||||||
|
run: echo hi
|
||||||
|
shell: bash
|
||||||
|
`);
|
||||||
|
const diagnostics = await validate(doc);
|
||||||
|
expect(diagnostics.some(d => d.code === "expression-literal-text-in-condition")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows if without expression markers (auto-wrapped)", async () => {
|
||||||
|
const doc = createActionDocument(`
|
||||||
|
name: My Action
|
||||||
|
description: If without markers
|
||||||
|
runs:
|
||||||
|
using: composite
|
||||||
|
steps:
|
||||||
|
- if: github.event_name == 'push'
|
||||||
|
run: echo hi
|
||||||
|
shell: bash
|
||||||
|
`);
|
||||||
|
const diagnostics = await validate(doc);
|
||||||
|
expect(diagnostics.some(d => d.code === "expression-literal-text-in-condition")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows success() function", async () => {
|
||||||
|
const doc = createActionDocument(`
|
||||||
|
name: My Action
|
||||||
|
description: Success function
|
||||||
|
runs:
|
||||||
|
using: composite
|
||||||
|
steps:
|
||||||
|
- if: success()
|
||||||
|
run: echo hi
|
||||||
|
shell: bash
|
||||||
|
`);
|
||||||
|
const diagnostics = await validate(doc);
|
||||||
|
expect(diagnostics.some(d => d.code === "expression-literal-text-in-condition")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("errors on format with literal text in if", async () => {
|
||||||
|
const doc = createActionDocument(`
|
||||||
|
name: My Action
|
||||||
|
description: Format with literal text
|
||||||
|
runs:
|
||||||
|
using: composite
|
||||||
|
steps:
|
||||||
|
- if: \${{ format('event is {0}', github.event_name) }}
|
||||||
|
run: echo hi
|
||||||
|
shell: bash
|
||||||
|
`);
|
||||||
|
const diagnostics = await validate(doc);
|
||||||
|
expect(diagnostics.some(d => d.code === "expression-literal-text-in-condition")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows format with only replacement tokens", async () => {
|
||||||
|
const doc = createActionDocument(`
|
||||||
|
name: My Action
|
||||||
|
description: Format with only tokens
|
||||||
|
runs:
|
||||||
|
using: composite
|
||||||
|
steps:
|
||||||
|
- if: \${{ format('{0}', github.event_name) }}
|
||||||
|
run: echo hi
|
||||||
|
shell: bash
|
||||||
|
`);
|
||||||
|
const diagnostics = await validate(doc);
|
||||||
|
expect(diagnostics.some(d => d.code === "expression-literal-text-in-condition")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("validates if in uses-step", async () => {
|
||||||
|
const doc = createActionDocument(`
|
||||||
|
name: My Action
|
||||||
|
description: If in uses step
|
||||||
|
runs:
|
||||||
|
using: composite
|
||||||
|
steps:
|
||||||
|
- if: push == \${{ github.event_name }}
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
`);
|
||||||
|
const diagnostics = await validate(doc);
|
||||||
|
expect(diagnostics.some(d => d.code === "expression-literal-text-in-condition")).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("pre-if and post-if validation", () => {
|
||||||
|
it("errors on explicit expression with literal text in pre-if for node action", async () => {
|
||||||
|
const doc = createActionDocument(`
|
||||||
|
name: My Action
|
||||||
|
description: Literal text in pre-if
|
||||||
|
runs:
|
||||||
|
using: node20
|
||||||
|
main: index.js
|
||||||
|
pre: setup.js
|
||||||
|
pre-if: push == \${{ github.event_name }}
|
||||||
|
`);
|
||||||
|
const diagnostics = await validate(doc);
|
||||||
|
// Explicit ${{ }} syntax is not allowed for pre-if, so we get that error
|
||||||
|
expect(diagnostics.some(d => d.code === "explicit-expression-not-allowed")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("errors on explicit expression with literal text in post-if for node action", async () => {
|
||||||
|
const doc = createActionDocument(`
|
||||||
|
name: My Action
|
||||||
|
description: Literal text in post-if
|
||||||
|
runs:
|
||||||
|
using: node20
|
||||||
|
main: index.js
|
||||||
|
post: cleanup.js
|
||||||
|
post-if: event == \${{ github.event_name }}
|
||||||
|
`);
|
||||||
|
const diagnostics = await validate(doc);
|
||||||
|
// Explicit ${{ }} syntax is not allowed for post-if, so we get that error
|
||||||
|
expect(diagnostics.some(d => d.code === "explicit-expression-not-allowed")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("errors on explicit expression with literal text in pre-if for docker action", async () => {
|
||||||
|
const doc = createActionDocument(`
|
||||||
|
name: My Action
|
||||||
|
description: Literal text in pre-if
|
||||||
|
runs:
|
||||||
|
using: docker
|
||||||
|
image: Dockerfile
|
||||||
|
pre-entrypoint: /setup.sh
|
||||||
|
pre-if: push == \${{ github.event_name }}
|
||||||
|
`);
|
||||||
|
const diagnostics = await validate(doc);
|
||||||
|
// Explicit ${{ }} syntax is not allowed for pre-if, so we get that error
|
||||||
|
expect(diagnostics.some(d => d.code === "explicit-expression-not-allowed")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("errors on explicit expression with literal text in post-if for docker action", async () => {
|
||||||
|
const doc = createActionDocument(`
|
||||||
|
name: My Action
|
||||||
|
description: Literal text in post-if
|
||||||
|
runs:
|
||||||
|
using: docker
|
||||||
|
image: Dockerfile
|
||||||
|
post-entrypoint: /cleanup.sh
|
||||||
|
post-if: event == \${{ github.event_name }}
|
||||||
|
`);
|
||||||
|
const diagnostics = await validate(doc);
|
||||||
|
// Explicit ${{ }} syntax is not allowed for post-if, so we get that error
|
||||||
|
expect(diagnostics.some(d => d.code === "explicit-expression-not-allowed")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows valid expression in pre-if for node action", async () => {
|
||||||
|
const doc = createActionDocument(`
|
||||||
|
name: My Action
|
||||||
|
description: Valid pre-if
|
||||||
|
runs:
|
||||||
|
using: node20
|
||||||
|
main: index.js
|
||||||
|
pre: setup.js
|
||||||
|
pre-if: success()
|
||||||
|
`);
|
||||||
|
const diagnostics = await validate(doc);
|
||||||
|
expect(diagnostics.some(d => d.code === "expression-literal-text-in-condition")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows valid expression in post-if for node action", async () => {
|
||||||
|
const doc = createActionDocument(`
|
||||||
|
name: My Action
|
||||||
|
description: Valid post-if
|
||||||
|
runs:
|
||||||
|
using: node20
|
||||||
|
main: index.js
|
||||||
|
post: cleanup.js
|
||||||
|
post-if: always()
|
||||||
|
`);
|
||||||
|
const diagnostics = await validate(doc);
|
||||||
|
expect(diagnostics.some(d => d.code === "expression-literal-text-in-condition")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("errors on explicit expression syntax in pre-if", async () => {
|
||||||
|
const doc = createActionDocument(`
|
||||||
|
name: My Action
|
||||||
|
description: Explicit expression in pre-if
|
||||||
|
runs:
|
||||||
|
using: node20
|
||||||
|
main: index.js
|
||||||
|
pre: setup.js
|
||||||
|
pre-if: \${{ runner.os == 'Windows' }}
|
||||||
|
`);
|
||||||
|
const diagnostics = await validate(doc);
|
||||||
|
expect(diagnostics.some(d => d.code === "explicit-expression-not-allowed")).toBe(true);
|
||||||
|
expect(diagnostics.some(d => d.message.includes("pre-if"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("errors on explicit expression syntax in post-if", async () => {
|
||||||
|
const doc = createActionDocument(`
|
||||||
|
name: My Action
|
||||||
|
description: Explicit expression in post-if
|
||||||
|
runs:
|
||||||
|
using: node20
|
||||||
|
main: index.js
|
||||||
|
post: cleanup.js
|
||||||
|
post-if: \${{ always() }}
|
||||||
|
`);
|
||||||
|
const diagnostics = await validate(doc);
|
||||||
|
expect(diagnostics.some(d => d.code === "explicit-expression-not-allowed")).toBe(true);
|
||||||
|
expect(diagnostics.some(d => d.message.includes("post-if"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows expression with failure() in post-if", async () => {
|
||||||
|
const doc = createActionDocument(`
|
||||||
|
name: My Action
|
||||||
|
description: Valid post-if
|
||||||
|
runs:
|
||||||
|
using: node20
|
||||||
|
main: index.js
|
||||||
|
post: cleanup.js
|
||||||
|
post-if: failure()
|
||||||
|
`);
|
||||||
|
const diagnostics = await validate(doc);
|
||||||
|
expect(diagnostics.some(d => d.code === "expression-literal-text-in-condition")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows expression with cancelled() in post-if", async () => {
|
||||||
|
const doc = createActionDocument(`
|
||||||
|
name: My Action
|
||||||
|
description: Valid post-if
|
||||||
|
runs:
|
||||||
|
using: node20
|
||||||
|
main: index.js
|
||||||
|
post: cleanup.js
|
||||||
|
post-if: cancelled()
|
||||||
|
`);
|
||||||
|
const diagnostics = await validate(doc);
|
||||||
|
expect(diagnostics.some(d => d.code === "expression-literal-text-in-condition")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("format string validation", () => {
|
||||||
|
it("errors on format() with too few arguments in composite step if", async () => {
|
||||||
|
const doc = createActionDocument(`
|
||||||
|
name: My Action
|
||||||
|
description: Format mismatch
|
||||||
|
runs:
|
||||||
|
using: composite
|
||||||
|
steps:
|
||||||
|
- if: format('{0} {1}', 'only-one')
|
||||||
|
run: echo hi
|
||||||
|
shell: bash
|
||||||
|
`);
|
||||||
|
const diagnostics = await validate(doc);
|
||||||
|
expect(diagnostics.some(d => d.code === "format-arg-count-mismatch")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("errors on invalid format string in composite step if", async () => {
|
||||||
|
const doc = createActionDocument(`
|
||||||
|
name: My Action
|
||||||
|
description: Invalid format
|
||||||
|
runs:
|
||||||
|
using: composite
|
||||||
|
steps:
|
||||||
|
- if: format('{', 'arg')
|
||||||
|
run: echo hi
|
||||||
|
shell: bash
|
||||||
|
`);
|
||||||
|
const diagnostics = await validate(doc);
|
||||||
|
expect(diagnostics.some(d => d.code === "invalid-format-string")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("errors on format() with too few arguments in pre-if", async () => {
|
||||||
|
const doc = createActionDocument(`
|
||||||
|
name: My Action
|
||||||
|
description: Format mismatch in pre-if
|
||||||
|
runs:
|
||||||
|
using: node20
|
||||||
|
main: index.js
|
||||||
|
pre: setup.js
|
||||||
|
pre-if: format('{0} {1}', 'only-one')
|
||||||
|
`);
|
||||||
|
const diagnostics = await validate(doc);
|
||||||
|
expect(diagnostics.some(d => d.code === "format-arg-count-mismatch")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("errors on format() with too few arguments in post-if", async () => {
|
||||||
|
const doc = createActionDocument(`
|
||||||
|
name: My Action
|
||||||
|
description: Format mismatch in post-if
|
||||||
|
runs:
|
||||||
|
using: node20
|
||||||
|
main: index.js
|
||||||
|
post: cleanup.js
|
||||||
|
post-if: format('{0} {1} {2}', 'a', 'b')
|
||||||
|
`);
|
||||||
|
const diagnostics = await validate(doc);
|
||||||
|
expect(diagnostics.some(d => d.code === "format-arg-count-mismatch")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows valid format() call in composite step if", async () => {
|
||||||
|
const doc = createActionDocument(`
|
||||||
|
name: My Action
|
||||||
|
description: Valid format
|
||||||
|
runs:
|
||||||
|
using: composite
|
||||||
|
steps:
|
||||||
|
- if: format('{0} {1}', 'a', 'b') == 'a b'
|
||||||
|
run: echo hi
|
||||||
|
shell: bash
|
||||||
|
`);
|
||||||
|
const diagnostics = await validate(doc);
|
||||||
|
expect(diagnostics.some(d => d.code === "format-arg-count-mismatch")).toBe(false);
|
||||||
|
expect(diagnostics.some(d => d.code === "invalid-format-string")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows valid format() call in pre-if", async () => {
|
||||||
|
const doc = createActionDocument(`
|
||||||
|
name: My Action
|
||||||
|
description: Valid format in pre-if
|
||||||
|
runs:
|
||||||
|
using: node20
|
||||||
|
main: index.js
|
||||||
|
pre: setup.js
|
||||||
|
pre-if: format('{0}', runner.os) == 'Linux'
|
||||||
|
`);
|
||||||
|
const diagnostics = await validate(doc);
|
||||||
|
expect(diagnostics.some(d => d.code === "format-arg-count-mismatch")).toBe(false);
|
||||||
|
expect(diagnostics.some(d => d.code === "invalid-format-string")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("errors on format() with too few arguments in run expression", async () => {
|
||||||
|
const doc = createActionDocument(`
|
||||||
|
name: My Action
|
||||||
|
description: Format mismatch in run
|
||||||
|
runs:
|
||||||
|
using: composite
|
||||||
|
steps:
|
||||||
|
- run: echo \${{ format('{0} {1}', 'only-one') }}
|
||||||
|
shell: bash
|
||||||
|
`);
|
||||||
|
const diagnostics = await validate(doc);
|
||||||
|
expect(diagnostics.some(d => d.code === "format-arg-count-mismatch")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("errors on format() with too few arguments in input default", async () => {
|
||||||
|
const doc = createActionDocument(`
|
||||||
|
name: My Action
|
||||||
|
description: Format mismatch in input default
|
||||||
|
inputs:
|
||||||
|
greeting:
|
||||||
|
description: Greeting message
|
||||||
|
default: \${{ format('{0} {1}', 'hello') }}
|
||||||
|
runs:
|
||||||
|
using: node20
|
||||||
|
main: index.js
|
||||||
|
`);
|
||||||
|
const diagnostics = await validate(doc);
|
||||||
|
expect(diagnostics.some(d => d.code === "format-arg-count-mismatch")).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,20 +2,30 @@
|
|||||||
* Validation for action.yml / action.yaml manifest files
|
* Validation for action.yml / action.yaml manifest files
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {isMapping} from "@actions/workflow-parser";
|
import {Lexer, Parser} from "@actions/expressions";
|
||||||
|
import {Expr} from "@actions/expressions/ast";
|
||||||
|
import {isBasicExpression, isMapping, isString} from "@actions/workflow-parser";
|
||||||
import {isActionStep} from "@actions/workflow-parser/model/type-guards";
|
import {isActionStep} from "@actions/workflow-parser/model/type-guards";
|
||||||
import {ErrorPolicy} from "@actions/workflow-parser/model/convert";
|
import {ErrorPolicy} from "@actions/workflow-parser/model/convert";
|
||||||
|
import {ensureStatusFunction} from "@actions/workflow-parser/model/converter/if-condition";
|
||||||
|
import {splitAllowedContext} from "@actions/workflow-parser/templates/allowed-context";
|
||||||
|
import {BasicExpressionToken} from "@actions/workflow-parser/templates/tokens/basic-expression-token";
|
||||||
import {MappingToken} from "@actions/workflow-parser/templates/tokens/mapping-token";
|
import {MappingToken} from "@actions/workflow-parser/templates/tokens/mapping-token";
|
||||||
import {SequenceToken} from "@actions/workflow-parser/templates/tokens/sequence-token";
|
import {SequenceToken} from "@actions/workflow-parser/templates/tokens/sequence-token";
|
||||||
|
import {StringToken} from "@actions/workflow-parser/templates/tokens/string-token";
|
||||||
import {TemplateToken} from "@actions/workflow-parser/templates/tokens/template-token";
|
import {TemplateToken} from "@actions/workflow-parser/templates/tokens/template-token";
|
||||||
|
import {TokenRange} from "@actions/workflow-parser/templates/tokens/token-range";
|
||||||
import {TemplateValidationError} from "@actions/workflow-parser/templates/template-validation-error";
|
import {TemplateValidationError} from "@actions/workflow-parser/templates/template-validation-error";
|
||||||
import {File} from "@actions/workflow-parser/workflows/file";
|
import {File} from "@actions/workflow-parser/workflows/file";
|
||||||
import {TextDocument} from "vscode-languageserver-textdocument";
|
import {TextDocument} from "vscode-languageserver-textdocument";
|
||||||
import {Diagnostic, DiagnosticSeverity} from "vscode-languageserver-types";
|
import {Diagnostic, DiagnosticSeverity} from "vscode-languageserver-types";
|
||||||
import {error} from "./log.js";
|
import {error} from "./log.js";
|
||||||
import {mapRange} from "./utils/range.js";
|
import {mapRange} from "./utils/range.js";
|
||||||
|
import {hasFormatWithLiteralText} from "./utils/validate-if.js";
|
||||||
|
import {validateStepUsesFormat} from "./utils/validate-uses.js";
|
||||||
import {getOrConvertActionTemplate, getOrParseAction} from "./utils/workflow-cache.js";
|
import {getOrConvertActionTemplate, getOrParseAction} from "./utils/workflow-cache.js";
|
||||||
import {validateActionReference} from "./validate-action-reference.js";
|
import {validateActionReference} from "./validate-action-reference.js";
|
||||||
|
import {validateFormatCalls} from "./validate-format-string.js";
|
||||||
import {ValidationConfig} from "./validate.js";
|
import {ValidationConfig} from "./validate.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -114,10 +124,24 @@ export async function validateAction(textDocument: TextDocument, config?: Valida
|
|||||||
if (isActionStep(step) && isMapping(stepToken)) {
|
if (isActionStep(step) && isMapping(stepToken)) {
|
||||||
await validateActionReference(diagnostics, stepToken, step, config);
|
await validateActionReference(diagnostics, stepToken, step, config);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate step tokens (uses format, if conditions)
|
||||||
|
if (isMapping(stepToken)) {
|
||||||
|
validateCompositeStepTokens(diagnostics, stepToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate pre-if and post-if for node and docker actions
|
||||||
|
const runsMapping = findRunsMapping(result.value);
|
||||||
|
if (runsMapping) {
|
||||||
|
validateRunsIfConditions(diagnostics, runsMapping);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate format() calls in all expressions throughout the action
|
||||||
|
validateAllExpressions(diagnostics, result.value);
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error(`Unhandled error while validating action file: ${(e as Error).message}`);
|
error(`Unhandled error while validating action file: ${(e as Error).message}`);
|
||||||
}
|
}
|
||||||
@@ -125,6 +149,148 @@ export async function validateAction(textDocument: TextDocument, config?: Valida
|
|||||||
return diagnostics;
|
return diagnostics;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates tokens within a composite action step.
|
||||||
|
* Checks `uses` format and `if` literal text detection.
|
||||||
|
*/
|
||||||
|
function validateCompositeStepTokens(diagnostics: Diagnostic[], stepToken: MappingToken): void {
|
||||||
|
for (let i = 0; i < stepToken.count; i++) {
|
||||||
|
const {key, value} = stepToken.get(i);
|
||||||
|
const keyStr = isString(key) ? key.value.toLowerCase() : "";
|
||||||
|
|
||||||
|
// Validate `uses` field format
|
||||||
|
if (keyStr === "uses" && isString(value)) {
|
||||||
|
validateStepUsesFormat(diagnostics, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate `if` field for literal text outside expressions
|
||||||
|
if (keyStr === "if" && value.range) {
|
||||||
|
if (isString(value)) {
|
||||||
|
// Plain string if condition (no ${{ }} markers)
|
||||||
|
validateIfCondition(diagnostics, value);
|
||||||
|
} else if (isBasicExpression(value)) {
|
||||||
|
// Expression token - check for format() with literal text
|
||||||
|
// This happens when the parser converts "push == ${{ expr }}" to format('push == {0}', expr)
|
||||||
|
validateIfConditionExpression(diagnostics, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates an `if` condition (StringToken).
|
||||||
|
* Checks for literal text outside expressions and validates format() calls.
|
||||||
|
*/
|
||||||
|
function validateIfCondition(diagnostics: Diagnostic[], token: StringToken): void {
|
||||||
|
const condition = token.value.trim();
|
||||||
|
if (!condition) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get allowed context for step-if from the token's definition
|
||||||
|
const allowedContext = token.definitionInfo?.allowedContext || [];
|
||||||
|
const {namedContexts, functions} = splitAllowedContext(allowedContext);
|
||||||
|
|
||||||
|
// Ensure the condition has a status function, wrapping if needed
|
||||||
|
const finalCondition = ensureStatusFunction(condition, token.definitionInfo);
|
||||||
|
|
||||||
|
// Create a BasicExpressionToken for validation
|
||||||
|
const expressionToken = new BasicExpressionToken(
|
||||||
|
token.file,
|
||||||
|
token.range,
|
||||||
|
finalCondition,
|
||||||
|
token.definitionInfo,
|
||||||
|
undefined,
|
||||||
|
token.source,
|
||||||
|
undefined,
|
||||||
|
token.blockScalarHeader
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check for literal text in the expression (format with literal text)
|
||||||
|
try {
|
||||||
|
const l = new Lexer(expressionToken.expression);
|
||||||
|
const lr = l.lex();
|
||||||
|
const p = new Parser(lr.tokens, namedContexts, functions);
|
||||||
|
const expr = p.parse();
|
||||||
|
|
||||||
|
if (hasFormatWithLiteralText(expr)) {
|
||||||
|
diagnostics.push({
|
||||||
|
message:
|
||||||
|
"Conditional expression contains literal text outside replacement tokens. This will cause the expression to always evaluate to truthy. Did you mean to put the entire expression inside ${{ }}?",
|
||||||
|
range: mapRange(token.range),
|
||||||
|
severity: DiagnosticSeverity.Error,
|
||||||
|
code: "expression-literal-text-in-condition"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate format() function calls
|
||||||
|
validateFormatCallsAndAddDiagnostics(diagnostics, expr, token.range);
|
||||||
|
} catch {
|
||||||
|
// Ignore parse errors here - they'll be caught by schema validation
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates an `if` condition (BasicExpressionToken).
|
||||||
|
* Checks for literal text outside expressions.
|
||||||
|
* Called when the parser has converted "push == ${{ expr }}" to format('push == {0}', expr).
|
||||||
|
* Note: format() validation is handled by validateAllExpressions for BasicExpressionTokens.
|
||||||
|
*/
|
||||||
|
function validateIfConditionExpression(diagnostics: Diagnostic[], token: BasicExpressionToken): void {
|
||||||
|
const allowedContext = token.definitionInfo?.allowedContext || [];
|
||||||
|
const {namedContexts, functions} = splitAllowedContext(allowedContext);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const l = new Lexer(token.expression);
|
||||||
|
const lr = l.lex();
|
||||||
|
const p = new Parser(lr.tokens, namedContexts, functions);
|
||||||
|
const expr = p.parse();
|
||||||
|
|
||||||
|
if (hasFormatWithLiteralText(expr)) {
|
||||||
|
diagnostics.push({
|
||||||
|
message:
|
||||||
|
"Conditional expression contains literal text outside replacement tokens. This will cause the expression to always evaluate to truthy. Did you mean to put the entire expression inside ${{ }}?",
|
||||||
|
range: mapRange(token.range),
|
||||||
|
severity: DiagnosticSeverity.Error,
|
||||||
|
code: "expression-literal-text-in-condition"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Note: format() validation is done by validateAllExpressions() for all BasicExpressionTokens
|
||||||
|
} catch {
|
||||||
|
// Ignore parse errors here - they'll be caught by schema validation
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to validate format() function calls and add diagnostics.
|
||||||
|
*/
|
||||||
|
function validateFormatCallsAndAddDiagnostics(
|
||||||
|
diagnostics: Diagnostic[],
|
||||||
|
expr: Expr,
|
||||||
|
range: TokenRange | undefined
|
||||||
|
): void {
|
||||||
|
const formatErrors = validateFormatCalls(expr);
|
||||||
|
for (const formatError of formatErrors) {
|
||||||
|
if (formatError.type === "invalid-syntax") {
|
||||||
|
diagnostics.push({
|
||||||
|
message: `Invalid format string: ${formatError.message}`,
|
||||||
|
range: mapRange(range),
|
||||||
|
severity: DiagnosticSeverity.Error,
|
||||||
|
code: "invalid-format-string"
|
||||||
|
});
|
||||||
|
} else if (formatError.type === "arg-count-mismatch") {
|
||||||
|
diagnostics.push({
|
||||||
|
message: `Format string references argument {${formatError.expected - 1}} but only ${
|
||||||
|
formatError.provided
|
||||||
|
} argument(s) provided`,
|
||||||
|
range: mapRange(range),
|
||||||
|
severity: DiagnosticSeverity.Error,
|
||||||
|
code: "format-arg-count-mismatch"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Find the steps sequence token from the raw action template.
|
* Find the steps sequence token from the raw action template.
|
||||||
* Traverses the token tree looking for the "composite-steps" definition.
|
* Traverses the token tree looking for the "composite-steps" definition.
|
||||||
@@ -138,6 +304,77 @@ function findStepsSequence(root: TemplateToken): SequenceToken | undefined {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the runs mapping token from the raw action template.
|
||||||
|
*/
|
||||||
|
function findRunsMapping(root: TemplateToken): MappingToken | undefined {
|
||||||
|
if (root instanceof MappingToken) {
|
||||||
|
for (let i = 0; i < root.count; i++) {
|
||||||
|
const {key, value} = root.get(i);
|
||||||
|
if (key.toString().toLowerCase() === "runs" && value instanceof MappingToken) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates pre-if and post-if conditions at the runs level (for node and docker actions).
|
||||||
|
* Checks for literal text outside expressions that would always be truthy.
|
||||||
|
*/
|
||||||
|
function validateRunsIfConditions(diagnostics: Diagnostic[], runsMapping: MappingToken): void {
|
||||||
|
for (let i = 0; i < runsMapping.count; i++) {
|
||||||
|
const {key, value} = runsMapping.get(i);
|
||||||
|
const keyStr = key.toString().toLowerCase();
|
||||||
|
|
||||||
|
// Validate pre-if and post-if fields for literal text
|
||||||
|
if ((keyStr === "pre-if" || keyStr === "post-if") && value.range) {
|
||||||
|
if (isString(value)) {
|
||||||
|
// Plain string condition (no ${{ }} markers)
|
||||||
|
validateIfCondition(diagnostics, value);
|
||||||
|
} else if (isBasicExpression(value)) {
|
||||||
|
// The runner doesn't support explicit ${{ }} syntax for pre-if/post-if
|
||||||
|
// Only implicit expressions are allowed
|
||||||
|
diagnostics.push({
|
||||||
|
message: `Explicit expression syntax \${{ }} is not supported for '${keyStr}'. Remove the \${{ }} markers and use the expression directly.`,
|
||||||
|
range: mapRange(value.range),
|
||||||
|
severity: DiagnosticSeverity.Error,
|
||||||
|
code: "explicit-expression-not-allowed"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates format() function calls in all expressions throughout the action template.
|
||||||
|
* This catches format string errors in any expression, not just if conditions.
|
||||||
|
*/
|
||||||
|
function validateAllExpressions(diagnostics: Diagnostic[], root: TemplateToken): void {
|
||||||
|
for (const [, token] of TemplateToken.traverse(root)) {
|
||||||
|
if (token instanceof BasicExpressionToken) {
|
||||||
|
// Process original expressions if available (for combined expressions like "${{ a }} text ${{ b }}")
|
||||||
|
// This ensures error ranges point to the correct original expression location
|
||||||
|
for (const expression of token.originalExpressions || [token]) {
|
||||||
|
const allowedContext = expression.definitionInfo?.allowedContext || [];
|
||||||
|
const {namedContexts, functions} = splitAllowedContext(allowedContext);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const l = new Lexer(expression.expression);
|
||||||
|
const lr = l.lex();
|
||||||
|
const p = new Parser(lr.tokens, namedContexts, functions);
|
||||||
|
const expr = p.parse();
|
||||||
|
|
||||||
|
validateFormatCallsAndAddDiagnostics(diagnostics, expr, expression.range);
|
||||||
|
} catch {
|
||||||
|
// Ignore parse errors - they'll be caught by schema validation
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validates that the keys under `runs:` are valid for the specified `using:` type.
|
* Validates that the keys under `runs:` are valid for the specified `using:` type.
|
||||||
* Also filters out schema errors (in place) that this validation replaces with more specific messages.
|
* Also filters out schema errors (in place) that this validation replaces with more specific messages.
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import {FeatureFlags, Lexer, Parser, data} from "@actions/expressions";
|
import {FeatureFlags, Lexer, Parser} from "@actions/expressions";
|
||||||
import {Expr, FunctionCall, Literal, Logical} from "@actions/expressions/ast";
|
import {Expr} from "@actions/expressions/ast";
|
||||||
import {TemplateParseResult, WorkflowTemplate, isBasicExpression, isMapping, isString} from "@actions/workflow-parser";
|
import {TemplateParseResult, WorkflowTemplate, isBasicExpression, isMapping, isString} from "@actions/workflow-parser";
|
||||||
import {ErrorPolicy} from "@actions/workflow-parser/model/convert";
|
import {ErrorPolicy} from "@actions/workflow-parser/model/convert";
|
||||||
import {getCronDescription, hasCronIntervalLessThan5Minutes} from "@actions/workflow-parser/model/converter/cron";
|
import {getCronDescription, hasCronIntervalLessThan5Minutes} from "@actions/workflow-parser/model/converter/cron";
|
||||||
@@ -24,6 +24,8 @@ import {error} from "./log.js";
|
|||||||
import {isActionDocument} from "./utils/document-type.js";
|
import {isActionDocument} from "./utils/document-type.js";
|
||||||
import {findToken} from "./utils/find-token.js";
|
import {findToken} from "./utils/find-token.js";
|
||||||
import {mapRange} from "./utils/range.js";
|
import {mapRange} from "./utils/range.js";
|
||||||
|
import {hasFormatWithLiteralText} from "./utils/validate-if.js";
|
||||||
|
import {validateStepUsesFormat, warnIfShortSha} from "./utils/validate-uses.js";
|
||||||
import {getOrConvertWorkflowTemplate, getOrParseWorkflow} from "./utils/workflow-cache.js";
|
import {getOrConvertWorkflowTemplate, getOrParseWorkflow} from "./utils/workflow-cache.js";
|
||||||
import {validateActionReference} from "./validate-action-reference.js";
|
import {validateActionReference} from "./validate-action-reference.js";
|
||||||
import {validateAction} from "./validate-action.js";
|
import {validateAction} from "./validate-action.js";
|
||||||
@@ -285,116 +287,6 @@ function validateCronExpression(diagnostics: Diagnostic[], token: StringToken):
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Matches a short SHA (7-8 hex characters) that looks like it should be a full SHA
|
|
||||||
const SHORT_SHA_PATTERN = /^[0-9a-f]{7,8}$/i;
|
|
||||||
const SHORT_SHA_DOCS_URL =
|
|
||||||
"https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions#using-third-party-actions";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks if a ref looks like a short SHA and adds a warning if so.
|
|
||||||
* Returns true if a warning was added.
|
|
||||||
*/
|
|
||||||
function warnIfShortSha(diagnostics: Diagnostic[], token: StringToken, ref: string): boolean {
|
|
||||||
if (SHORT_SHA_PATTERN.test(ref)) {
|
|
||||||
diagnostics.push({
|
|
||||||
message: `The provided ref '${ref}' may be a shortened commit SHA. If so, please use the full 40-character commit SHA instead, as short SHAs are not supported.`,
|
|
||||||
severity: DiagnosticSeverity.Warning,
|
|
||||||
range: mapRange(token.range),
|
|
||||||
code: "short-sha-ref",
|
|
||||||
codeDescription: {
|
|
||||||
href: SHORT_SHA_DOCS_URL
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validates the format of a step's `uses` field.
|
|
||||||
*
|
|
||||||
* Valid formats:
|
|
||||||
* - docker://image:tag
|
|
||||||
* - ./local/path
|
|
||||||
* - .\local\path (Windows)
|
|
||||||
* - {owner}/{repo}@{ref}
|
|
||||||
* - {owner}/{repo}/{path}@{ref}
|
|
||||||
*/
|
|
||||||
function validateStepUsesFormat(diagnostics: Diagnostic[], token: StringToken): void {
|
|
||||||
const uses = token.value;
|
|
||||||
|
|
||||||
// Empty uses value
|
|
||||||
if (!uses) {
|
|
||||||
diagnostics.push({
|
|
||||||
message: "`uses' value in action cannot be blank",
|
|
||||||
severity: DiagnosticSeverity.Error,
|
|
||||||
range: mapRange(token.range),
|
|
||||||
code: "invalid-uses-format"
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Docker image reference - always valid format
|
|
||||||
if (uses.startsWith("docker://")) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Local action path - always valid format
|
|
||||||
if (uses.startsWith("./") || uses.startsWith(".\\")) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remote action: must be {owner}/{repo}[/path]@{ref}
|
|
||||||
const atSegments = uses.split("@");
|
|
||||||
|
|
||||||
// Must have exactly one @
|
|
||||||
if (atSegments.length !== 2) {
|
|
||||||
addStepUsesFormatError(diagnostics, token);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const [repoPath, gitRef] = atSegments;
|
|
||||||
|
|
||||||
// Ref cannot be empty
|
|
||||||
if (!gitRef) {
|
|
||||||
addStepUsesFormatError(diagnostics, token);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Split by / or \ to get path segments
|
|
||||||
const pathSegments = repoPath.split(/[\\/]/);
|
|
||||||
|
|
||||||
// Must have at least owner and repo (both non-empty)
|
|
||||||
if (pathSegments.length < 2 || !pathSegments[0] || !pathSegments[1]) {
|
|
||||||
addStepUsesFormatError(diagnostics, token);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if this is a reusable workflow reference (should be at job level, not step)
|
|
||||||
// Path would be like: owner/repo/.github/workflows/file.yml
|
|
||||||
if (pathSegments.length >= 4 && pathSegments[2] === ".github" && pathSegments[3] === "workflows") {
|
|
||||||
diagnostics.push({
|
|
||||||
message: "Reusable workflows should be referenced at the top-level `jobs.<job_id>.uses` key, not within steps",
|
|
||||||
severity: DiagnosticSeverity.Error,
|
|
||||||
range: mapRange(token.range),
|
|
||||||
code: "invalid-uses-format"
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Warn if ref looks like a short SHA
|
|
||||||
warnIfShortSha(diagnostics, token, gitRef);
|
|
||||||
}
|
|
||||||
|
|
||||||
function addStepUsesFormatError(diagnostics: Diagnostic[], token: StringToken): void {
|
|
||||||
diagnostics.push({
|
|
||||||
message: `Expected format {owner}/{repo}[/path]@{ref}. Actual '${token.value}'`,
|
|
||||||
severity: DiagnosticSeverity.Error,
|
|
||||||
range: mapRange(token.range),
|
|
||||||
code: "invalid-uses-format"
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validates the format of a job's `uses` field (reusable workflow reference).
|
* Validates the format of a job's `uses` field (reusable workflow reference).
|
||||||
*
|
*
|
||||||
@@ -639,64 +531,6 @@ function getProviderContext(
|
|||||||
return getWorkflowContext(documentUri, template, path);
|
return getWorkflowContext(documentUri, template, path);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks if a format function contains literal text in its format string.
|
|
||||||
* This indicates user confusion about how expressions work.
|
|
||||||
*
|
|
||||||
* Example: format('push == {0}', github.event_name)
|
|
||||||
* The literal text "push == " will always evaluate to truthy.
|
|
||||||
*
|
|
||||||
* @param expr The expression to check
|
|
||||||
* @returns true if the expression is a format() call with literal text
|
|
||||||
*/
|
|
||||||
function hasFormatWithLiteralText(expr: Expr): boolean {
|
|
||||||
// If this is a logical AND expression (from ensureStatusFunction wrapping)
|
|
||||||
// check the right side for the format call
|
|
||||||
if (expr instanceof Logical && expr.operator.lexeme === "&&" && expr.args.length === 2) {
|
|
||||||
return hasFormatWithLiteralText(expr.args[1]);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!(expr instanceof FunctionCall)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if this is a format function
|
|
||||||
if (expr.functionName.lexeme.toLowerCase() !== "format") {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if the first argument is a string literal
|
|
||||||
if (expr.args.length < 1) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const firstArg = expr.args[0];
|
|
||||||
if (!(firstArg instanceof Literal) || firstArg.literal.kind !== data.Kind.String) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the format string and trim whitespace
|
|
||||||
const formatString = firstArg.literal.coerceString();
|
|
||||||
const trimmed = formatString.trim();
|
|
||||||
|
|
||||||
// Check if there's literal text (non-replacement tokens) after trimming
|
|
||||||
let inToken = false;
|
|
||||||
for (let i = 0; i < trimmed.length; i++) {
|
|
||||||
if (!inToken && trimmed[i] === "{") {
|
|
||||||
inToken = true;
|
|
||||||
} else if (inToken && trimmed[i] === "}") {
|
|
||||||
inToken = false;
|
|
||||||
} else if (inToken && trimmed[i] >= "0" && trimmed[i] <= "9") {
|
|
||||||
// OK - this is a replacement token like {0}, {1}, etc.
|
|
||||||
} else {
|
|
||||||
// Found literal text
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function validateExpression(
|
async function validateExpression(
|
||||||
diagnostics: Diagnostic[],
|
diagnostics: Diagnostic[],
|
||||||
token: BasicExpressionToken,
|
token: BasicExpressionToken,
|
||||||
|
|||||||
@@ -295,7 +295,7 @@ jobs:
|
|||||||
`;
|
`;
|
||||||
const result = await validate(createDocument("wf.yaml", input));
|
const result = await validate(createDocument("wf.yaml", input));
|
||||||
expect(result).toContainEqual({
|
expect(result).toContainEqual({
|
||||||
message: "`uses' value in action cannot be blank",
|
message: "'uses' value in action cannot be blank",
|
||||||
severity: DiagnosticSeverity.Error,
|
severity: DiagnosticSeverity.Error,
|
||||||
range: {
|
range: {
|
||||||
start: {line: 5, character: 12},
|
start: {line: 5, character: 12},
|
||||||
|
|||||||
@@ -137,6 +137,23 @@
|
|||||||
],
|
],
|
||||||
"string": {}
|
"string": {}
|
||||||
},
|
},
|
||||||
|
"runs-if": {
|
||||||
|
"description": "Condition to control when this action's pre or post script runs.",
|
||||||
|
"context": [
|
||||||
|
"runner",
|
||||||
|
"github",
|
||||||
|
"job",
|
||||||
|
"strategy",
|
||||||
|
"matrix",
|
||||||
|
"env",
|
||||||
|
"inputs",
|
||||||
|
"always(0,0)",
|
||||||
|
"success(0,0)",
|
||||||
|
"failure(0,0)",
|
||||||
|
"cancelled(0,0)"
|
||||||
|
],
|
||||||
|
"string": {}
|
||||||
|
},
|
||||||
"runs": {
|
"runs": {
|
||||||
"one-of": [
|
"one-of": [
|
||||||
"container-runs",
|
"container-runs",
|
||||||
@@ -242,7 +259,7 @@
|
|||||||
"description": "Allows you to run a script before the entrypoint action begins.\n\n[Documentation](https://docs.github.com/actions/creating-actions/metadata-syntax-for-github-actions#runspre-entrypoint)"
|
"description": "Allows you to run a script before the entrypoint action begins.\n\n[Documentation](https://docs.github.com/actions/creating-actions/metadata-syntax-for-github-actions#runspre-entrypoint)"
|
||||||
},
|
},
|
||||||
"pre-if": {
|
"pre-if": {
|
||||||
"type": "non-empty-string",
|
"type": "runs-if",
|
||||||
"description": "Allows you to define conditions for the pre: action execution. The pre: action will only run if the conditions in pre-if are met. If not set, then pre-if defaults to always().\n\n[Documentation](https://docs.github.com/actions/creating-actions/metadata-syntax-for-github-actions#runspre-if)"
|
"description": "Allows you to define conditions for the pre: action execution. The pre: action will only run if the conditions in pre-if are met. If not set, then pre-if defaults to always().\n\n[Documentation](https://docs.github.com/actions/creating-actions/metadata-syntax-for-github-actions#runspre-if)"
|
||||||
},
|
},
|
||||||
"post-entrypoint": {
|
"post-entrypoint": {
|
||||||
@@ -250,7 +267,7 @@
|
|||||||
"description": "Allows you to run a cleanup script once the runs.entrypoint action has completed.\n\n[Documentation](https://docs.github.com/actions/creating-actions/metadata-syntax-for-github-actions#runspost-entrypoint)"
|
"description": "Allows you to run a cleanup script once the runs.entrypoint action has completed.\n\n[Documentation](https://docs.github.com/actions/creating-actions/metadata-syntax-for-github-actions#runspost-entrypoint)"
|
||||||
},
|
},
|
||||||
"post-if": {
|
"post-if": {
|
||||||
"type": "non-empty-string",
|
"type": "runs-if",
|
||||||
"description": "Allows you to define conditions for the post: action execution. The post: action will only run if the conditions in post-if are met. If not set, then post-if defaults to always().\n\n[Documentation](https://docs.github.com/actions/creating-actions/metadata-syntax-for-github-actions#runspost-if)"
|
"description": "Allows you to define conditions for the post: action execution. The post: action will only run if the conditions in post-if are met. If not set, then post-if defaults to always().\n\n[Documentation](https://docs.github.com/actions/creating-actions/metadata-syntax-for-github-actions#runspost-if)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -275,7 +292,7 @@
|
|||||||
"description": "Allows you to run a script at the start of a job, before the main: action begins. You can use pre: to run prerequisite setup scripts.\n\n[Documentation](https://docs.github.com/actions/creating-actions/metadata-syntax-for-github-actions#runspre)"
|
"description": "Allows you to run a script at the start of a job, before the main: action begins. You can use pre: to run prerequisite setup scripts.\n\n[Documentation](https://docs.github.com/actions/creating-actions/metadata-syntax-for-github-actions#runspre)"
|
||||||
},
|
},
|
||||||
"pre-if": {
|
"pre-if": {
|
||||||
"type": "non-empty-string",
|
"type": "runs-if",
|
||||||
"description": "Allows you to define conditions for the pre: action execution. The pre: action will only run if the conditions in pre-if are met. If not set, then pre-if defaults to always().\n\n[Documentation](https://docs.github.com/actions/creating-actions/metadata-syntax-for-github-actions#runspre-if)"
|
"description": "Allows you to define conditions for the pre: action execution. The pre: action will only run if the conditions in pre-if are met. If not set, then pre-if defaults to always().\n\n[Documentation](https://docs.github.com/actions/creating-actions/metadata-syntax-for-github-actions#runspre-if)"
|
||||||
},
|
},
|
||||||
"post": {
|
"post": {
|
||||||
@@ -283,7 +300,7 @@
|
|||||||
"description": "Allows you to run a script at the end of a job, once the main: action has completed. You can use post: to run cleanup scripts.\n\n[Documentation](https://docs.github.com/actions/creating-actions/metadata-syntax-for-github-actions#runspost)"
|
"description": "Allows you to run a script at the end of a job, once the main: action has completed. You can use post: to run cleanup scripts.\n\n[Documentation](https://docs.github.com/actions/creating-actions/metadata-syntax-for-github-actions#runspost)"
|
||||||
},
|
},
|
||||||
"post-if": {
|
"post-if": {
|
||||||
"type": "non-empty-string",
|
"type": "runs-if",
|
||||||
"description": "Allows you to define conditions for the post: action execution. The post: action will only run if the conditions in post-if are met. If not set, then post-if defaults to always().\n\n[Documentation](https://docs.github.com/actions/creating-actions/metadata-syntax-for-github-actions#runspost-if)"
|
"description": "Allows you to define conditions for the post: action execution. The post: action will only run if the conditions in post-if are met. If not set, then post-if defaults to always().\n\n[Documentation](https://docs.github.com/actions/creating-actions/metadata-syntax-for-github-actions#runspost-if)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user