Compare commits

...
Author SHA1 Message Date
Salman Muin Kayser Chishti 7520561423 Add workflows permission to schema
Add 'workflows' as a valid permission scope in the permissions-mapping
schema. The workflows permission supports only 'write' (or none),
matching the GitHub App permissions model where workflows: write
allows updating GitHub Actions workflow files.

This enables autocomplete and validation for 'permissions: workflows:'
in workflow YAML files.
2026-04-10 23:26:45 +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]andGitHub Actions d5670c383a Release extension version 0.3.51 (#346)
Co-authored-by: GitHub Actions <[email protected]>
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
15 changed files with 128 additions and 117 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", "name": "@actions/expressions",
"version": "0.3.50", "version": "0.3.51",
"license": "MIT", "license": "MIT",
"type": "module", "type": "module",
"source": "./src/index.ts", "source": "./src/index.ts",
+1 -2
View File
@@ -55,8 +55,7 @@ describe("FeatureFlags", () => {
"missingInputsQuickfix", "missingInputsQuickfix",
"blockScalarChompingWarning", "blockScalarChompingWarning",
"allowCaseFunction", "allowCaseFunction",
"allowCopilotRequestsPermission", "allowCopilotRequestsPermission"
"allowServiceContainerCommand"
]); ]);
}); });
}); });
+1 -8
View File
@@ -40,12 +40,6 @@ export interface ExperimentalFeatures {
* @default false * @default false
*/ */
allowCopilotRequestsPermission?: boolean; allowCopilotRequestsPermission?: boolean;
/**
* Enable `entrypoint` and `command` keys in service containers (`jobs.<job_id>.services.*`).
* @default false
*/
allowServiceContainerCommand?: boolean;
} }
/** /**
@@ -61,8 +55,7 @@ const allFeatureKeys: ExperimentalFeatureKey[] = [
"missingInputsQuickfix", "missingInputsQuickfix",
"blockScalarChompingWarning", "blockScalarChompingWarning",
"allowCaseFunction", "allowCaseFunction",
"allowCopilotRequestsPermission", "allowCopilotRequestsPermission"
"allowServiceContainerCommand"
]; ];
export class FeatureFlags { export class FeatureFlags {
+3 -3
View File
@@ -1,6 +1,6 @@
{ {
"name": "@actions/languageserver", "name": "@actions/languageserver",
"version": "0.3.50", "version": "0.3.51",
"description": "Language server for GitHub Actions", "description": "Language server for GitHub Actions",
"license": "MIT", "license": "MIT",
"type": "module", "type": "module",
@@ -48,8 +48,8 @@
"actions-languageserver": "./bin/actions-languageserver" "actions-languageserver": "./bin/actions-languageserver"
}, },
"dependencies": { "dependencies": {
"@actions/languageservice": "^0.3.50", "@actions/languageservice": "^0.3.51",
"@actions/workflow-parser": "^0.3.50", "@actions/workflow-parser": "^0.3.51",
"@octokit/rest": "^21.1.1", "@octokit/rest": "^21.1.1",
"@octokit/types": "^9.0.0", "@octokit/types": "^9.0.0",
"vscode-languageserver": "^8.0.2", "vscode-languageserver": "^8.0.2",
+3 -3
View File
@@ -1,6 +1,6 @@
{ {
"name": "@actions/languageservice", "name": "@actions/languageservice",
"version": "0.3.50", "version": "0.3.51",
"description": "Language service for GitHub Actions", "description": "Language service for GitHub Actions",
"license": "MIT", "license": "MIT",
"type": "module", "type": "module",
@@ -47,8 +47,8 @@
"watch": "tsc --build tsconfig.build.json --watch" "watch": "tsc --build tsconfig.build.json --watch"
}, },
"dependencies": { "dependencies": {
"@actions/expressions": "^0.3.50", "@actions/expressions": "^0.3.51",
"@actions/workflow-parser": "^0.3.50", "@actions/workflow-parser": "^0.3.51",
"vscode-languageserver-textdocument": "^1.0.7", "vscode-languageserver-textdocument": "^1.0.7",
"vscode-languageserver-types": "^3.17.2", "vscode-languageserver-types": "^3.17.2",
"vscode-uri": "^3.0.8", "vscode-uri": "^3.0.8",
+39
View File
@@ -1016,6 +1016,45 @@ jobs:
}); });
}); });
describe("permissions workflows completion", () => {
it("includes workflows in top-level permissions", 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("workflows");
});
it("offers only write and none for workflows", async () => {
const input = `on: push
permissions:
workflows: |`;
const result = await complete(...getPositionFromCursor(input));
expect(result).not.toBeUndefined();
const labels = result.map(x => x.label);
expect(labels).toContain("write");
expect(labels).not.toContain("read");
});
it("includes workflows in job-level permissions", async () => {
const input = `on: push
jobs:
build:
runs-on: ubuntu-latest
permissions:
|`;
const result = await complete(...getPositionFromCursor(input));
expect(result).not.toBeUndefined();
const labels = result.map(x => x.label);
expect(labels).toContain("workflows");
});
});
describe("service container command/entrypoint completion", () => { describe("service container command/entrypoint completion", () => {
it("suggests entrypoint and command in service container", async () => { it("suggests entrypoint and command in service container", async () => {
const input = `on: push const input = `on: push
@@ -105,13 +105,6 @@
"job": { "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`." "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": { "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).\"" "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).\""
}, },
@@ -29,7 +29,6 @@ export function getGithubContext(workflowContext: WorkflowContext | undefined, m
"graphql_url", "graphql_url",
"head_ref", "head_ref",
"job", "job",
"job_workflow_sha",
"path", "path",
"ref", "ref",
"ref_name", "ref_name",
@@ -1,22 +1,16 @@
import {FeatureFlags} from "@actions/expressions";
import {registerLogger} from "./log.js"; import {registerLogger} from "./log.js";
import {createDocument} from "./test-utils/document.js"; import {createDocument} from "./test-utils/document.js";
import {TestLogger} from "./test-utils/logger.js"; import {TestLogger} from "./test-utils/logger.js";
import {clearCache} from "./utils/workflow-cache.js"; import {clearCache} from "./utils/workflow-cache.js";
import {validate, ValidationConfig} from "./validate.js"; import {validate} from "./validate.js";
registerLogger(new TestLogger()); registerLogger(new TestLogger());
const configWithFlag: ValidationConfig = {
featureFlags: new FeatureFlags({allowServiceContainerCommand: true})
};
beforeEach(() => { beforeEach(() => {
clearCache(); clearCache();
}); });
describe("service container command/entrypoint", () => { describe("service container command/entrypoint", () => {
describe("with feature flag enabled", () => {
it("allows command in service container", async () => { it("allows command in service container", async () => {
const input = ` const input = `
on: push on: push
@@ -30,7 +24,7 @@ jobs:
steps: steps:
- run: echo hi - run: echo hi
`; `;
const result = await validate(createDocument("wf.yaml", input), configWithFlag); const result = await validate(createDocument("wf.yaml", input));
const commandErrors = result.filter(d => d.message.includes("command")); const commandErrors = result.filter(d => d.message.includes("command"));
expect(commandErrors).toEqual([]); expect(commandErrors).toEqual([]);
}); });
@@ -48,7 +42,7 @@ jobs:
steps: steps:
- run: echo hi - run: echo hi
`; `;
const result = await validate(createDocument("wf.yaml", input), configWithFlag); const result = await validate(createDocument("wf.yaml", input));
const entrypointErrors = result.filter(d => d.message.includes("entrypoint")); const entrypointErrors = result.filter(d => d.message.includes("entrypoint"));
expect(entrypointErrors).toEqual([]); expect(entrypointErrors).toEqual([]);
}); });
@@ -67,12 +61,12 @@ jobs:
steps: steps:
- run: echo hi - run: echo hi
`; `;
const result = await validate(createDocument("wf.yaml", input), configWithFlag); const result = await validate(createDocument("wf.yaml", input));
const relevantErrors = result.filter(d => d.message.includes("command") || d.message.includes("entrypoint")); const relevantErrors = result.filter(d => d.message.includes("command") || d.message.includes("entrypoint"));
expect(relevantErrors).toEqual([]); expect(relevantErrors).toEqual([]);
}); });
it("rejects command in job container even with flag enabled", async () => { it("rejects command in job container", async () => {
const input = ` const input = `
on: push on: push
jobs: jobs:
@@ -84,12 +78,12 @@ jobs:
steps: steps:
- run: echo hi - run: echo hi
`; `;
const result = await validate(createDocument("wf.yaml", input), configWithFlag); const result = await validate(createDocument("wf.yaml", input));
const commandErrors = result.filter(d => d.message.includes("command")); const commandErrors = result.filter(d => d.message.includes("command"));
expect(commandErrors.length).toBeGreaterThan(0); expect(commandErrors.length).toBeGreaterThan(0);
}); });
it("rejects entrypoint in job container even with flag enabled", async () => { it("rejects entrypoint in job container", async () => {
const input = ` const input = `
on: push on: push
jobs: jobs:
@@ -100,48 +94,9 @@ jobs:
entrypoint: /bin/bash entrypoint: /bin/bash
steps: steps:
- run: echo hi - run: echo hi
`;
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
const entrypointErrors = result.filter(d => d.message.includes("entrypoint"));
expect(entrypointErrors.length).toBeGreaterThan(0);
});
});
describe("with feature flag disabled", () => {
it("rejects 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.length).toBeGreaterThan(0);
});
it("rejects 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 result = await validate(createDocument("wf.yaml", input));
const entrypointErrors = result.filter(d => d.message.includes("entrypoint")); const entrypointErrors = result.filter(d => d.message.includes("entrypoint"));
expect(entrypointErrors.length).toBeGreaterThan(0); expect(entrypointErrors.length).toBeGreaterThan(0);
}); });
}); });
});
+1 -1
View File
@@ -6,5 +6,5 @@
"languageservice", "languageservice",
"languageserver" "languageserver"
], ],
"version": "0.3.50" "version": "0.3.51"
} }
+9 -9
View File
@@ -136,7 +136,7 @@
}, },
"expressions": { "expressions": {
"name": "@actions/expressions", "name": "@actions/expressions",
"version": "0.3.50", "version": "0.3.51",
"license": "MIT", "license": "MIT",
"devDependencies": { "devDependencies": {
"@types/jest": "^29.0.3", "@types/jest": "^29.0.3",
@@ -396,11 +396,11 @@
}, },
"languageserver": { "languageserver": {
"name": "@actions/languageserver", "name": "@actions/languageserver",
"version": "0.3.50", "version": "0.3.51",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@actions/languageservice": "^0.3.50", "@actions/languageservice": "^0.3.51",
"@actions/workflow-parser": "^0.3.50", "@actions/workflow-parser": "^0.3.51",
"@octokit/rest": "^21.1.1", "@octokit/rest": "^21.1.1",
"@octokit/types": "^9.0.0", "@octokit/types": "^9.0.0",
"vscode-languageserver": "^8.0.2", "vscode-languageserver": "^8.0.2",
@@ -927,11 +927,11 @@
}, },
"languageservice": { "languageservice": {
"name": "@actions/languageservice", "name": "@actions/languageservice",
"version": "0.3.50", "version": "0.3.51",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@actions/expressions": "^0.3.50", "@actions/expressions": "^0.3.51",
"@actions/workflow-parser": "^0.3.50", "@actions/workflow-parser": "^0.3.51",
"vscode-languageserver-textdocument": "^1.0.7", "vscode-languageserver-textdocument": "^1.0.7",
"vscode-languageserver-types": "^3.17.2", "vscode-languageserver-types": "^3.17.2",
"vscode-uri": "^3.0.8", "vscode-uri": "^3.0.8",
@@ -14020,10 +14020,10 @@
}, },
"workflow-parser": { "workflow-parser": {
"name": "@actions/workflow-parser", "name": "@actions/workflow-parser",
"version": "0.3.50", "version": "0.3.51",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@actions/expressions": "^0.3.50", "@actions/expressions": "^0.3.51",
"cronstrue": "^2.21.0", "cronstrue": "^2.21.0",
"yaml": "^2.0.0-8" "yaml": "^2.0.0-8"
}, },
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@actions/workflow-parser", "name": "@actions/workflow-parser",
"version": "0.3.50", "version": "0.3.51",
"license": "MIT", "license": "MIT",
"type": "module", "type": "module",
"source": "./src/index.ts", "source": "./src/index.ts",
@@ -48,7 +48,7 @@
"watch": "tsc --build tsconfig.build.json --watch" "watch": "tsc --build tsconfig.build.json --watch"
}, },
"dependencies": { "dependencies": {
"@actions/expressions": "^0.3.50", "@actions/expressions": "^0.3.51",
"cronstrue": "^2.21.0", "cronstrue": "^2.21.0",
"yaml": "^2.0.0-8" "yaml": "^2.0.0-8"
}, },
@@ -146,15 +146,11 @@ export function convertToServiceContainer(context: TemplateContext, container: T
export function convertToJobServices(context: TemplateContext, services: TemplateToken): Container[] | undefined { export function convertToJobServices(context: TemplateContext, services: TemplateToken): Container[] | undefined {
const serviceList: Container[] = []; const serviceList: Container[] = [];
const flags = context.state.featureFlags as import("@actions/expressions/features").FeatureFlags | undefined;
const useServiceContainer = flags?.isEnabled("allowServiceContainerCommand") ?? false;
const mapping = services.assertMapping("services"); const mapping = services.assertMapping("services");
for (const service of mapping) { for (const service of mapping) {
service.key.assertString("service key"); service.key.assertString("service key");
const container = useServiceContainer const container = convertToServiceContainer(context, service.value);
? convertToServiceContainer(context, service.value)
: convertToJobContainer(context, service.value);
if (container) { if (container) {
serviceList.push(container); serviceList.push(container);
} }
+4
View File
@@ -1649,6 +1649,10 @@
"statuses": { "statuses": {
"type": "permission-level-any", "type": "permission-level-any",
"description": "Commit statuses." "description": "Commit statuses."
},
"workflows": {
"type": "permission-level-write-or-no-access",
"description": "Update GitHub Actions workflow files."
} }
} }
} }