Compare commits

..

13 Commits

Author SHA1 Message Date
github-actions[bot] 3734de18ee Release extension version 0.3.30 (#274)
Co-authored-by: GitHub Actions <github-actions@github.com>
2025-12-30 12:01:49 -06:00
eric sciple 90e7932e97 Add runs-on label completions for mapping syntax (#273)
Provides runner label completions (ubuntu-latest, macos-latest, etc.)
when using the runs-on mapping syntax with the labels property:

  jobs:
    build:
      runs-on:
        labels: |

  jobs:
    build:
      runs-on:
        labels:
          - |

Previously, completions only worked for the simple runs-on syntax:

  jobs:
    build:
      runs-on: |

The fix registers the same value provider for both 'runs-on' and
'runs-on-labels' definition keys in the schema.
2025-12-30 10:30:19 -06:00
github-actions[bot] f84e42c1f1 Release extension version 0.3.29 (#272)
Co-authored-by: GitHub Actions <github-actions@github.com>
2025-12-29 22:27:06 -06:00
eric sciple 08c78d2a73 Replace cron info diagnostics with inlay hints (#270)
- Remove DiagnosticSeverity.Information for valid cron expressions
- Add new inlay-hints.ts module with getInlayHints() function
- Register inlayHintProvider capability in language server
- Display human-readable cron descriptions as inline hints

Related #269
2025-12-29 13:47:30 -06:00
eric sciple 26f3969cde Add escape hatch completions to switch structural forms (#271)
When completing an empty value position (e.g., `runs-on: |`), add special
completions that let users switch to alternative structural forms:

- "(switch to list)" - restructures to `key:\n  - `
- "(switch to mapping)" - restructures to `key:\n  `

These help users escape "dead end" situations where the current form has
no valid completions but alternative forms are available in the schema.
2025-12-29 12:54:53 -06:00
eric sciple 61a6fc54f2 Use detail field for one-of qualifiers instead of label (#266)
- Move qualifiers (list, full syntax) from label to detail field
- Remove filterText since labels are now clean
- Update distinctValues to preserve variants with different details
- Standard LSP pattern: detail shown after label in completion UI
2025-12-29 10:45:46 -06:00
eric sciple 6511be5ab4 Fix autocomplete showing mapping keys for empty values (#268)
Follow-up to #265

When completing an empty value (e.g., `permissions: |`), mapping keys were
incorrectly shown alongside scalar values. This made completions confusing.

Before:
- `permissions: |` showed read-all, write-all, AND actions, contents, etc.
- `on: |` showed check_run AND check_run (full syntax), etc.

After:
- `permissions: |` shows only read-all and write-all
- `on: |` shows only event names like push, check_run
- `concurrency: |` shows no completions (user types their own group name)

Users who want the mapping form choose (full syntax) completions at the
parent level.
2025-12-29 09:50:59 -06:00
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
22 changed files with 1279 additions and 109 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@actions/expressions",
"version": "0.3.26",
"version": "0.3.30",
"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.30",
"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.30",
"@actions/workflow-parser": "^0.3.30",
"@octokit/rest": "^21.1.1",
"@octokit/types": "^9.0.0",
"vscode-languageserver": "^8.0.2",
+11 -2
View File
@@ -1,4 +1,4 @@
import {documentLinks, hover, validate, ValidationConfig} from "@actions/languageservice";
import {documentLinks, getInlayHints, hover, validate, ValidationConfig} from "@actions/languageservice";
import {registerLogger, setLogLevel} from "@actions/languageservice/log";
import {clearCache, clearCacheEntry} from "@actions/languageservice/utils/workflow-cache";
import {Octokit} from "@octokit/rest";
@@ -12,6 +12,8 @@ import {
HoverParams,
InitializeParams,
InitializeResult,
InlayHint,
InlayHintParams,
TextDocumentIdentifier,
TextDocumentPositionParams,
TextDocuments,
@@ -72,7 +74,8 @@ export function initConnection(connection: Connection) {
hoverProvider: true,
documentLinkProvider: {
resolveProvider: false
}
},
inlayHintProvider: true
}
};
@@ -158,6 +161,12 @@ export function initConnection(connection: Connection) {
return documentLinks(getDocument(documents, textDocument), repoContext?.workspaceUri);
});
connection.languages.inlayHint.on(async ({textDocument}: InlayHintParams): Promise<InlayHint[] | null> => {
return timeOperation("inlayHints", () => {
return getInlayHints(getDocument(documents, textDocument));
});
});
// Make the text document manager listen on the connection
// for open, change and close text document events
documents.listen(connection);
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@actions/languageservice",
"version": "0.3.26",
"version": "0.3.30",
"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.30",
"@actions/workflow-parser": "^0.3.30",
"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 () => {
+377 -38
View File
@@ -19,9 +19,12 @@ describe("completion", () => {
const result = await complete(...getPositionFromCursor(input));
expect(result).not.toBeUndefined();
expect(result.length).toEqual(12);
// 12 runner labels + 2 escape hatches (switch to list, switch to full syntax)
expect(result.length).toEqual(14);
const labels = result.map(x => x.label);
expect(labels).toContain("macos-latest");
expect(labels).toContain("(switch to list)");
expect(labels).toContain("(switch to mapping)");
});
it("needs", async () => {
@@ -44,7 +47,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 +73,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 () => {
@@ -95,6 +98,7 @@ jobs:
release:
types: |`;
const result = await complete(...getPositionFromCursor(input));
// Expect string values plus escape hatch to switch to list form
expect(result.map(x => x.label)).toEqual([
"created",
"deleted",
@@ -102,7 +106,8 @@ jobs:
"prereleased",
"published",
"released",
"unpublished"
"unpublished",
"(switch to list)"
]);
});
@@ -190,8 +195,11 @@ jobs:
const result = await complete(...getPositionFromCursor(input), {valueProviderConfig: config});
expect(result).not.toBeUndefined();
expect(result.length).toEqual(1);
// Custom value plus escape hatches for list and full syntax
expect(result.length).toEqual(3);
expect(result[0].label).toEqual("my-custom-label");
expect(result.map(x => x.label)).toContain("(switch to list)");
expect(result.map(x => x.label)).toContain("(switch to mapping)");
});
it("custom value providers for sequences", async () => {
@@ -212,7 +220,9 @@ jobs:
expect(result[0].label).toEqual("my-custom-label");
});
it("does not show parent mapping sibling keys", async () => {
it("does not show mapping keys or parent sibling keys in Key mode", async () => {
// At `container: |`, the scalar form is a string with no constants.
// Mapping keys should NOT be shown inline - but escape hatch to full syntax IS shown.
const input = `on: push
jobs:
build:
@@ -220,20 +230,21 @@ jobs:
runs-on: ubuntu-latest`;
const result = await complete(...getPositionFromCursor(input));
expect(result).not.toBeUndefined();
expect(result.length).toEqual(6);
// Should not contain other top-level job keys like `if` and `runs-on`
expect(result.map(x => x.label)).not.toContain("if");
expect(result.map(x => x.label)).not.toContain("runs-on");
// Only escape hatch to full syntax (container has mapping form but no sequence)
expect(result.map(x => x.label)).toEqual(["(switch to mapping)"]);
});
it("shows mapping keys within a new map ", async () => {
it("does not show mapping keys in Key mode when structure is uncommitted", async () => {
// At `concurrency: |`, user is in Key mode but hasn't committed to a structure.
// The scalar form is a string with no constants, so no scalar completions.
// But escape hatch to full syntax IS shown as a way out.
const input = `on: push
jobs:
build:
concurrency: |`;
const result = await complete(...getPositionFromCursor(input));
expect(result).not.toBeUndefined();
expect(result.map(x => x.label).sort()).toEqual(["cancel-in-progress", "group"]);
expect(result.map(x => x.label)).toEqual(["(switch to mapping)"]);
});
it("job key", async () => {
@@ -243,7 +254,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 +265,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 +277,10 @@ jobs:
concurrency: 'group-name'`;
const result = await complete(...getPositionFromCursor(input));
expect(result).not.toBeUndefined();
expect(result).toHaveLength(20);
// Verify we get job-level completions, but concurrency is already present so excluded
expect(result.length).toBeGreaterThan(20);
expect(result.some(x => x.label === "runs-on")).toBe(true);
expect(result.some(x => x.label === "concurrency")).toBe(false);
});
it("step key without space after colon", async () => {
@@ -335,7 +349,9 @@ jobs:
- uses: actions/checkout@v2
`;
const result = await complete(...getPositionFromCursor(input));
expect(result).toHaveLength(17);
// Verify we get job-level completions including runs-on variants
expect(result.length).toBeGreaterThan(20);
expect(result.some(x => x.label === "steps")).toBe(true);
});
it("complete from behind a colon will replace it", async () => {
@@ -348,7 +364,8 @@ jobs:
- uses: actions/checkout@v2
`;
const result = await complete(...getPositionFromCursor(input));
expect(result).toHaveLength(17);
// Verify we get job-level completions
expect(result.length).toBeGreaterThan(20);
const textEdit = result[0].textEdit as TextEdit;
expect(textEdit.range).toEqual({
start: {line: 5, character: 4},
@@ -447,8 +464,9 @@ jobs:
"timeout-minutes: "
]);
// One-of
expect(result.filter(x => x.label === "concurrency").map(x => x.textEdit?.newText)).toEqual(["concurrency: "]);
// One-of (scalar variant)
const concurrencyScalar = result.find(x => x.label === "concurrency" && x.detail === undefined);
expect(concurrencyScalar?.textEdit?.newText).toEqual("concurrency: ");
});
it("custom indentation", async () => {
@@ -470,20 +488,21 @@ jobs:
"timeout-minutes: "
]);
// One-of
expect(result.filter(x => x.label === "concurrency").map(x => x.textEdit?.newText)).toEqual(["concurrency: "]);
// One-of (scalar variant)
const concurrencyScalar = result.find(x => x.label === "concurrency" && x.detail === undefined);
expect(concurrencyScalar?.textEdit?.newText).toEqual("concurrency: ");
});
});
it("adds a new line and indentation for mapping keys when the key is given", async () => {
it("does not show mapping keys in Key mode for one-of with mapping variant", async () => {
// At `concurrency: |`, mapping keys should NOT be shown.
// Users who want the mapping form should use `concurrency (full syntax)` at parent level.
const input = "concurrency: |";
const result = await complete(...getPositionFromCursor(input));
expect(result.filter(x => x.label === "cancel-in-progress").map(x => x.textEdit?.newText)).toEqual([
"\n cancel-in-progress: "
]);
expect(result.filter(x => x.label === "group").map(x => x.textEdit?.newText)).toEqual(["\n group: "]);
expect(result.filter(x => x.label === "cancel-in-progress")).toEqual([]);
expect(result.filter(x => x.label === "group")).toEqual([]);
});
it("does not add new line if no key in line", async () => {
@@ -494,12 +513,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 () => {
@@ -507,30 +529,347 @@ jobs:
const result = await complete(...getPositionFromCursor(input));
expect(result.filter(x => x.label === "types").map(x => x.textEdit?.newText)).toEqual(["types: "]);
// Scalar variant inserts "types: "
const scalarVariant = result.find(x => x.label === "types" && x.detail === undefined);
expect(scalarVariant?.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 with detail "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 only scalar options for one-of in Key mode when user hasn't committed to a type", async () => {
// At `permissions: |` user hasn't typed anything yet - show only scalar options
// Mapping keys are NOT shown because they would require a newline
// Users who want the mapping form can use `permissions (full syntax)` at the parent level
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
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: "]);
// Mapping keys should NOT be shown - they require a newline which is confusing inline
expect(result.filter(x => x.label === "actions")).toEqual([]);
expect(result.filter(x => x.label === "contents")).toEqual([]);
});
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 both simple and full syntax for null+mapping one-of", async () => {
// check_run is a one-of: [null, mapping]. Show both:
// - check_run (simple, just the key with colon)
// - check_run with detail "full syntax" (ready to add mapping keys)
const input = "on:\n |";
const result = await complete(...getPositionFromCursor(input));
// Should have both check_run (scalar) and check_run with detail "full syntax"
const checkRunVariants = result.filter(x => x.label === "check_run");
expect(checkRunVariants.some(x => x.detail === undefined)).toBe(true);
expect(checkRunVariants.some(x => x.detail === "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 (scalar), runs-on with detail "list", and runs-on with detail "full syntax"
const runsOnVariants = result.filter(x => x.label === "runs-on");
expect(runsOnVariants.length).toBe(3);
expect(runsOnVariants.some(x => x.detail === undefined)).toBe(true);
expect(runsOnVariants.some(x => x.detail === "list")).toBe(true);
expect(runsOnVariants.some(x => x.detail === "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));
const runsOnVariants = result.filter(x => x.label === "runs-on");
// Scalar: just key with colon and space
expect(runsOnVariants.find(x => x.detail === undefined)?.textEdit?.newText).toEqual("runs-on: ");
// Sequence: key with colon, newline, and list item
expect(runsOnVariants.find(x => x.detail === "list")?.textEdit?.newText).toEqual("runs-on:\n - ");
// Mapping: key with colon, newline, and indentation for nested keys
expect(runsOnVariants.find(x => x.detail === "full syntax")?.textEdit?.newText).toEqual("runs-on:\n ");
});
it("generates correct insertText for one-of variants in parent mode", async () => {
// concurrency is a one-of: [string, mapping] - testing parent mode (inside mapping)
// At `concurrency:\n |`, user HAS committed to mapping structure, so mapping keys are shown
const input = "concurrency:\n |";
const result = await complete(...getPositionFromCursor(input));
// In parent mode: just key + colon + space (no leading newline)
expect(result.find(x => x.label === "group")?.textEdit?.newText).toEqual("group: ");
// Boolean in parent mode (cancel-in-progress): key + colon + space
expect(result.find(x => x.label === "cancel-in-progress")?.textEdit?.newText).toEqual("cancel-in-progress: ");
});
it("uses sortText for ordering qualified one-of variants", async () => {
// runs-on has multiple structural types, so variants need sorting
const input = `on: push
jobs:
build:
|`;
const result = await complete(...getPositionFromCursor(input));
const runsOnVariants = result.filter(x => x.label === "runs-on");
// Scalar: no sortText needed (sorts naturally first)
expect(runsOnVariants.find(x => x.detail === undefined)?.sortText).toBeUndefined();
// Sequence and mapping: sortText controls ordering
expect(runsOnVariants.find(x => x.detail === "list")?.sortText).toEqual("runs-on 1");
expect(runsOnVariants.find(x => x.detail === "full syntax")?.sortText).toEqual("runs-on 2");
});
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" && x.detail === undefined);
expect(checkRun?.textEdit?.newText).toEqual("check_run");
// Full syntax form should NOT be shown in Key mode - it requires a newline
// which is confusing when typing inline. Users who want the mapping form
// can use `on (full syntax)` at the parent level.
expect(result.find(x => x.label === "check_run" && x.detail === "full syntax")).toBeUndefined();
});
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([]);
});
describe("escape hatch completions", () => {
it("runs-on shows switch to list and full syntax", async () => {
const input = `on: push
jobs:
build:
runs-on: |`;
const result = await complete(...getPositionFromCursor(input));
// Should have escape hatches at the end
const switchToList = result.find(x => x.label === "(switch to list)");
const switchToFull = result.find(x => x.label === "(switch to mapping)");
expect(switchToList).toBeDefined();
expect(switchToFull).toBeDefined();
// Escape hatches should sort last
expect(switchToList!.sortText).toEqual("zzz_switch_1");
expect(switchToFull!.sortText).toEqual("zzz_switch_2");
// Escape hatches should have textEdit that restructures the YAML
const listEdit = switchToList!.textEdit as TextEdit;
const fullEdit = switchToFull!.textEdit as TextEdit;
expect(listEdit.newText).toEqual("runs-on:\n - ");
expect(fullEdit.newText).toEqual("runs-on:\n ");
// TextEdit range should cover from key start to cursor position
expect(listEdit.range.start).toEqual({line: 3, character: 4});
expect(fullEdit.range.start).toEqual({line: 3, character: 4});
});
it("permissions shows only switch to full syntax (no sequence form)", async () => {
const input = `on: push
permissions: |`;
const result = await complete(...getPositionFromCursor(input));
// Should have full syntax escape hatch but NOT list (permissions has no sequence form)
expect(result.some(x => x.label === "(switch to mapping)")).toBe(true);
expect(result.some(x => x.label === "(switch to list)")).toBe(false);
});
it("escape hatches are not shown when value is non-empty", async () => {
const input = `on: push
jobs:
build:
runs-on: ubuntu-|`;
const result = await complete(...getPositionFromCursor(input));
// User has started typing a scalar value, no escape hatches
expect(result.some(x => x.label === "(switch to list)")).toBe(false);
expect(result.some(x => x.label === "(switch to mapping)")).toBe(false);
});
it("escape hatches are not shown when inside a sequence", async () => {
const input = `on: push
jobs:
build:
runs-on:
- |`;
const result = await complete(...getPositionFromCursor(input));
// User is already in sequence form, no escape hatches
expect(result.some(x => x.label === "(switch to list)")).toBe(false);
expect(result.some(x => x.label === "(switch to mapping)")).toBe(false);
});
it("escape hatches are not shown when inside a mapping", async () => {
const input = `on: push
jobs:
build:
runs-on:
group: |`;
const result = await complete(...getPositionFromCursor(input));
// User is in mapping form completing a value, no escape hatches for the parent
expect(result.some(x => x.label === "(switch to list)")).toBe(false);
expect(result.some(x => x.label === "(switch to mapping)")).toBe(false);
});
it("escape hatches ARE shown even when no scalar completions exist", async () => {
// concurrency: | has no scalar constants, but escape hatch provides a way out
const input = `on: push
jobs:
build:
concurrency: |`;
const result = await complete(...getPositionFromCursor(input));
// Escape hatch to mapping should be available even with no scalar completions
expect(result.map(x => x.label)).toEqual(["(switch to mapping)"]);
});
it("pure mapping type (strategy) shows switch to mapping", async () => {
const input = `on: push
jobs:
build:
strategy: |`;
const result = await complete(...getPositionFromCursor(input));
expect(result.some(x => x.label === "(switch to mapping)")).toBe(true);
});
it("pure sequence type (steps) shows switch to list", async () => {
const input = `on: push
jobs:
build:
steps: |`;
const result = await complete(...getPositionFromCursor(input));
expect(result.some(x => x.label === "(switch to list)")).toBe(true);
});
it("selecting switch to list restructures YAML", async () => {
const input = `on: push
jobs:
build:
runs-on: |`;
const result = await complete(...getPositionFromCursor(input));
const switchToList = result.find(x => x.label === "(switch to list)");
const textEdit = switchToList!.textEdit as TextEdit;
// Applying this edit to "runs-on: " should produce "runs-on:\n - "
expect(textEdit.newText).toEqual("runs-on:\n - ");
});
});
describe("runs-on mapping syntax", () => {
it("provides label completions for labels as scalar", async () => {
const input = `on: push
jobs:
build:
runs-on:
labels: |`;
const result = await complete(...getPositionFromCursor(input));
// Should show runner labels
expect(result.some(x => x.label === "ubuntu-latest")).toBe(true);
expect(result.some(x => x.label === "macos-latest")).toBe(true);
expect(result.some(x => x.label === "self-hosted")).toBe(true);
});
it("provides label completions for labels as sequence item", async () => {
const input = `on: push
jobs:
build:
runs-on:
labels:
- |`;
const result = await complete(...getPositionFromCursor(input));
// Should show runner labels
expect(result.some(x => x.label === "ubuntu-latest")).toBe(true);
expect(result.some(x => x.label === "macos-latest")).toBe(true);
expect(result.some(x => x.label === "self-hosted")).toBe(true);
});
it("excludes already used labels in sequence", async () => {
const input = `on: push
jobs:
build:
runs-on:
labels:
- ubuntu-latest
- |`;
const result = await complete(...getPositionFromCursor(input));
// Should NOT show ubuntu-latest since it's already in the list
expect(result.some(x => x.label === "ubuntu-latest")).toBe(false);
// But should show other labels
expect(result.some(x => x.label === "macos-latest")).toBe(true);
});
});
});
+211 -4
View File
@@ -2,6 +2,8 @@ import {complete as completeExpression, DescriptionDictionary} from "@actions/ex
import {CompletionItem as ExpressionCompletionItem} from "@actions/expressions/completion";
import {isBasicExpression, isSequence, isString} from "@actions/workflow-parser";
import {ErrorPolicy} from "@actions/workflow-parser/model/convert";
import {DefinitionType} from "@actions/workflow-parser/templates/schema/definition-type";
import {OneOfDefinition} from "@actions/workflow-parser/templates/schema/one-of-definition";
import {OPEN_EXPRESSION} from "@actions/workflow-parser/templates/template-constants";
import {TemplateToken} from "@actions/workflow-parser/templates/tokens/index";
import {MappingToken} from "@actions/workflow-parser/templates/tokens/mapping-token";
@@ -9,6 +11,7 @@ import {TokenRange} from "@actions/workflow-parser/templates/tokens/token-range"
import {TokenType} from "@actions/workflow-parser/templates/tokens/types";
import {File} from "@actions/workflow-parser/workflows/file";
import {FileProvider} from "@actions/workflow-parser/workflows/file-provider";
import {getWorkflowSchema} from "@actions/workflow-parser/workflows/workflow-schema";
import {Position, TextDocument} from "vscode-languageserver-textdocument";
import {CompletionItem, CompletionItemKind, CompletionItemTag, Range, TextEdit} from "vscode-languageserver-types";
import {ContextProviderConfig} from "./context-providers/config.js";
@@ -24,7 +27,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
@@ -100,8 +103,17 @@ export async function complete(
const values = await getValues(token, keyToken, parent, config?.valueProviderConfig, workflowContext, indentString);
// Add escape hatch completions when completing an empty scalar value for a one-of field.
// These provide a way out of "dead end" situations where no scalar completions exist
// but alternative structural forms (list, mapping) are available.
const escapeHatches = getEscapeHatchCompletions(token, keyToken, indentString, newPos);
values.push(...escapeHatches);
// Figure out what text to replace when the user picks a completion.
// For example, if they typed `runs-|` and pick `runs-on`, we need to replace `runs-`.
let replaceRange: Range | undefined;
if (token?.range) {
// Prefer the token's range since it accounts for YAML syntax like quotes
replaceRange = mapRange(token.range);
} else if (!token) {
// Not a valid token, create a range from the current position
@@ -127,20 +139,44 @@ export async function complete(
return values.map(value => {
const newText = value.insertText || value.label;
// Escape hatches provide their own textEdit to restructure the YAML
let textEdit: TextEdit;
if (value.textEdit) {
textEdit = TextEdit.replace(value.textEdit.range, value.textEdit.newText);
} else if (replaceRange) {
textEdit = TextEdit.replace(replaceRange, newText);
} else {
textEdit = TextEdit.insert(position, newText);
}
const item: CompletionItem = {
label: value.label,
detail: value.detail,
filterText: value.filterText,
sortText: value.sortText,
documentation: value.description && {
kind: "markdown",
value: value.description
},
tags: value.deprecated ? [CompletionItemTag.Deprecated] : undefined,
textEdit: replaceRange ? TextEdit.replace(replaceRange, newText) : TextEdit.insert(position, newText)
textEdit
};
return item;
});
}
/**
* 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 +216,181 @@ 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;
}
}
/**
* Generates escape hatch completions that allow switching from scalar form to
* alternative structural forms (sequence or mapping) when the value is empty.
*
* For example, at `runs-on: |`, this adds "(switch to list)" and "(switch to full syntax)"
* completions that restructure the YAML to `runs-on:\n - |` or `runs-on:\n |`.
*
* Only shown when:
* - Completing in value position (keyToken exists)
* - Value is empty (user hasn't committed to a structure yet)
* - Definition allows sequence or mapping structure
*/
function getEscapeHatchCompletions(
token: TemplateToken | null,
keyToken: TemplateToken | null,
indentation: string,
position: Position
): Value[] {
// Only show escape hatches when value is empty
const tokenStructure = getTokenStructure(token);
if (tokenStructure !== undefined) {
return [];
}
// Need a key token with a definition
if (!keyToken?.definition) {
return [];
}
// Determine which structural types are available from the definition
const def = keyToken.definition;
const schema = getWorkflowSchema();
const buckets = {
sequence: false,
mapping: false
};
if (def instanceof OneOfDefinition) {
// OneOf: check each variant
for (const variantKey of def.oneOf) {
const variantDef = schema.definitions[variantKey];
if (variantDef) {
switch (variantDef.definitionType) {
case DefinitionType.Sequence:
buckets.sequence = true;
break;
case DefinitionType.Mapping:
buckets.mapping = true;
break;
}
}
}
} else {
// Single definition type
switch (def.definitionType) {
case DefinitionType.Sequence:
buckets.sequence = true;
break;
case DefinitionType.Mapping:
buckets.mapping = true;
break;
}
}
const results: Value[] = [];
const keyName = isString(keyToken) ? keyToken.value : "";
const keyRange = keyToken.range;
if (!keyRange || !keyName) {
return [];
}
// Calculate the range from key start to current position
// This covers "key: " so we can replace it with "key:\n - " or "key:\n "
const editRange = {
start: {line: keyRange.start.line - 1, character: keyRange.start.column - 1},
end: {line: position.line, character: position.character}
};
if (buckets.sequence) {
results.push({
label: "(switch to list)",
sortText: "zzz_switch_1",
filterText: keyName, // Allow filtering by key name
textEdit: {
range: editRange,
newText: `${keyName}:\n${indentation}- `
}
});
}
if (buckets.mapping) {
results.push({
label: "(switch to mapping)",
sortText: "zzz_switch_2",
filterText: keyName, // Allow filtering by key name
textEdit: {
range: editRange,
newText: `${keyName}:\n${indentation}`
}
});
}
return results;
}
/**
* 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 +460,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;
}
+7 -3
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);
const labels = result.map(x => x.label);
expect(labels).toEqual([
expect(result.length).toEqual(13);
const labelsWithDetails = result.map(x => (x.detail ? `${x.label} (${x.detail})` : x.label));
expect(labelsWithDetails).toEqual([
"concurrency",
"concurrency (full syntax)",
"defaults",
"description",
"env",
"jobs",
"name",
"on",
"on (list)",
"on (full syntax)",
"permissions",
"permissions (full syntax)",
"run-name"
]);
});
+1
View File
@@ -2,6 +2,7 @@ export {complete} from "./complete.js";
export {ContextProviderConfig} from "./context-providers/config.js";
export {documentLinks} from "./document-links.js";
export {hover} from "./hover.js";
export {getInlayHints} from "./inlay-hints.js";
export {Logger, LogLevel, registerLogger, setLogLevel} from "./log.js";
export {validate, ValidationConfig, ActionsMetadataProvider} from "./validate.js";
export {ValueProviderConfig, ValueProviderKind} from "./value-providers/config.js";
+116
View File
@@ -0,0 +1,116 @@
import {InlayHintKind} from "vscode-languageserver-types";
import {getInlayHints} from "./inlay-hints.js";
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";
registerLogger(new TestLogger());
beforeEach(() => {
clearCache();
});
describe("inlay-hints", () => {
describe("cron expressions", () => {
it("returns inlay hint for valid cron expression", () => {
const input = `on:
schedule:
- cron: '0 * * * *'
`;
const document = createDocument("test.yaml", input);
const hints = getInlayHints(document);
expect(hints).toHaveLength(1);
expect(hints[0].label).toBe("→ Runs every hour");
expect(hints[0].kind).toBe(InlayHintKind.Parameter);
expect(hints[0].paddingLeft).toBe(true);
});
it("returns correct position at end of cron value", () => {
const input = `on:
schedule:
- cron: '0 3 * * 1'
`;
const document = createDocument("test.yaml", input);
const hints = getInlayHints(document);
expect(hints).toHaveLength(1);
// Position should be at the end of the cron string value (after the closing quote)
// Line 3 (0-indexed: 2), end of '0 3 * * 1'
expect(hints[0].position.line).toBe(2);
});
it("returns no hint for invalid cron expression", () => {
const input = `on:
schedule:
- cron: 'invalid cron'
`;
const document = createDocument("test.yaml", input);
const hints = getInlayHints(document);
expect(hints).toHaveLength(0);
});
it("returns multiple hints for multiple cron expressions", () => {
const input = `on:
schedule:
- cron: '0 * * * *'
- cron: '0 0 * * *'
`;
const document = createDocument("test.yaml", input);
const hints = getInlayHints(document);
expect(hints).toHaveLength(2);
expect(hints[0].label).toBe("→ Runs every hour");
expect(hints[1].label).toBe("→ Runs at 00:00");
});
it("returns hint with descriptive label for weekly cron", () => {
const input = `on:
schedule:
- cron: '0 3 * * 1'
`;
const document = createDocument("test.yaml", input);
const hints = getInlayHints(document);
expect(hints).toHaveLength(1);
expect(hints[0].label).toContain("Monday");
});
it("returns no hints for empty workflow", () => {
const input = ``;
const document = createDocument("test.yaml", input);
const hints = getInlayHints(document);
expect(hints).toHaveLength(0);
});
it("returns no hints for workflow without schedule", () => {
const input = `on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: echo hello
`;
const document = createDocument("test.yaml", input);
const hints = getInlayHints(document);
expect(hints).toHaveLength(0);
});
it("returns hint for frequent cron that triggers warning", () => {
// Even crons that trigger the <5min warning should still get inlay hints
const input = `on:
schedule:
- cron: '* * * * *'
`;
const document = createDocument("test.yaml", input);
const hints = getInlayHints(document);
expect(hints).toHaveLength(1);
expect(hints[0].label).toBe("→ Runs every minute");
});
});
});
+56
View File
@@ -0,0 +1,56 @@
import {isString} from "@actions/workflow-parser";
import {getCronDescription} from "@actions/workflow-parser/model/converter/cron";
import {TemplateToken} from "@actions/workflow-parser/templates/tokens/template-token";
import {File} from "@actions/workflow-parser/workflows/file";
import {TextDocument} from "vscode-languageserver-textdocument";
import {InlayHint, InlayHintKind} from "vscode-languageserver-types";
import {fetchOrParseWorkflow} from "./utils/workflow-cache.js";
/**
* Returns inlay hints for a workflow document.
* Currently supports cron expressions, showing a human-readable description
* of the schedule inline after the cron value.
*
* @param document Text document to get inlay hints for
* @returns Array of inlay hints
*/
export function getInlayHints(document: TextDocument): InlayHint[] {
const file: File = {
name: document.uri,
content: document.getText()
};
const result = fetchOrParseWorkflow(file, document.uri);
if (!result?.value) {
return [];
}
const hints: InlayHint[] = [];
// Traverse the workflow AST to find cron expressions
for (const [parent, token, key] of TemplateToken.traverse(result.value)) {
const validationToken = key || parent || token;
const validationDefinition = validationToken.definition;
// Check for cron-pattern tokens
if (isString(token) && token.range && validationDefinition?.key === "cron-pattern") {
const cronValue = token.value;
const description = getCronDescription(cronValue);
if (description) {
// Position the hint at the end of the cron value
hints.push({
position: {
line: token.range.end.line - 1, // Convert from 1-based to 0-based
character: token.range.end.column - 1 // Convert from 1-based to 0-based
},
label: `${description}`,
kind: InlayHintKind.Parameter,
paddingLeft: true
});
}
}
}
return hints;
}
@@ -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
+2 -20
View File
@@ -231,7 +231,7 @@ jobs:
} as Diagnostic);
});
it("cron with interval of 5 minutes or more shows info", async () => {
it("cron with interval of 5 minutes or more shows no diagnostic", async () => {
const result = await validate(
createDocument(
"wf.yaml",
@@ -245,25 +245,7 @@ jobs:
{valueProviderConfig: defaultValueProviders}
);
expect(result.length).toBe(1);
expect(result[0]).toEqual({
message: "Runs every 5 minutes",
severity: DiagnosticSeverity.Information,
code: "on-schedule",
codeDescription: {
href: "https://docs.github.com/actions/using-workflows/workflow-syntax-for-github-actions#onschedule"
},
range: {
end: {
character: 25,
line: 2
},
start: {
character: 12,
line: 2
}
}
} as Diagnostic);
expect(result.length).toBe(0);
});
it("cron with comma-separated minutes less than 5 apart shows warning", async () => {
+26 -6
View File
@@ -258,18 +258,32 @@ function validateCronExpression(diagnostics: Diagnostic[], token: StringToken):
href: CRON_SCHEDULE_DOCS_URL
}
});
} else {
// Show info message for valid cron expressions
}
}
// 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: description,
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),
severity: DiagnosticSeverity.Information,
code: "on-schedule",
code: "short-sha-ref",
codeDescription: {
href: CRON_SCHEDULE_DOCS_URL
href: SHORT_SHA_DOCS_URL
}
});
return true;
}
return false;
}
/**
@@ -343,6 +357,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 +518,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([]);
});
});
});
});
@@ -7,11 +7,26 @@ export interface Value {
/** Optional description to show when auto-completing */
description?: string;
/** Optional detail shown after the label, e.g. type or kind information */
detail?: string;
/** Whether this value is deprecated */
deprecated?: boolean;
/** 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;
/** Custom text edit with specific range, overrides default range calculation */
textEdit?: {
range: {start: {line: number; character: number}; end: {line: number; character: number}};
newText: string;
};
}
export enum ValueProviderKind {
@@ -19,6 +19,11 @@ export const DEFAULT_RUNNER_LABELS = [
"self-hosted"
];
const runsOnValueProvider = {
kind: ValueProviderKind.SuggestedValues,
get: () => Promise.resolve(stringsToValues(DEFAULT_RUNNER_LABELS))
};
export const defaultValueProviders: ValueProviderConfig = {
needs: {
kind: ValueProviderKind.AllowedValues,
@@ -32,8 +37,6 @@ export const defaultValueProviders: ValueProviderConfig = {
kind: ValueProviderKind.SuggestedValues,
get: (context, existingValues) => Promise.resolve(reusableJobSecrets(context, existingValues))
},
"runs-on": {
kind: ValueProviderKind.SuggestedValues,
get: () => Promise.resolve(stringsToValues(DEFAULT_RUNNER_LABELS))
}
"runs-on": runsOnValueProvider,
"runs-on-labels": runsOnValueProvider
};
+216 -12
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,23 +162,188 @@ 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;
}
}
// In Key mode (after colon, e.g., `on: |`), only include scalar variants when
// completing an empty value. Mapping/sequence forms require newlines which is
// confusing when typing inline. Users who want those forms can use completions
// like `(full syntax)` or `(list)` at the parent level.
if (!tokenStructure && mode === DefinitionValueMode.Key) {
const variantBucket = getStructuralBucket(variantDef.definitionType);
if (variantBucket !== "scalar") {
continue;
}
}
values.push(...definitionValues(variantDef, indentation, mode, tokenStructure));
}
return distinctValues(values);
}
/**
* Deduplicates values by label and detail.
* Values with the same label but different details are preserved as distinct items.
*/
function distinctValues(values: Value[]): Value[] {
const map = new Map<string, Value>();
for (const value of values) {
map.set(value.label, value);
// Include detail in the key to preserve variants with different details
const key = value.detail ? `${value.label}\0${value.detail}` : value.label;
map.set(key, 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: key,
description,
detail: needsQualifier ? "list" : undefined,
insertText,
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: key,
description,
detail: needsQualifier ? "full syntax" : undefined,
insertText,
sortText: needsQualifier ? `${key} 2` : undefined
});
}
return results;
}
+1 -1
View File
@@ -6,5 +6,5 @@
"languageservice",
"languageserver"
],
"version": "0.3.26"
"version": "0.3.30"
}
+9 -9
View File
@@ -136,7 +136,7 @@
},
"expressions": {
"name": "@actions/expressions",
"version": "0.3.26",
"version": "0.3.30",
"license": "MIT",
"devDependencies": {
"@types/jest": "^29.0.3",
@@ -396,11 +396,11 @@
},
"languageserver": {
"name": "@actions/languageserver",
"version": "0.3.26",
"version": "0.3.30",
"license": "MIT",
"dependencies": {
"@actions/languageservice": "^0.3.26",
"@actions/workflow-parser": "^0.3.26",
"@actions/languageservice": "^0.3.30",
"@actions/workflow-parser": "^0.3.30",
"@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.30",
"license": "MIT",
"dependencies": {
"@actions/expressions": "^0.3.26",
"@actions/workflow-parser": "^0.3.26",
"@actions/expressions": "^0.3.30",
"@actions/workflow-parser": "^0.3.30",
"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.30",
"license": "MIT",
"dependencies": {
"@actions/expressions": "^0.3.26",
"@actions/expressions": "^0.3.30",
"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.30",
"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.30",
"cronstrue": "^2.21.0",
"yaml": "^2.0.0-8"
},