Compare commits

..
Author SHA1 Message Date
eric scipleandCopilot 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 <[email protected]>
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
30 changed files with 2344 additions and 2874 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
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@actions/expressions",
"version": "0.3.47",
"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 -7
View File
@@ -51,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",
"allowCronTimezone",
"allowCopilotRequestsPermission"
]);
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 timezone input in cron schedule mappings.
* @default false
*/
allowCronTimezone?: boolean;
/**
* Enable the copilot-requests permission in workflow permissions.
* @default false
*/
allowCopilotRequestsPermission?: boolean;
containerImageValidation?: boolean;
}
/**
@@ -60,9 +49,7 @@ export type ExperimentalFeatureKey = Exclude<keyof ExperimentalFeatures, "all">;
const allFeatureKeys: ExperimentalFeatureKey[] = [
"missingInputsQuickfix",
"blockScalarChompingWarning",
"allowCaseFunction",
"allowCronTimezone",
"allowCopilotRequestsPermission"
"containerImageValidation"
];
export class FeatureFlags {
+5 -6
View File
@@ -1,6 +1,6 @@
{
"name": "@actions/languageserver",
"version": "0.3.47",
"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.47",
"@actions/workflow-parser": "^0.3.47",
"@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.47",
"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.47",
"@actions/workflow-parser": "^0.3.47",
"@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([
@@ -1181,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 -139
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,136 +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 when allowCronTimezone is enabled", async () => {
const input = `on:
schedule:
- |`;
const result = await complete(...getPositionFromCursor(input), {
featureFlags: new FeatureFlags({allowCronTimezone: true})
});
expect(result).not.toBeUndefined();
const labels = result.map(x => x.label);
expect(labels).toContain("cron");
expect(labels).toContain("timezone");
});
it("excludes timezone when allowCronTimezone is disabled", async () => {
const input = `on:
schedule:
- |`;
const result = await complete(...getPositionFromCursor(input), {
featureFlags: new FeatureFlags({allowCronTimezone: false})
});
expect(result).not.toBeUndefined();
const labels = result.map(x => x.label);
expect(labels).toContain("cron");
expect(labels).not.toContain("timezone");
});
it("excludes timezone when no feature flags are provided", 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).not.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");
});
});
-13
View File
@@ -163,19 +163,6 @@ export async function complete(
values = filterActionRunsCompletions(values, path, parsedTemplate.value);
}
// Filter `timezone` from schedule completions when the feature flag is disabled
if (!config?.featureFlags?.isEnabled("allowCronTimezone") && parent?.definition?.key === "schedule") {
values = values.filter(v => v.label !== "timezone");
}
// 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);
+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 -2
View File
@@ -84,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
+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.47"
"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.47",
"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.47",
"@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"
}
}
-137
View File
@@ -1,5 +1,4 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import {FeatureFlags} from "@actions/expressions/features";
import {nullTrace} from "../test-utils/null-trace.js";
import {parseWorkflow} from "../workflows/workflow-parser.js";
import {convertWorkflowTemplate, ErrorPolicy} from "./convert.js";
@@ -579,140 +578,4 @@ jobs:
}
});
});
describe("schedule timezone with feature flags", () => {
it("allows timezone when allowCronTimezone is enabled", 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,
featureFlags: new FeatureFlags({allowCronTimezone: true})
});
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 timezone is present but allowCronTimezone is disabled", 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,
featureFlags: new FeatureFlags({allowCronTimezone: false})
});
// When timezone feature is disabled, error points at the timezone key
expect(result.context.errors.getErrors()).toHaveLength(1);
expect(result.context.errors.getErrors()[0].message).toContain("Key 'timezone' is not supported");
// Schedule entry is dropped due to unsupported key
expect(template.events?.schedule).toHaveLength(0);
});
it("reports error when timezone is present with no feature flags provided", async () => {
const result = parseWorkflow(
{
name: "wf.yaml",
content: `on:
schedule:
- cron: '0 0 * * *'
timezone: America/New_York
jobs:
build:
runs-on: ubuntu-latest`
},
nullTrace
);
await convertWorkflowTemplate(result.context, result.value!, undefined, {
errorPolicy: ErrorPolicy.TryConversion
});
// Default is timezone disabled, so error points at the timezone key
expect(result.context.errors.getErrors()).toHaveLength(1);
expect(result.context.errors.getErrors()[0].message).toContain("Key 'timezone' is not supported");
});
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,
featureFlags: new FeatureFlags({allowCronTimezone: true})
});
// 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 when allowCronTimezone is enabled", 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,
featureFlags: new FeatureFlags({allowCronTimezone: true})
});
expect(result.context.errors.getErrors()).toHaveLength(0);
expect(template.events?.schedule).toHaveLength(1);
expect(template.events?.schedule?.[0]).toEqual({
cron: "0 0 * * *"
});
});
});
});
+12 -11
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";
@@ -40,16 +40,16 @@ export type WorkflowTemplateConverterOptions = {
errorPolicy?: ErrorPolicy;
/**
* Optional feature flags to control which experimental features are enabled.
* 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,9 +61,9 @@ export async function convertWorkflowTemplate(
const result = {} as WorkflowTemplate;
const opts = getOptionsWithDefaults(options);
// Store feature flags in context state so converters can access them
if (opts.featureFlags) {
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) {
@@ -144,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
@@ -154,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
};
}
@@ -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);
});
});
});
@@ -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,13 +251,13 @@ export function convertToJobContainer(context: TemplateContext, container: Templ
}
}
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 = convertToJobContainer(context, service.value);
const container = convertToJobContainerLegacy(context, service.value);
if (container) {
serviceList.push(container);
}
@@ -84,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;
+12 -36
View File
@@ -1,4 +1,3 @@
import {FeatureFlags} from "@actions/expressions/features";
import {TemplateContext} from "../../templates/template-context.js";
import {MappingToken} from "../../templates/tokens/mapping-token.js";
import {SequenceToken} from "../../templates/tokens/sequence-token.js";
@@ -56,8 +55,7 @@ export function convertOn(context: TemplateContext, token: TemplateToken): Event
// Schedule is the only event that can be a sequence, handle that separately
if (eventName === "schedule") {
const scheduleToken = item.value.assertSequence(`event ${eventName}`);
const featureFlags = context.state["featureFlags"] as FeatureFlags | undefined;
result.schedule = convertSchedule(context, scheduleToken, featureFlags);
result.schedule = convertSchedule(context, scheduleToken);
continue;
}
@@ -149,47 +147,25 @@ function convertFilter<T extends TypesFilterConfig & WorkflowFilterConfig & Vers
return result;
}
function convertSchedule(
context: TemplateContext,
token: SequenceToken,
featureFlags?: FeatureFlags
): ScheduleConfig[] | undefined {
const flags = featureFlags ?? new FeatureFlags();
const allowTimezone = flags.isEnabled("allowCronTimezone");
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") {
if (allowTimezone) {
const timezone = entry.value.assertString(`schedule timezone`);
config.timezone = timezone.value;
} else {
context.error(key, `Key 'timezone' is not supported`);
valid = false;
}
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;
@@ -196,7 +196,6 @@ export type SecretConfig = {
export type ScheduleConfig = {
cron: string;
timezone?: string;
};
export type WorkflowFilterConfig = {
+4 -19
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."
@@ -2349,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": {
@@ -2394,7 +2390,7 @@
"matrix"
],
"one-of": [
"non-empty-string",
"string",
"container-mapping"
]
},
@@ -2624,25 +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
}
}
}
}
}
+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