Compare commits

..
Author SHA1 Message Date
Salman Chishti b500dbdf2d Remove duplicate test file and fix expression bypass
- Remove duplicate test file (errors-job-environment-deployment-disabled-feature.yml)
- Move deployment feature flag check before expression skip so
  deployment: ${{ ... }} is still gated by allowDeploymentKeyword
2026-03-18 15:51:16 +00:00
Salman Chishti 64aae8a102 Add deployment field to job environment
Add support for the 'deployment' boolean property under 'environment:' in
workflow YAML. When set to false, the job accesses environment protection
rules and secrets without creating a deployment record.

Changes:
- Add 'deployment' to job-environment-mapping schema (workflow-v1.0.json)
- Add 'deployment' to ActionsEnvironmentReference type
- Add feature-gated parsing in environment converter
- Add 'allowDeploymentKeyword' experimental feature flag
- Add xlang test data for enabled/disabled feature flag scenarios
2026-03-18 15:33:51 +00:00
22 changed files with 224 additions and 63 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@actions/expressions",
"version": "0.3.49",
"version": "0.3.47",
"license": "MIT",
"type": "module",
"source": "./src/index.ts",
+3 -1
View File
@@ -55,7 +55,9 @@ describe("FeatureFlags", () => {
"missingInputsQuickfix",
"blockScalarChompingWarning",
"allowCaseFunction",
"allowCopilotRequestsPermission"
"allowCronTimezone",
"allowCopilotRequestsPermission",
"allowDeploymentKeyword"
]);
});
});
+15 -1
View File
@@ -35,11 +35,23 @@ 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 deployment keyword in workflow job environment.
* @default false
*/
allowDeploymentKeyword?: boolean;
}
/**
@@ -55,7 +67,9 @@ const allFeatureKeys: ExperimentalFeatureKey[] = [
"missingInputsQuickfix",
"blockScalarChompingWarning",
"allowCaseFunction",
"allowCopilotRequestsPermission"
"allowCronTimezone",
"allowCopilotRequestsPermission",
"allowDeploymentKeyword"
];
export class FeatureFlags {
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@actions/languageserver",
"version": "0.3.49",
"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.49",
"@actions/workflow-parser": "^0.3.49",
"@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",
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@actions/languageservice",
"version": "0.3.49",
"version": "0.3.47",
"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.49",
"@actions/workflow-parser": "^0.3.49",
"@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",
+30 -2
View File
@@ -927,7 +927,35 @@ jobs:
});
describe("schedule timezone completion", () => {
it("includes timezone for schedule", async () => {
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:
- |`;
@@ -936,7 +964,7 @@ describe("schedule timezone completion", () => {
expect(result).not.toBeUndefined();
const labels = result.map(x => x.label);
expect(labels).toContain("cron");
expect(labels).toContain("timezone");
expect(labels).not.toContain("timezone");
});
});
+5
View File
@@ -163,6 +163,11 @@ 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") &&
-18
View File
@@ -368,24 +368,6 @@ jobs:
});
});
describe("environment deployment", () => {
it("allows deployment boolean under environment mapping", async () => {
const workflow = `
on: push
jobs:
build:
runs-on: ubuntu-latest
environment:
name: prod
deployment: false
steps:
- run: echo
`;
const result = await validate(createDocument("wf.yaml", workflow));
expect(result).toEqual([]);
});
});
describe("workflow_dispatch", () => {
it("allows empty string in choice options", async () => {
const result = await validate(
+2 -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
+1 -1
View File
@@ -6,5 +6,5 @@
"languageservice",
"languageserver"
],
"version": "0.3.49"
"version": "0.3.47"
}
+9 -9
View File
@@ -136,7 +136,7 @@
},
"expressions": {
"name": "@actions/expressions",
"version": "0.3.49",
"version": "0.3.47",
"license": "MIT",
"devDependencies": {
"@types/jest": "^29.0.3",
@@ -396,11 +396,11 @@
},
"languageserver": {
"name": "@actions/languageserver",
"version": "0.3.49",
"version": "0.3.47",
"license": "MIT",
"dependencies": {
"@actions/languageservice": "^0.3.49",
"@actions/workflow-parser": "^0.3.49",
"@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",
@@ -927,11 +927,11 @@
},
"languageservice": {
"name": "@actions/languageservice",
"version": "0.3.49",
"version": "0.3.47",
"license": "MIT",
"dependencies": {
"@actions/expressions": "^0.3.49",
"@actions/workflow-parser": "^0.3.49",
"@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",
@@ -14020,10 +14020,10 @@
},
"workflow-parser": {
"name": "@actions/workflow-parser",
"version": "0.3.49",
"version": "0.3.47",
"license": "MIT",
"dependencies": {
"@actions/expressions": "^0.3.49",
"@actions/expressions": "^0.3.47",
"cronstrue": "^2.21.0",
"yaml": "^2.0.0-8"
},
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@actions/workflow-parser",
"version": "0.3.49",
"version": "0.3.47",
"license": "MIT",
"type": "module",
"source": "./src/index.ts",
@@ -48,7 +48,7 @@
"watch": "tsc --build tsconfig.build.json --watch"
},
"dependencies": {
"@actions/expressions": "^0.3.49",
"@actions/expressions": "^0.3.47",
"cronstrue": "^2.21.0",
"yaml": "^2.0.0-8"
},
+61 -6
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";
@@ -579,8 +580,8 @@ jobs:
});
});
describe("schedule timezone", () => {
it("allows timezone in schedule", async () => {
describe("schedule timezone with feature flags", () => {
it("allows timezone when allowCronTimezone is enabled", async () => {
const result = parseWorkflow(
{
name: "wf.yaml",
@@ -596,7 +597,8 @@ jobs:
);
const template = await convertWorkflowTemplate(result.context, result.value!, undefined, {
errorPolicy: ErrorPolicy.TryConversion
errorPolicy: ErrorPolicy.TryConversion,
featureFlags: new FeatureFlags({allowCronTimezone: true})
});
expect(result.context.errors.getErrors()).toHaveLength(0);
@@ -607,6 +609,57 @@ 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(
{
@@ -622,7 +675,8 @@ jobs:
);
const template = await convertWorkflowTemplate(result.context, result.value!, undefined, {
errorPolicy: ErrorPolicy.TryConversion
errorPolicy: ErrorPolicy.TryConversion,
featureFlags: new FeatureFlags({allowCronTimezone: true})
});
// Both schema validation and converter report the missing cron
@@ -635,7 +689,7 @@ jobs:
expect(template.events?.schedule).toHaveLength(0);
});
it("converts schedule without timezone", async () => {
it("converts schedule without timezone when allowCronTimezone is enabled", async () => {
const result = parseWorkflow(
{
name: "wf.yaml",
@@ -650,7 +704,8 @@ jobs:
);
const template = await convertWorkflowTemplate(result.context, result.value!, undefined, {
errorPolicy: ErrorPolicy.TryConversion
errorPolicy: ErrorPolicy.TryConversion,
featureFlags: new FeatureFlags({allowCronTimezone: true})
});
expect(result.context.errors.getErrors()).toHaveLength(0);
+6 -2
View File
@@ -40,8 +40,7 @@ export type WorkflowTemplateConverterOptions = {
errorPolicy?: ErrorPolicy;
/**
* Feature flags for experimental features.
* This option is not currently used but keeping it for future use.
* Optional feature flags to control which experimental features are enabled.
*/
featureFlags?: FeatureFlags;
};
@@ -62,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
+17 -4
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,7 +149,13 @@ 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) {
@@ -165,8 +173,13 @@ function convertSchedule(context: TemplateContext, token: SequenceToken): Schedu
}
config.cron = cron.value;
} else if (key.value === "timezone") {
const timezone = entry.value.assertString(`schedule timezone`);
config.timezone = timezone.value;
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(key, `Invalid schedule key`);
valid = false;
@@ -1,3 +1,4 @@
import {FeatureFlags} from "@actions/expressions/features";
import {TemplateContext} from "../../../templates/template-context.js";
import {TemplateToken} from "../../../templates/tokens/template-token.js";
import {isScalar} from "../../../templates/tokens/type-guards.js";
@@ -22,6 +23,18 @@ export function convertToActionsEnvironmentRef(
for (const property of environmentMapping) {
const propertyName = property.key.assertString("job environment key");
// Check deployment feature flag before skipping expressions,
// so deployment: ${{ ... }} is still gated by the flag
if (propertyName.value === "deployment") {
const featureFlags = context.state["featureFlags"] as FeatureFlags | undefined;
const flags = featureFlags ?? new FeatureFlags();
if (!flags.isEnabled("allowDeploymentKeyword")) {
context.error(property.key, `The key 'deployment' is not allowed`);
continue;
}
}
if (property.key.isExpression || property.value.isExpression) {
continue;
}
@@ -35,13 +48,9 @@ export function convertToActionsEnvironmentRef(
result.url = property.value;
break;
case "deployment": {
const deploymentValue = property.value.assertBoolean("job environment deployment");
if (deploymentValue.value === false) {
result.skipDeployment = true;
}
case "deployment":
result.deployment = property.value;
break;
}
}
}
@@ -26,7 +26,7 @@ export type ConcurrencySetting = {
export type ActionsEnvironmentReference = {
name?: TemplateToken;
url?: TemplateToken;
skipDeployment?: boolean;
deployment?: TemplateToken;
};
export type WorkflowJob = Job | ReusableWorkflowJob;
+1 -1
View File
@@ -2082,7 +2082,7 @@
},
"deployment": {
"type": "boolean",
"description": "Whether to create a deployment record for this environment. Defaults to true."
"description": "Whether to create a deployment for this environment. Set to `false` to access environment secrets and variables without creating a deployment record. Defaults to `true`."
}
}
}
+6 -1
View File
@@ -1,6 +1,7 @@
import * as fs from "fs";
import * as path from "path";
import * as YAML from "yaml";
import {FeatureFlags} from "@actions/expressions/features";
import {convertWorkflowTemplate} from "./model/convert.js";
import {NoOperationTraceWriter} from "./templates/trace-writer.js";
import {File} from "./workflows/file.js";
@@ -10,6 +11,7 @@ import {parseWorkflow} from "./workflows/workflow-parser.js";
interface TestOptions {
"include-source"?: boolean;
"allow-deployment-keyword"?: boolean;
skip?: string[];
}
@@ -85,7 +87,10 @@ describe("x-lang tests", () => {
parseResult.value!, // eslint-disable-line @typescript-eslint/no-non-null-assertion
testFileProvider,
{
fetchReusableWorkflowDepth: 1
fetchReusableWorkflowDepth: 1,
featureFlags: new FeatureFlags({
allowDeploymentKeyword: testOptions["allow-deployment-keyword"]
})
}
);
@@ -0,0 +1,21 @@
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
---
{
"errors": [
{
"Message": ".github/workflows/errors-job-environment-deployment-disabled-feature-default-go.yml (Line: 6, Col: 7): The key 'deployment' is not allowed"
}
]
}
@@ -0,0 +1,21 @@
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
---
{
"errors": [
{
"Message": ".github/workflows/errors-job-environment-deployment-disabled-feature.yml (Line: 6, Col: 7): The key 'deployment' is not allowed"
}
]
}
@@ -1,6 +1,7 @@
include-source: false # Drop file/line/col from output
skip:
- C#
allow-deployment-keyword: true
---
on: push
jobs: