diff --git a/browser-playground/package.json b/browser-playground/package.json index d20a4ca..067a069 100644 --- a/browser-playground/package.json +++ b/browser-playground/package.json @@ -1,12 +1,12 @@ { "name": "browser-playground", - "version": "0.1.153", + "version": "0.1.157", "description": "", "private": true, "main": "index.js", "type": "module", "dependencies": { - "@github/actions-languageserver": "^0.1.153", + "@github/actions-languageserver": "^0.1.157", "monaco-editor-webpack-plugin": "^7.0.1", "monaco-editor-workers": "^0.34.2", "monaco-languageclient": "^4.0.3", diff --git a/expressions/README.md b/expressions/README.md index b2a15ad..2076ada 100644 --- a/expressions/README.md +++ b/expressions/README.md @@ -1,6 +1,6 @@ -# actions-expressions +# actions/expressions -`actions-expressions` is a library to parse and evaluate GitHub Actions [expressions](https://docs.github.com/actions/learn-github-actions/expressions). +`@actions/expressions` is a library to parse and evaluate GitHub Actions [expressions](https://docs.github.com/actions/learn-github-actions/expressions). ## Installation diff --git a/expressions/package.json b/expressions/package.json index 4d1f60a..8f1613b 100755 --- a/expressions/package.json +++ b/expressions/package.json @@ -1,6 +1,6 @@ { "name": "@github/actions-expressions", - "version": "0.1.153", + "version": "0.1.157", "license": "MIT", "type": "module", "source": "./src/index.ts", diff --git a/languageserver/README.md b/languageserver/README.md index 9c0cbf3..6698e5c 100644 --- a/languageserver/README.md +++ b/languageserver/README.md @@ -1,4 +1,4 @@ -# actions-languageserver +# actions/languageserver `actions-languageserver` hosts the `actions-languageservice` and makes it available via the [language server protocol](https://microsoft.github.io/language-server-protocol/) (LSP) as a standalone language server. diff --git a/languageserver/package.json b/languageserver/package.json index e541571..cfb19f8 100644 --- a/languageserver/package.json +++ b/languageserver/package.json @@ -1,6 +1,6 @@ { "name": "@github/actions-languageserver", - "version": "0.1.153", + "version": "0.1.157", "description": "Language server for GitHub Actions", "license": "MIT", "type": "module", @@ -38,8 +38,8 @@ "watch": "tsc --build tsconfig.build.json --watch" }, "dependencies": { - "@github/actions-languageservice": "^0.1.153", - "@github/actions-workflow-parser": "^0.1.153", + "@github/actions-languageservice": "^0.1.157", + "@github/actions-workflow-parser": "^0.1.157", "@octokit/rest": "^19.0.7", "vscode-languageserver": "^8.0.2", "vscode-languageserver-textdocument": "^1.0.7", diff --git a/languageserver/src/description-provider.ts b/languageserver/src/description-provider.ts index 128a1ee..3a8403a 100644 --- a/languageserver/src/description-provider.ts +++ b/languageserver/src/description-provider.ts @@ -2,17 +2,22 @@ import {DescriptionProvider} from "@github/actions-languageservice/hover"; import {Octokit} from "@octokit/rest"; import {getActionInputDescription} from "./description-providers/action-input"; import {TTLCache} from "./utils/cache"; +import {getActionDescription} from "./description-providers/action-description"; export function descriptionProvider(client: Octokit | undefined, cache: TTLCache): DescriptionProvider { const getDescription: DescriptionProvider["getDescription"] = async (context, token, path) => { - if (!client) { + if (!client || !context.step) { return undefined; } const parent = path[path.length - 1]; - if (context.step && parent.definition?.key === "step-with") { + if (parent.definition?.key === "step-with") { return await getActionInputDescription(client, cache, context.step, token); } + + if (parent.definition?.key === "step-uses") { + return await getActionDescription(client, cache, context.step); + } }; return { diff --git a/languageserver/src/description-providers/action-description.test.ts b/languageserver/src/description-providers/action-description.test.ts new file mode 100644 index 0000000..72a0bd8 --- /dev/null +++ b/languageserver/src/description-providers/action-description.test.ts @@ -0,0 +1,59 @@ +import {Octokit} from "@octokit/rest"; +import fetchMock from "fetch-mock"; +import {createWorkflowContext} from "../test-utils/workflow-context"; +import {TTLCache} from "../utils/cache"; +import {getActionDescription} from "./action-description"; +import {actionsCheckoutMetadata} from "../test-utils/action-metadata"; + +const workflow = ` +name: Hello World +on: workflow_dispatch +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 +`; + +async function getDescription(mock: fetchMock.FetchMockSandbox) { + const workflowContext = await createWorkflowContext(workflow, "build", 0); + + return await getActionDescription( + new Octokit({ + request: { + fetch: mock + } + }), + new TTLCache(), + workflowContext.step! + ); +} + +describe("action descriptions", () => { + it("actions/checkout description", async () => { + const mock = fetchMock + .sandbox() + .getOnce("https://api.github.com/repos/actions/checkout/contents/action.yml?ref=v3", actionsCheckoutMetadata); + + expect(await getDescription(mock)).toEqual( + "[**Checkout**](https://www.github.com/actions/checkout/tree/v3/)\n\nCheckout a Git repository at a particular version" + ); + }); + + it("action does not exist", async () => { + const mock = fetchMock + .sandbox() + .getOnce("https://api.github.com/repos/actions/checkout/contents/action.yml?ref=v3", 404) + .getOnce("https://api.github.com/repos/actions/checkout/contents/action.yaml?ref=v3", 404); + + expect(await getDescription(mock)).toBeUndefined(); + }); + + it("invalid metadata", async () => { + const mock = fetchMock + .sandbox() + .getOnce("https://api.github.com/repos/actions/checkout/contents/action.yml?ref=v3", {}); + + expect(await getDescription(mock)).toBeUndefined(); + }); +}); diff --git a/languageserver/src/description-providers/action-description.ts b/languageserver/src/description-providers/action-description.ts new file mode 100644 index 0000000..cd0866b --- /dev/null +++ b/languageserver/src/description-providers/action-description.ts @@ -0,0 +1,23 @@ +import {actionUrl, parseActionReference} from "@github/actions-languageservice/action"; +import {isActionStep} from "@github/actions-workflow-parser/model/type-guards"; +import {Step} from "@github/actions-workflow-parser/model/workflow-template"; +import {Octokit} from "@octokit/rest"; +import {fetchActionMetadata} from "../utils/action-metadata"; +import {TTLCache} from "../utils/cache"; + +export async function getActionDescription(client: Octokit, cache: TTLCache, step: Step): Promise { + if (!isActionStep(step)) { + return undefined; + } + const action = parseActionReference(step.uses.value); + if (!action) { + return undefined; + } + + const metadata = await fetchActionMetadata(client, cache, action); + if (!metadata?.name || !metadata?.description) { + return undefined; + } + + return `[**${metadata.name}**](${actionUrl(action)})\n\n${metadata.description}`; +} diff --git a/languageserver/src/description-providers/action-input.test.ts b/languageserver/src/description-providers/action-input.test.ts index 9912982..0e2ce7d 100644 --- a/languageserver/src/description-providers/action-input.test.ts +++ b/languageserver/src/description-providers/action-input.test.ts @@ -5,6 +5,7 @@ import fetchMock from "fetch-mock"; import {createWorkflowContext} from "../test-utils/workflow-context"; import {TTLCache} from "../utils/cache"; import {getActionInputDescription} from "./action-input"; +import {actionsCheckoutMetadata} from "../test-utils/action-metadata"; const workflow = ` name: Hello World @@ -16,49 +17,6 @@ jobs: - uses: actions/checkout@v3 `; -// A simplified version of the action.yml file from actions/checkout -const actionMetadataContent = ` -name: 'Checkout' -description: 'Checkout a Git repository at a particular version' -inputs: - repository: - description: Repository name with owner. For example, actions/checkout - default: \${{ github.repository }} - ref: - description: The branch, tag or SHA to checkout. - required: true - token: - description: Personal access token (PAT) used to fetch the repository. - default: \${{ github.token }} - repo: - description: 'Repository name with owner. For example, actions/checkout' - deprecationMessage: 'Use repository instead' -runs: - using: node16 - main: dist/index.js - post: dist/index.js -`; - -// Based on https://api.github.com/repos/actions/checkout/contents/action.yml?ref=v3 -const actionMetadata = { - name: "action.yml", - path: "action.yml", - sha: "cab09ebd3a964aba67b57f9727f5f6fff1372b04", - size: 3649, - url: "https://api.github.com/repos/actions/checkout/contents/action.yml?ref=v3", - html_url: "https://github.com/actions/checkout/blob/v3/action.yml", - git_url: "https://api.github.com/repos/actions/checkout/git/blobs/cab09ebd3a964aba67b57f9727f5f6fff1372b04", - download_url: "https://raw.githubusercontent.com/actions/checkout/v3/action.yml", - type: "file", - content: Buffer.from(actionMetadataContent).toString("base64"), - encoding: "base64", - _links: { - self: "https://api.github.com/repos/actions/checkout/contents/action.yml?ref=v3", - git: "https://api.github.com/repos/actions/checkout/git/blobs/cab09ebd3a964aba67b57f9727f5f6fff1372b04", - html: "https://github.com/actions/checkout/blob/v3/action.yml" - } -}; - async function getDescription(input: string, mock: fetchMock.FetchMockSandbox) { const workflowContext = await createWorkflowContext(workflow, "build", 0); @@ -74,11 +32,11 @@ async function getDescription(input: string, mock: fetchMock.FetchMockSandbox) { ); } -describe("action descriptions", () => { +describe("action input descriptions", () => { it("optional input", async () => { const mock = fetchMock .sandbox() - .getOnce("https://api.github.com/repos/actions/checkout/contents/action.yml?ref=v3", actionMetadata); + .getOnce("https://api.github.com/repos/actions/checkout/contents/action.yml?ref=v3", actionsCheckoutMetadata); expect(await getDescription("repository", mock)).toEqual( "Repository name with owner. For example, actions/checkout" @@ -88,7 +46,7 @@ describe("action descriptions", () => { it("required input", async () => { const mock = fetchMock .sandbox() - .getOnce("https://api.github.com/repos/actions/checkout/contents/action.yml?ref=v3", actionMetadata); + .getOnce("https://api.github.com/repos/actions/checkout/contents/action.yml?ref=v3", actionsCheckoutMetadata); expect(await getDescription("ref", mock)).toEqual("The branch, tag or SHA to checkout.\n\n**Required**"); }); @@ -96,7 +54,7 @@ describe("action descriptions", () => { it("deprecated input", async () => { const mock = fetchMock .sandbox() - .getOnce("https://api.github.com/repos/actions/checkout/contents/action.yml?ref=v3", actionMetadata); + .getOnce("https://api.github.com/repos/actions/checkout/contents/action.yml?ref=v3", actionsCheckoutMetadata); expect(await getDescription("repo", mock)).toEqual( "Repository name with owner. For example, actions/checkout\n\n**Deprecated**" @@ -106,7 +64,7 @@ describe("action descriptions", () => { it("invalid input", async () => { const mock = fetchMock .sandbox() - .getOnce("https://api.github.com/repos/actions/checkout/contents/action.yml?ref=v3", actionMetadata); + .getOnce("https://api.github.com/repos/actions/checkout/contents/action.yml?ref=v3", actionsCheckoutMetadata); expect(await getDescription("typo", mock)).toBeUndefined(); }); diff --git a/languageserver/src/test-utils/action-metadata.ts b/languageserver/src/test-utils/action-metadata.ts new file mode 100644 index 0000000..9be3868 --- /dev/null +++ b/languageserver/src/test-utils/action-metadata.ts @@ -0,0 +1,42 @@ +// A simplified version of the action.yml file from actions/checkout +const checkoutMetadataContent = ` +name: 'Checkout' +description: 'Checkout a Git repository at a particular version' +inputs: + repository: + description: Repository name with owner. For example, actions/checkout + default: \${{ github.repository }} + ref: + description: The branch, tag or SHA to checkout. + required: true + token: + description: Personal access token (PAT) used to fetch the repository. + default: \${{ github.token }} + repo: + description: 'Repository name with owner. For example, actions/checkout' + deprecationMessage: 'Use repository instead' +runs: + using: node16 + main: dist/index.js + post: dist/index.js +`; + +// Based on https://api.github.com/repos/actions/checkout/contents/action.yml?ref=v3 +export const actionsCheckoutMetadata = { + name: "action.yml", + path: "action.yml", + sha: "cab09ebd3a964aba67b57f9727f5f6fff1372b04", + size: 3649, + url: "https://api.github.com/repos/actions/checkout/contents/action.yml?ref=v3", + html_url: "https://github.com/actions/checkout/blob/v3/action.yml", + git_url: "https://api.github.com/repos/actions/checkout/git/blobs/cab09ebd3a964aba67b57f9727f5f6fff1372b04", + download_url: "https://raw.githubusercontent.com/actions/checkout/v3/action.yml", + type: "file", + content: Buffer.from(checkoutMetadataContent).toString("base64"), + encoding: "base64", + _links: { + self: "https://api.github.com/repos/actions/checkout/contents/action.yml?ref=v3", + git: "https://api.github.com/repos/actions/checkout/git/blobs/cab09ebd3a964aba67b57f9727f5f6fff1372b04", + html: "https://github.com/actions/checkout/blob/v3/action.yml" + } +}; diff --git a/languageservice/README.md b/languageservice/README.md index f853365..76a6072 100644 --- a/languageservice/README.md +++ b/languageservice/README.md @@ -1,4 +1,4 @@ -# actions-languageservice +# actions/languageservice This package contains the logic for the GitHub Actions workflows language server. @@ -22,14 +22,6 @@ The language service features use three sources of information: * _value providers_ which can dynamically add values to the schema, for example, the list of available labels for a repository when validating `runs-on`. * _context providers_ which can dynamically provide available contexts used in [expressions](https://docs.github.com/actions/reference/context-and-expression-syntax-for-github-actions#about-contexts-and-expressions). For example, the contents of the `github.event` context for a given workflow file. -##### Value Providers - -TODO - -##### Context Providers - -TODO - #### Validation Validate a workflow file, returns an array of [`Diagnostic`](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#diagnostic) objects. @@ -60,7 +52,43 @@ const hover = await hover(document, {line: 0, character: 1}); // { contents: { k #### Auto-completion -TODO +```typescript +import {complete} from "@actions/languageservice"; + +const document = { + uri: "file:///path/to/file", + getText: () => `on: +jobs: + build: + runs-on: ubuntu-latest + steps: + - run: echo hello` +}; + +// Trigger completion for `on: |` +const suggestions = await complete(document, {line: 0, character: 4}); +``` + +will return + +```jsonc +[{ + "documentation": { + "kind": "markdown", + "value": "Runs your workflow when branch protection rules in the workflow repository are changed.", + }, + "label": "branch_protection_rule", + "textEdit": { + "newText": "branch_protection_rule", + "range": { + "end": {"character": 4, "line": 0,}, + "start": {"character": 4, "line": 0}, + }, + }, +}, +//... other events +] +``` ## Contributing diff --git a/languageservice/package.json b/languageservice/package.json index 203d90e..9aa131b 100644 --- a/languageservice/package.json +++ b/languageservice/package.json @@ -1,6 +1,6 @@ { "name": "@github/actions-languageservice", - "version": "0.1.153", + "version": "0.1.157", "description": "Language service for GitHub Actions", "license": "MIT", "type": "module", @@ -39,8 +39,8 @@ "watch": "tsc --build tsconfig.build.json --watch" }, "dependencies": { - "@github/actions-expressions": "^0.1.153", - "@github/actions-workflow-parser": "^0.1.153", + "@github/actions-expressions": "^0.1.157", + "@github/actions-workflow-parser": "^0.1.157", "vscode-languageserver-textdocument": "^1.0.7", "vscode-languageserver-types": "^3.17.2", "yaml": "^2.1.1" diff --git a/languageservice/src/action.ts b/languageservice/src/action.ts index b10a166..8aa6e8b 100644 --- a/languageservice/src/action.ts +++ b/languageservice/src/action.ts @@ -1,5 +1,7 @@ // https://docs.github.com/en/actions/creating-actions/metadata-syntax-for-github-actions export type ActionMetadata = { + name: string; + description: string; inputs?: ActionInputs; outputs?: ActionOutputs; }; @@ -63,3 +65,10 @@ export function actionIdentifier(ref: ActionReference): string { } return `${ref.owner}/${ref.name}/${ref.ref}`; } + +export function actionUrl(actionRef: ActionReference): string { + // TODO: Support base uri for GHES + const gitHubBaseUri = "https://www.github.com/"; + + return `${gitHubBaseUri}${actionRef.owner}/${actionRef.name}/tree/${actionRef.ref}/${actionRef.path || ""}`; +} diff --git a/languageservice/src/complete.test.ts b/languageservice/src/complete.test.ts index b6c7436..02fab47 100644 --- a/languageservice/src/complete.test.ts +++ b/languageservice/src/complete.test.ts @@ -320,6 +320,37 @@ on: expect(result).toHaveLength(0); }); + it("null strings still give suggestions", async () => { + const input = ` +on: push +jobs: + one: + runs-on: ubuntu-latest + |: + - uses: actions/checkout@v2 +`; + const result = await complete(...getPositionFromCursor(input)); + expect(result).toHaveLength(16); + }); + + it("complete from behind a colon will replace it", async () => { + const input = ` +on: push +jobs: + one: + runs-on: ubuntu-latest + |: + - uses: actions/checkout@v2 +`; + const result = await complete(...getPositionFromCursor(input)); + expect(result).toHaveLength(16); + let textEdit = result[0].textEdit as TextEdit; + expect(textEdit.range).toEqual({ + start: {line: 5, character: 4}, + end: {line: 5, character: 5} + }); + }); + it("well known mapping keys have descriptions", async () => { const input = ` o| diff --git a/languageservice/src/complete.ts b/languageservice/src/complete.ts index 5489c63..dac1bab 100644 --- a/languageservice/src/complete.ts +++ b/languageservice/src/complete.ts @@ -108,7 +108,19 @@ export async function complete( // Get the length of the current word const val = line.match(/[\w_-]*$/)?.[0].length || 0; - replaceRange = Range.create({line: position.line, character: position.character - val}, position); + // Check if we need to remove a trailing colon + const charAfterPos = textDocument.getText({ + start: {line: position.line, character: position.character}, + end: {line: position.line, character: position.character + 1} + }); + if (charAfterPos === ":") { + replaceRange = Range.create( + {line: position.line, character: position.character - val}, + {line: position.line, character: position.character + 1} + ); + } else { + replaceRange = Range.create({line: position.line, character: position.character - val}, position); + } } return values.map(value => { diff --git a/languageservice/src/document-links.ts b/languageservice/src/document-links.ts index 42ec015..e995bee 100644 --- a/languageservice/src/document-links.ts +++ b/languageservice/src/document-links.ts @@ -4,7 +4,7 @@ import {isJob} from "@github/actions-workflow-parser/model/type-guards"; import {File} from "@github/actions-workflow-parser/workflows/file"; import {TextDocument} from "vscode-languageserver-textdocument"; import {DocumentLink} from "vscode-languageserver-types"; -import {parseActionReference} from "./action"; +import {actionUrl, parseActionReference} from "./action"; import {nullTrace} from "./nulltrace"; import {mapRange} from "./utils/range"; @@ -26,9 +26,6 @@ export async function documentLinks(document: TextDocument): Promise= 0; + const containsExpression = isString(token) && token.value != null && token.value.indexOf(OPEN_EXPRESSION) >= 0; return isAlwaysExpression || containsExpression; } diff --git a/languageservice/src/validate.actions.test.ts b/languageservice/src/validate.actions.test.ts index fa00c5e..0f4bcd8 100644 --- a/languageservice/src/validate.actions.test.ts +++ b/languageservice/src/validate.actions.test.ts @@ -14,6 +14,8 @@ const validationConfig: ValidationConfig = { switch (ref.owner + "/" + ref.name + "@" + ref.ref) { case "actions/checkout@v3": metadata = { + name: "Checkout", + description: "Checkout a Git repository at a particular version", inputs: { repository: { description: "Repository name with owner", @@ -24,6 +26,9 @@ const validationConfig: ValidationConfig = { break; case "actions/setup-node@v1": metadata = { + name: "Setup Node.js environment", + description: + "Setup a Node.js environment by adding problem matchers and optionally downloading and adding it to the PATH.", inputs: { version: { description: "Deprecated. Use node-version instead. Will not be supported after October 1, 2019", @@ -35,6 +40,8 @@ const validationConfig: ValidationConfig = { break; case "actions/deploy-pages@main": metadata = { + name: "Deploy GitHub Pages site", + description: "A GitHub Action to deploy an artifact as a GitHub Pages site", inputs: { token: { required: true, @@ -46,6 +53,8 @@ const validationConfig: ValidationConfig = { break; case "actions/cache@v1": metadata = { + name: "Cache", + description: "Cache artifacts like dependencies and build outputs to improve workflow execution time", inputs: { path: { description: "A directory to store and save the cache", @@ -63,7 +72,10 @@ const validationConfig: ValidationConfig = { }; break; case "actions/action-no-input@v1": - metadata = {}; + metadata = { + name: "Action with no inputs", + description: "An action with no inputs" + }; } return metadata; } diff --git a/lerna.json b/lerna.json index bbe1c70..0135705 100644 --- a/lerna.json +++ b/lerna.json @@ -1,5 +1,5 @@ { "$schema": "node_modules/lerna/schemas/lerna-schema.json", "useWorkspaces": true, - "version": "0.1.153" + "version": "0.1.157" } diff --git a/package-lock.json b/package-lock.json index 9e8c02d..36b8fee 100644 --- a/package-lock.json +++ b/package-lock.json @@ -111,10 +111,10 @@ } }, "browser-playground": { - "version": "0.1.153", + "version": "0.1.157", "license": "MIT", "dependencies": { - "@github/actions-languageserver": "^0.1.153", + "@github/actions-languageserver": "^0.1.157", "monaco-editor-webpack-plugin": "^7.0.1", "monaco-editor-workers": "^0.34.2", "monaco-languageclient": "^4.0.3", @@ -135,7 +135,7 @@ }, "expressions": { "name": "@github/actions-expressions", - "version": "0.1.153", + "version": "0.1.157", "license": "MIT", "devDependencies": { "@types/jest": "^29.0.3", @@ -151,11 +151,11 @@ }, "languageserver": { "name": "@github/actions-languageserver", - "version": "0.1.153", + "version": "0.1.157", "license": "MIT", "dependencies": { - "@github/actions-languageservice": "^0.1.153", - "@github/actions-workflow-parser": "^0.1.153", + "@github/actions-languageservice": "^0.1.157", + "@github/actions-workflow-parser": "^0.1.157", "@octokit/rest": "^19.0.7", "vscode-languageserver": "^8.0.2", "vscode-languageserver-textdocument": "^1.0.7", @@ -176,11 +176,11 @@ }, "languageservice": { "name": "@github/actions-languageservice", - "version": "0.1.153", + "version": "0.1.157", "license": "MIT", "dependencies": { - "@github/actions-expressions": "^0.1.153", - "@github/actions-workflow-parser": "^0.1.153", + "@github/actions-expressions": "^0.1.157", + "@github/actions-workflow-parser": "^0.1.157", "vscode-languageserver-textdocument": "^1.0.7", "vscode-languageserver-types": "^3.17.2", "yaml": "^2.1.1" @@ -14265,10 +14265,10 @@ }, "workflow-parser": { "name": "@github/actions-workflow-parser", - "version": "0.1.153", + "version": "0.1.157", "license": "MIT", "dependencies": { - "@github/actions-expressions": "^0.1.153", + "@github/actions-expressions": "^0.1.157", "cronstrue": "^2.21.0", "yaml": "^2.0.0-8" }, @@ -14827,8 +14827,8 @@ "@github/actions-languageserver": { "version": "file:languageserver", "requires": { - "@github/actions-languageservice": "^0.1.153", - "@github/actions-workflow-parser": "^0.1.153", + "@github/actions-languageservice": "^0.1.157", + "@github/actions-workflow-parser": "^0.1.157", "@octokit/rest": "^19.0.7", "@types/jest": "^29.0.3", "fetch-mock": "^9.11.0", @@ -14845,8 +14845,8 @@ "@github/actions-languageservice": { "version": "file:languageservice", "requires": { - "@github/actions-expressions": "^0.1.153", - "@github/actions-workflow-parser": "^0.1.153", + "@github/actions-expressions": "^0.1.157", + "@github/actions-workflow-parser": "^0.1.157", "@types/jest": "^29.0.3", "jest": "^29.0.3", "prettier": "^2.8.3", @@ -14863,7 +14863,7 @@ "@github/actions-workflow-parser": { "version": "file:workflow-parser", "requires": { - "@github/actions-expressions": "^0.1.153", + "@github/actions-expressions": "^0.1.157", "@types/jest": "^29.0.3", "@typescript-eslint/eslint-plugin": "^5.40.0", "@typescript-eslint/parser": "^5.40.0", @@ -17857,7 +17857,7 @@ "browser-playground": { "version": "file:browser-playground", "requires": { - "@github/actions-languageserver": "^0.1.153", + "@github/actions-languageserver": "^0.1.157", "css-loader": "^6.7.2", "monaco-editor-webpack-plugin": "^7.0.1", "monaco-editor-workers": "^0.34.2", diff --git a/workflow-parser/README.md b/workflow-parser/README.md new file mode 100644 index 0000000..e480668 --- /dev/null +++ b/workflow-parser/README.md @@ -0,0 +1,87 @@ +# actions/workflow-parser + +`@actions/workflow-parser` is a library to parse GitHub Actions [workflows](https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions). + +## Installation + +The [package](https://www.npmjs.com/package/@actions/workflow-parser) contains TypeScript types and compiled ECMAScript modules. + +```bash +npm install @actions/workflow-parser +``` + +## Usage + +The parser is driven by a custom schema defined in [`worflows-v1.0.json`](./src/workflow-v1.0.json). + +### Simple example + +`parseWorkflow` parses the workflow YAML into an intermediate representation and validates that it conforms to the schema. Any parsing errors are returned in the `errors` property of the result context. + +```typescript +var trace: TraceWriter; + +const result = parseWorkflow( + { + name: "test.yaml", + content: `on: push +jobs: + build: + runs-on: ubuntu-latest + steps: + - run: echo 'hello'` + }, + trace +); +``` + +`convertWorkflowTemplate` then takes that intermediate representation and converts it to a [`WorkflowTemplate`](./src/workflow-template.ts) object, which is a more convenient representation for working with workflows. + +```typescript +const workflowTemplate = await convertWorkflowTemplate(result.context, result.value); + +// workflowTemplate.jobs[0].id === "build" +// workflowTemplate.jobs[0].steps[0].run === "echo 'hello'" +``` + +## Contributing + +See [CONTRIBUTING.md](../CONTRIBUTING.md) at the root of the repository for general guidelines and recommendations. + +Similar to [`actions/expressions](../expressions/), this project is just one of multiple implementations of the GitHub Actions workflow parser. We therefore cannot accept contributions that significantly modify the schema or the overall behavior of the parser. If you would like to propose changes to Actions itself, please use our [Community Forum](https://github.com/community/community/discussions/categories/actions-and-packages). + +If you do want to contribute, please run [prettier](https://prettier.io/) to format your code and add unit tests as appropriate before submitting your PR. [./testdata](./testdata) contains test cases that all implementations should pass, please also make sure those tests are still passing. + +### Build + +```bash +npm run build +``` + +or to watch for changes + +```bash +npm run watch +``` + +### Test + +```bash +npm test +``` + +or to watch for changes and run tests: + +```bash +npm run test-watch +``` + +### Lint + +```bash +npm run format-check +``` + +## License + +This project is licensed under the terms of the MIT open source license. Please refer to [MIT](../LICENSE) for the full terms. diff --git a/workflow-parser/package.json b/workflow-parser/package.json index 4bc712e..d4b0e26 100644 --- a/workflow-parser/package.json +++ b/workflow-parser/package.json @@ -1,6 +1,6 @@ { "name": "@github/actions-workflow-parser", - "version": "0.1.153", + "version": "0.1.157", "license": "MIT", "type": "module", "source": "./src/index.ts", @@ -40,7 +40,7 @@ "watch": "tsc --build tsconfig.build.json --watch" }, "dependencies": { - "@github/actions-expressions": "^0.1.153", + "@github/actions-expressions": "^0.1.157", "cronstrue": "^2.21.0", "yaml": "^2.0.0-8" }, diff --git a/workflow-parser/src/templates/template-reader.ts b/workflow-parser/src/templates/template-reader.ts index 7d5bce0..02df3d7 100644 --- a/workflow-parser/src/templates/template-reader.ts +++ b/workflow-parser/src/templates/template-reader.ts @@ -203,14 +203,15 @@ class TemplateReader { ); // Duplicate - const upperKey = nextKey.value.toUpperCase(); - if (upperKeys[upperKey]) { - this._context.error(nextKey, `'${nextKey.value}' is already defined`); - this.skipValue(); - continue; + if (nextKey.value) { + const upperKey = nextKey.value.toUpperCase(); + if (upperKeys[upperKey]) { + this._context.error(nextKey, `'${nextKey.value}' is already defined`); + this.skipValue(); + continue; + } + upperKeys[upperKey] = true; } - upperKeys[upperKey] = true; - // Well known const nextPropertyDef = this._schema.matchPropertyAndFilter(mappingDefinitions, nextKey.value); if (nextPropertyDef) { @@ -339,13 +340,15 @@ class TemplateReader { ); // Duplicate - const upperKey = nextKey.value.toUpperCase(); - if (upperKeys[upperKey]) { - this._context.error(nextKey, `'${nextKey.value}' is already defined`); - this.skipValue(); - continue; + if (nextKey.value) { + const upperKey = nextKey.value.toUpperCase(); + if (upperKeys[upperKey]) { + this._context.error(nextKey, `'${nextKey.value}' is already defined`); + this.skipValue(); + continue; + } + upperKeys[upperKey] = true; } - upperKeys[upperKey] = true; // Validate this.validate(nextKey, keyDefinition); @@ -443,7 +446,7 @@ class TemplateReader { private parseScalar(token: LiteralToken, definitionInfo: DefinitionInfo): ScalarToken { // Not a string - if (!isString(token)) { + if (!isString(token) || !token.value) { return token; } diff --git a/workflow-parser/src/workflow-v1.0.json b/workflow-parser/src/workflow-v1.0.json index ec233b9..b3ce7ec 100644 --- a/workflow-parser/src/workflow-v1.0.json +++ b/workflow-parser/src/workflow-v1.0.json @@ -2044,8 +2044,7 @@ "continue-on-error": "step-continue-on-error", "timeout-minutes": "step-timeout-minutes", "uses": { - "description": "Selects an action to run as part of a step in your job. An action is a reusable unit of code. You can use an action defined in the same repository as the workflow, a public repository, or in a published Docker container image.", - "type": "non-empty-string", + "type": "step-uses", "required": true }, "with": "step-with", @@ -2053,6 +2052,12 @@ } } }, + "step-uses": { + "description": "Selects an action to run as part of a step in your job. An action is a reusable unit of code. You can use an action defined in the same repository as the workflow, a public repository, or in a published Docker container image.", + "string": { + "require-non-empty": true + } + }, "step-continue-on-error": { "context": [ "github",