Compare commits

..

2 Commits

Author SHA1 Message Date
eric sciple c85997ad0d Gate container image validation behind feature flag
Add containerImageValidation experimental feature flag that gates the
new container image validation behind an opt-in toggle. When the flag
is off (default), the legacy converter logic is used. When enabled,
the improved validation with expression handling runs.

The legacy code is duplicated to keep code paths fully isolated and
make the eventual cleanup diff minimal — just delete the legacy
functions and the flag guards.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-02-13 21:02:30 +00:00
eric sciple 671f92dbc6 Add validation for empty container image
Related PR:
- https://github.com/actions/runner/pull/4220

Relaxing schema non-empty-string for container/service image and moving to custom validation. This matches current production behavior which allows empty string at runtime, but not parse time.
2026-02-05 23:11:58 +00:00
44 changed files with 2358 additions and 3545 deletions
-3
View File
@@ -1,4 +1 @@
* @actions/actions-vscode-reviewers
# Owners maintaining https://github.com/actions/runner-images
/languageservice/src/value-providers/default.ts @actions/runner-images-writers @actions/actions-vscode-reviewers
+3 -3
View File
@@ -14,7 +14,7 @@ jobs:
strategy:
matrix:
node-version: [20.x, 22.x, 24.x]
node-version: [18.x, 20.x, 22.x]
steps:
- uses: actions/checkout@v4
@@ -37,10 +37,10 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Use Node.js 24.x
- name: Use Node.js 22.x
uses: actions/setup-node@v4
with:
node-version: 24.x
node-version: 22.x
cache: 'npm'
registry-url: 'https://npm.pkg.github.com'
- run: npm ci
+1 -1
View File
@@ -29,7 +29,7 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: 24.x
node-version: "16"
- name: Bump version and push
run: |
+11 -3
View File
@@ -59,7 +59,7 @@ jobs:
permissions:
contents: write
id-token: write
packages: write
env:
PKG_VERSION: "" # will be set in the workflow
@@ -69,8 +69,9 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: 24.x
node-version: 22.x
cache: "npm"
scope: '@actions'
- name: Parse version from lerna.json
run: |
@@ -96,6 +97,13 @@ jobs:
core.summary.addLink(`Release v${{ env.PKG_VERSION }}`, release.data.html_url);
await core.summary.write();
- name: setup authentication
run: echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" >> .npmrc
env:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Publish packages
run: |
npx lerna publish ${{ env.PKG_VERSION }} --yes --no-git-reset --no-git-tag-version
lerna publish ${{ env.PKG_VERSION }} --yes --no-git-reset --no-git-tag-version
env:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
+1 -2
View File
@@ -3,5 +3,4 @@ dist
*.md
*.js
*.json
*.d.ts
/.nx/workspace-data
*.d.ts
-33
View File
@@ -1,33 +0,0 @@
# Agents
## Build
```
npx lerna run build
```
## Test
```
npm -w @actions/expressions test
npm -w @actions/workflow-parser test
npm -w @actions/languageservice test
```
## Format
Always run formatting before committing:
```
npx prettier --write <changed files>
```
Verify with:
```
npm run format-check -ws
```
## Feature flags
Feature flags are defined in `expressions/src/features.ts` (`ExperimentalFeatures` interface + `allFeatureKeys` array). They are plumbed through `ConvertOptions`, `CompletionConfig`, `ValidationConfig`, and `initializationOptions`. When a feature graduates to stable, remove its flag and make the behavior unconditional.
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@actions/expressions",
"version": "0.3.54",
"version": "0.3.44",
"license": "MIT",
"type": "module",
"source": "./src/index.ts",
@@ -44,7 +44,7 @@
"watch": "tsc --build tsconfig.build.json --watch"
},
"engines": {
"node": ">= 20"
"node": ">= 18"
},
"files": [
"dist/**/*"
+3 -7
View File
@@ -35,6 +35,7 @@ export function complete(
context: Dictionary,
extensionFunctions: FunctionInfo[],
functions?: Map<string, FunctionDefinition>,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
featureFlags?: FeatureFlags
): CompletionItem[] {
// Lex
@@ -66,7 +67,7 @@ export function complete(
const result = contextKeys(context);
// Merge with functions
result.push(...functionItems(extensionFunctions, featureFlags));
result.push(...functionItems(extensionFunctions));
return result;
}
@@ -91,15 +92,10 @@ export function complete(
return contextKeys(result);
}
function functionItems(extensionFunctions: FunctionInfo[], featureFlags?: FeatureFlags): CompletionItem[] {
function functionItems(extensionFunctions: FunctionInfo[]): CompletionItem[] {
const result: CompletionItem[] = [];
const flags = featureFlags ?? new FeatureFlags();
for (const fdef of [...Object.values(wellKnownFunctions), ...extensionFunctions]) {
// Filter out case function if feature is disabled
if (fdef.name === "case" && !flags.isEnabled("allowCaseFunction")) {
continue;
}
result.push({
label: fdef.name,
description: fdef.description,
+1 -8
View File
@@ -25,7 +25,6 @@ describe("FeatureFlags", () => {
it("returns true when all is enabled", () => {
const flags = new FeatureFlags({all: true});
expect(flags.isEnabled("missingInputsQuickfix")).toBe(true);
expect(flags.isEnabled("allowConcurrencyQueue")).toBe(true);
});
it("explicit feature flag takes precedence over all:true", () => {
@@ -52,13 +51,7 @@ describe("FeatureFlags", () => {
it("returns all features when all is enabled", () => {
const flags = new FeatureFlags({all: true});
expect(flags.getEnabledFeatures()).toEqual([
"missingInputsQuickfix",
"blockScalarChompingWarning",
"allowCaseFunction",
"allowCopilotRequestsPermission",
"allowConcurrencyQueue"
]);
expect(flags.getEnabledFeatures()).toEqual(["missingInputsQuickfix", "blockScalarChompingWarning"]);
});
});
});
+4 -17
View File
@@ -30,22 +30,11 @@ export interface ExperimentalFeatures {
blockScalarChompingWarning?: boolean;
/**
* Enable the case() function in expressions.
* Enable improved container image validation that handles
* expressions gracefully and validates empty/docker:// images.
* @default false
*/
allowCaseFunction?: boolean;
/**
* Enable the copilot-requests permission in workflow permissions.
* @default false
*/
allowCopilotRequestsPermission?: boolean;
/**
* Enable the queue property in workflow concurrency settings.
* @default false
*/
allowConcurrencyQueue?: boolean;
containerImageValidation?: boolean;
}
/**
@@ -60,9 +49,7 @@ export type ExperimentalFeatureKey = Exclude<keyof ExperimentalFeatures, "all">;
const allFeatureKeys: ExperimentalFeatureKey[] = [
"missingInputsQuickfix",
"blockScalarChompingWarning",
"allowCaseFunction",
"allowCopilotRequestsPermission",
"allowConcurrencyQueue"
"containerImageValidation"
];
export class FeatureFlags {
-1
View File
@@ -127,7 +127,6 @@ initializationOptions: {
|---------|-------------|
| `missingInputsQuickfix` | Code action to add missing required inputs for actions |
| `blockScalarChompingWarning` | Warn when block scalars (`\|` or `>`) use implicit clip chomping, which adds a trailing newline that may be unintentional |
| `allowConcurrencyQueue` | Enable the `concurrency.queue` workflow property |
Individual feature flags take precedence over `all`. For example, `{ all: true, missingInputsQuickfix: false }` enables all experimental features except `missingInputsQuickfix`.
+5 -6
View File
@@ -1,6 +1,6 @@
{
"name": "@actions/languageserver",
"version": "0.3.54",
"version": "0.3.44",
"description": "Language server for GitHub Actions",
"license": "MIT",
"type": "module",
@@ -48,8 +48,8 @@
"actions-languageserver": "./bin/actions-languageserver"
},
"dependencies": {
"@actions/languageservice": "^0.3.54",
"@actions/workflow-parser": "^0.3.54",
"@actions/languageservice": "^0.3.44",
"@actions/workflow-parser": "^0.3.44",
"@octokit/rest": "^21.1.1",
"@octokit/types": "^9.0.0",
"vscode-languageserver": "^8.0.2",
@@ -57,7 +57,7 @@
"yaml": "^2.1.3"
},
"engines": {
"node": ">= 20"
"node": ">= 18"
},
"files": [
"dist/**/*",
@@ -73,10 +73,9 @@
"eslint-plugin-prettier": "^4.2.1",
"fetch-mock": "^9.11.0",
"jest": "^29.0.3",
"node-fetch": "^2.6.7",
"prettier": "^2.8.3",
"rimraf": "^3.0.2",
"ts-jest": "^29.0.3",
"typescript": "^5.8.3"
"typescript": "^4.8.4"
}
}
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "@actions/languageservice",
"version": "0.3.54",
"version": "0.3.44",
"description": "Language service for GitHub Actions",
"license": "MIT",
"type": "module",
@@ -47,15 +47,15 @@
"watch": "tsc --build tsconfig.build.json --watch"
},
"dependencies": {
"@actions/expressions": "^0.3.54",
"@actions/workflow-parser": "^0.3.54",
"@actions/expressions": "^0.3.44",
"@actions/workflow-parser": "^0.3.44",
"vscode-languageserver-textdocument": "^1.0.7",
"vscode-languageserver-types": "^3.17.2",
"vscode-uri": "^3.0.8",
"yaml": "^2.1.1"
},
"engines": {
"node": ">= 20"
"node": ">= 18"
},
"files": [
"dist/**/*"
@@ -74,6 +74,6 @@
"rimraf": "^3.0.2",
"ts-jest": "^29.0.3",
"ts-node": "^10.9.1",
"typescript": "^5.8.3"
"typescript": "^4.8.4"
}
}
@@ -1,5 +1,5 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import {data, DescriptionDictionary, FeatureFlags} from "@actions/expressions";
import {data, DescriptionDictionary} from "@actions/expressions";
import {CompletionItem, CompletionItemKind, MarkupContent} from "vscode-languageserver-types";
import {complete, getExpressionInput} from "./complete.js";
import {ContextProviderConfig} from "./context-providers/config.js";
@@ -69,8 +69,7 @@ describe("expressions", () => {
it("single region", async () => {
const input = "run-name: ${{ | }}";
const result = await complete(...getPositionFromCursor(input), {
contextProviderConfig,
featureFlags: new FeatureFlags({allowCaseFunction: true})
contextProviderConfig
});
expect(result.map(x => x.label)).toEqual([
@@ -113,8 +112,7 @@ describe("expressions", () => {
it("single region with existing input", async () => {
const input = "run-name: ${{ g| }}";
const result = await complete(...getPositionFromCursor(input), {
contextProviderConfig,
featureFlags: new FeatureFlags({allowCaseFunction: true})
contextProviderConfig
});
expect(result.map(x => x.label)).toEqual([
@@ -135,8 +133,7 @@ describe("expressions", () => {
it("single region with existing condition", async () => {
const input = "run-name: ${{ g| == 'test' }}";
const result = await complete(...getPositionFromCursor(input), {
contextProviderConfig,
featureFlags: new FeatureFlags({allowCaseFunction: true})
contextProviderConfig
});
expect(result.map(x => x.label)).toEqual([
@@ -157,8 +154,7 @@ describe("expressions", () => {
it("multiple regions with partial function", async () => {
const input = "run-name: Run a ${{ inputs.test }} one-line script ${{ from|('test') == inputs.name }}";
const result = await complete(...getPositionFromCursor(input), {
contextProviderConfig,
featureFlags: new FeatureFlags({allowCaseFunction: true})
contextProviderConfig
});
expect(result.map(x => x.label)).toEqual([
@@ -179,8 +175,7 @@ describe("expressions", () => {
it("multiple regions - first region", async () => {
const input = "run-name: test-${{ git| == 1 }}-${{ github.event }}";
const result = await complete(...getPositionFromCursor(input), {
contextProviderConfig,
featureFlags: new FeatureFlags({allowCaseFunction: true})
contextProviderConfig
});
expect(result.map(x => x.label)).toEqual([
@@ -201,8 +196,7 @@ describe("expressions", () => {
it("multiple regions", async () => {
const input = "run-name: test-${{ github }}-${{ | }}";
const result = await complete(...getPositionFromCursor(input), {
contextProviderConfig,
featureFlags: new FeatureFlags({allowCaseFunction: true})
contextProviderConfig
});
expect(result.map(x => x.label)).toEqual([
@@ -1164,16 +1158,7 @@ jobs:
`;
const result = await complete(...getPositionFromCursor(input), {contextProviderConfig});
expect(result.map(x => x.label)).toEqual([
"check_run_id",
"container",
"services",
"status",
"workflow_file_path",
"workflow_ref",
"workflow_repository",
"workflow_sha"
]);
expect(result.map(x => x.label)).toEqual(["check_run_id", "container", "services", "status"]);
});
it("job context is suggested within a job output", async () => {
@@ -1190,8 +1175,7 @@ jobs:
`;
const result = await complete(...getPositionFromCursor(input), {
contextProviderConfig,
featureFlags: new FeatureFlags({allowCaseFunction: true})
contextProviderConfig
});
expect(result.map(x => x.label)).toEqual([
"env",
+5 -146
View File
@@ -6,7 +6,6 @@ import {getPositionFromCursor} from "./test-utils/cursor-position.js";
import {TestLogger} from "./test-utils/logger.js";
import {clearCache} from "./utils/workflow-cache.js";
import {ValueProviderConfig, ValueProviderKind} from "./value-providers/config.js";
import {FeatureFlags} from "@actions/expressions/features";
registerLogger(new TestLogger());
@@ -20,8 +19,8 @@ describe("completion", () => {
const result = await complete(...getPositionFromCursor(input));
expect(result).not.toBeUndefined();
// 28 runner labels + 2 escape hatches (switch to list, switch to full syntax)
expect(result.length).toEqual(30);
// 12 runner labels + 2 escape hatches (switch to list, switch to full syntax)
expect(result.length).toEqual(14);
const labels = result.map(x => x.label);
expect(labels).toContain("macos-latest");
expect(labels).toContain("(switch to list)");
@@ -60,7 +59,7 @@ jobs:
const result = await complete(...getPositionFromCursor(input));
expect(result).not.toBeUndefined();
expect(result.length).toEqual(27);
expect(result.length).toEqual(11);
const labels = result.map(x => x.label);
expect(labels).toContain("macos-latest");
@@ -898,11 +897,9 @@ jobs:
});
describe("expression completions", () => {
it("include case function when enabled", async () => {
it("includes case function", async () => {
const input = "on: push\njobs:\n build:\n runs-on: ${{ c|";
const result = await complete(...getPositionFromCursor(input), {
featureFlags: new FeatureFlags({allowCaseFunction: true})
});
const result = await complete(...getPositionFromCursor(input));
expect(result).not.toBeUndefined();
// Expression completions starting with 'c': case, contains
@@ -910,143 +907,5 @@ jobs:
expect(labels).toContain("case");
expect(labels).toContain("contains");
});
it("exclude case function when disabled", async () => {
const input = "on: push\njobs:\n build:\n runs-on: ${{ c|";
const result = await complete(...getPositionFromCursor(input), {
featureFlags: new FeatureFlags({allowCaseFunction: false})
});
expect(result).not.toBeUndefined();
// Expression completions starting with 'c': contains
const labels = result.map(x => x.label);
expect(labels).not.toContain("case");
expect(labels).toContain("contains");
});
});
});
describe("schedule timezone completion", () => {
it("includes timezone for schedule", async () => {
const input = `on:
schedule:
- |`;
const result = await complete(...getPositionFromCursor(input));
expect(result).not.toBeUndefined();
const labels = result.map(x => x.label);
expect(labels).toContain("cron");
expect(labels).toContain("timezone");
});
});
describe("permissions copilot-requests completion", () => {
it("includes copilot-requests when allowCopilotRequestsPermission is enabled", async () => {
const input = `on: push
permissions:
|`;
const result = await complete(...getPositionFromCursor(input), {
featureFlags: new FeatureFlags({allowCopilotRequestsPermission: true})
});
expect(result).not.toBeUndefined();
const labels = result.map(x => x.label);
expect(labels).toContain("actions");
expect(labels).toContain("copilot-requests");
});
it("excludes copilot-requests when allowCopilotRequestsPermission is disabled", async () => {
const input = `on: push
permissions:
|`;
const result = await complete(...getPositionFromCursor(input), {
featureFlags: new FeatureFlags({allowCopilotRequestsPermission: false})
});
expect(result).not.toBeUndefined();
const labels = result.map(x => x.label);
expect(labels).toContain("actions");
expect(labels).not.toContain("copilot-requests");
});
it("excludes copilot-requests when no feature flags are provided", async () => {
const input = `on: push
permissions:
|`;
const result = await complete(...getPositionFromCursor(input));
expect(result).not.toBeUndefined();
const labels = result.map(x => x.label);
expect(labels).toContain("actions");
expect(labels).not.toContain("copilot-requests");
});
it("includes copilot-requests in job-level permissions when allowCopilotRequestsPermission is enabled", async () => {
const input = `on: push
jobs:
build:
runs-on: ubuntu-latest
permissions:
|`;
const result = await complete(...getPositionFromCursor(input), {
featureFlags: new FeatureFlags({allowCopilotRequestsPermission: true})
});
expect(result).not.toBeUndefined();
const labels = result.map(x => x.label);
expect(labels).toContain("actions");
expect(labels).toContain("copilot-requests");
});
it("excludes copilot-requests from job-level permissions when allowCopilotRequestsPermission is disabled", async () => {
const input = `on: push
jobs:
build:
runs-on: ubuntu-latest
permissions:
|`;
const result = await complete(...getPositionFromCursor(input), {
featureFlags: new FeatureFlags({allowCopilotRequestsPermission: false})
});
expect(result).not.toBeUndefined();
const labels = result.map(x => x.label);
expect(labels).toContain("actions");
expect(labels).not.toContain("copilot-requests");
});
});
describe("service container command/entrypoint completion", () => {
it("suggests entrypoint and command in service container", async () => {
const input = `on: push
jobs:
build:
runs-on: ubuntu-latest
services:
redis:
image: redis
|`;
const result = await complete(...getPositionFromCursor(input));
expect(result).not.toBeUndefined();
const labels = result.map(x => x.label);
expect(labels).toContain("entrypoint");
expect(labels).toContain("command");
});
it("does not suggest entrypoint and command in job container", async () => {
const input = `on: push
jobs:
build:
runs-on: ubuntu-latest
container:
image: node:20
|`;
const result = await complete(...getPositionFromCursor(input));
expect(result).not.toBeUndefined();
const labels = result.map(x => x.label);
expect(labels).not.toContain("entrypoint");
expect(labels).not.toContain("command");
});
});
+1 -10
View File
@@ -116,8 +116,7 @@ export async function complete(
config,
{
fetchReusableWorkflowDepth: config?.fileProvider ? 1 : 0,
errorPolicy: ErrorPolicy.TryConversion,
featureFlags: config?.featureFlags
errorPolicy: ErrorPolicy.TryConversion
},
true
);
@@ -164,14 +163,6 @@ export async function complete(
values = filterActionRunsCompletions(values, path, parsedTemplate.value);
}
// Filter `copilot-requests` from permissions completions when the feature flag is disabled
if (
!config?.featureFlags?.isEnabled("allowCopilotRequestsPermission") &&
parent?.definition?.key === "permissions-mapping"
) {
values = values.filter(v => v.label !== "copilot-requests");
}
// Offer "(switch to list)" / "(switch to mapping)" when the schema allows alternative forms
const escapeHatches = getEscapeHatchCompletions(token, keyToken, indentString, newPos, schema);
values.push(...escapeHatches);
@@ -105,6 +105,13 @@
"job": {
"description": "The [`job_id`](https://docs.github.com/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_id) of the current job.\nNote: This context property is set by the Actions runner, and is only available within the execution `steps` of a job. Otherwise, the value of this property will be `null`."
},
"job_workflow_sha": {
"description": "For jobs using a reusable workflow, the commit SHA for the reusable workflow file.",
"versions": {
"ghes": ">=3.9",
"ghae": ">=3.9"
}
},
"path": {
"description": "Path on the runner to the file that sets system PATH variables from workflow commands. This file is unique to the current step and is a different file for each step in a job. For more information, see \"[Workflow commands for GitHub Actions](https://docs.github.com/actions/using-workflows/workflow-commands-for-github-actions#adding-a-system-path).\""
},
@@ -218,18 +225,6 @@
},
"check_run_id": {
"description": "The unique identifier of the check run for this job."
},
"workflow_file_path": {
"description": "The path of the workflow file that contains the job. For example, `.github/workflows/my-workflow.yml`."
},
"workflow_ref": {
"description": "The ref path to the workflow file that contains the job. For example, `octocat/hello-world/.github/workflows/my-workflow.yml@refs/heads/my_branch`."
},
"workflow_repository": {
"description": "The owner and repository name of the workflow file that contains the job. For example, `octocat/Hello-World`."
},
"workflow_sha": {
"description": "The commit SHA of the workflow file that contains the job."
}
},
"secrets": {
@@ -29,6 +29,7 @@ export function getGithubContext(workflowContext: WorkflowContext | undefined, m
"graphql_url",
"head_ref",
"job",
"job_workflow_sha",
"path",
"ref",
"ref_name",
@@ -18,16 +18,12 @@ describe("job context", () => {
expect(context.pairs().length).toBe(0);
});
it("returns status, check_run_id, and workflow fields when job has no container or services", () => {
it("returns status and check_run_id when job has no container or services", () => {
const workflowContext = {job: {}} as WorkflowContext;
const context = getJobContext(workflowContext);
expect(context.get("status")).toBeDefined();
expect(context.get("check_run_id")).toBeDefined();
expect(context.get("workflow_ref")).toBeDefined();
expect(context.get("workflow_sha")).toBeDefined();
expect(context.get("workflow_repository")).toBeDefined();
expect(context.get("workflow_file_path")).toBeDefined();
expect(context.get("container")).toBeUndefined();
expect(context.get("services")).toBeUndefined();
});
@@ -177,21 +173,4 @@ describe("job context", () => {
expect(redis.getDescription("ports")).toBeDefined();
});
});
describe("workflow context fields", () => {
it("includes workflow context fields with descriptions", () => {
const workflowContext = {job: {}} as WorkflowContext;
const context = getJobContext(workflowContext);
expect(context.get("workflow_ref")).toBeDefined();
expect(context.get("workflow_sha")).toBeDefined();
expect(context.get("workflow_repository")).toBeDefined();
expect(context.get("workflow_file_path")).toBeDefined();
expect(context.getDescription("workflow_ref")).toBeDefined();
expect(context.getDescription("workflow_sha")).toBeDefined();
expect(context.getDescription("workflow_repository")).toBeDefined();
expect(context.getDescription("workflow_file_path")).toBeDefined();
});
});
});
+1 -7
View File
@@ -5,7 +5,7 @@ import {WorkflowContext} from "../context/workflow-context.js";
import {getDescription} from "./descriptions.js";
/**
* Returns the job context with container, services, status, check_run_id, and workflow identity fields.
* Returns the job context with container, services, status, and check_run_id.
*/
export function getJobContext(workflowContext: WorkflowContext): DescriptionDictionary {
// https://docs.github.com/en/actions/learn-github-actions/contexts#job-context
@@ -42,12 +42,6 @@ export function getJobContext(workflowContext: WorkflowContext): DescriptionDict
// Check run ID
jobContext.add("check_run_id", new data.StringData(""), getDescription("job", "check_run_id"));
// Workflow context fields (populated at runtime for reusable workflow jobs)
jobContext.add("workflow_file_path", new data.StringData(""), getDescription("job", "workflow_file_path"));
jobContext.add("workflow_ref", new data.StringData(""), getDescription("job", "workflow_ref"));
jobContext.add("workflow_repository", new data.StringData(""), getDescription("job", "workflow_repository"));
jobContext.add("workflow_sha", new data.StringData(""), getDescription("job", "workflow_sha"));
return jobContext;
}
+2 -6
View File
@@ -120,9 +120,7 @@ jobs:
`;
const result = await hover(...getPositionFromCursor(input));
expect(result).not.toBeUndefined();
expect(result?.contents).toEqual(
"A cron expression that represents a schedule. A scheduled workflow will run at most once every 5 minutes."
);
expect(result?.contents).toEqual("");
});
it("on an invalid cron schedule", async () => {
@@ -132,9 +130,7 @@ jobs:
`;
const result = await hover(...getPositionFromCursor(input));
expect(result).not.toBeUndefined();
expect(result?.contents).toEqual(
"A cron expression that represents a schedule. A scheduled workflow will run at most once every 5 minutes."
);
expect(result?.contents).toEqual("");
});
it("shows context inherited from parent nodes", async () => {
@@ -1,4 +1,3 @@
import {FeatureFlags} from "@actions/expressions/features";
import {DiagnosticSeverity} from "vscode-languageserver-types";
import {validate} from "./validate.js";
import {createDocument} from "./test-utils/document.js";
@@ -8,10 +7,6 @@ beforeEach(() => {
clearCache();
});
const queueValidationConfig = {
featureFlags: new FeatureFlags({allowConcurrencyQueue: true})
};
describe("validate concurrency deadlock", () => {
describe("should error on matching concurrency groups", () => {
it("simple string match", async () => {
@@ -248,186 +243,3 @@ jobs:
});
});
});
describe("validate concurrency queue + cancel-in-progress conflict", () => {
describe("should error", () => {
it("workflow-level queue: max with cancel-in-progress: true", async () => {
const input = `
on: push
concurrency:
group: deploy
cancel-in-progress: true
queue: max
jobs:
job1:
runs-on: ubuntu-latest
steps:
- run: echo hi`;
const result = await validate(createDocument("wf.yaml", input), queueValidationConfig);
const queueErrors = result.filter(d => d.message.includes("queue: max"));
expect(queueErrors).toHaveLength(1);
expect(queueErrors[0]).toMatchObject({
message: "'queue: max' cannot be combined with 'cancel-in-progress: true'.",
severity: DiagnosticSeverity.Error
});
});
it("job-level queue: max with cancel-in-progress: true", async () => {
const input = `
on: push
jobs:
job1:
runs-on: ubuntu-latest
concurrency:
group: deploy
cancel-in-progress: true
queue: max
steps:
- run: echo hi`;
const result = await validate(createDocument("wf.yaml", input), queueValidationConfig);
const queueErrors = result.filter(d => d.message.includes("queue: max"));
expect(queueErrors).toHaveLength(1);
expect(queueErrors[0]).toMatchObject({
severity: DiagnosticSeverity.Error
});
});
it("both workflow and job level have the conflict", async () => {
const input = `
on: push
concurrency:
group: deploy
cancel-in-progress: true
queue: max
jobs:
job1:
runs-on: ubuntu-latest
concurrency:
group: build
cancel-in-progress: true
queue: max
steps:
- run: echo hi`;
const result = await validate(createDocument("wf.yaml", input), queueValidationConfig);
const queueErrors = result.filter(d => d.message.includes("queue: max"));
expect(queueErrors).toHaveLength(2);
});
});
describe("should not error", () => {
it("queue: max without cancel-in-progress", async () => {
const input = `
on: push
concurrency:
group: deploy
queue: max
jobs:
job1:
runs-on: ubuntu-latest
steps:
- run: echo hi`;
const result = await validate(createDocument("wf.yaml", input));
const queueErrors = result.filter(d => d.message.includes("queue: max"));
expect(queueErrors).toHaveLength(0);
});
it("queue: single with cancel-in-progress: true", async () => {
const input = `
on: push
concurrency:
group: deploy
cancel-in-progress: true
queue: single
jobs:
job1:
runs-on: ubuntu-latest
steps:
- run: echo hi`;
const result = await validate(createDocument("wf.yaml", input));
const queueErrors = result.filter(d => d.message.includes("queue: max"));
expect(queueErrors).toHaveLength(0);
});
it("cancel-in-progress: false with queue: max", async () => {
const input = `
on: push
concurrency:
group: deploy
cancel-in-progress: false
queue: max
jobs:
job1:
runs-on: ubuntu-latest
steps:
- run: echo hi`;
const result = await validate(createDocument("wf.yaml", input));
const queueErrors = result.filter(d => d.message.includes("queue: max"));
expect(queueErrors).toHaveLength(0);
});
it("no queue property", async () => {
const input = `
on: push
concurrency:
group: deploy
cancel-in-progress: true
jobs:
job1:
runs-on: ubuntu-latest
steps:
- run: echo hi`;
const result = await validate(createDocument("wf.yaml", input));
const queueErrors = result.filter(d => d.message.includes("queue: max"));
expect(queueErrors).toHaveLength(0);
});
it("string form concurrency (no mapping)", async () => {
const input = `
on: push
concurrency: deploy
jobs:
job1:
runs-on: ubuntu-latest
steps:
- run: echo hi`;
const result = await validate(createDocument("wf.yaml", input));
const queueErrors = result.filter(d => d.message.includes("queue: max"));
expect(queueErrors).toHaveLength(0);
});
it("does not report queue conflict when the feature is disabled", async () => {
const input = `
on: push
concurrency:
group: deploy
cancel-in-progress: true
queue: max
jobs:
job1:
runs-on: ubuntu-latest
steps:
- run: echo hi`;
const result = await validate(createDocument("wf.yaml", input));
const queueConflictErrors = result.filter(d => d.message.includes("queue: max"));
expect(queueConflictErrors).toHaveLength(0);
});
});
});
@@ -432,24 +432,6 @@ jobs:
expect(result).toEqual([]);
});
it("job.workflow_* fields", async () => {
const input = `
on: push
jobs:
test:
runs-on: ubuntu-latest
steps:
- run: echo \${{ job.workflow_ref }}
- run: echo \${{ job.workflow_sha }}
- run: echo \${{ job.workflow_repository }}
- run: echo \${{ job.workflow_file_path }}
`;
const result = await validate(createDocument("wf.yaml", input));
expect(result).toEqual([]);
});
it("job.services.<service_id>", async () => {
const input = `
on: push
@@ -1,102 +0,0 @@
import {registerLogger} from "./log.js";
import {createDocument} from "./test-utils/document.js";
import {TestLogger} from "./test-utils/logger.js";
import {clearCache} from "./utils/workflow-cache.js";
import {validate} from "./validate.js";
registerLogger(new TestLogger());
beforeEach(() => {
clearCache();
});
describe("service container command/entrypoint", () => {
it("allows command in service container", async () => {
const input = `
on: push
jobs:
build:
runs-on: ubuntu-latest
services:
redis:
image: redis
command: --port 6380
steps:
- run: echo hi
`;
const result = await validate(createDocument("wf.yaml", input));
const commandErrors = result.filter(d => d.message.includes("command"));
expect(commandErrors).toEqual([]);
});
it("allows entrypoint in service container", async () => {
const input = `
on: push
jobs:
build:
runs-on: ubuntu-latest
services:
redis:
image: redis
entrypoint: /usr/local/bin/redis-server
steps:
- run: echo hi
`;
const result = await validate(createDocument("wf.yaml", input));
const entrypointErrors = result.filter(d => d.message.includes("entrypoint"));
expect(entrypointErrors).toEqual([]);
});
it("allows both command and entrypoint in service container", async () => {
const input = `
on: push
jobs:
build:
runs-on: ubuntu-latest
services:
redis:
image: redis
entrypoint: /usr/local/bin/redis-server
command: --port 6380
steps:
- run: echo hi
`;
const result = await validate(createDocument("wf.yaml", input));
const relevantErrors = result.filter(d => d.message.includes("command") || d.message.includes("entrypoint"));
expect(relevantErrors).toEqual([]);
});
it("rejects command in job container", async () => {
const input = `
on: push
jobs:
build:
runs-on: ubuntu-latest
container:
image: node:20
command: node
steps:
- run: echo hi
`;
const result = await validate(createDocument("wf.yaml", input));
const commandErrors = result.filter(d => d.message.includes("command"));
expect(commandErrors.length).toBeGreaterThan(0);
});
it("rejects entrypoint in job container", async () => {
const input = `
on: push
jobs:
build:
runs-on: ubuntu-latest
container:
image: node:20
entrypoint: /bin/bash
steps:
- run: echo hi
`;
const result = await validate(createDocument("wf.yaml", input));
const entrypointErrors = result.filter(d => d.message.includes("entrypoint"));
expect(entrypointErrors.length).toBeGreaterThan(0);
});
});
-18
View File
@@ -368,24 +368,6 @@ jobs:
});
});
describe("environment deployment", () => {
it("allows deployment boolean under environment mapping", async () => {
const workflow = `
on: push
jobs:
build:
runs-on: ubuntu-latest
environment:
name: prod
deployment: false
steps:
- run: echo
`;
const result = await validate(createDocument("wf.yaml", workflow));
expect(result).toEqual([]);
});
});
describe("workflow_dispatch", () => {
it("allows empty string in choice options", async () => {
const result = await validate(
+2 -64
View File
@@ -1,13 +1,6 @@
import {FeatureFlags, Lexer, Parser} from "@actions/expressions";
import {Expr} from "@actions/expressions/ast";
import {
TemplateParseResult,
WorkflowTemplate,
isBasicExpression,
isBoolean,
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 {getCronDescription, hasCronIntervalLessThan5Minutes} from "@actions/workflow-parser/model/converter/cron";
import {ensureStatusFunction} from "@actions/workflow-parser/model/converter/if-condition";
@@ -91,8 +84,7 @@ async function validateWorkflow(textDocument: TextDocument, config?: ValidationC
// Errors will be updated in the context. Attempt to do the conversion anyway in order to give the user more information
const template = await getOrConvertWorkflowTemplate(result.context, result.value, textDocument.uri, config, {
fetchReusableWorkflowDepth: config?.fileProvider ? 1 : 0,
errorPolicy: ErrorPolicy.TryConversion,
featureFlags: config?.featureFlags
errorPolicy: ErrorPolicy.TryConversion
});
// Validate expressions and value providers
@@ -246,11 +238,6 @@ async function additionalValidations(
// Validate concurrency deadlock between workflow and job levels
validateConcurrencyDeadlock(diagnostics, template);
// Validate incompatible concurrency options
if (featureFlags?.isEnabled("allowConcurrencyQueue")) {
validateConcurrencyQueueCancelInProgress(diagnostics, template);
}
}
function invalidValue(diagnostics: Diagnostic[], token: StringToken, kind: ValueProviderKind) {
@@ -676,55 +663,6 @@ function validateConcurrencyDeadlock(diagnostics: Diagnostic[], template: Workfl
}
}
/**
* Validates that `queue: max` and `cancel-in-progress: true` are not both set
* in a concurrency mapping, as this combination is invalid.
*/
function validateConcurrencyQueueCancelInProgress(diagnostics: Diagnostic[], template: WorkflowTemplate): void {
// Check workflow-level concurrency
if (template.concurrency) {
checkConcurrencyQueueConflict(diagnostics, template.concurrency);
}
// Check job-level concurrency
for (const job of template.jobs || []) {
if (job.concurrency) {
checkConcurrencyQueueConflict(diagnostics, job.concurrency);
}
}
}
function checkConcurrencyQueueConflict(diagnostics: Diagnostic[], token: TemplateToken): void {
if (!isMapping(token)) {
return;
}
let hasQueueMax = false;
let hasCancelInProgressTrue = false;
let queueRange: TokenRange | undefined;
for (const pair of token) {
if (!isString(pair.key) || pair.key.isExpression || pair.value.isExpression) {
continue;
}
if (pair.key.value === "queue" && isString(pair.value) && pair.value.value === "max") {
hasQueueMax = true;
queueRange = pair.key.range;
}
if (pair.key.value === "cancel-in-progress" && isBoolean(pair.value) && pair.value.value) {
hasCancelInProgressTrue = true;
}
}
if (hasQueueMax && hasCancelInProgressTrue && queueRange) {
diagnostics.push({
message: "'queue: max' cannot be combined with 'cancel-in-progress: true'.",
range: mapRange(queueRange),
severity: DiagnosticSeverity.Error
});
}
}
/**
* Extracts the static concurrency group name from a concurrency token.
* Returns undefined if the token is an expression or doesn't have a static group.
+9 -26
View File
@@ -4,36 +4,19 @@ import {reusableJobInputs} from "./reusable-job-inputs.js";
import {reusableJobSecrets} from "./reusable-job-secrets.js";
import {stringsToValues} from "./strings-to-values.js";
// Refer to: https://github.com/actions/runner-images?tab=readme-ov-file#available-images
export const DEFAULT_RUNNER_LABELS = [
"codespaces-prebuild",
"macos-13",
"macos-13-large",
"macos-13-xlarge",
"macos-14",
"macos-14-large",
"macos-14-xlarge",
"macos-15",
"macos-15-intel",
"macos-15-large",
"macos-15-xlarge",
"macos-26",
"macos-26-large",
"macos-26-xlarge",
"macos-latest",
"macos-latest-large",
"macos-latest-xlarge",
"self-hosted",
"ubuntu-22.04",
"ubuntu-22.04-arm",
"ubuntu-24.04",
"ubuntu-24.04-arm",
"ubuntu-latest",
"ubuntu-24.04",
"ubuntu-22.04",
"ubuntu-20.04",
"ubuntu-slim",
"windows-latest",
"windows-2022",
"windows-2025",
"windows-2025-vs2026",
"windows-latest"
"windows-2019",
"macos-latest",
"macos-15",
"macos-14",
"self-hosted"
];
const runsOnValueProvider = {
+1 -1
View File
@@ -6,5 +6,5 @@
"languageservice",
"languageserver"
],
"version": "0.3.54"
"version": "0.3.44"
}
+1736 -2397
View File
File diff suppressed because it is too large Load Diff
+4 -1
View File
@@ -9,7 +9,10 @@
"./languageserver"
],
"devDependencies": {
"lerna": "^9.0.0",
"lerna": "^8.2.2",
"typescript": "5.8.3"
},
"overrides": {
"typescript": "$typescript"
}
}
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@actions/workflow-parser",
"version": "0.3.54",
"version": "0.3.44",
"license": "MIT",
"type": "module",
"source": "./src/index.ts",
@@ -48,12 +48,12 @@
"watch": "tsc --build tsconfig.build.json --watch"
},
"dependencies": {
"@actions/expressions": "^0.3.54",
"@actions/expressions": "^0.3.44",
"cronstrue": "^2.21.0",
"yaml": "^2.0.0-8"
},
"engines": {
"node": ">= 20"
"node": ">= 18"
},
"files": [
"dist/**/*"
@@ -69,6 +69,6 @@
"prettier": "^2.8.3",
"rimraf": "^3.0.2",
"ts-jest": "^29.0.3",
"typescript": "^5.8.3"
"typescript": "^4.8.4"
}
}
-82
View File
@@ -578,86 +578,4 @@ jobs:
}
});
});
describe("schedule timezone", () => {
it("allows timezone in schedule", async () => {
const result = parseWorkflow(
{
name: "wf.yaml",
content: `on:
schedule:
- cron: '0 0 * * *'
timezone: America/New_York
jobs:
build:
runs-on: ubuntu-latest`
},
nullTrace
);
const template = await convertWorkflowTemplate(result.context, result.value!, undefined, {
errorPolicy: ErrorPolicy.TryConversion
});
expect(result.context.errors.getErrors()).toHaveLength(0);
expect(template.events?.schedule).toHaveLength(1);
expect(template.events?.schedule?.[0]).toEqual({
cron: "0 0 * * *",
timezone: "America/New_York"
});
});
it("reports error when cron is missing from schedule entry", async () => {
const result = parseWorkflow(
{
name: "wf.yaml",
content: `on:
schedule:
- timezone: America/New_York
jobs:
build:
runs-on: ubuntu-latest`
},
nullTrace
);
const template = await convertWorkflowTemplate(result.context, result.value!, undefined, {
errorPolicy: ErrorPolicy.TryConversion
});
// Both schema validation and converter report the missing cron
expect(result.context.errors.getErrors().length).toBeGreaterThanOrEqual(1);
const errorMessages = result.context.errors
.getErrors()
.map(e => e.message)
.join(", ");
expect(errorMessages).toMatch(/Required property is missing: cron|Missing required key 'cron'/);
expect(template.events?.schedule).toHaveLength(0);
});
it("converts schedule without timezone", async () => {
const result = parseWorkflow(
{
name: "wf.yaml",
content: `on:
schedule:
- cron: '0 0 * * *'
jobs:
build:
runs-on: ubuntu-latest`
},
nullTrace
);
const template = await convertWorkflowTemplate(result.context, result.value!, undefined, {
errorPolicy: ErrorPolicy.TryConversion
});
expect(result.context.errors.getErrors()).toHaveLength(0);
expect(template.events?.schedule).toHaveLength(1);
expect(template.events?.schedule?.[0]).toEqual({
cron: "0 0 * * *"
});
});
});
});
+12 -8
View File
@@ -1,4 +1,4 @@
import {FeatureFlags} from "@actions/expressions/features";
import {FeatureFlags} from "@actions/expressions";
import {TemplateContext} from "../templates/template-context.js";
import {TemplateToken, TemplateTokenError} from "../templates/tokens/template-token.js";
import {FileProvider} from "../workflows/file-provider.js";
@@ -41,15 +41,15 @@ export type WorkflowTemplateConverterOptions = {
/**
* Feature flags for experimental features.
* When not provided, all experimental features are disabled.
*/
featureFlags?: FeatureFlags;
};
const defaultOptions: Required<WorkflowTemplateConverterOptions> = {
const defaultOptions: Omit<Required<WorkflowTemplateConverterOptions>, "featureFlags"> = {
maxReusableWorkflowDepth: 4,
fetchReusableWorkflowDepth: 0,
errorPolicy: ErrorPolicy.ReturnErrorsOnly,
featureFlags: new FeatureFlags()
errorPolicy: ErrorPolicy.ReturnErrorsOnly
};
export async function convertWorkflowTemplate(
@@ -61,7 +61,10 @@ export async function convertWorkflowTemplate(
const result = {} as WorkflowTemplate;
const opts = getOptionsWithDefaults(options);
context.state.featureFlags = opts.featureFlags;
// Store feature flags in context for converter functions
if (options.featureFlags) {
context.state["featureFlags"] = options.featureFlags;
}
if (context.errors.getErrors().length > 0 && opts.errorPolicy === ErrorPolicy.ReturnErrorsOnly) {
result.errors = context.errors.getErrors().map(x => ({
@@ -141,7 +144,9 @@ export async function convertWorkflowTemplate(
return result;
}
function getOptionsWithDefaults(options: WorkflowTemplateConverterOptions): Required<WorkflowTemplateConverterOptions> {
function getOptionsWithDefaults(
options: WorkflowTemplateConverterOptions
): Omit<Required<WorkflowTemplateConverterOptions>, "featureFlags"> {
return {
maxReusableWorkflowDepth:
options.maxReusableWorkflowDepth !== undefined
@@ -151,7 +156,6 @@ function getOptionsWithDefaults(options: WorkflowTemplateConverterOptions): Requ
options.fetchReusableWorkflowDepth !== undefined
? options.fetchReusableWorkflowDepth
: defaultOptions.fetchReusableWorkflowDepth,
errorPolicy: options.errorPolicy !== undefined ? options.errorPolicy : defaultOptions.errorPolicy,
featureFlags: options.featureFlags ?? defaultOptions.featureFlags
errorPolicy: options.errorPolicy !== undefined ? options.errorPolicy : defaultOptions.errorPolicy
};
}
@@ -1,12 +1,10 @@
import type {FeatureFlags} from "@actions/expressions/features";
import {TemplateContext} from "../../templates/template-context.js";
import {TemplateToken} from "../../templates/tokens/template-token.js";
import {isString} from "../../templates/tokens/type-guards.js";
import {ConcurrencyQueue, ConcurrencySetting} from "../workflow-template.js";
import {ConcurrencySetting} from "../workflow-template.js";
export function convertConcurrency(context: TemplateContext, token: TemplateToken): ConcurrencySetting {
const result: ConcurrencySetting = {};
const featureFlags = context.state.featureFlags as FeatureFlags | undefined;
if (token.isExpression) {
return result;
@@ -28,11 +26,6 @@ export function convertConcurrency(context: TemplateContext, token: TemplateToke
case "cancel-in-progress":
result.cancelInProgress = property.value.assertBoolean("cancel-in-progress").value;
break;
case "queue":
if (featureFlags?.isEnabled("allowConcurrencyQueue")) {
result.queue = property.value.assertString("queue").value as ConcurrencyQueue;
}
break;
default:
context.error(propertyName, `Invalid property name: ${propertyName.value}`);
}
@@ -0,0 +1,318 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import {nullTrace} from "../../test-utils/null-trace.js";
import {parseWorkflow} from "../../workflows/workflow-parser.js";
import {convertWorkflowTemplate, ErrorPolicy} from "../convert.js";
// Minimal FeatureFlags-compatible object for tests
const featureFlags = {isEnabled: (f: string) => f === "containerImageValidation"};
async function getErrors(content: string): Promise<string[]> {
const result = parseWorkflow({name: "wf.yaml", content}, nullTrace);
result.context.state["featureFlags"] = featureFlags;
const template = await convertWorkflowTemplate(result.context, result.value!, undefined, {
errorPolicy: ErrorPolicy.TryConversion
});
return (template.errors ?? []).map((e: {Message: string}) => e.Message);
}
function expectNoContainerErrors(errors: string[]): void {
const containerErrors = errors.filter(e => e.includes("Container image"));
expect(containerErrors).toHaveLength(0);
}
function expectContainerError(errors: string[], count = 1): void {
const containerErrors = errors.filter(e => e.includes("Container image cannot be empty"));
expect(containerErrors).toHaveLength(count);
}
describe("container image validation", () => {
describe("shorthand form", () => {
it("container: '' is silent for job container", async () => {
const errors = await getErrors(`on: push
jobs:
build:
runs-on: linux
container: ''
steps:
- run: echo hi`);
expectNoContainerErrors(errors);
});
it("container: docker:// errors", async () => {
const errors = await getErrors(`on: push
jobs:
build:
runs-on: linux
container: docker://
steps:
- run: echo hi`);
expectContainerError(errors);
});
it("container: valid-image passes", async () => {
const errors = await getErrors(`on: push
jobs:
build:
runs-on: linux
container: ubuntu:16.04
steps:
- run: echo hi`);
expectNoContainerErrors(errors);
});
});
describe("mapping form", () => {
it("container image: '' errors", async () => {
const errors = await getErrors(`on: push
jobs:
build:
runs-on: linux
container:
image: ''
steps:
- run: echo hi`);
expectContainerError(errors);
});
it("container image: docker:// errors", async () => {
const errors = await getErrors(`on: push
jobs:
build:
runs-on: linux
container:
image: docker://
steps:
- run: echo hi`);
expectContainerError(errors);
});
it("container: {} (empty object, missing image) errors", async () => {
const errors = await getErrors(`on: push
jobs:
build:
runs-on: linux
container: {}
steps:
- run: echo hi`);
expectContainerError(errors);
});
it("container image: null errors", async () => {
const errors = await getErrors(`on: push
jobs:
build:
runs-on: linux
container:
image:
steps:
- run: echo hi`);
expectContainerError(errors);
});
it("empty image with expression in other field still errors", async () => {
const errors = await getErrors(`on: push
jobs:
build:
runs-on: linux
container:
image: ''
options: \${{ matrix.opts }}
steps:
- run: echo hi`);
expectContainerError(errors);
});
});
describe("services shorthand", () => {
it("services svc: '' errors", async () => {
const errors = await getErrors(`on: push
jobs:
build:
runs-on: linux
services:
svc: ''
steps:
- run: echo hi`);
expectContainerError(errors);
});
it("services svc: docker:// errors", async () => {
const errors = await getErrors(`on: push
jobs:
build:
runs-on: linux
services:
svc: docker://
steps:
- run: echo hi`);
expectContainerError(errors);
});
});
describe("services mapping", () => {
it("services svc image: '' errors", async () => {
const errors = await getErrors(`on: push
jobs:
build:
runs-on: linux
services:
svc:
image: ''
steps:
- run: echo hi`);
expectContainerError(errors);
});
it("services svc image: docker:// errors", async () => {
const errors = await getErrors(`on: push
jobs:
build:
runs-on: linux
services:
svc:
image: docker://
steps:
- run: echo hi`);
expectContainerError(errors);
});
it("services svc: {} (empty object) errors", async () => {
const errors = await getErrors(`on: push
jobs:
build:
runs-on: linux
services:
svc: {}
steps:
- run: echo hi`);
expectContainerError(errors);
});
it("empty image with expression sibling service still errors", async () => {
const errors = await getErrors(`on: push
jobs:
build:
runs-on: linux
services:
svc1:
image: ''
svc2: \${{ matrix.svc }}
steps:
- run: echo hi`);
expectContainerError(errors);
});
});
describe("expression safety", () => {
it("container: expression skips validation", async () => {
const errors = await getErrors(`on: push
jobs:
build:
runs-on: linux
container: \${{ matrix.container }}
steps:
- run: echo hi`);
expectNoContainerErrors(errors);
});
it("container image: expression skips validation", async () => {
const errors = await getErrors(`on: push
jobs:
build:
runs-on: linux
container:
image: \${{ matrix.image }}
options: --privileged
steps:
- run: echo hi`);
expectNoContainerErrors(errors);
});
it("container with expression key skips validation", async () => {
const errors = await getErrors(`on: push
jobs:
build:
runs-on: linux
container:
\${{ vars.KEY }}: ubuntu
steps:
- run: echo hi`);
expectNoContainerErrors(errors);
});
it("services: expression skips validation", async () => {
const errors = await getErrors(`on: push
jobs:
build:
runs-on: linux
services: \${{ fromJSON(inputs.services) }}
steps:
- run: echo hi`);
expectNoContainerErrors(errors);
});
it("services with expression alias key skips validation", async () => {
const errors = await getErrors(`on: push
jobs:
build:
runs-on: linux
services:
\${{ matrix.alias }}: postgres
steps:
- run: echo hi`);
expectNoContainerErrors(errors);
});
it("services container with expression key skips validation", async () => {
const errors = await getErrors(`on: push
jobs:
build:
runs-on: linux
services:
db:
\${{ vars.KEY }}: postgres
steps:
- run: echo hi`);
expectNoContainerErrors(errors);
});
it("container with all expression fields skips validation", async () => {
const errors = await getErrors(`on: push
jobs:
build:
runs-on: linux
container:
image: \${{ matrix.image }}
options: \${{ matrix.options }}
steps:
- run: echo hi`);
expectNoContainerErrors(errors);
});
it("services svc: expression skips validation", async () => {
const errors = await getErrors(`on: push
jobs:
build:
runs-on: linux
services:
db: \${{ matrix.db }}
steps:
- run: echo hi`);
expectNoContainerErrors(errors);
});
it("services image: expression skips validation", async () => {
const errors = await getErrors(`on: push
jobs:
build:
runs-on: linux
services:
db:
image: \${{ matrix.db_image }}
options: --health-cmd pg_isready
steps:
- run: echo hi`);
expectNoContainerErrors(errors);
});
});
});
+189 -82
View File
@@ -1,17 +1,199 @@
import {FeatureFlags} from "@actions/expressions";
import {TemplateContext} from "../../templates/template-context.js";
import {MappingToken, SequenceToken, StringToken, TemplateToken} from "../../templates/tokens/index.js";
import {isString} from "../../templates/tokens/type-guards.js";
import {Container, Credential} from "../workflow-template.js";
export function convertToJobContainer(context: TemplateContext, container: TemplateToken): Container | undefined {
function getFeatureFlags(context: TemplateContext): FeatureFlags | undefined {
return context.state["featureFlags"] as FeatureFlags | undefined;
}
const DOCKER_URI_PREFIX = "docker://";
function isEmptyImage(value: string): boolean {
const trimmed = value.startsWith(DOCKER_URI_PREFIX) ? value.substring(DOCKER_URI_PREFIX.length) : value;
return trimmed.length === 0;
}
export function convertToJobContainer(
context: TemplateContext,
container: TemplateToken,
isServiceContainer = false
): Container | undefined {
// Feature flag guard — use legacy implementation when flag is off
if (!getFeatureFlags(context)?.isEnabled("containerImageValidation")) {
return convertToJobContainerLegacy(context, container);
}
if (container.isExpression) {
return;
}
// Shorthand form
if (isString(container)) {
const image = container.assertString("container item");
if (!image || image.value.length === 0) {
if (isServiceContainer) {
context.error(container, "Container image cannot be empty");
}
return;
}
if (isEmptyImage(image.value)) {
context.error(container, "Container image cannot be empty");
return;
}
return {image};
}
// Mapping form
const mapping = container.assertMapping("container item");
if (!mapping) {
return;
}
let image: StringToken | undefined;
let env: MappingToken | undefined;
let ports: SequenceToken | undefined;
let volumes: SequenceToken | undefined;
let options: StringToken | undefined;
let credentials: Credential | undefined;
let hasExpressionKey = false;
let hasExpression = false;
for (const item of mapping) {
if (item.key.isExpression) {
hasExpressionKey = true;
continue;
}
const key = item.key.assertString("container item key");
switch (key.value) {
case "image":
if (item.value.isExpression) {
hasExpression = true;
break;
}
image = item.value.assertString("container image");
break;
case "credentials":
if (!item.value.isExpression) {
credentials = convertCredentials(context, item.value);
}
break;
case "env":
if (!item.value.isExpression) {
env = item.value.assertMapping("container env");
}
break;
case "ports":
if (!item.value.isExpression) {
ports = item.value.assertSequence("container ports");
}
break;
case "volumes":
if (!item.value.isExpression) {
volumes = item.value.assertSequence("container volumes");
}
break;
case "options":
if (!item.value.isExpression) {
options = item.value.assertString("container options");
}
break;
default:
context.error(key, `Unexpected container item key: ${key.value}`);
}
}
// Validate image
if (image) {
if (isEmptyImage(image.value)) {
context.error(image, "Container image cannot be empty");
return;
}
return {image, credentials, env, ports, volumes, options};
}
// No image key — skip error if expression keys could provide one
if (!hasExpressionKey && !hasExpression) {
context.error(container, "Container image cannot be empty");
}
}
export function convertToJobServices(context: TemplateContext, services: TemplateToken): Container[] | undefined {
// Feature flag guard — use legacy implementation when flag is off
if (!getFeatureFlags(context)?.isEnabled("containerImageValidation")) {
return convertToJobServicesLegacy(context, services);
}
if (services.isExpression) {
return;
}
const serviceList: Container[] = [];
const mapping = services.assertMapping("services");
for (const service of mapping) {
if (service.key.isExpression) {
continue;
}
service.key.assertString("service key");
const container = convertToJobContainer(context, service.value, true);
if (container) {
serviceList.push(container);
}
}
return serviceList;
}
function convertCredentials(context: TemplateContext, value: TemplateToken): Credential | undefined {
const mapping = value.assertMapping("credentials");
if (!mapping) {
return;
}
let username: StringToken | undefined;
let password: StringToken | undefined;
for (const item of mapping) {
if (item.key.isExpression) {
continue;
}
const key = item.key.assertString("credentials item");
if (item.value.isExpression) {
continue;
}
switch (key.value) {
case "username":
username = item.value.assertString("credentials username");
break;
case "password":
password = item.value.assertString("credentials password");
break;
default:
context.error(key, `credentials key ${key.value}`);
}
}
return {username, password};
}
// ===== Legacy implementations (remove when containerImageValidation graduates) =====
function convertToJobContainerLegacy(context: TemplateContext, container: TemplateToken): Container | undefined {
let image: StringToken | undefined;
let env: MappingToken | undefined;
let ports: SequenceToken | undefined;
let volumes: SequenceToken | undefined;
let options: StringToken | undefined;
// Skip validation for expressions for now to match
// behavior of the other parsers
for (const [, token] of TemplateToken.traverse(container)) {
if (token.isExpression) {
return;
@@ -19,7 +201,6 @@ export function convertToJobContainer(context: TemplateContext, container: Templ
}
if (isString(container)) {
// Workflow uses shorthand syntax `container: image-name`
image = container.assertString("container item");
return {image: image};
}
@@ -35,7 +216,7 @@ export function convertToJobContainer(context: TemplateContext, container: Templ
image = value.assertString("container image");
break;
case "credentials":
convertToJobCredentials(context, value);
convertToJobCredentialsLegacy(context, value);
break;
case "env":
env = value.assertMapping("container env");
@@ -70,87 +251,13 @@ export function convertToJobContainer(context: TemplateContext, container: Templ
}
}
export function convertToServiceContainer(context: TemplateContext, container: TemplateToken): Container | undefined {
let image: StringToken | undefined;
let env: MappingToken | undefined;
let ports: SequenceToken | undefined;
let volumes: SequenceToken | undefined;
let options: StringToken | undefined;
let entrypoint: StringToken | undefined;
let command: StringToken | undefined;
// Skip validation for expressions for now to match
// behavior of the other parsers
for (const [, token] of TemplateToken.traverse(container)) {
if (token.isExpression) {
return;
}
}
if (isString(container)) {
image = container.assertString("container item");
return {image: image};
}
const mapping = container.assertMapping("container item");
if (mapping)
for (const item of mapping) {
const key = item.key.assertString("container item key");
const value = item.value;
switch (key.value) {
case "image":
image = value.assertString("container image");
break;
case "credentials":
convertToJobCredentials(context, value);
break;
case "env":
env = value.assertMapping("container env");
for (const envItem of env) {
envItem.key.assertString("container env value");
}
break;
case "ports":
ports = value.assertSequence("container ports");
for (const port of ports) {
port.assertString("container port");
}
break;
case "volumes":
volumes = value.assertSequence("container volumes");
for (const volume of volumes) {
volume.assertString("container volume");
}
break;
case "options":
options = value.assertString("container options");
break;
case "entrypoint":
entrypoint = value.assertString("container entrypoint");
break;
case "command":
command = value.assertString("container command");
break;
default:
context.error(key, `Unexpected container item key: ${key.value}`);
}
}
if (!image) {
context.error(container, "Container image cannot be empty");
} else {
return {image, env, ports, volumes, options, entrypoint, command};
}
}
export function convertToJobServices(context: TemplateContext, services: TemplateToken): Container[] | undefined {
function convertToJobServicesLegacy(context: TemplateContext, services: TemplateToken): Container[] | undefined {
const serviceList: Container[] = [];
const mapping = services.assertMapping("services");
for (const service of mapping) {
service.key.assertString("service key");
const container = convertToServiceContainer(context, service.value);
const container = convertToJobContainerLegacy(context, service.value);
if (container) {
serviceList.push(container);
}
@@ -158,7 +265,7 @@ export function convertToJobServices(context: TemplateContext, services: Templat
return serviceList;
}
function convertToJobCredentials(context: TemplateContext, value: TemplateToken): Credential | undefined {
function convertToJobCredentialsLegacy(context: TemplateContext, value: TemplateToken): Credential | undefined {
const mapping = value.assertMapping("credentials");
let username: StringToken | undefined;
+10 -21
View File
@@ -149,34 +149,23 @@ function convertFilter<T extends TypesFilterConfig & WorkflowFilterConfig & Vers
function convertSchedule(context: TemplateContext, token: SequenceToken): ScheduleConfig[] | undefined {
const result = [] as ScheduleConfig[];
for (const item of token) {
const mappingToken = item.assertMapping(`event schedule`);
const config: ScheduleConfig = {cron: ""};
let valid = true;
for (const entry of mappingToken) {
const key = entry.key.assertString(`schedule key`);
if (key.value === "cron") {
const cron = entry.value.assertString(`schedule cron`);
if (mappingToken.count == 1) {
const schedule = mappingToken.get(0);
const scheduleKey = schedule.key.assertString(`schedule key`);
if (scheduleKey.value == "cron") {
const cron = schedule.value.assertString(`schedule cron`);
// Validate the cron string
if (!isValidCron(cron.value)) {
context.error(cron, "Invalid cron expression. Expected format: '* * * * *' (minute hour day month weekday)");
}
config.cron = cron.value;
} else if (key.value === "timezone") {
const timezone = entry.value.assertString(`schedule timezone`);
config.timezone = timezone.value;
result.push({cron: cron.value});
} else {
context.error(key, `Invalid schedule key`);
valid = false;
context.error(scheduleKey, `Invalid schedule key`);
}
}
if (valid && config.cron) {
result.push(config);
} else if (valid && !config.cron) {
context.error(mappingToken, "Missing required key 'cron' in schedule entry");
} else {
context.error(mappingToken, "Invalid format for 'schedule'");
}
}
+2 -2
View File
@@ -50,7 +50,7 @@ export function convertJob(context: TemplateContext, jobKey: StringToken, token:
break;
case "container":
convertToJobContainer(context, item.value);
handleTemplateTokenErrors(item.value, context, undefined, () => convertToJobContainer(context, item.value));
container = item.value;
break;
@@ -103,7 +103,7 @@ export function convertJob(context: TemplateContext, jobKey: StringToken, token:
break;
case "services":
convertToJobServices(context, item.value);
handleTemplateTokenErrors(item.value, context, undefined, () => convertToJobServices(context, item.value));
services = item.value;
break;
@@ -34,14 +34,6 @@ export function convertToActionsEnvironmentRef(
case "url":
result.url = property.value;
break;
case "deployment": {
const deploymentValue = property.value.assertBoolean("job environment deployment");
if (deploymentValue.value === false) {
result.skipDeployment = true;
}
break;
}
}
}
@@ -18,18 +18,14 @@ export type WorkflowTemplate = {
}[];
};
export type ConcurrencyQueue = "single" | "max";
export type ConcurrencySetting = {
group?: StringToken;
cancelInProgress?: boolean;
queue?: ConcurrencyQueue;
};
export type ActionsEnvironmentReference = {
name?: TemplateToken;
url?: TemplateToken;
skipDeployment?: boolean;
};
export type WorkflowJob = Job | ReusableWorkflowJob;
@@ -78,8 +74,6 @@ export type Container = {
ports?: SequenceToken;
volumes?: SequenceToken;
options?: StringToken;
entrypoint?: StringToken;
command?: StringToken;
};
export type Credential = {
@@ -202,7 +196,6 @@ export type SecretConfig = {
export type ScheduleConfig = {
cron: string;
timezone?: string;
};
export type WorkflowFilterConfig = {
+5 -70
View File
@@ -1602,10 +1602,6 @@
"type": "permission-level-any",
"description": "Repository contents, commits, branches, downloads, releases, and merges."
},
"copilot-requests": {
"type": "permission-level-write-or-no-access",
"description": "GitHub Copilot requests."
},
"deployments": {
"type": "permission-level-any",
"description": "Deployments and deployment statuses."
@@ -1644,15 +1640,11 @@
},
"security-events": {
"type": "permission-level-any",
"description": "Code scanning alerts."
"description": "Code scanning and Dependabot alerts."
},
"statuses": {
"type": "permission-level-any",
"description": "Commit statuses."
},
"vulnerability-alerts": {
"type": "permission-level-read-or-no-access",
"description": "Dependabot alerts."
}
}
}
@@ -2054,20 +2046,10 @@
"cancel-in-progress": {
"type": "boolean",
"description": "To cancel any currently running job or workflow in the same concurrency group, specify cancel-in-progress: true."
},
"queue": {
"type": "concurrency-queue",
"description": "The queuing mode for the concurrency group. When set to `max`, workflows or jobs will wait in a queue for the concurrency group up to the maximum queue length. Default: `single` meaning at most one item can be pending."
}
}
}
},
"concurrency-queue": {
"allowed-values": [
"single",
"max"
]
},
"job-environment": {
"description": "The environment that the job references. All environment protection rules must pass before a job referencing the environment is sent to a runner.",
"context": [
@@ -2093,10 +2075,6 @@
"url": {
"type": "string-runner-context-no-secrets",
"description": "The environment URL, which maps to `environment_url` in the deployments API."
},
"deployment": {
"type": "boolean",
"description": "Whether to create a deployment record for this environment. Defaults to true."
}
}
}
@@ -2367,7 +2345,7 @@
"mapping": {
"properties": {
"image": {
"type": "non-empty-string",
"type": "string",
"description": "Use `jobs.<job_id>.container.image` to define the Docker image to use as the container to run the action. The value can be the Docker Hub image or a registry name."
},
"options": {
@@ -2412,8 +2390,8 @@
"matrix"
],
"one-of": [
"non-empty-string",
"service-container-mapping"
"string",
"container-mapping"
]
},
"container-registry-credentials": {
@@ -2642,57 +2620,14 @@
"cron-mapping": {
"mapping": {
"properties": {
"cron": {
"type": "cron-pattern",
"required": true
},
"timezone": "timezone-string"
"cron": "cron-pattern"
}
}
},
"cron-pattern": {
"description": "A cron expression that represents a schedule. A scheduled workflow will run at most once every 5 minutes.",
"string": {
"require-non-empty": true
}
},
"timezone-string": {
"description": "A string that represents the time zone a scheduled workflow will run relative to in IANA format (e.g. 'America/New_York' or 'Europe/London'). If omitted, the workflow will run relative to midnight UTC.",
"string": {
"require-non-empty": true
}
},
"service-container-mapping": {
"mapping": {
"properties": {
"image": {
"type": "non-empty-string",
"description": "The Docker image to use as the container. The value can be the Docker Hub image or a registry name."
},
"options": {
"type": "string",
"description": "Additional Docker container resource options."
},
"env": "container-env",
"ports": {
"type": "sequence-of-non-empty-string",
"description": "An array of ports to expose on the container."
},
"volumes": {
"type": "sequence-of-non-empty-string",
"description": "An array of volumes for the container to use. You can use volumes to share data between services or other steps in a job. You can specify named Docker volumes, anonymous Docker volumes, or bind mounts on the host."
},
"credentials": "container-registry-credentials",
"entrypoint": {
"type": "string",
"description": "Override the default ENTRYPOINT in the service container image."
},
"command": {
"type": "string",
"description": "Override the default CMD in the service container image."
}
}
}
}
}
}
+1 -28
View File
@@ -25,11 +25,7 @@ jobs:
concurrency:
group: ref
cancel-in-progress: ${{ github.ref }}
build5:
runs-on: ubuntu-latest
concurrency:
group: deploy
queue: max
---
{
@@ -145,29 +141,6 @@ jobs:
]
},
"runs-on": "macos-latest"
},
{
"type": "job",
"id": "build5",
"name": "build5",
"if": {
"type": 3,
"expr": "success()"
},
"concurrency": {
"type": 2,
"map": [
{
"Key": "group",
"Value": "deploy"
},
{
"Key": "queue",
"Value": "max"
}
]
},
"runs-on": "ubuntu-latest"
}
]
}
@@ -1,91 +0,0 @@
include-source: false # Drop file/line/col from output
skip:
- C#
---
on: push
jobs:
build:
environment:
name: production
deployment: false
runs-on: ubuntu-latest
steps:
- run: echo hi
build2:
environment:
name: staging
deployment: true
runs-on: ubuntu-latest
steps:
- run: echo hi
---
{
"jobs": [
{
"type": "job",
"id": "build",
"name": "build",
"if": {
"type": 3,
"expr": "success()"
},
"environment": {
"type": 2,
"map": [
{
"Key": "name",
"Value": "production"
},
{
"Key": "deployment",
"Value": false
}
]
},
"runs-on": "ubuntu-latest",
"steps": [
{
"id": "__run",
"if": {
"type": 3,
"expr": "success()"
},
"run": "echo hi"
}
]
},
{
"type": "job",
"id": "build2",
"name": "build2",
"if": {
"type": 3,
"expr": "success()"
},
"environment": {
"type": 2,
"map": [
{
"Key": "name",
"Value": "staging"
},
{
"Key": "deployment",
"Value": true
}
]
},
"runs-on": "ubuntu-latest",
"steps": [
{
"id": "__run",
"if": {
"type": 3,
"expr": "success()"
},
"run": "echo hi"
}
]
}
]
}
+1
View File
@@ -91,3 +91,4 @@ yaml-schema-sequence.yml
yaml-schema-str-flow-styles.yml
yaml-schema-string.yml
yaml-schema-timestamp.yml
job-container-invalid.yml