Compare commits

..

6 Commits

Author SHA1 Message Date
github-actions[bot] a06ceee92b Release extension version 0.3.28 (#267)
Co-authored-by: GitHub Actions <github-actions@github.com>
2025-12-23 14:24:06 -06:00
eric sciple efd53330a3 Remove invalid autocomplete options for committed structural types (#265)
Recent autocomplete improvements (typing activation, completion chaining, schema
variant surfacing) now guide users to discover the full schema naturally. This
change removes the legacy behavior that showed invalid options and silently
transformed YAML upon insertion.

Key changes:
- Filter one-of completion options based on the token's actual structural type
- When user commits to scalar (non-empty string), only show scalar options
- When user commits to mapping/sequence, only show those options
- Skip null-only scalars in Key mode to prevent clobbering string constants
- Scalar event completions (e.g., check_run at 'on: |') now insert inline

This ensures that when a user explicitly chooses a simplified form, they only
see values valid for that form, creating a cleaner and more predictable
autocomplete experience.
2025-12-23 10:25:49 -06:00
github-actions[bot] 86888cf4c8 Release extension version 0.3.27 (#264)
Co-authored-by: GitHub Actions <github-actions@github.com>
2025-12-22 11:28:48 -06:00
Robin Neatherway ed4c2ce44c Add support for job.check_run_id (#205)
This was recently added: https://github.com/orgs/community/discussions/8945#discussioncomment-14374985
2025-12-22 11:11:34 -06:00
eric sciple 9bb4c76612 Expand one-of keys to multiple completion items (#261)
* Expand one-of keys to multiple completion items

Some workflow fields accept multiple YAML structures (scalar, sequence, or
mapping), but completions previously only showed a single option—leaving users
unaware of the full schema flexibility. This change surfaces structural options
and inserts the correct YAML scaffolding so users land in the right place to
keep typing.

Example: runs-on

Completing runs-on now shows three options:
- runs-on         → Ready for a string like ubuntu-latest
- runs-on (list)  → Ready to add runner labels
- runs-on (full syntax) → Ready for labels:, group:, etc.

Notes:
- Qualifiers (list) and (full syntax) only appear when multiple structural types exist
- Scalar completions use the plain key name
- Qualified variants use filterText matching the base key

* Sort expanded one-of completions: scalar, list, full syntax
2025-12-22 10:49:49 -06:00
eric sciple 8b86b48961 Add warning for short SHA refs in uses (#260) 2025-12-22 08:34:29 -06:00
16 changed files with 710 additions and 51 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@actions/expressions",
"version": "0.3.26",
"version": "0.3.28",
"license": "MIT",
"type": "module",
"source": "./src/index.ts",
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@actions/languageserver",
"version": "0.3.26",
"version": "0.3.28",
"description": "Language server for GitHub Actions",
"license": "MIT",
"type": "module",
@@ -48,8 +48,8 @@
"actions-languageserver": "./bin/actions-languageserver"
},
"dependencies": {
"@actions/languageservice": "^0.3.26",
"@actions/workflow-parser": "^0.3.26",
"@actions/languageservice": "^0.3.28",
"@actions/workflow-parser": "^0.3.28",
"@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.26",
"version": "0.3.28",
"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.26",
"@actions/workflow-parser": "^0.3.26",
"@actions/expressions": "^0.3.28",
"@actions/workflow-parser": "^0.3.28",
"vscode-languageserver-textdocument": "^1.0.7",
"vscode-languageserver-types": "^3.17.2",
"vscode-uri": "^3.0.8",
@@ -1110,7 +1110,7 @@ jobs:
`;
const result = await complete(...getPositionFromCursor(input), {contextProviderConfig});
expect(result.map(x => x.label)).toEqual(["container", "services", "status"]);
expect(result.map(x => x.label)).toEqual(["check_run_id", "container", "services", "status"]);
});
it("job context is suggested within a job output", async () => {
+155 -16
View File
@@ -44,7 +44,7 @@ jobs:
const result = await complete(...getPositionFromCursor(input));
expect(result).not.toBeUndefined();
expect(result.length).toEqual(9);
expect(result.length).toEqual(13);
expect(result[0].label).toEqual("concurrency");
});
@@ -70,7 +70,7 @@ jobs:
|`;
const result = await complete(...getPositionFromCursor(input));
expect(result).not.toBeUndefined();
expect(result.length).toEqual(21);
expect(result.length).toEqual(30);
});
it("string definition completion in sequence", async () => {
@@ -243,7 +243,7 @@ jobs:
runs-|`;
const result = await complete(...getPositionFromCursor(input));
expect(result).not.toBeUndefined();
expect(result).toHaveLength(21);
expect(result).toHaveLength(30);
});
it("job key with comment afterwards", async () => {
@@ -254,7 +254,7 @@ jobs:
#`;
const result = await complete(...getPositionFromCursor(input));
expect(result).not.toBeUndefined();
expect(result).toHaveLength(21);
expect(result).toHaveLength(30);
});
it("job key with other values afterwards", async () => {
@@ -266,7 +266,7 @@ jobs:
concurrency: 'group-name'`;
const result = await complete(...getPositionFromCursor(input));
expect(result).not.toBeUndefined();
expect(result).toHaveLength(20);
expect(result).toHaveLength(29);
});
it("step key without space after colon", async () => {
@@ -335,7 +335,7 @@ jobs:
- uses: actions/checkout@v2
`;
const result = await complete(...getPositionFromCursor(input));
expect(result).toHaveLength(17);
expect(result).toHaveLength(25);
});
it("complete from behind a colon will replace it", async () => {
@@ -348,7 +348,7 @@ jobs:
- uses: actions/checkout@v2
`;
const result = await complete(...getPositionFromCursor(input));
expect(result).toHaveLength(17);
expect(result).toHaveLength(25);
const textEdit = result[0].textEdit as TextEdit;
expect(textEdit.range).toEqual({
start: {line: 5, character: 4},
@@ -494,12 +494,15 @@ jobs:
expect(result.filter(x => x.label === "run-name").map(x => x.textEdit?.newText)).toEqual(["run-name: "]);
});
it("adds new line for nested mapping", async () => {
it("does not show mapping keys when user has started typing a scalar value", async () => {
// User typed `workflow_dispatch: in` - they've committed to a scalar value
// Should not show mapping keys like `inputs`
const input = "on:\n workflow_dispatch: in|";
const result = await complete(...getPositionFromCursor(input));
expect(result.filter(x => x.label === "inputs").map(x => x.textEdit?.newText)).toEqual(["\n inputs:\n "]);
// No mapping keys should be shown since user started typing a scalar
expect(result.filter(x => x.label === "inputs")).toEqual([]);
});
it("adds : for one-of", async () => {
@@ -510,27 +513,163 @@ jobs:
expect(result.filter(x => x.label === "types").map(x => x.textEdit?.newText)).toEqual(["types: "]);
});
it("adds newline and indentation for one-of in key mode", async () => {
it("does not show mapping keys for one-of when user has typed a scalar value", async () => {
// User typed `check_run: ty` - they've committed to scalar form
// The only valid value for check_run scalar is null, so no completions
const input = "on:\n check_run: ty|";
const result = await complete(...getPositionFromCursor(input));
// When completing a one-of property in key mode (after colon on same line),
// insert newline + indentation + key + colon to create valid YAML structure
expect(result.filter(x => x.label === "types").map(x => x.textEdit?.newText)).toEqual(["\n types: "]);
// check_run's scalar form only accepts null, so typing anything should show no completions
// (we don't show mapping keys like `types` anymore - user should use `check_run (full syntax)` instead)
expect(result.filter(x => x.label === "types")).toEqual([]);
});
it("handles mixed string and mapping completions for one-of in key mode", async () => {
it("shows all options for one-of when user hasn't committed to a type yet", async () => {
// At `permissions: |` user hasn't typed anything yet - show all options
const input = "on: push\npermissions: |";
const result = await complete(...getPositionFromCursor(input));
// String values (read-all, write-all) should insert directly without newline
// String values (read-all, write-all) should be available
expect(result.filter(x => x.label === "read-all").map(x => x.textEdit?.newText)).toEqual(["read-all"]);
expect(result.filter(x => x.label === "write-all").map(x => x.textEdit?.newText)).toEqual(["write-all"]);
// Mapping keys with one-of types should insert with newline and indentation
// Mapping keys should also be available (user hasn't committed yet)
expect(result.filter(x => x.label === "actions").map(x => x.textEdit?.newText)).toEqual(["\n actions: "]);
expect(result.filter(x => x.label === "contents").map(x => x.textEdit?.newText)).toEqual(["\n contents: "]);
});
it("filters to scalar options when user has started typing a scalar", async () => {
// User typed `permissions: r` - they've committed to scalar form
const input = "on: push\npermissions: r|";
const result = await complete(...getPositionFromCursor(input));
// Only scalar values should be shown (filtering on 'r')
expect(result.some(x => x.label === "read-all")).toBe(true);
// Mapping keys should NOT be shown
expect(result.filter(x => x.label === "actions")).toEqual([]);
expect(result.filter(x => x.label === "contents")).toEqual([]);
});
it("shows full syntax for null+mapping one-of (skips null-only scalar)", async () => {
// check_run is a one-of: [null, mapping].
// Since the scalar form is only null (no string constants), we skip it
// to avoid clobbering string constants from elsewhere in the schema.
// User should see check_run (full syntax) for the mapping form.
const input = "on:\n |";
const result = await complete(...getPositionFromCursor(input));
// Should NOT have plain check_run (null-only scalar is skipped)
// Instead, string constant check_run from on-string-strict is available
expect(result.some(x => x.label === "check_run")).toBe(true);
// Full syntax variant should be available
expect(result.some(x => x.label === "check_run (full syntax)")).toBe(true);
});
it("shows all three variants for scalar+sequence+mapping one-of", async () => {
// runs-on is a one-of: [string, sequence, mapping]
const input = `on: push
jobs:
build:
|`;
const result = await complete(...getPositionFromCursor(input));
// Should have runs-on, runs-on (list), and runs-on (full syntax)
expect(result.some(x => x.label === "runs-on")).toBe(true);
expect(result.some(x => x.label === "runs-on (list)")).toBe(true);
expect(result.some(x => x.label === "runs-on (full syntax)")).toBe(true);
});
it("generates correct insertText for one-of variants in parent mode", async () => {
// runs-on is a one-of: [string, sequence, mapping]
const input = `on: push
jobs:
build:
|`;
const result = await complete(...getPositionFromCursor(input));
// Scalar: just key with colon and space
expect(result.find(x => x.label === "runs-on")?.textEdit?.newText).toEqual("runs-on: ");
// Sequence: key with colon, newline, and list item
expect(result.find(x => x.label === "runs-on (list)")?.textEdit?.newText).toEqual("runs-on:\n - ");
// Mapping: key with colon, newline, and indentation for nested keys
expect(result.find(x => x.label === "runs-on (full syntax)")?.textEdit?.newText).toEqual("runs-on:\n ");
});
it("generates correct insertText for one-of variants in key mode", async () => {
// concurrency is a one-of: [string, mapping] - testing key mode (after colon on same line)
const input = "concurrency: |";
const result = await complete(...getPositionFromCursor(input));
// Scalar in key mode: newline + indented key + colon + space
expect(result.find(x => x.label === "group")?.textEdit?.newText).toEqual("\n group: ");
// Boolean in key mode (cancel-in-progress): newline + indented key + colon + space
expect(result.find(x => x.label === "cancel-in-progress")?.textEdit?.newText).toEqual("\n cancel-in-progress: ");
});
it("uses base key as filterText for qualified one-of variants", async () => {
// runs-on has multiple structural types, so variants get qualifiers
const input = `on: push
jobs:
build:
|`;
const result = await complete(...getPositionFromCursor(input));
// Scalar: no qualifier, so no filterText needed
expect(result.find(x => x.label === "runs-on")?.filterText).toBeUndefined();
// Sequence and mapping: qualified labels should filter on base key
expect(result.find(x => x.label === "runs-on (list)")?.filterText).toEqual("runs-on");
expect(result.find(x => x.label === "runs-on (full syntax)")?.filterText).toEqual("runs-on");
});
it("scalar event completion inserts inline without newline", async () => {
// At `on: |` user is completing the value for 'on' key
// Scalar events like `push`, `check_run` should insert inline
const input = "on: |";
const result = await complete(...getPositionFromCursor(input));
// Scalar forms should NOT have newline - they insert inline
const push = result.find(x => x.label === "push");
expect(push?.textEdit?.newText).toEqual("push");
const checkRun = result.find(x => x.label === "check_run");
expect(checkRun?.textEdit?.newText).toEqual("check_run");
// Full syntax form inserts as a mapping key (with newline in Key mode)
// This is expected behavior - it starts the mapping form
const checkRunFull = result.find(x => x.label === "check_run (full syntax)");
// In Key mode: \n + indent + key + : + \n + indent + indent (for nested content)
expect(checkRunFull?.textEdit?.newText).toEqual("\n check_run:\n ");
});
it("filters to sequence options when user has started a sequence", async () => {
// User started a sequence with `- ` syntax - they've committed to sequence form
const input = `on: push
jobs:
build:
runs-on:
- |`;
const result = await complete(...getPositionFromCursor(input));
// Should show runner labels (sequence item values)
expect(result.some(x => x.label === "ubuntu-latest")).toBe(true);
expect(result.some(x => x.label === "macos-latest")).toBe(true);
// Should NOT show mapping keys like `group` or `labels` (those are for full syntax)
expect(result.filter(x => x.label === "group")).toEqual([]);
expect(result.filter(x => x.label === "labels")).toEqual([]);
});
});
+81 -3
View File
@@ -24,7 +24,7 @@ import {isPlaceholder, transform} from "./utils/transform.js";
import {fetchOrConvertWorkflowTemplate, fetchOrParseWorkflow} from "./utils/workflow-cache.js";
import {Value, ValueProviderConfig} from "./value-providers/config.js";
import {defaultValueProviders} from "./value-providers/default.js";
import {DefinitionValueMode, definitionValues} from "./value-providers/definition.js";
import {DefinitionValueMode, definitionValues, TokenStructure} from "./value-providers/definition.js";
export function getExpressionInput(input: string, pos: number): string {
// Find start marker around the cursor position
@@ -129,6 +129,8 @@ export async function complete(
const item: CompletionItem = {
label: value.label,
filterText: value.filterText,
sortText: value.sortText,
documentation: value.description && {
kind: "markdown",
value: value.description
@@ -141,6 +143,17 @@ export async function complete(
});
}
/**
* Retrieves completion values for a token based on value providers and definitions.
*
* This function determines which values to suggest for auto-completion by:
* 1. First checking for custom value providers configured for the token's definition key
* 2. Then checking for default value providers for the token's definition key
* 3. Finally falling back to values derived from the token's schema definition
*
* The results are filtered to exclude duplicates (e.g., keys already defined in a mapping
* or values already present in a sequence) and sorted alphabetically.
*/
async function getValues(
token: TemplateToken | null,
keyToken: TemplateToken | null,
@@ -180,10 +193,75 @@ async function getValues(
return [];
}
const values = definitionValues(def, indentation, keyToken ? DefinitionValueMode.Key : DefinitionValueMode.Parent);
// When a schema allows multiple formats (e.g., `runs-on` can be a string OR a mapping),
// only suggest completions that match what the user has already started typing.
// For example, if they've started a mapping, don't suggest string values.
const tokenStructure = getTokenStructure(token);
const values = definitionValues(
def,
indentation,
keyToken ? DefinitionValueMode.Key : DefinitionValueMode.Parent,
tokenStructure
);
return filterAndSortCompletionOptions(values, existingValues);
}
/**
* Determines what YAML structure the user has committed to, if any.
*
* Returns:
* - "mapping" if the user has started a key-value structure (e.g., `runs-on:\n group: |`)
* - "sequence" if the user has started a list (e.g., `runs-on:\n - |`)
* - "scalar" if the user has started typing a plain value (e.g., `runs-on: ubuntu-|`)
* - undefined if the user hasn't committed yet (e.g., `runs-on: |` with nothing typed)
*/
function getTokenStructure(token: TemplateToken | null): TokenStructure {
if (!token) {
return undefined;
}
switch (token.templateTokenType) {
case TokenType.Mapping:
return "mapping";
case TokenType.Sequence:
return "sequence";
case TokenType.Null:
// Null means `key: ` with nothing - user hasn't committed to a type yet
return undefined;
case TokenType.String: {
// Empty string means `key: |` - user hasn't committed yet
// Non-empty string means user has started typing a scalar value
const stringToken = token.assertString("getTokenStructure expected string token");
if (stringToken.value === "") {
return undefined;
}
return "scalar";
}
case TokenType.Boolean:
case TokenType.Number:
return "scalar";
default:
return undefined;
}
}
/**
* Collects values that are already present in the current context, so they can be
* excluded from completion suggestions.
*
* For sequences (lists), returns all existing items. For example, if the user has:
* labels:
* - bug
* - |
* This returns {"bug"} so we don't suggest "bug" again.
*
* For mappings, returns all existing keys. For example, if the user has:
* jobs:
* build:
* runs-on: ubuntu-latest
* |
* This returns {"runs-on"} so we don't suggest "runs-on" again.
*/
export function getExistingValues(token: TemplateToken | null, parent: TemplateToken) {
// For incomplete YAML, we may only have a parent token
if (token) {
@@ -253,7 +331,7 @@ function getExpressionCompletionItems(
function filterAndSortCompletionOptions(options: Value[], existingValues?: Set<string>) {
options = options.filter(x => !existingValues?.has(x.label));
options.sort((a, b) => a.label.localeCompare(b.label));
options.sort((a, b) => (a.sortText ?? a.label).localeCompare(b.sortText ?? b.label));
return options;
}
@@ -35,6 +35,9 @@ export function getJobContext(workflowContext: WorkflowContext): DescriptionDict
// Status
jobContext.add("status", new data.Null());
// Check run ID
jobContext.add("check_run_id", new data.Null());
return jobContext;
}
+5 -1
View File
@@ -21,17 +21,21 @@ describe("end-to-end", () => {
const result = await complete(...getPositionFromCursor(input));
expect(result).not.toBeUndefined();
expect(result.length).toEqual(9);
expect(result.length).toEqual(13);
const labels = result.map(x => x.label);
expect(labels).toEqual([
"concurrency",
"concurrency (full syntax)",
"defaults",
"description",
"env",
"jobs",
"name",
"on",
"on (list)",
"on (full syntax)",
"permissions",
"permissions (full syntax)",
"run-name"
]);
});
@@ -417,6 +417,21 @@ jobs:
expect(result).toEqual([]);
});
it("job.check_run_id", async () => {
const input = `
on: push
jobs:
test:
runs-on: ubuntu-latest
steps:
- run: echo \${{ job.check_run_id }}
`;
const result = await validate(createDocument("wf.yaml", input));
expect(result).toEqual([]);
});
it("job.services.<service_id>", async () => {
const input = `
on: push
+31
View File
@@ -272,6 +272,31 @@ function validateCronExpression(diagnostics: Diagnostic[], token: StringToken):
}
}
// Matches a short SHA (7-8 hex characters) that looks like it should be a full SHA
const SHORT_SHA_PATTERN = /^[0-9a-f]{7,8}$/i;
const SHORT_SHA_DOCS_URL =
"https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions#using-third-party-actions";
/**
* Checks if a ref looks like a short SHA and adds a warning if so.
* Returns true if a warning was added.
*/
function warnIfShortSha(diagnostics: Diagnostic[], token: StringToken, ref: string): boolean {
if (SHORT_SHA_PATTERN.test(ref)) {
diagnostics.push({
message: `The provided ref '${ref}' may be a shortened commit SHA. If so, please use the full 40-character commit SHA instead, as short SHAs are not supported.`,
severity: DiagnosticSeverity.Warning,
range: mapRange(token.range),
code: "short-sha-ref",
codeDescription: {
href: SHORT_SHA_DOCS_URL
}
});
return true;
}
return false;
}
/**
* Validates the format of a step's `uses` field.
*
@@ -343,6 +368,9 @@ function validateStepUsesFormat(diagnostics: Diagnostic[], token: StringToken):
});
return;
}
// Warn if ref looks like a short SHA
warnIfShortSha(diagnostics, token, gitRef);
}
function addStepUsesFormatError(diagnostics: Diagnostic[], token: StringToken): void {
@@ -501,6 +529,9 @@ function validateWorkflowUsesFormat(diagnostics: Diagnostic[], token: StringToke
addWorkflowUsesFormatError(diagnostics, token, "invalid workflow file name");
return;
}
// Warn if version looks like a short SHA
warnIfShortSha(diagnostics, token, version);
}
function addWorkflowUsesFormatError(diagnostics: Diagnostic[], token: StringToken, reason: string): void {
@@ -891,4 +891,200 @@ jobs:
});
});
});
describe("short SHA warnings", () => {
describe("step uses", () => {
it("warns on 7-char short SHA", async () => {
const input = `on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@a1b2c3d
`;
const result = await validate(createDocument("wf.yaml", input));
expect(result).toEqual([
{
message:
"The provided ref 'a1b2c3d' may be a shortened commit SHA. If so, please use the full 40-character commit SHA instead, as short SHAs are not supported.",
severity: DiagnosticSeverity.Warning,
range: {
start: {line: 5, character: 12},
end: {line: 5, character: 36}
},
code: "short-sha-ref",
codeDescription: {
href: "https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions#using-third-party-actions"
}
}
]);
});
it("warns on 8-char short SHA", async () => {
const input = `on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@a1b2c3d4
`;
const result = await validate(createDocument("wf.yaml", input));
expect(result).toEqual([
{
message:
"The provided ref 'a1b2c3d4' may be a shortened commit SHA. If so, please use the full 40-character commit SHA instead, as short SHAs are not supported.",
severity: DiagnosticSeverity.Warning,
range: {
start: {line: 5, character: 12},
end: {line: 5, character: 37}
},
code: "short-sha-ref",
codeDescription: {
href: "https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions#using-third-party-actions"
}
}
]);
});
it("does not warn on full SHA (40 chars)", async () => {
const input = `on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2
`;
const result = await validate(createDocument("wf.yaml", input));
expect(result).toEqual([]);
});
it("does not warn on tag ref", async () => {
const input = `on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
`;
const result = await validate(createDocument("wf.yaml", input));
expect(result).toEqual([]);
});
it("does not warn on branch ref", async () => {
const input = `on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@main
`;
const result = await validate(createDocument("wf.yaml", input));
expect(result).toEqual([]);
});
it("does not warn on Docker action", async () => {
const input = `on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: docker://alpine:3.8
`;
const result = await validate(createDocument("wf.yaml", input));
expect(result).toEqual([]);
});
it("does not warn on local action", async () => {
const input = `on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: ./my-action
`;
const result = await validate(createDocument("wf.yaml", input));
expect(result).toEqual([]);
});
});
describe("workflow uses", () => {
it("warns on 7-char short SHA in reusable workflow", async () => {
const input = `on: push
jobs:
test:
uses: owner/repo/.github/workflows/ci.yml@a1b2c3d
`;
const result = await validate(createDocument("wf.yaml", input));
expect(result).toEqual([
{
message:
"The provided ref 'a1b2c3d' may be a shortened commit SHA. If so, please use the full 40-character commit SHA instead, as short SHAs are not supported.",
severity: DiagnosticSeverity.Warning,
range: {
start: {line: 3, character: 10},
end: {line: 3, character: 53}
},
code: "short-sha-ref",
codeDescription: {
href: "https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions#using-third-party-actions"
}
}
]);
});
it("warns on 8-char short SHA in reusable workflow", async () => {
const input = `on: push
jobs:
test:
uses: owner/repo/.github/workflows/ci.yml@a1b2c3d4
`;
const result = await validate(createDocument("wf.yaml", input));
expect(result).toEqual([
{
message:
"The provided ref 'a1b2c3d4' may be a shortened commit SHA. If so, please use the full 40-character commit SHA instead, as short SHAs are not supported.",
severity: DiagnosticSeverity.Warning,
range: {
start: {line: 3, character: 10},
end: {line: 3, character: 54}
},
code: "short-sha-ref",
codeDescription: {
href: "https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions#using-third-party-actions"
}
}
]);
});
it("does not warn on full SHA in reusable workflow", async () => {
const input = `on: push
jobs:
test:
uses: owner/repo/.github/workflows/ci.yml@a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2
`;
const result = await validate(createDocument("wf.yaml", input));
expect(result).toEqual([]);
});
it("does not warn on tag ref in reusable workflow", async () => {
const input = `on: push
jobs:
test:
uses: owner/repo/.github/workflows/ci.yml@v1
`;
const result = await validate(createDocument("wf.yaml", input));
expect(result).toEqual([]);
});
it("does not warn on local workflow", async () => {
const input = `on: push
jobs:
test:
uses: ./.github/workflows/ci.yml
`;
const result = await validate(createDocument("wf.yaml", input));
expect(result).toEqual([]);
});
});
});
});
@@ -12,6 +12,12 @@ export interface Value {
/** Alternative insert text, if not given `label` will be used */
insertText?: string;
/** Alternative filter text, if not given `label` will be used for filtering */
filterText?: string;
/** Sort text to control ordering, if not given `label` will be used for sorting */
sortText?: string;
}
export enum ValueProviderKind {
+198 -11
View File
@@ -1,3 +1,4 @@
import {NullDefinition} from "@actions/workflow-parser/templates/schema/null-definition";
import {BooleanDefinition} from "@actions/workflow-parser/templates/schema/boolean-definition";
import {Definition} from "@actions/workflow-parser/templates/schema/definition";
import {DefinitionType} from "@actions/workflow-parser/templates/schema/definition-type";
@@ -24,7 +25,35 @@ export enum DefinitionValueMode {
Key
}
export function definitionValues(def: Definition, indentation: string, mode: DefinitionValueMode): Value[] {
/**
* What YAML structure the user has started typing.
* Used to filter completions - e.g., if user started a mapping, don't show string completions.
*/
export type TokenStructure = "scalar" | "sequence" | "mapping" | undefined;
/**
* Generates completion values from a workflow schema definition.
*
* This is the fallback when no custom or default value provider exists for a token.
* It reads the schema definition to determine what values are valid.
*
* Examples:
* - For a job definition this returns keys like "runs-on", "steps", "env", "timeout-minutes", etc.
* - For `shell: |`, the schema says it's a string with no constants,
* so this returns no completions
* - For `continue-on-error: |` on a step, the schema has a boolean definition,
* so this returns ["true", "false"]
*
* @param tokenStructure - If provided, filters completions to only those matching
* the YAML structure the user has already started (e.g., only mapping keys if
* they've started a mapping)
*/
export function definitionValues(
def: Definition,
indentation: string,
mode: DefinitionValueMode,
tokenStructure?: TokenStructure
): Value[] {
const schema = getWorkflowSchema();
if (def instanceof MappingDefinition) {
@@ -32,7 +61,7 @@ export function definitionValues(def: Definition, indentation: string, mode: Def
}
if (def instanceof OneOfDefinition) {
return oneOfValues(def, schema.definitions, indentation, mode);
return oneOfValues(def, schema.definitions, indentation, mode, tokenStructure);
}
if (def instanceof BooleanDefinition) {
@@ -58,6 +87,16 @@ export function definitionValues(def: Definition, indentation: string, mode: Def
return [];
}
/**
* Returns completion items for keys in a mapping (object).
*
* For example, given the job definition, this returns completions for
* "runs-on", "steps", "env", etc. Each completion includes appropriate
* insert text based on the expected value type:
* - Sequence properties insert `key:\n - ` to start a list
* - Mapping properties insert `key:\n ` to start nested keys
* - Scalar properties insert `key: ` for inline values
*/
function mappingValues(
mappingDefinition: MappingDefinition,
definitions: {[key: string]: Definition},
@@ -91,13 +130,13 @@ function mappingValues(
}
break;
case DefinitionType.OneOf:
if (mode == DefinitionValueMode.Key) {
insertText = `\n${indentation}${key}: `;
} else {
insertText = `${key}: `;
}
break;
case DefinitionType.OneOf: {
// Expand one-of into multiple completions based on structural type
const oneOfDef = typeDef as OneOfDefinition;
const expanded = expandOneOfToCompletions(oneOfDef, definitions, key, description, indentation, mode);
properties.push(...expanded);
continue; // Skip the default push below
}
case DefinitionType.String:
case DefinitionType.Boolean:
@@ -123,15 +162,43 @@ function mappingValues(
return properties;
}
/**
* Returns completions for values that can be one of several types.
*
* For example, `on:` can be a string ("push"), a list (["push", "pull_request"]),
* or a mapping with event configuration. This function collects completions from
* all valid variants.
*
* If the user has already started typing a specific structure (e.g., started a list),
* only completions for that structure are returned.
*/
function oneOfValues(
oneOfDefinition: OneOfDefinition,
definitions: {[key: string]: Definition},
indentation: string,
mode: DefinitionValueMode
mode: DefinitionValueMode,
tokenStructure?: TokenStructure
): Value[] {
const values: Value[] = [];
for (const key of oneOfDefinition.oneOf) {
values.push(...definitionValues(definitions[key], indentation, mode));
const variantDef = definitions[key];
// Should never happen - the schema should always have valid references
if (!variantDef) {
continue;
}
// Skip variants that don't match what the user has already started typing.
// For example, if user is at `runs-on:\n |` (inside a mapping), skip the string
// variant - only include the mapping variant that suggests keys like "group" or "labels".
if (tokenStructure) {
const variantBucket = getStructuralBucket(variantDef.definitionType);
if (variantBucket !== tokenStructure) {
continue;
}
}
values.push(...definitionValues(variantDef, indentation, mode, tokenStructure));
}
return distinctValues(values);
}
@@ -143,3 +210,123 @@ function distinctValues(values: Value[]): Value[] {
}
return Array.from(map.values());
}
/**
* Bucket type for one-of expansion
*/
type StructuralBucket = "scalar" | "sequence" | "mapping";
/**
* Get the structural bucket for a definition type.
* Nested one-of is treated as scalar.
*/
function getStructuralBucket(defType: DefinitionType): StructuralBucket {
switch (defType) {
case DefinitionType.Sequence:
return "sequence";
case DefinitionType.Mapping:
return "mapping";
default:
// String, Boolean, Number, Null, OneOf (nested), AllowedValues -> scalar
// Note, nested OneOf is assumed to be all scalar values, which is true in practice.
return "scalar";
}
}
/**
* Creates completion items for a key whose value can be multiple formats.
*
* For example, `runs-on` can be a string, list, or mapping. This function creates
* separate completions for each format:
* - "runs-on" for the string form (`runs-on: ubuntu-latest`)
* - "runs-on (list)" for the list form (`runs-on:\n - ubuntu-latest`)
* - "runs-on (full syntax)" for the mapping form (`runs-on:\n group: my-group`)
*
* The qualifier (list/full syntax) is only added when multiple formats exist.
*/
function expandOneOfToCompletions(
oneOfDef: OneOfDefinition,
definitions: {[key: string]: Definition},
key: string,
description: string | undefined,
indentation: string,
mode: DefinitionValueMode
): Value[] {
// Bucket variants by structural type
const buckets: Record<StructuralBucket, boolean> = {
scalar: false,
sequence: false,
mapping: false
};
// Track if scalar bucket only contains null (no actual string/boolean/number values)
let scalarIsOnlyNull = true;
for (const variantKey of oneOfDef.oneOf) {
const variantDef = definitions[variantKey];
if (variantDef) {
const bucket = getStructuralBucket(variantDef.definitionType);
buckets[bucket] = true;
// Check if this scalar is NOT null
if (bucket === "scalar" && !(variantDef instanceof NullDefinition)) {
scalarIsOnlyNull = false;
}
}
}
const results: Value[] = [];
// Count how many structural types are present
const bucketCount = [buckets.scalar, buckets.sequence, buckets.mapping].filter(Boolean).length;
const needsQualifier = bucketCount > 1;
// Emit completions in order: scalar, sequence, mapping
// Use sortText to preserve this order (scalar sorts first, then 1=sequence, 2=mapping)
//
// In Key mode (after colon on same line), skip the key completion if scalar only allows null.
// Example: at `on: |`, we want `check_run` to insert inline, not start a new mapping.
//
// In Parent mode (typing a new key), we DO show it since `check_run:` with no value
// is valid (triggers on all check_run events).
const skipNullOnlyScalar = mode === DefinitionValueMode.Key && scalarIsOnlyNull;
if (buckets.scalar && !skipNullOnlyScalar) {
// If cursor is after colon (`on: |`), insert newline first so result is `on:\n check_run: `
const insertText = mode === DefinitionValueMode.Key ? `\n${indentation}${key}: ` : `${key}: `;
results.push({
label: key,
description,
insertText
});
}
if (buckets.sequence) {
const insertText =
mode === DefinitionValueMode.Key
? `\n${indentation}${key}:\n${indentation}${indentation}- `
: `${key}:\n${indentation}- `;
results.push({
label: needsQualifier ? `${key} (list)` : key,
description,
insertText,
filterText: needsQualifier ? key : undefined,
sortText: needsQualifier ? `${key} 1` : undefined
});
}
if (buckets.mapping) {
const insertText =
mode === DefinitionValueMode.Key
? `\n${indentation}${key}:\n${indentation}${indentation}`
: `${key}:\n${indentation}`;
results.push({
label: needsQualifier ? `${key} (full syntax)` : key,
description,
insertText,
filterText: needsQualifier ? key : undefined,
sortText: needsQualifier ? `${key} 2` : undefined
});
}
return results;
}
+1 -1
View File
@@ -6,5 +6,5 @@
"languageservice",
"languageserver"
],
"version": "0.3.26"
"version": "0.3.28"
}
+9 -9
View File
@@ -136,7 +136,7 @@
},
"expressions": {
"name": "@actions/expressions",
"version": "0.3.26",
"version": "0.3.28",
"license": "MIT",
"devDependencies": {
"@types/jest": "^29.0.3",
@@ -396,11 +396,11 @@
},
"languageserver": {
"name": "@actions/languageserver",
"version": "0.3.26",
"version": "0.3.28",
"license": "MIT",
"dependencies": {
"@actions/languageservice": "^0.3.26",
"@actions/workflow-parser": "^0.3.26",
"@actions/languageservice": "^0.3.28",
"@actions/workflow-parser": "^0.3.28",
"@octokit/rest": "^21.1.1",
"@octokit/types": "^9.0.0",
"vscode-languageserver": "^8.0.2",
@@ -940,11 +940,11 @@
},
"languageservice": {
"name": "@actions/languageservice",
"version": "0.3.26",
"version": "0.3.28",
"license": "MIT",
"dependencies": {
"@actions/expressions": "^0.3.26",
"@actions/workflow-parser": "^0.3.26",
"@actions/expressions": "^0.3.28",
"@actions/workflow-parser": "^0.3.28",
"vscode-languageserver-textdocument": "^1.0.7",
"vscode-languageserver-types": "^3.17.2",
"vscode-uri": "^3.0.8",
@@ -13345,10 +13345,10 @@
},
"workflow-parser": {
"name": "@actions/workflow-parser",
"version": "0.3.26",
"version": "0.3.28",
"license": "MIT",
"dependencies": {
"@actions/expressions": "^0.3.26",
"@actions/expressions": "^0.3.28",
"cronstrue": "^2.21.0",
"yaml": "^2.0.0-8"
},
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@actions/workflow-parser",
"version": "0.3.26",
"version": "0.3.28",
"license": "MIT",
"type": "module",
"source": "./src/index.ts",
@@ -48,7 +48,7 @@
"watch": "tsc --build tsconfig.build.json --watch"
},
"dependencies": {
"@actions/expressions": "^0.3.26",
"@actions/expressions": "^0.3.28",
"cronstrue": "^2.21.0",
"yaml": "^2.0.0-8"
},