Compare commits

...

22 Commits

Author SHA1 Message Date
Salman Chishti 77ed325c44 Merge pull request #361 from actions/release/0.3.54
Build & Test / build (20.x) (push) Has been cancelled
Build & Test / build (22.x) (push) Has been cancelled
Build & Test / build (24.x) (push) Has been cancelled
Build & Test / check-generated (push) Has been cancelled
Release version 0.3.54
2026-04-21 20:25:41 +01:00
GitHub Actions 1372d6dec7 Release extension version 0.3.54 2026-04-21 19:11:32 +00:00
Salman Chishti a06de82217 Merge pull request #356 from actions/vulnerability-alerts-permission
Add vulnerability-alerts permission to workflow schema
2026-04-21 20:04:10 +01:00
eric sciple 36b909a32d Revert "Add field_added and field_removed issue event types to workflows (#351)" (#360) 2026-04-16 13:10:37 -05:00
Armağan 9a8a94bd21 Add field_added and field_removed issue event types to workflows (#351) 2026-04-16 10:55:56 -05:00
github-actions[bot] 8aa246e9d9 Release extension version 0.3.53 (#359)
Co-authored-by: GitHub Actions <github-actions@github.com>
2026-04-15 15:16:47 -05:00
Jason Ginchereau ffc3778653 Add concurrency queue support (#355) 2026-04-15 14:36:13 -05:00
Salman Muin Kayser Chishti 38f730cdce Add vulnerability-alerts permission to workflow schema
Add vulnerability-alerts as a new read-only permission key in the
permissions-mapping. This permission allows workflows to read
Dependabot alerts via GITHUB_TOKEN.

Uses permission-level-read-or-no-access type (read and none only).
Updated security-events description to reflect it covers code
scanning alerts only.
2026-04-15 02:40:02 +00:00
Salman Chishti a810405967 Merge pull request #354 from actions/release/0.3.52
Release version 0.3.52
2026-04-14 15:39:15 +01:00
GitHub Actions 840d04cea8 Release extension version 0.3.52 2026-04-14 14:27:30 +00:00
Salman Chishti 0446b065b0 Merge pull request #348 from actions/salmanmkc/job-workflow-context-properties
feat: add job.workflow_* context properties
2026-04-14 13:45:08 +01:00
Salman Muin Kayser Chishti 763dff2018 fix: address review nits - update doc comments, test names, and description wording
- Update getJobContext doc comment to include workflow identity fields
- Rename test to reflect all returned fields, not just status/check_run_id
- Rename validate test to 'job.workflow_* fields' covering all 4 properties
- Clarify workflow_ref description: 'ref path to' instead of 'ref of'
2026-04-14 10:55:58 +00:00
Salman Muin Kayser Chishti 0c9d817440 feat: add job.workflow_* context properties
Add workflow_ref, workflow_sha, workflow_repository, and
workflow_file_path to the job context for reusable workflow jobs.
These fields provide direct access to the workflow file information
without needing to parse github.workflow_ref.

- Add 4 new fields to getJobContext() in job.ts
- Add descriptions in descriptions.json
- Update autocomplete test expectations
- Add validation and unit tests
2026-04-10 22:23:20 +01:00
eric sciple cc316ab9de Remove phantom github.job_workflow_sha from language service (#347)
This property is listed in the GitHub context provider but is never
populated at runtime by the runner. Users see it in autocomplete,
use it in workflows, and it silently evaluates to empty string.

Remove from keys array and description metadata.
2026-04-03 18:33:15 -05:00
github-actions[bot] d5670c383a Release extension version 0.3.51 (#346)
Co-authored-by: GitHub Actions <github-actions@github.com>
2026-04-03 10:34:58 -05:00
eric sciple f62a0e189d Remove allowServiceContainerCommand feature flag (#345)
Service container entrypoint/command support is now unconditional.
2026-04-03 10:29:34 -05:00
github-actions[bot] 9e1662f1d4 Release extension version 0.3.50 (#344)
Co-authored-by: GitHub Actions <github-actions@github.com>
2026-03-31 20:46:47 -05:00
eric sciple 5db2e80f32 Add entrypoint and command keys for service containers (#343)
Introduce service-container-mapping schema definition with entrypoint
and command properties, gated behind allowServiceContainerCommand
feature flag. Job containers remain unaffected.
2026-03-31 15:45:18 -05:00
github-actions[bot] 83de320ba9 Release extension version 0.3.49 (#342)
Co-authored-by: GitHub Actions <github-actions@github.com>
2026-03-20 09:47:56 -05:00
Angel Kou 74e6638098 Remove timezone feature flag in languageservice (#341)
* Remove timezone feature flag in languageservice

* Prettier

* Address comment

---------

Co-authored-by: Angel Kou <jiakou@microsoft.com>
2026-03-19 14:10:38 -07:00
github-actions[bot] f8b8b57248 Release extension version 0.3.48 (#340)
Co-authored-by: GitHub Actions <github-actions@github.com>
2026-03-18 11:02:31 -05:00
eric sciple aa1e7d8aec Add deployment key support for job environment (#338)
Add a boolean 'deployment' property to the job environment mapping.
When set to false, the parsed environment reference sets
skipDeployment to signal that no deployment record should be created.
2026-03-18 10:53:25 -05:00
32 changed files with 823 additions and 166 deletions
+33
View File
@@ -0,0 +1,33 @@
# 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.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@actions/expressions",
"version": "0.3.47",
"version": "0.3.54",
"license": "MIT",
"type": "module",
"source": "./src/index.ts",
+3 -2
View File
@@ -25,6 +25,7 @@ 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", () => {
@@ -55,8 +56,8 @@ describe("FeatureFlags", () => {
"missingInputsQuickfix",
"blockScalarChompingWarning",
"allowCaseFunction",
"allowCronTimezone",
"allowCopilotRequestsPermission"
"allowCopilotRequestsPermission",
"allowConcurrencyQueue"
]);
});
});
+8 -8
View File
@@ -35,17 +35,17 @@ export interface ExperimentalFeatures {
*/
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;
/**
* Enable the queue property in workflow concurrency settings.
* @default false
*/
allowConcurrencyQueue?: boolean;
}
/**
@@ -61,8 +61,8 @@ const allFeatureKeys: ExperimentalFeatureKey[] = [
"missingInputsQuickfix",
"blockScalarChompingWarning",
"allowCaseFunction",
"allowCronTimezone",
"allowCopilotRequestsPermission"
"allowCopilotRequestsPermission",
"allowConcurrencyQueue"
];
export class FeatureFlags {
+1
View File
@@ -127,6 +127,7 @@ 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`.
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@actions/languageserver",
"version": "0.3.47",
"version": "0.3.54",
"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.54",
"@actions/workflow-parser": "^0.3.54",
"@octokit/rest": "^21.1.1",
"@octokit/types": "^9.0.0",
"vscode-languageserver": "^8.0.2",
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@actions/languageservice",
"version": "0.3.47",
"version": "0.3.54",
"description": "Language service for GitHub Actions",
"license": "MIT",
"type": "module",
@@ -47,8 +47,8 @@
"watch": "tsc --build tsconfig.build.json --watch"
},
"dependencies": {
"@actions/expressions": "^0.3.47",
"@actions/workflow-parser": "^0.3.47",
"@actions/expressions": "^0.3.54",
"@actions/workflow-parser": "^0.3.54",
"vscode-languageserver-textdocument": "^1.0.7",
"vscode-languageserver-types": "^3.17.2",
"vscode-uri": "^3.0.8",
@@ -1164,7 +1164,16 @@ jobs:
`;
const result = await complete(...getPositionFromCursor(input), {contextProviderConfig});
expect(result.map(x => x.label)).toEqual(["check_run_id", "container", "services", "status"]);
expect(result.map(x => x.label)).toEqual([
"check_run_id",
"container",
"services",
"status",
"workflow_file_path",
"workflow_ref",
"workflow_repository",
"workflow_sha"
]);
});
it("job context is suggested within a job output", async () => {
+37 -30
View File
@@ -927,35 +927,7 @@ jobs:
});
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 () => {
it("includes timezone for schedule", async () => {
const input = `on:
schedule:
- |`;
@@ -964,7 +936,7 @@ describe("schedule timezone completion", () => {
expect(result).not.toBeUndefined();
const labels = result.map(x => x.label);
expect(labels).toContain("cron");
expect(labels).not.toContain("timezone");
expect(labels).toContain("timezone");
});
});
@@ -1043,3 +1015,38 @@ jobs:
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");
});
});
+2 -6
View File
@@ -116,7 +116,8 @@ export async function complete(
config,
{
fetchReusableWorkflowDepth: config?.fileProvider ? 1 : 0,
errorPolicy: ErrorPolicy.TryConversion
errorPolicy: ErrorPolicy.TryConversion,
featureFlags: config?.featureFlags
},
true
);
@@ -163,11 +164,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") &&
@@ -105,13 +105,6 @@
"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).\""
},
@@ -225,6 +218,18 @@
},
"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,7 +29,6 @@ export function getGithubContext(workflowContext: WorkflowContext | undefined, m
"graphql_url",
"head_ref",
"job",
"job_workflow_sha",
"path",
"ref",
"ref_name",
@@ -18,12 +18,16 @@ describe("job context", () => {
expect(context.pairs().length).toBe(0);
});
it("returns status and check_run_id when job has no container or services", () => {
it("returns status, check_run_id, and workflow fields 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();
});
@@ -173,4 +177,21 @@ 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();
});
});
});
+7 -1
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, and check_run_id.
* Returns the job context with container, services, status, check_run_id, and workflow identity fields.
*/
export function getJobContext(workflowContext: WorkflowContext): DescriptionDictionary {
// https://docs.github.com/en/actions/learn-github-actions/contexts#job-context
@@ -42,6 +42,12 @@ 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;
}
@@ -1,3 +1,4 @@
import {FeatureFlags} from "@actions/expressions/features";
import {DiagnosticSeverity} from "vscode-languageserver-types";
import {validate} from "./validate.js";
import {createDocument} from "./test-utils/document.js";
@@ -7,6 +8,10 @@ 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 () => {
@@ -243,3 +248,186 @@ 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,6 +432,24 @@ 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
@@ -0,0 +1,102 @@
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,6 +368,24 @@ 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(
+62 -1
View File
@@ -1,6 +1,13 @@
import {FeatureFlags, Lexer, Parser} from "@actions/expressions";
import {Expr} from "@actions/expressions/ast";
import {TemplateParseResult, WorkflowTemplate, isBasicExpression, isMapping, isString} from "@actions/workflow-parser";
import {
TemplateParseResult,
WorkflowTemplate,
isBasicExpression,
isBoolean,
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";
@@ -239,6 +246,11 @@ 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) {
@@ -664,6 +676,55 @@ 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.
+1 -1
View File
@@ -6,5 +6,5 @@
"languageservice",
"languageserver"
],
"version": "0.3.47"
"version": "0.3.54"
}
+9 -9
View File
@@ -136,7 +136,7 @@
},
"expressions": {
"name": "@actions/expressions",
"version": "0.3.47",
"version": "0.3.54",
"license": "MIT",
"devDependencies": {
"@types/jest": "^29.0.3",
@@ -396,11 +396,11 @@
},
"languageserver": {
"name": "@actions/languageserver",
"version": "0.3.47",
"version": "0.3.54",
"license": "MIT",
"dependencies": {
"@actions/languageservice": "^0.3.47",
"@actions/workflow-parser": "^0.3.47",
"@actions/languageservice": "^0.3.54",
"@actions/workflow-parser": "^0.3.54",
"@octokit/rest": "^21.1.1",
"@octokit/types": "^9.0.0",
"vscode-languageserver": "^8.0.2",
@@ -927,11 +927,11 @@
},
"languageservice": {
"name": "@actions/languageservice",
"version": "0.3.47",
"version": "0.3.54",
"license": "MIT",
"dependencies": {
"@actions/expressions": "^0.3.47",
"@actions/workflow-parser": "^0.3.47",
"@actions/expressions": "^0.3.54",
"@actions/workflow-parser": "^0.3.54",
"vscode-languageserver-textdocument": "^1.0.7",
"vscode-languageserver-types": "^3.17.2",
"vscode-uri": "^3.0.8",
@@ -14020,10 +14020,10 @@
},
"workflow-parser": {
"name": "@actions/workflow-parser",
"version": "0.3.47",
"version": "0.3.54",
"license": "MIT",
"dependencies": {
"@actions/expressions": "^0.3.47",
"@actions/expressions": "^0.3.54",
"cronstrue": "^2.21.0",
"yaml": "^2.0.0-8"
},
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@actions/workflow-parser",
"version": "0.3.47",
"version": "0.3.54",
"license": "MIT",
"type": "module",
"source": "./src/index.ts",
@@ -48,7 +48,7 @@
"watch": "tsc --build tsconfig.build.json --watch"
},
"dependencies": {
"@actions/expressions": "^0.3.47",
"@actions/expressions": "^0.3.54",
"cronstrue": "^2.21.0",
"yaml": "^2.0.0-8"
},
+6 -61
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";
@@ -580,8 +579,8 @@ jobs:
});
});
describe("schedule timezone with feature flags", () => {
it("allows timezone when allowCronTimezone is enabled", async () => {
describe("schedule timezone", () => {
it("allows timezone in schedule", async () => {
const result = parseWorkflow(
{
name: "wf.yaml",
@@ -597,8 +596,7 @@ jobs:
);
const template = await convertWorkflowTemplate(result.context, result.value!, undefined, {
errorPolicy: ErrorPolicy.TryConversion,
featureFlags: new FeatureFlags({allowCronTimezone: true})
errorPolicy: ErrorPolicy.TryConversion
});
expect(result.context.errors.getErrors()).toHaveLength(0);
@@ -609,57 +607,6 @@ jobs:
});
});
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(
{
@@ -675,8 +622,7 @@ jobs:
);
const template = await convertWorkflowTemplate(result.context, result.value!, undefined, {
errorPolicy: ErrorPolicy.TryConversion,
featureFlags: new FeatureFlags({allowCronTimezone: true})
errorPolicy: ErrorPolicy.TryConversion
});
// Both schema validation and converter report the missing cron
@@ -689,7 +635,7 @@ jobs:
expect(template.events?.schedule).toHaveLength(0);
});
it("converts schedule without timezone when allowCronTimezone is enabled", async () => {
it("converts schedule without timezone", async () => {
const result = parseWorkflow(
{
name: "wf.yaml",
@@ -704,8 +650,7 @@ jobs:
);
const template = await convertWorkflowTemplate(result.context, result.value!, undefined, {
errorPolicy: ErrorPolicy.TryConversion,
featureFlags: new FeatureFlags({allowCronTimezone: true})
errorPolicy: ErrorPolicy.TryConversion
});
expect(result.context.errors.getErrors()).toHaveLength(0);
+2 -5
View File
@@ -40,7 +40,7 @@ export type WorkflowTemplateConverterOptions = {
errorPolicy?: ErrorPolicy;
/**
* Optional feature flags to control which experimental features are enabled.
* Feature flags for experimental features.
*/
featureFlags?: FeatureFlags;
};
@@ -61,10 +61,7 @@ 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;
}
context.state.featureFlags = opts.featureFlags;
if (context.errors.getErrors().length > 0 && opts.errorPolicy === ErrorPolicy.ReturnErrorsOnly) {
result.errors = context.errors.getErrors().map(x => ({
@@ -1,10 +1,12 @@
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 {ConcurrencySetting} from "../workflow-template.js";
import {ConcurrencyQueue, 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;
@@ -26,6 +28,11 @@ 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}`);
}
@@ -70,13 +70,87 @@ 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 {
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 = convertToServiceContainer(context, service.value);
if (container) {
serviceList.push(container);
}
+4 -17
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,13 +147,7 @@ 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) {
@@ -173,13 +165,8 @@ function convertSchedule(
}
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;
}
const timezone = entry.value.assertString(`schedule timezone`);
config.timezone = timezone.value;
} else {
context.error(key, `Invalid schedule key`);
valid = false;
@@ -34,6 +34,14 @@ 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,14 +18,18 @@ 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;
@@ -74,6 +78,8 @@ export type Container = {
ports?: SequenceToken;
volumes?: SequenceToken;
options?: StringToken;
entrypoint?: StringToken;
command?: StringToken;
};
export type Credential = {
+53 -3
View File
@@ -1644,11 +1644,15 @@
},
"security-events": {
"type": "permission-level-any",
"description": "Code scanning and Dependabot alerts."
"description": "Code scanning alerts."
},
"statuses": {
"type": "permission-level-any",
"description": "Commit statuses."
},
"vulnerability-alerts": {
"type": "permission-level-read-or-no-access",
"description": "Dependabot alerts."
}
}
}
@@ -2050,10 +2054,20 @@
"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": [
@@ -2079,6 +2093,10 @@
"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."
}
}
}
@@ -2395,7 +2413,7 @@
],
"one-of": [
"non-empty-string",
"container-mapping"
"service-container-mapping"
]
},
"container-registry-credentials": {
@@ -2643,6 +2661,38 @@
"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."
}
}
}
}
}
}
}
+28 -1
View File
@@ -25,7 +25,11 @@ jobs:
concurrency:
group: ref
cancel-in-progress: ${{ github.ref }}
build5:
runs-on: ubuntu-latest
concurrency:
group: deploy
queue: max
---
{
@@ -141,6 +145,29 @@ 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"
}
]
}
@@ -0,0 +1,91 @@
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"
}
]
}
]
}