Compare commits

...

13 Commits

Author SHA1 Message Date
Salman Chishti b56cf5c252 Disable deployment record for release workflow
Set deployment: false on the publish environment so the release
job accesses environment protection rules and secrets without
creating a deployment record.
2026-03-18 15:57:15 +00:00
github-actions[bot] bd6ce5923b Release extension version 0.3.47 (#336)
Co-authored-by: GitHub Actions <github-actions@github.com>
2026-03-10 11:38:05 -05:00
Tim Rogers 3de9820cd8 Add copilot-requests permission, gated by feature flag (#335)
* Add copilot-requests permission gated by feature flag

This add a new 'copilot-requests' permission to the workflow schema,
gated behind the 'allowCopilotRequestsPermission' experimental
feature flag.

When the flag is disabled (default), `copilot-requests` is filtered
out of autocomplete suggestions. When enabled, it appears
alongside other permissions like actions, contents, pull-requests,
etc.

* Update workflow-parser/src/workflow-v1.0.json

* Add additional unit test coverage

* Fix formatting
2026-03-10 09:48:54 -05:00
Angel Kou a7f581bde5 Add timezone to workflow and pass FF (#334)
* Add timezone to workflow and pass FF

* Prettier fixes

* Prettier fixes

* Prettier fixes

* Guard timezone autocomplete behind FF

* Prettier fix

* Address PR comments

* Prettier fix

* Remove comma

* Remove template assignment

* Move description

* Fix test

* Prettier again!

* Address comments

* Change error when timezone key is entered but FF is off

* Prettier

---------

Co-authored-by: Angel Kou <jiakou@microsoft.com>
2026-03-05 17:59:56 -08:00
github-actions[bot] 8c0a3a947b Release extension version 0.3.46 (#333)
Co-authored-by: GitHub Actions <github-actions@github.com>
2026-02-26 09:57:24 -06:00
eric sciple eb71b18f2b Revert "Merge pull request #320 from actions/allanguigou/default-case" (#332)
This reverts commit 191a7b6a00, reversing
changes made to 448180bd7f.
2026-02-26 09:50:07 -06:00
eric sciple 92c5235a00 Upgrade lerna to v9 for OIDC trusted publishing (#330)
- Upgrade lerna from v8 to v9 (adds OIDC trusted publishing support)
- Remove registry-url, scope, and packages:write from release workflow
- Remove NPM_CONFIG_PROVENANCE env (automatic with OIDC)
- Update workspace typescript devDependency from ^4.8.4 to ^5.8.3
- Remove root typescript override (no longer needed)
2026-02-25 19:58:54 -06:00
eric sciple 9f770badd3 Upgrade Node.js to 24 for npm trusted publishing (#329) 2026-02-25 15:04:40 -06:00
eric sciple 9dd856db3d Switch to npm trusted publishing (OIDC) (#327)
Replace NPM_TOKEN-based authentication with OIDC trusted publishing.
This eliminates the need for long-lived npm access tokens.

Changes:
- Add id-token: write permission to the release job
- Add registry-url to setup-node
- Remove the setup authentication step (.npmrc token write)
- Remove NPM_TOKEN env var from the Publish packages step

Requires trusted publisher configuration on npmjs.com for each package.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-02-25 13:15:38 -06:00
github-actions[bot] 4a881d9ea1 Release extension version 0.3.45 (#326)
Co-authored-by: GitHub Actions <github-actions@github.com>
2026-02-24 11:19:31 -06:00
Paulo Santos 6a0408d237 Update default runner image labels (#325)
* update default runner image labels

* chore: format in style of file

* remove old labels

* tests: update expected length of runner labels in tests

* tests: fix another test, missed
2026-02-24 11:02:54 -06:00
Paulo Santos 0c2f39f1d0 Add @actions/runner-images-writers to CODEOWNERS (#324)
* added @actions/runner-images-writers to CODEOWNERS

* target specific file and add comment

* added both teams to file ownership
2026-02-24 11:02:35 -06:00
eric sciple fb5c6e4f27 Add private repository access to step-uses description (#322)
Update the step-uses description to mention that actions can also be
used from private repositories when access is enabled via repository
settings.

Fixes #319
2026-01-30 09:23:48 -06:00
26 changed files with 2906 additions and 1849 deletions
+3
View File
@@ -1 +1,4 @@
* @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: [18.x, 20.x, 22.x]
node-version: [20.x, 22.x, 24.x]
steps:
- uses: actions/checkout@v4
@@ -37,10 +37,10 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Use Node.js 22.x
- name: Use Node.js 24.x
uses: actions/setup-node@v4
with:
node-version: 22.x
node-version: 24.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: "16"
node-version: 24.x
- name: Bump version and push
run: |
+6 -12
View File
@@ -50,7 +50,9 @@ jobs:
return true;
release:
environment: publish
environment:
name: publish
deployment: false
needs: check-version-change
if: ${{ needs.check-version-change.outputs.changed == 'true' }}
@@ -59,7 +61,7 @@ jobs:
permissions:
contents: write
packages: write
id-token: write
env:
PKG_VERSION: "" # will be set in the workflow
@@ -69,9 +71,8 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: 22.x
node-version: 24.x
cache: "npm"
scope: '@actions'
- name: Parse version from lerna.json
run: |
@@ -97,13 +98,6 @@ 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: |
lerna publish ${{ env.PKG_VERSION }} --yes --no-git-reset --no-git-tag-version
env:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
npx lerna publish ${{ env.PKG_VERSION }} --yes --no-git-reset --no-git-tag-version
+2 -1
View File
@@ -3,4 +3,5 @@ dist
*.md
*.js
*.json
*.d.ts
*.d.ts
/.nx/workspace-data
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@actions/expressions",
"version": "0.3.44",
"version": "0.3.47",
"license": "MIT",
"type": "module",
"source": "./src/index.ts",
@@ -44,7 +44,7 @@
"watch": "tsc --build tsconfig.build.json --watch"
},
"engines": {
"node": ">= 18"
"node": ">= 20"
},
"files": [
"dist/**/*"
+7 -3
View File
@@ -35,7 +35,6 @@ export function complete(
context: Dictionary,
extensionFunctions: FunctionInfo[],
functions?: Map<string, FunctionDefinition>,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
featureFlags?: FeatureFlags
): CompletionItem[] {
// Lex
@@ -67,7 +66,7 @@ export function complete(
const result = contextKeys(context);
// Merge with functions
result.push(...functionItems(extensionFunctions));
result.push(...functionItems(extensionFunctions, featureFlags));
return result;
}
@@ -92,10 +91,15 @@ export function complete(
return contextKeys(result);
}
function functionItems(extensionFunctions: FunctionInfo[]): CompletionItem[] {
function functionItems(extensionFunctions: FunctionInfo[], featureFlags?: FeatureFlags): 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,
+7 -1
View File
@@ -51,7 +51,13 @@ describe("FeatureFlags", () => {
it("returns all features when all is enabled", () => {
const flags = new FeatureFlags({all: true});
expect(flags.getEnabledFeatures()).toEqual(["missingInputsQuickfix", "blockScalarChompingWarning"]);
expect(flags.getEnabledFeatures()).toEqual([
"missingInputsQuickfix",
"blockScalarChompingWarning",
"allowCaseFunction",
"allowCronTimezone",
"allowCopilotRequestsPermission"
]);
});
});
});
+25 -1
View File
@@ -28,6 +28,24 @@ export interface ExperimentalFeatures {
* @default false
*/
blockScalarChompingWarning?: boolean;
/**
* Enable the case() function in expressions.
* @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;
}
/**
@@ -39,7 +57,13 @@ export type ExperimentalFeatureKey = Exclude<keyof ExperimentalFeatures, "all">;
* All known experimental feature keys.
* This list must be kept in sync with the ExperimentalFeatures interface.
*/
const allFeatureKeys: ExperimentalFeatureKey[] = ["missingInputsQuickfix", "blockScalarChompingWarning"];
const allFeatureKeys: ExperimentalFeatureKey[] = [
"missingInputsQuickfix",
"blockScalarChompingWarning",
"allowCaseFunction",
"allowCronTimezone",
"allowCopilotRequestsPermission"
];
export class FeatureFlags {
private readonly features: ExperimentalFeatures;
+6 -5
View File
@@ -1,6 +1,6 @@
{
"name": "@actions/languageserver",
"version": "0.3.44",
"version": "0.3.47",
"description": "Language server for GitHub Actions",
"license": "MIT",
"type": "module",
@@ -48,8 +48,8 @@
"actions-languageserver": "./bin/actions-languageserver"
},
"dependencies": {
"@actions/languageservice": "^0.3.44",
"@actions/workflow-parser": "^0.3.44",
"@actions/languageservice": "^0.3.47",
"@actions/workflow-parser": "^0.3.47",
"@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": ">= 18"
"node": ">= 20"
},
"files": [
"dist/**/*",
@@ -73,9 +73,10 @@
"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": "^4.8.4"
"typescript": "^5.8.3"
}
}
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "@actions/languageservice",
"version": "0.3.44",
"version": "0.3.47",
"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.44",
"@actions/workflow-parser": "^0.3.44",
"@actions/expressions": "^0.3.47",
"@actions/workflow-parser": "^0.3.47",
"vscode-languageserver-textdocument": "^1.0.7",
"vscode-languageserver-types": "^3.17.2",
"vscode-uri": "^3.0.8",
"yaml": "^2.1.1"
},
"engines": {
"node": ">= 18"
"node": ">= 20"
},
"files": [
"dist/**/*"
@@ -74,6 +74,6 @@
"rimraf": "^3.0.2",
"ts-jest": "^29.0.3",
"ts-node": "^10.9.1",
"typescript": "^4.8.4"
"typescript": "^5.8.3"
}
}
@@ -1,5 +1,5 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import {data, DescriptionDictionary} from "@actions/expressions";
import {data, DescriptionDictionary, FeatureFlags} 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,7 +69,8 @@ describe("expressions", () => {
it("single region", async () => {
const input = "run-name: ${{ | }}";
const result = await complete(...getPositionFromCursor(input), {
contextProviderConfig
contextProviderConfig,
featureFlags: new FeatureFlags({allowCaseFunction: true})
});
expect(result.map(x => x.label)).toEqual([
@@ -112,7 +113,8 @@ describe("expressions", () => {
it("single region with existing input", async () => {
const input = "run-name: ${{ g| }}";
const result = await complete(...getPositionFromCursor(input), {
contextProviderConfig
contextProviderConfig,
featureFlags: new FeatureFlags({allowCaseFunction: true})
});
expect(result.map(x => x.label)).toEqual([
@@ -133,7 +135,8 @@ describe("expressions", () => {
it("single region with existing condition", async () => {
const input = "run-name: ${{ g| == 'test' }}";
const result = await complete(...getPositionFromCursor(input), {
contextProviderConfig
contextProviderConfig,
featureFlags: new FeatureFlags({allowCaseFunction: true})
});
expect(result.map(x => x.label)).toEqual([
@@ -154,7 +157,8 @@ 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
contextProviderConfig,
featureFlags: new FeatureFlags({allowCaseFunction: true})
});
expect(result.map(x => x.label)).toEqual([
@@ -175,7 +179,8 @@ describe("expressions", () => {
it("multiple regions - first region", async () => {
const input = "run-name: test-${{ git| == 1 }}-${{ github.event }}";
const result = await complete(...getPositionFromCursor(input), {
contextProviderConfig
contextProviderConfig,
featureFlags: new FeatureFlags({allowCaseFunction: true})
});
expect(result.map(x => x.label)).toEqual([
@@ -196,7 +201,8 @@ describe("expressions", () => {
it("multiple regions", async () => {
const input = "run-name: test-${{ github }}-${{ | }}";
const result = await complete(...getPositionFromCursor(input), {
contextProviderConfig
contextProviderConfig,
featureFlags: new FeatureFlags({allowCaseFunction: true})
});
expect(result.map(x => x.label)).toEqual([
@@ -1175,7 +1181,8 @@ jobs:
`;
const result = await complete(...getPositionFromCursor(input), {
contextProviderConfig
contextProviderConfig,
featureFlags: new FeatureFlags({allowCaseFunction: true})
});
expect(result.map(x => x.label)).toEqual([
"env",
+139 -5
View File
@@ -6,6 +6,7 @@ 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());
@@ -19,8 +20,8 @@ describe("completion", () => {
const result = await complete(...getPositionFromCursor(input));
expect(result).not.toBeUndefined();
// 12 runner labels + 2 escape hatches (switch to list, switch to full syntax)
expect(result.length).toEqual(14);
// 28 runner labels + 2 escape hatches (switch to list, switch to full syntax)
expect(result.length).toEqual(30);
const labels = result.map(x => x.label);
expect(labels).toContain("macos-latest");
expect(labels).toContain("(switch to list)");
@@ -59,7 +60,7 @@ jobs:
const result = await complete(...getPositionFromCursor(input));
expect(result).not.toBeUndefined();
expect(result.length).toEqual(11);
expect(result.length).toEqual(27);
const labels = result.map(x => x.label);
expect(labels).toContain("macos-latest");
@@ -897,9 +898,11 @@ jobs:
});
describe("expression completions", () => {
it("includes case function", async () => {
it("include case function when enabled", async () => {
const input = "on: push\njobs:\n build:\n runs-on: ${{ c|";
const result = await complete(...getPositionFromCursor(input));
const result = await complete(...getPositionFromCursor(input), {
featureFlags: new FeatureFlags({allowCaseFunction: true})
});
expect(result).not.toBeUndefined();
// Expression completions starting with 'c': case, contains
@@ -907,5 +910,136 @@ 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,6 +163,19 @@ 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);
+7 -3
View File
@@ -120,7 +120,9 @@ jobs:
`;
const result = await hover(...getPositionFromCursor(input));
expect(result).not.toBeUndefined();
expect(result?.contents).toEqual("");
expect(result?.contents).toEqual(
"A cron expression that represents a schedule. A scheduled workflow will run at most once every 5 minutes."
);
});
it("on an invalid cron schedule", async () => {
@@ -130,7 +132,9 @@ jobs:
`;
const result = await hover(...getPositionFromCursor(input));
expect(result).not.toBeUndefined();
expect(result?.contents).toEqual("");
expect(result?.contents).toEqual(
"A cron expression that represents a schedule. A scheduled workflow will run at most once every 5 minutes."
);
});
it("shows context inherited from parent nodes", async () => {
@@ -195,7 +199,7 @@ jobs:
const result = await hover(...getPositionFromCursor(input), testHoverConfig("uses", "step-uses", undefined));
expect(result).not.toBeUndefined();
expect(result?.contents).toEqual(
"Selects an action to run as part of a step in your job. An action is a reusable unit of code. You can use an action defined in the same repository as the workflow, a public repository, or in a published Docker container image."
"Selects an action to run as part of a step in your job. An action is a reusable unit of code. You can use an action defined in the same repository as the workflow, a public repository, a [private repository with access enabled](https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/enabling-features-for-your-repository/managing-github-actions-settings-for-a-repository#allowing-access-to-components-in-a-private-repository), or in a published Docker container image."
);
});
});
+2 -1
View File
@@ -84,7 +84,8 @@ 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
errorPolicy: ErrorPolicy.TryConversion,
featureFlags: config?.featureFlags
});
// Validate expressions and value providers
+28 -11
View File
@@ -4,19 +4,36 @@ 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 = [
"ubuntu-latest",
"ubuntu-24.04",
"ubuntu-22.04",
"ubuntu-20.04",
"ubuntu-slim",
"windows-latest",
"windows-2022",
"windows-2019",
"macos-latest",
"macos-15",
"codespaces-prebuild",
"macos-13",
"macos-13-large",
"macos-13-xlarge",
"macos-14",
"self-hosted"
"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-slim",
"windows-2022",
"windows-2025",
"windows-2025-vs2026",
"windows-latest"
];
const runsOnValueProvider = {
+1 -1
View File
@@ -6,5 +6,5 @@
"languageservice",
"languageserver"
],
"version": "0.3.44"
"version": "0.3.47"
}
+2422 -1761
View File
File diff suppressed because it is too large Load Diff
+1 -4
View File
@@ -9,10 +9,7 @@
"./languageserver"
],
"devDependencies": {
"lerna": "^8.2.2",
"lerna": "^9.0.0",
"typescript": "5.8.3"
},
"overrides": {
"typescript": "$typescript"
}
}
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@actions/workflow-parser",
"version": "0.3.44",
"version": "0.3.47",
"license": "MIT",
"type": "module",
"source": "./src/index.ts",
@@ -48,12 +48,12 @@
"watch": "tsc --build tsconfig.build.json --watch"
},
"dependencies": {
"@actions/expressions": "^0.3.44",
"@actions/expressions": "^0.3.47",
"cronstrue": "^2.21.0",
"yaml": "^2.0.0-8"
},
"engines": {
"node": ">= 18"
"node": ">= 20"
},
"files": [
"dist/**/*"
@@ -69,6 +69,6 @@
"prettier": "^2.8.3",
"rimraf": "^3.0.2",
"ts-jest": "^29.0.3",
"typescript": "^4.8.4"
"typescript": "^5.8.3"
}
}
+137
View File
@@ -1,4 +1,5 @@
/* 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";
@@ -578,4 +579,140 @@ 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 * * *"
});
});
});
});
+15 -2
View File
@@ -1,3 +1,4 @@
import {FeatureFlags} from "@actions/expressions/features";
import {TemplateContext} from "../templates/template-context.js";
import {TemplateToken, TemplateTokenError} from "../templates/tokens/template-token.js";
import {FileProvider} from "../workflows/file-provider.js";
@@ -37,12 +38,18 @@ export type WorkflowTemplateConverterOptions = {
* By default, conversion will be skipped if there are errors in the {@link TemplateContext}.
*/
errorPolicy?: ErrorPolicy;
/**
* Optional feature flags to control which experimental features are enabled.
*/
featureFlags?: FeatureFlags;
};
const defaultOptions: Required<WorkflowTemplateConverterOptions> = {
maxReusableWorkflowDepth: 4,
fetchReusableWorkflowDepth: 0,
errorPolicy: ErrorPolicy.ReturnErrorsOnly
errorPolicy: ErrorPolicy.ReturnErrorsOnly,
featureFlags: new FeatureFlags()
};
export async function convertWorkflowTemplate(
@@ -54,6 +61,11 @@ 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;
}
if (context.errors.getErrors().length > 0 && opts.errorPolicy === ErrorPolicy.ReturnErrorsOnly) {
result.errors = context.errors.getErrors().map(x => ({
Message: x.message
@@ -142,6 +154,7 @@ function getOptionsWithDefaults(options: WorkflowTemplateConverterOptions): Requ
options.fetchReusableWorkflowDepth !== undefined
? options.fetchReusableWorkflowDepth
: defaultOptions.fetchReusableWorkflowDepth,
errorPolicy: options.errorPolicy !== undefined ? options.errorPolicy : defaultOptions.errorPolicy
errorPolicy: options.errorPolicy !== undefined ? options.errorPolicy : defaultOptions.errorPolicy,
featureFlags: options.featureFlags ?? defaultOptions.featureFlags
};
}
+36 -12
View File
@@ -1,3 +1,4 @@
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";
@@ -55,7 +56,8 @@ 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}`);
result.schedule = convertSchedule(context, scheduleToken);
const featureFlags = context.state["featureFlags"] as FeatureFlags | undefined;
result.schedule = convertSchedule(context, scheduleToken, featureFlags);
continue;
}
@@ -147,25 +149,47 @@ function convertFilter<T extends TypesFilterConfig & WorkflowFilterConfig & Vers
return result;
}
function convertSchedule(context: TemplateContext, token: SequenceToken): ScheduleConfig[] | undefined {
function convertSchedule(
context: TemplateContext,
token: SequenceToken,
featureFlags?: FeatureFlags
): ScheduleConfig[] | undefined {
const flags = featureFlags ?? new FeatureFlags();
const allowTimezone = flags.isEnabled("allowCronTimezone");
const result = [] as ScheduleConfig[];
for (const item of token) {
const mappingToken = item.assertMapping(`event schedule`);
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
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 (!isValidCron(cron.value)) {
context.error(cron, "Invalid cron expression. Expected format: '* * * * *' (minute hour day month weekday)");
}
result.push({cron: cron.value});
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;
}
} else {
context.error(scheduleKey, `Invalid schedule key`);
context.error(key, `Invalid schedule key`);
valid = false;
}
} else {
context.error(mappingToken, "Invalid format for 'schedule'");
}
if (valid && config.cron) {
result.push(config);
} else if (valid && !config.cron) {
context.error(mappingToken, "Missing required key 'cron' in schedule entry");
}
}
@@ -196,6 +196,7 @@ export type SecretConfig = {
export type ScheduleConfig = {
cron: string;
timezone?: string;
};
export type WorkflowFilterConfig = {
+18 -3
View File
@@ -1602,6 +1602,10 @@
"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."
@@ -2172,7 +2176,7 @@
}
},
"step-uses": {
"description": "Selects an action to run as part of a step in your job. An action is a reusable unit of code. You can use an action defined in the same repository as the workflow, a public repository, or in a published Docker container image.",
"description": "Selects an action to run as part of a step in your job. An action is a reusable unit of code. You can use an action defined in the same repository as the workflow, a public repository, a [private repository with access enabled](https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/enabling-features-for-your-repository/managing-github-actions-settings-for-a-repository#allowing-access-to-components-in-a-private-repository), or in a published Docker container image.",
"string": {
"require-non-empty": true
}
@@ -2620,14 +2624,25 @@
"cron-mapping": {
"mapping": {
"properties": {
"cron": "cron-pattern"
"cron": {
"type": "cron-pattern",
"required": true
},
"timezone": "timezone-string"
}
}
},
"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
}
}
}
}
}