diff --git a/languageservice/src/complete.expressions.test.ts b/languageservice/src/complete.expressions.test.ts index a8463ef..d6756d3 100644 --- a/languageservice/src/complete.expressions.test.ts +++ b/languageservice/src/complete.expressions.test.ts @@ -1268,7 +1268,7 @@ jobs: on: push jobs: a: - uses: ./reusable-workflow-with-outputs.yaml + uses: ./.github/workflows/reusable-workflow-with-outputs.yaml b: needs: [a] runs-on: ubuntu-latest diff --git a/languageservice/src/complete.reusable-workflows.test.ts b/languageservice/src/complete.reusable-workflows.test.ts index 79d1495..3fde8c0 100644 --- a/languageservice/src/complete.reusable-workflows.test.ts +++ b/languageservice/src/complete.reusable-workflows.test.ts @@ -21,7 +21,7 @@ on: push jobs: build: - uses: ./reusable-workflow-with-inputs.yaml + uses: ./.github/workflows/reusable-workflow-with-inputs.yaml with: | `; @@ -49,7 +49,7 @@ on: push jobs: build: - uses: ./reusable-workflow-with-inputs.yaml + uses: ./.github/workflows/reusable-workflow-with-inputs.yaml with: username: monalisa | @@ -74,7 +74,7 @@ on: push jobs: build: - uses: ./reusable-workflow-with-inputs.yaml + uses: ./.github/workflows/reusable-workflow-with-inputs.yaml secrets: | `; @@ -102,7 +102,7 @@ on: push jobs: build: - uses: ./reusable-workflow-with-inputs.yaml + uses: ./.github/workflows/reusable-workflow-with-inputs.yaml secrets: | `; const result = await complete(...getPositionFromCursor(input), {fileProvider: testFileProvider}); @@ -117,7 +117,7 @@ on: push jobs: build: - uses: ./reusable-workflow-with-inputs.yaml + uses: ./.github/workflows/reusable-workflow-with-inputs.yaml secrets: envPAT: "myPAT" | diff --git a/languageservice/src/context-providers/needs.test.ts b/languageservice/src/context-providers/needs.test.ts index 88baecf..8cad20a 100644 --- a/languageservice/src/context-providers/needs.test.ts +++ b/languageservice/src/context-providers/needs.test.ts @@ -111,7 +111,7 @@ jobs: on: push jobs: a: - uses: ./reusable-workflow-with-outputs.yaml + uses: ./.github/workflows/reusable-workflow-with-outputs.yaml b: needs: [a] diff --git a/languageservice/src/hover.reusable-workflow.test.ts b/languageservice/src/hover.reusable-workflow.test.ts index a8d9459..d31330d 100644 --- a/languageservice/src/hover.reusable-workflow.test.ts +++ b/languageservice/src/hover.reusable-workflow.test.ts @@ -14,7 +14,7 @@ on: push jobs: build: - uses: ./reusable-workflow-with-inputs.yaml + uses: ./.github/workflows/reusable-workflow-with-inputs.yaml with: us|ername: `; @@ -31,7 +31,7 @@ on: push jobs: build: - uses: ./reusable-workflow-with-inputs-no-description.yaml + uses: ./.github/workflows/reusable-workflow-with-inputs-no-description.yaml with: us|ername: `; @@ -48,7 +48,7 @@ on: push jobs: build: - uses: ./reusable-workflow-with-outputs.yaml + uses: ./.github/workflows/reusable-workflow-with-outputs.yaml echo_outputs: runs-on: ubuntu-latest needs: build diff --git a/languageservice/src/test-utils/test-file-provider.ts b/languageservice/src/test-utils/test-file-provider.ts index 966d9f7..40e41aa 100644 --- a/languageservice/src/test-utils/test-file-provider.ts +++ b/languageservice/src/test-utils/test-file-provider.ts @@ -5,9 +5,9 @@ export const testFileProvider: FileProvider = { // eslint-disable-next-line @typescript-eslint/require-await getFileContent: async ref => { switch (fileIdentifier(ref)) { - case "monalisa/octocat/workflow.yaml@main": + case "monalisa/octocat/.github/workflows/workflow.yaml@main": return { - name: "monalisa/octocat/workflow.yaml", + name: "monalisa/octocat/.github/workflows/workflow.yaml", content: ` on: workflow_call jobs: @@ -31,9 +31,9 @@ jobs: ` }; - case "./reusable-workflow.yaml": + case "./.github/workflows/reusable-workflow.yaml": return { - name: "reusable-workflow.yaml", + name: ".github/workflows/reusable-workflow.yaml", content: ` on: workflow_call jobs: @@ -44,9 +44,9 @@ jobs: ` }; - case "./reusable-workflow-with-inputs.yaml": + case "./.github/workflows/reusable-workflow-with-inputs.yaml": return { - name: "reusable-workflow-with-inputs.yaml", + name: ".github/workflows/reusable-workflow-with-inputs.yaml", content: ` on: workflow_call: @@ -76,9 +76,9 @@ jobs: ` }; - case "./reusable-workflow-with-inputs-no-description.yaml": + case "./.github/workflows/reusable-workflow-with-inputs-no-description.yaml": return { - name: "reusable-workflow-with-inputs.yaml", + name: ".github/workflows/reusable-workflow-with-inputs.yaml", content: ` on: workflow_call: @@ -95,9 +95,9 @@ jobs: ` }; - case "./reusable-workflow-with-outputs.yaml": + case "./.github/workflows/reusable-workflow-with-outputs.yaml": return { - name: "reusable-workflow-with-outputs.yaml", + name: ".github/workflows/reusable-workflow-with-outputs.yaml", content: ` on: workflow_call: diff --git a/languageservice/src/validate.expressions.test.ts b/languageservice/src/validate.expressions.test.ts index a237787..dbf83ec 100644 --- a/languageservice/src/validate.expressions.test.ts +++ b/languageservice/src/validate.expressions.test.ts @@ -635,7 +635,7 @@ jobs: fail-fast: true matrix: node: [14, 16] - uses: ./reusable-workflow-with-inputs.yaml + uses: ./.github/workflows/reusable-workflow-with-inputs.yaml with: username: User-\${{ strategy.fail-fast }} `; @@ -654,7 +654,7 @@ jobs: strategy: matrix: node: [14, 16] - uses: ./reusable-workflow-with-inputs.yaml + uses: ./.github/workflows/reusable-workflow-with-inputs.yaml with: username: \${{ matrix.node }} `; diff --git a/languageservice/src/validate.ts b/languageservice/src/validate.ts index b00380a..cf27522 100644 --- a/languageservice/src/validate.ts +++ b/languageservice/src/validate.ts @@ -143,11 +143,29 @@ async function additionalValidations( } } + // Validate step uses field format + if (isString(token) && token.range && validationDefinition?.key === "step-uses") { + validateStepUsesFormat(diagnostics, token); + } + + // Validate action metadata (inputs, required fields) for regular steps if (token.definition?.key === "regular-step" && token.range) { const context = getProviderContext(documentUri, template, root, token.range); await validateAction(diagnostics, token, context.step, config); } + // Validate job-level reusable workflow uses field format + if ( + isString(token) && + token.range && + key && + isString(key) && + key.value === "uses" && + parent?.definition?.key === "workflow-job" + ) { + validateWorkflowUsesFormat(diagnostics, token); + } + // Allowed values coming from the schema have already been validated. Only check if // a value provider is defined for a token and if it is, validate the values match. if (token.range && validationDefinition) { @@ -198,6 +216,307 @@ function invalidValue(diagnostics: Diagnostic[], token: StringToken, kind: Value } } +/** + * Validates the format of a step's `uses` field. + * + * Valid formats: + * - docker://image:tag + * - ./local/path + * - .\local\path (Windows) + * - {owner}/{repo}@{ref} + * - {owner}/{repo}/{path}@{ref} + */ +function validateStepUsesFormat(diagnostics: Diagnostic[], token: StringToken): void { + const uses = token.value; + + // Empty uses value + if (!uses) { + diagnostics.push({ + message: "`uses' value in action cannot be blank", + severity: DiagnosticSeverity.Error, + range: mapRange(token.range), + code: "invalid-uses-format" + }); + return; + } + + // Docker image reference - always valid format + if (uses.startsWith("docker://")) { + return; + } + + // Local action path - always valid format + if (uses.startsWith("./") || uses.startsWith(".\\")) { + return; + } + + // Remote action: must be {owner}/{repo}[/path]@{ref} + const atSegments = uses.split("@"); + + // Must have exactly one @ + if (atSegments.length !== 2) { + addStepUsesFormatError(diagnostics, token); + return; + } + + const [repoPath, gitRef] = atSegments; + + // Ref cannot be empty + if (!gitRef) { + addStepUsesFormatError(diagnostics, token); + return; + } + + // Split by / or \ to get path segments + const pathSegments = repoPath.split(/[\\/]/); + + // Must have at least owner and repo (both non-empty) + if (pathSegments.length < 2 || !pathSegments[0] || !pathSegments[1]) { + addStepUsesFormatError(diagnostics, token); + return; + } + + // Check if this is a reusable workflow reference (should be at job level, not step) + // Path would be like: owner/repo/.github/workflows/file.yml + if (pathSegments.length >= 4 && pathSegments[2] === ".github" && pathSegments[3] === "workflows") { + diagnostics.push({ + message: "Reusable workflows should be referenced at the top-level `jobs..uses` key, not within steps", + severity: DiagnosticSeverity.Error, + range: mapRange(token.range), + code: "invalid-uses-format" + }); + return; + } +} + +function addStepUsesFormatError(diagnostics: Diagnostic[], token: StringToken): void { + diagnostics.push({ + message: `Expected format {owner}/{repo}[/path]@{ref}. Actual '${token.value}'`, + severity: DiagnosticSeverity.Error, + range: mapRange(token.range), + code: "invalid-uses-format" + }); +} + +/** + * Validates the format of a job's `uses` field (reusable workflow reference). + * + * Valid formats: + * - {owner}/{repo}/.github/workflows/{filename}.yml@{ref} + * - {owner}/{repo}/.github/workflows/{filename}.yaml@{ref} + * - {owner}/{repo}/.github/workflows-lab/{filename}.yml@{ref} + * - {owner}/{repo}/.github/workflows-lab/{filename}.yaml@{ref} + * - ./.github/workflows/{filename}.yml + * - ./.github/workflows/{filename}.yaml + * - ./.github/workflows-lab/{filename}.yml + * - ./.github/workflows-lab/{filename}.yaml + */ +function validateWorkflowUsesFormat(diagnostics: Diagnostic[], token: StringToken): void { + const uses = token.value; + + // Local workflow reference + if (uses.startsWith("./.github/workflows/") || uses.startsWith("./.github/workflows-lab/")) { + // Cannot have @ version for local workflows + if (uses.includes("@")) { + addWorkflowUsesFormatError(diagnostics, token, "cannot specify version when calling local workflows"); + return; + } + + // Must have .yml or .yaml extension + if (!uses.endsWith(".yml") && !uses.endsWith(".yaml")) { + addWorkflowUsesFormatError( + diagnostics, + token, + "workflow file should have either a '.yml' or '.yaml' file extension" + ); + return; + } + + // Must be at top level of .github/workflows/ or .github/workflows-lab/ (no subdirectories) + const pathParts = uses.split("/"); + if (pathParts.length !== 4) { + // Expected: ".", ".github", "workflows" or "workflows-lab", "filename.yml" + addWorkflowUsesFormatError( + diagnostics, + token, + "workflows must be defined at the top level of the .github/workflows/ directory" + ); + return; + } + + // Filename cannot be just the extension + const filename = pathParts[3]; + if (filename === ".yml" || filename === ".yaml") { + addWorkflowUsesFormatError(diagnostics, token, "invalid workflow file name"); + return; + } + + return; + } + + // Remote workflow reference: must have @ for version + const atSegments = uses.split("@"); + if (atSegments.length === 1) { + addWorkflowUsesFormatError(diagnostics, token, "no version specified"); + return; + } + if (atSegments.length > 2) { + addWorkflowUsesFormatError(diagnostics, token, "too many '@' in workflow reference"); + return; + } + + const [pathPart, version] = atSegments; + + // Version cannot be empty + if (!version) { + addWorkflowUsesFormatError(diagnostics, token, "no version specified"); + return; + } + + // Must contain .github/workflows or .github/workflows-lab path + const workflowsMatch = pathPart.match(/\.github\/workflows(-lab)?\//); + if (!workflowsMatch || workflowsMatch.index === undefined) { + addWorkflowUsesFormatError(diagnostics, token, "references to workflows must be rooted in '.github/workflows'"); + return; + } + + // Split to get owner/repo and path + const pathIdx = workflowsMatch.index; + const nwoPart = pathPart.substring(0, pathIdx); + const workflowPath = pathPart.substring(pathIdx); + + // Validate NWO part: must be owner/repo/ + const nwoSegments = nwoPart.split("/").filter(s => s.length > 0); + if (nwoSegments.length !== 2) { + addWorkflowUsesFormatError( + diagnostics, + token, + "references to workflows must be prefixed with format 'owner/repository/' or './' for local workflows" + ); + return; + } + + // Validate owner and repo names + const [owner, repo] = nwoSegments; + const nwoError = validateNWO(owner, repo); + if (nwoError) { + addWorkflowUsesFormatError(diagnostics, token, nwoError); + return; + } + + // Validate ref/version format + const refError = validateRefName(version); + if (refError) { + addWorkflowUsesFormatError(diagnostics, token, refError); + return; + } + + // Validate workflow path is at top level + const workflowPathParts = workflowPath.split("/"); + if (workflowPathParts.length !== 3) { + // Expected: ".github", "workflows" or "workflows-lab", "filename.yml" + addWorkflowUsesFormatError( + diagnostics, + token, + "workflows must be defined at the top level of the .github/workflows/ directory" + ); + return; + } + + // Must have .yml or .yaml extension + const filename = workflowPathParts[2]; + if (!filename.endsWith(".yml") && !filename.endsWith(".yaml")) { + addWorkflowUsesFormatError( + diagnostics, + token, + "workflow file should have either a '.yml' or '.yaml' file extension" + ); + return; + } + + // Filename cannot be just the extension + if (filename === ".yml" || filename === ".yaml") { + addWorkflowUsesFormatError(diagnostics, token, "invalid workflow file name"); + return; + } +} + +function addWorkflowUsesFormatError(diagnostics: Diagnostic[], token: StringToken, reason: string): void { + diagnostics.push({ + message: `Invalid workflow reference '${token.value}': ${reason}`, + severity: DiagnosticSeverity.Error, + range: mapRange(token.range), + code: "invalid-workflow-uses-format" + }); +} + +/** + * Validates the git ref/version format. + * Based on Launch's ValidateRefName function. + */ +function validateRefName(refname: string): string | undefined { + if (refname.length === 0) { + return "no version specified"; + } + + // Cannot be the single character '@' + if (refname === "@") { + return "version cannot be the single character '@'"; + } + + // Cannot have certain invalid characters or sequences + const invalidSequences = ["?", "*", "[", "]", "\\", "~", "^", ":", "@{", "..", "//"]; + for (const seq of invalidSequences) { + if (refname.includes(seq)) { + return `invalid character '${seq}' in version`; + } + } + + // Cannot begin or end with a slash '/' or a dot '.' + if (refname.startsWith("/") || refname.endsWith("/") || refname.startsWith(".") || refname.endsWith(".")) { + return "version cannot begin or end with a slash '/' or a dot '.'"; + } + + // No slash-separated component can begin with a dot '.' or end with the sequence '.lock' + const components = refname.split("/"); + for (const component of components) { + if (component.startsWith(".") || component.endsWith(".lock")) { + return `invalid version: ${refname}`; + } + } + + // No ASCII control characters or whitespace + // eslint-disable-next-line no-control-regex + if (/[\x00-\x1f\x7f]/.test(refname)) { + return "version cannot have ASCII control characters"; + } + + if (/\s/.test(refname)) { + return "version cannot have whitespace"; + } + + return undefined; +} + +/** + * Validates owner and repository names. + * Based on Launch's ValidateNWO function. + */ +function validateNWO(owner: string, repo: string): string | undefined { + // Owner name: can have word chars, dots, and hyphens + // \w in JS regex is [a-zA-Z0-9_] + if (!/^[\w.-]+$/.test(owner)) { + return "owner name must be a valid repository owner name"; + } + + // Repository name: can have word chars, dots, and hyphens + if (!/^[\w.-]+$/.test(repo)) { + return "repository name is invalid"; + } + + return undefined; +} + function getProviderContext( documentUri: URI, template: WorkflowTemplate, diff --git a/languageservice/src/validate.uses-format.test.ts b/languageservice/src/validate.uses-format.test.ts new file mode 100644 index 0000000..67777fc --- /dev/null +++ b/languageservice/src/validate.uses-format.test.ts @@ -0,0 +1,872 @@ +import {DiagnosticSeverity} from "vscode-languageserver-types"; +import {validate} from "./validate"; +import {createDocument} from "./test-utils/document"; +import {clearCache} from "./utils/workflow-cache"; + +beforeEach(() => { + clearCache(); +}); + +describe("validate uses format", () => { + describe("valid formats", () => { + it("standard org/repo@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("org/repo with path @ref", async () => { + const input = `on: push +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/aws/ec2@main +`; + const result = await validate(createDocument("wf.yaml", input)); + expect(result).toEqual([]); + }); + + it("org/repo with deep path @ref", async () => { + const input = `on: push +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/aws/nested/deep/path@v1 +`; + const result = await validate(createDocument("wf.yaml", input)); + expect(result).toEqual([]); + }); + + it("docker image", 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("docker image with registry", async () => { + const input = `on: push +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: docker://gcr.io/my-project/my-image:latest +`; + const result = await validate(createDocument("wf.yaml", input)); + expect(result).toEqual([]); + }); + + it("local path with ./", 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([]); + }); + + it("local path with ./ and subdirectories", async () => { + const input = `on: push +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: ./.github/actions/my-action +`; + const result = await validate(createDocument("wf.yaml", input)); + expect(result).toEqual([]); + }); + + it("local path with .\\ (Windows)", 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([]); + }); + + it("SHA ref", async () => { + const input = `on: push +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 +`; + const result = await validate(createDocument("wf.yaml", input)); + expect(result).toEqual([]); + }); + + it("branch ref", async () => { + const input = `on: push +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: owner/repo@feature/my-branch +`; + const result = await validate(createDocument("wf.yaml", input)); + expect(result).toEqual([]); + }); + }); + + describe("invalid formats", () => { + it("missing @ref", async () => { + const input = `on: push +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout +`; + const result = await validate(createDocument("wf.yaml", input)); + expect(result).toEqual([ + { + message: "Expected format {owner}/{repo}[/path]@{ref}. Actual 'actions/checkout'", + severity: DiagnosticSeverity.Error, + range: { + start: {line: 5, character: 12}, + end: {line: 5, character: 28} + }, + code: "invalid-uses-format" + } + ]); + }); + + it("empty ref", async () => { + const input = `on: push +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@ +`; + const result = await validate(createDocument("wf.yaml", input)); + expect(result).toEqual([ + { + message: "Expected format {owner}/{repo}[/path]@{ref}. Actual 'actions/checkout@'", + severity: DiagnosticSeverity.Error, + range: { + start: {line: 5, character: 12}, + end: {line: 5, character: 29} + }, + code: "invalid-uses-format" + } + ]); + }); + + it("missing org/owner", async () => { + const input = `on: push +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: checkout@v4 +`; + const result = await validate(createDocument("wf.yaml", input)); + expect(result).toEqual([ + { + message: "Expected format {owner}/{repo}[/path]@{ref}. Actual 'checkout@v4'", + severity: DiagnosticSeverity.Error, + range: { + start: {line: 5, character: 12}, + end: {line: 5, character: 23} + }, + code: "invalid-uses-format" + } + ]); + }); + + it("empty owner", async () => { + const input = `on: push +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: /repo@v4 +`; + const result = await validate(createDocument("wf.yaml", input)); + expect(result).toEqual([ + { + message: "Expected format {owner}/{repo}[/path]@{ref}. Actual '/repo@v4'", + severity: DiagnosticSeverity.Error, + range: { + start: {line: 5, character: 12}, + end: {line: 5, character: 20} + }, + code: "invalid-uses-format" + } + ]); + }); + + it("empty repo", async () => { + const input = `on: push +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: owner/@v4 +`; + const result = await validate(createDocument("wf.yaml", input)); + expect(result).toEqual([ + { + message: "Expected format {owner}/{repo}[/path]@{ref}. Actual 'owner/@v4'", + severity: DiagnosticSeverity.Error, + range: { + start: {line: 5, character: 12}, + end: {line: 5, character: 21} + }, + code: "invalid-uses-format" + } + ]); + }); + + it("multiple @ symbols", async () => { + const input = `on: push +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4@extra +`; + const result = await validate(createDocument("wf.yaml", input)); + expect(result).toEqual([ + { + message: "Expected format {owner}/{repo}[/path]@{ref}. Actual 'actions/checkout@v4@extra'", + severity: DiagnosticSeverity.Error, + range: { + start: {line: 5, character: 12}, + end: {line: 5, character: 37} + }, + code: "invalid-uses-format" + } + ]); + }); + + it("just a name with no slash", async () => { + const input = `on: push +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: checkout +`; + const result = await validate(createDocument("wf.yaml", input)); + expect(result).toEqual([ + { + message: "Expected format {owner}/{repo}[/path]@{ref}. Actual 'checkout'", + severity: DiagnosticSeverity.Error, + range: { + start: {line: 5, character: 12}, + end: {line: 5, character: 20} + }, + code: "invalid-uses-format" + } + ]); + }); + + it("empty uses value", async () => { + const input = `on: push +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: "" +`; + const result = await validate(createDocument("wf.yaml", input)); + expect(result).toContainEqual({ + message: "`uses' value in action cannot be blank", + severity: DiagnosticSeverity.Error, + range: { + start: {line: 5, character: 12}, + end: {line: 5, character: 14} + }, + code: "invalid-uses-format" + }); + }); + + it("reusable workflow in step", async () => { + const input = `on: push +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: owner/repo/.github/workflows/test.yml@main +`; + const result = await validate(createDocument("wf.yaml", input)); + expect(result).toEqual([ + { + message: + "Reusable workflows should be referenced at the top-level `jobs..uses` key, not within steps", + severity: DiagnosticSeverity.Error, + range: { + start: {line: 5, character: 12}, + end: {line: 5, character: 54} + }, + code: "invalid-uses-format" + } + ]); + }); + }); +}); + +describe("workflow uses format validation", () => { + beforeEach(() => { + clearCache(); + }); + + describe("valid formats", () => { + it("local workflow path", async () => { + const input = `on: push +jobs: + test: + uses: ./.github/workflows/test.yml +`; + const result = await validate(createDocument("wf.yaml", input)); + expect(result).toEqual([]); + }); + + it("local workflow path with yaml extension", async () => { + const input = `on: push +jobs: + test: + uses: ./.github/workflows/test.yaml +`; + const result = await validate(createDocument("wf.yaml", input)); + expect(result).toEqual([]); + }); + + it("remote workflow with version", async () => { + const input = `on: push +jobs: + test: + uses: owner/repo/.github/workflows/test.yml@v1 +`; + const result = await validate(createDocument("wf.yaml", input)); + expect(result).toEqual([]); + }); + + it("remote workflow with sha ref", async () => { + const input = `on: push +jobs: + test: + uses: owner/repo/.github/workflows/test.yml@abc123 +`; + const result = await validate(createDocument("wf.yaml", input)); + expect(result).toEqual([]); + }); + + it("remote workflow with branch ref", async () => { + const input = `on: push +jobs: + test: + uses: owner/repo/.github/workflows/test.yml@main +`; + const result = await validate(createDocument("wf.yaml", input)); + expect(result).toEqual([]); + }); + + it("remote workflow with yaml extension", async () => { + const input = `on: push +jobs: + test: + uses: owner/repo/.github/workflows/test.yaml@v1 +`; + const result = await validate(createDocument("wf.yaml", input)); + expect(result).toEqual([]); + }); + + it("local workflows-lab path", async () => { + const input = `on: push +jobs: + test: + uses: ./.github/workflows-lab/test.yml +`; + const result = await validate(createDocument("wf.yaml", input)); + expect(result).toEqual([]); + }); + + it("local workflows-lab path with yaml extension", async () => { + const input = `on: push +jobs: + test: + uses: ./.github/workflows-lab/test.yaml +`; + const result = await validate(createDocument("wf.yaml", input)); + expect(result).toEqual([]); + }); + + it("remote workflows-lab with version", async () => { + const input = `on: push +jobs: + test: + uses: owner/repo/.github/workflows-lab/test.yml@v1 +`; + const result = await validate(createDocument("wf.yaml", input)); + expect(result).toEqual([]); + }); + }); + + describe("invalid formats", () => { + it("remote workflow missing version", async () => { + const input = `on: push +jobs: + test: + uses: owner/repo/.github/workflows/test.yml +`; + const result = await validate(createDocument("wf.yaml", input)); + expect(result).toEqual([ + { + message: "Invalid workflow reference 'owner/repo/.github/workflows/test.yml': no version specified", + severity: DiagnosticSeverity.Error, + range: { + start: {line: 3, character: 10}, + end: {line: 3, character: 47} + }, + code: "invalid-workflow-uses-format" + } + ]); + }); + + it("local workflow with version", async () => { + const input = `on: push +jobs: + test: + uses: ./.github/workflows/test.yml@v1 +`; + const result = await validate(createDocument("wf.yaml", input)); + expect(result).toEqual([ + { + message: + "Invalid workflow reference './.github/workflows/test.yml@v1': cannot specify version when calling local workflows", + severity: DiagnosticSeverity.Error, + range: { + start: {line: 3, character: 10}, + end: {line: 3, character: 41} + }, + code: "invalid-workflow-uses-format" + } + ]); + }); + + it("missing .github/workflows path", async () => { + const input = `on: push +jobs: + test: + uses: owner/repo/test.yml@v1 +`; + const result = await validate(createDocument("wf.yaml", input)); + expect(result).toEqual([ + { + message: + "Invalid workflow reference 'owner/repo/test.yml@v1': references to workflows must be rooted in '.github/workflows'", + severity: DiagnosticSeverity.Error, + range: { + start: {line: 3, character: 10}, + end: {line: 3, character: 32} + }, + code: "invalid-workflow-uses-format" + } + ]); + }); + + it("invalid file extension", async () => { + const input = `on: push +jobs: + test: + uses: owner/repo/.github/workflows/test.txt@v1 +`; + const result = await validate(createDocument("wf.yaml", input)); + expect(result).toEqual([ + { + message: + "Invalid workflow reference 'owner/repo/.github/workflows/test.txt@v1': workflow file should have either a '.yml' or '.yaml' file extension", + severity: DiagnosticSeverity.Error, + range: { + start: {line: 3, character: 10}, + end: {line: 3, character: 50} + }, + code: "invalid-workflow-uses-format" + } + ]); + }); + + it("no extension", async () => { + const input = `on: push +jobs: + test: + uses: owner/repo/.github/workflows/test@v1 +`; + const result = await validate(createDocument("wf.yaml", input)); + expect(result).toEqual([ + { + message: + "Invalid workflow reference 'owner/repo/.github/workflows/test@v1': workflow file should have either a '.yml' or '.yaml' file extension", + severity: DiagnosticSeverity.Error, + range: { + start: {line: 3, character: 10}, + end: {line: 3, character: 46} + }, + code: "invalid-workflow-uses-format" + } + ]); + }); + + it("just a ref", async () => { + const input = `on: push +jobs: + test: + uses: test.yml@v1 +`; + const result = await validate(createDocument("wf.yaml", input)); + expect(result).toEqual([ + { + message: + "Invalid workflow reference 'test.yml@v1': references to workflows must be rooted in '.github/workflows'", + severity: DiagnosticSeverity.Error, + range: { + start: {line: 3, character: 10}, + end: {line: 3, character: 21} + }, + code: "invalid-workflow-uses-format" + } + ]); + }); + + it("local without .github/workflows", async () => { + const input = `on: push +jobs: + test: + uses: ./workflows/test.yml +`; + const result = await validate(createDocument("wf.yaml", input)); + expect(result).toEqual([ + { + message: "Invalid workflow reference './workflows/test.yml': no version specified", + severity: DiagnosticSeverity.Error, + range: { + start: {line: 3, character: 10}, + end: {line: 3, character: 30} + }, + code: "invalid-workflow-uses-format" + } + ]); + }); + + describe("invalid ref/version format", () => { + it("empty version after @", async () => { + const input = `on: push +jobs: + test: + uses: owner/repo/.github/workflows/test.yml@ +`; + const result = await validate(createDocument("wf.yaml", input)); + expect(result).toEqual([ + { + message: "Invalid workflow reference 'owner/repo/.github/workflows/test.yml@': no version specified", + severity: DiagnosticSeverity.Error, + range: { + start: {line: 3, character: 10}, + end: {line: 3, character: 48} + }, + code: "invalid-workflow-uses-format" + } + ]); + }); + + it("version with invalid character ?", async () => { + const input = `on: push +jobs: + test: + uses: owner/repo/.github/workflows/test.yml@v1? +`; + const result = await validate(createDocument("wf.yaml", input)); + expect(result).toEqual([ + { + message: + "Invalid workflow reference 'owner/repo/.github/workflows/test.yml@v1?': invalid character '?' in version", + severity: DiagnosticSeverity.Error, + range: { + start: {line: 3, character: 10}, + end: {line: 3, character: 51} + }, + code: "invalid-workflow-uses-format" + } + ]); + }); + + it("version with double dots", async () => { + const input = `on: push +jobs: + test: + uses: owner/repo/.github/workflows/test.yml@v1..v2 +`; + const result = await validate(createDocument("wf.yaml", input)); + expect(result).toEqual([ + { + message: + "Invalid workflow reference 'owner/repo/.github/workflows/test.yml@v1..v2': invalid character '..' in version", + severity: DiagnosticSeverity.Error, + range: { + start: {line: 3, character: 10}, + end: {line: 3, character: 54} + }, + code: "invalid-workflow-uses-format" + } + ]); + }); + + it("version ending with dot", async () => { + const input = `on: push +jobs: + test: + uses: owner/repo/.github/workflows/test.yml@v1. +`; + const result = await validate(createDocument("wf.yaml", input)); + expect(result).toEqual([ + { + message: + "Invalid workflow reference 'owner/repo/.github/workflows/test.yml@v1.': version cannot begin or end with a slash '/' or a dot '.'", + severity: DiagnosticSeverity.Error, + range: { + start: {line: 3, character: 10}, + end: {line: 3, character: 51} + }, + code: "invalid-workflow-uses-format" + } + ]); + }); + + it("version starting with slash", async () => { + const input = `on: push +jobs: + test: + uses: owner/repo/.github/workflows/test.yml@/v1 +`; + const result = await validate(createDocument("wf.yaml", input)); + expect(result).toEqual([ + { + message: + "Invalid workflow reference 'owner/repo/.github/workflows/test.yml@/v1': version cannot begin or end with a slash '/' or a dot '.'", + severity: DiagnosticSeverity.Error, + range: { + start: {line: 3, character: 10}, + end: {line: 3, character: 51} + }, + code: "invalid-workflow-uses-format" + } + ]); + }); + + it("version ending with .lock", async () => { + const input = `on: push +jobs: + test: + uses: owner/repo/.github/workflows/test.yml@refs/heads/main.lock +`; + const result = await validate(createDocument("wf.yaml", input)); + expect(result).toEqual([ + { + message: + "Invalid workflow reference 'owner/repo/.github/workflows/test.yml@refs/heads/main.lock': invalid version: refs/heads/main.lock", + severity: DiagnosticSeverity.Error, + range: { + start: {line: 3, character: 10}, + end: {line: 3, character: 68} + }, + code: "invalid-workflow-uses-format" + } + ]); + }); + + it("version with whitespace", async () => { + const input = `on: push +jobs: + test: + uses: owner/repo/.github/workflows/test.yml@v1 && rm -rf +`; + const result = await validate(createDocument("wf.yaml", input)); + expect(result).toEqual([ + { + message: + "Invalid workflow reference 'owner/repo/.github/workflows/test.yml@v1 && rm -rf': version cannot have whitespace", + severity: DiagnosticSeverity.Error, + range: { + start: {line: 3, character: 10}, + end: {line: 3, character: 60} + }, + code: "invalid-workflow-uses-format" + } + ]); + }); + + it("version with backslash", async () => { + const input = `on: push +jobs: + test: + uses: owner/repo/.github/workflows/test.yml@v1\\1 +`; + const result = await validate(createDocument("wf.yaml", input)); + expect(result).toEqual([ + { + message: + "Invalid workflow reference 'owner/repo/.github/workflows/test.yml@v1\\1': invalid character '\\' in version", + severity: DiagnosticSeverity.Error, + range: { + start: {line: 3, character: 10}, + end: {line: 3, character: 52} + }, + code: "invalid-workflow-uses-format" + } + ]); + }); + }); + + describe("invalid owner/repo names", () => { + it("owner with invalid characters", async () => { + const input = `on: push +jobs: + test: + uses: owner*/repo/.github/workflows/test.yml@v1 +`; + const result = await validate(createDocument("wf.yaml", input)); + expect(result).toEqual([ + { + message: + "Invalid workflow reference 'owner*/repo/.github/workflows/test.yml@v1': owner name must be a valid repository owner name", + severity: DiagnosticSeverity.Error, + range: { + start: {line: 3, character: 10}, + end: {line: 3, character: 51} + }, + code: "invalid-workflow-uses-format" + } + ]); + }); + + it("repo with invalid characters", async () => { + const input = `on: push +jobs: + test: + uses: owner/repo!name/.github/workflows/test.yml@v1 +`; + const result = await validate(createDocument("wf.yaml", input)); + expect(result).toEqual([ + { + message: + "Invalid workflow reference 'owner/repo!name/.github/workflows/test.yml@v1': repository name is invalid", + severity: DiagnosticSeverity.Error, + range: { + start: {line: 3, character: 10}, + end: {line: 3, character: 55} + }, + code: "invalid-workflow-uses-format" + } + ]); + }); + + it("owner with spaces", async () => { + const input = `on: push +jobs: + test: + uses: owner name/repo/.github/workflows/test.yml@v1 +`; + const result = await validate(createDocument("wf.yaml", input)); + expect(result).toEqual([ + { + message: + "Invalid workflow reference 'owner name/repo/.github/workflows/test.yml@v1': owner name must be a valid repository owner name", + severity: DiagnosticSeverity.Error, + range: { + start: {line: 3, character: 10}, + end: {line: 3, character: 55} + }, + code: "invalid-workflow-uses-format" + } + ]); + }); + }); + + describe("invalid workflow filename", () => { + it("filename is just .yml", async () => { + const input = `on: push +jobs: + test: + uses: owner/repo/.github/workflows/.yml@v1 +`; + const result = await validate(createDocument("wf.yaml", input)); + expect(result).toEqual([ + { + message: "Invalid workflow reference 'owner/repo/.github/workflows/.yml@v1': invalid workflow file name", + severity: DiagnosticSeverity.Error, + range: { + start: {line: 3, character: 10}, + end: {line: 3, character: 46} + }, + code: "invalid-workflow-uses-format" + } + ]); + }); + + it("filename is just .yaml", async () => { + const input = `on: push +jobs: + test: + uses: owner/repo/.github/workflows/.yaml@v1 +`; + const result = await validate(createDocument("wf.yaml", input)); + expect(result).toEqual([ + { + message: "Invalid workflow reference 'owner/repo/.github/workflows/.yaml@v1': invalid workflow file name", + severity: DiagnosticSeverity.Error, + range: { + start: {line: 3, character: 10}, + end: {line: 3, character: 47} + }, + code: "invalid-workflow-uses-format" + } + ]); + }); + + it("local workflow filename is just .yml", async () => { + const input = `on: push +jobs: + test: + uses: ./.github/workflows/.yml +`; + const result = await validate(createDocument("wf.yaml", input)); + expect(result).toEqual([ + { + message: "Invalid workflow reference './.github/workflows/.yml': invalid workflow file name", + severity: DiagnosticSeverity.Error, + range: { + start: {line: 3, character: 10}, + end: {line: 3, character: 34} + }, + code: "invalid-workflow-uses-format" + } + ]); + }); + }); + }); +}); diff --git a/languageservice/src/validate.workflow-references.test.ts b/languageservice/src/validate.workflow-references.test.ts index ec1c446..6932fa9 100644 --- a/languageservice/src/validate.workflow-references.test.ts +++ b/languageservice/src/validate.workflow-references.test.ts @@ -43,7 +43,7 @@ on: push jobs: build: - uses: monalisa/octocat/workflow.yaml@not-a-branch + uses: monalisa/octocat/.github/workflows/workflow.yaml@not-a-branch `; const result = await validate(createDocument("wf.yaml", input), { fileProvider: testFileProvider @@ -58,7 +58,7 @@ jobs: line: 5 }, end: { - character: 53, + character: 71, line: 5 } } @@ -72,7 +72,7 @@ on: push jobs: build: - uses: monalisa/octocat/workflow.yaml@main + uses: monalisa/octocat/.github/workflows/workflow.yaml@main `; const result = await validate(createDocument("wf.yaml", input), { fileProvider: testFileProvider @@ -87,7 +87,7 @@ on: push jobs: build: - uses: ./reusable-workflow.yaml + uses: ./.github/workflows/reusable-workflow.yaml `; const result = await validate(createDocument("wf.yaml", input), { fileProvider: testFileProvider @@ -102,7 +102,7 @@ on: push jobs: build: - uses: ./reusable-workflow-with-inputs.yaml + uses: ./.github/workflows/reusable-workflow-with-inputs.yaml secrets: envPAT: pat `; @@ -119,7 +119,7 @@ jobs: line: 5 }, end: { - character: 46, + character: 64, line: 5 } } @@ -133,7 +133,7 @@ on: push jobs: build: - uses: ./reusable-workflow-with-inputs.yaml + uses: ./.github/workflows/reusable-workflow-with-inputs.yaml with: username: monalisa secrets: