Compare commits

...

4 Commits

Author SHA1 Message Date
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 468 additions and 36 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@actions/expressions",
"version": "0.3.26",
"version": "0.3.27",
"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.27",
"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.27",
"@actions/workflow-parser": "^0.3.27",
"@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.27",
"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.27",
"@actions/workflow-parser": "^0.3.27",
"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 () => {
+84 -7
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},
@@ -533,4 +533,81 @@ jobs:
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("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 (full syntax) (ready to add mapping keys)
const input = "on:\n |";
const result = await complete(...getPositionFromCursor(input));
// Should have both check_run and check_run (full syntax)
expect(result.some(x => x.label === "check_run")).toBe(true);
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");
});
});
+3 -1
View File
@@ -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
@@ -253,7 +255,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 {
@@ -91,13 +91,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:
@@ -143,3 +143,101 @@ 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";
}
}
/**
* Expand a one-of definition into multiple completion items based on structural types.
* Returns one completion per unique structural type (scalar, sequence, mapping).
*/
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
};
for (const variantKey of oneOfDef.oneOf) {
const variantDef = definitions[variantKey];
if (variantDef) {
const bucket = getStructuralBucket(variantDef.definitionType);
buckets[bucket] = true;
}
}
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)
if (buckets.scalar) {
// In Key mode, insert newline and indentation to produce valid YAML structure
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.27"
}
+9 -9
View File
@@ -136,7 +136,7 @@
},
"expressions": {
"name": "@actions/expressions",
"version": "0.3.26",
"version": "0.3.27",
"license": "MIT",
"devDependencies": {
"@types/jest": "^29.0.3",
@@ -396,11 +396,11 @@
},
"languageserver": {
"name": "@actions/languageserver",
"version": "0.3.26",
"version": "0.3.27",
"license": "MIT",
"dependencies": {
"@actions/languageservice": "^0.3.26",
"@actions/workflow-parser": "^0.3.26",
"@actions/languageservice": "^0.3.27",
"@actions/workflow-parser": "^0.3.27",
"@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.27",
"license": "MIT",
"dependencies": {
"@actions/expressions": "^0.3.26",
"@actions/workflow-parser": "^0.3.26",
"@actions/expressions": "^0.3.27",
"@actions/workflow-parser": "^0.3.27",
"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.27",
"license": "MIT",
"dependencies": {
"@actions/expressions": "^0.3.26",
"@actions/expressions": "^0.3.27",
"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.27",
"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.27",
"cronstrue": "^2.21.0",
"yaml": "^2.0.0-8"
},