diff --git a/actions-expressions/.prettierignore b/actions-expressions/.prettierignore new file mode 100644 index 0000000..ce98533 --- /dev/null +++ b/actions-expressions/.prettierignore @@ -0,0 +1,5 @@ +node_modules +dist +*.md +*.js +*.json diff --git a/actions-languageserver/.prettierignore b/actions-languageserver/.prettierignore new file mode 100644 index 0000000..ce98533 --- /dev/null +++ b/actions-languageserver/.prettierignore @@ -0,0 +1,5 @@ +node_modules +dist +*.md +*.js +*.json diff --git a/actions-languageserver/src/test-utils/workflow-context.ts b/actions-languageserver/src/test-utils/workflow-context.ts index aef5cfe..88eb740 100644 --- a/actions-languageserver/src/test-utils/workflow-context.ts +++ b/actions-languageserver/src/test-utils/workflow-context.ts @@ -1,5 +1,6 @@ import {WorkflowContext} from "@github/actions-languageservice/context/workflow-context"; import {convertWorkflowTemplate, parseWorkflow, TraceWriter} from "@github/actions-workflow-parser"; +import {isJob} from "@github/actions-workflow-parser/model/type-guards"; const nullTrace: TraceWriter = { info: x => {}, @@ -16,7 +17,14 @@ export function createWorkflowContext(workflow: string, job?: string, stepIndex? const context: WorkflowContext = {uri: "test.yaml", template}; if (job) { - context.job = template.jobs.find(j => j.id.value === job); + const workflowJob = template.jobs.find(j => j.id.value === job); + if (workflowJob) { + if (isJob(workflowJob)) { + context.job = workflowJob; + } else { + context.reusableWorkflowJob = workflowJob; + } + } } if (stepIndex !== undefined) { diff --git a/actions-languageservice/src/context-providers/needs.ts b/actions-languageservice/src/context-providers/needs.ts index e6cb66d..e261b0d 100644 --- a/actions-languageservice/src/context-providers/needs.ts +++ b/actions-languageservice/src/context-providers/needs.ts @@ -1,6 +1,7 @@ import {data, DescriptionDictionary} from "@github/actions-expressions"; import {isScalar, isString} from "@github/actions-workflow-parser"; -import {Job} from "@github/actions-workflow-parser/model/workflow-template"; +import {isJob} from "@github/actions-workflow-parser/model/type-guards"; +import {Job, WorkflowJob} from "@github/actions-workflow-parser/model/workflow-template"; import {WorkflowContext} from "../context/workflow-context"; export function getNeedsContext(workflowContext: WorkflowContext): DescriptionDictionary { @@ -17,11 +18,13 @@ export function getNeedsContext(workflowContext: WorkflowContext): DescriptionDi return d; } -function needsJobContext(job?: Job): DescriptionDictionary { +function needsJobContext(job?: WorkflowJob): DescriptionDictionary { // https://docs.github.com/en/actions/learn-github-actions/contexts#needs-context const d = new DescriptionDictionary(); - d.add("outputs", jobOutputs(job)); + if (job && isJob(job)) { + d.add("outputs", jobOutputs(job)); + } // Can be "success", "failure", "cancelled", or "skipped" d.add("result", new data.Null()); diff --git a/actions-languageservice/src/context/workflow-context.ts b/actions-languageservice/src/context/workflow-context.ts index 6ba3f28..c4c20af 100644 --- a/actions-languageservice/src/context/workflow-context.ts +++ b/actions-languageservice/src/context/workflow-context.ts @@ -1,5 +1,6 @@ import {isMapping, isSequence, WorkflowTemplate} from "@github/actions-workflow-parser"; -import {Job, Step} from "@github/actions-workflow-parser/model/workflow-template"; +import {isJob, isReusableWorkflowJob} from "@github/actions-workflow-parser/model/type-guards"; +import {Step, Job, ReusableWorkflowJob} from "@github/actions-workflow-parser/model/workflow-template"; import {MappingToken} from "@github/actions-workflow-parser/templates/tokens/mapping-token"; import {SequenceToken} from "@github/actions-workflow-parser/templates/tokens/sequence-token"; import {StringToken} from "@github/actions-workflow-parser/templates/tokens/string-token"; @@ -10,9 +11,12 @@ export interface WorkflowContext { template: WorkflowTemplate | undefined; - /** If the context is for a position within a job, this will be the job */ + /** If the context is for a position within a regular job, this will be the job */ job?: Job; + /** If the context is for a position within a reusable workflow job, this will be the reusable workflow job */ + reusableWorkflowJob?: ReusableWorkflowJob; + /** If the context is for a position within a step, this will be the step */ step?: Step; } @@ -35,7 +39,15 @@ export function getWorkflowContext( switch (token.definition?.key) { case "job": { const jobID = (token as StringToken).value; - context.job = template.jobs.find(job => job.id.value === jobID); + const job = template.jobs.find(job => job.id.value === jobID); + if (!job) { + break; + } + if (isJob(job)) { + context.job = job; + } else if (isReusableWorkflowJob(job)) { + context.reusableWorkflowJob = job; + } break; } case "steps": { @@ -54,7 +66,10 @@ export function getWorkflowContext( } } - context.step = findStep(context.job?.steps, stepsSequence, stepToken); + if (context.job && isJob(context.job)) { + context.step = findStep(context.job.steps, stepsSequence, stepToken); + } + return context; } diff --git a/actions-languageservice/src/document-links.ts b/actions-languageservice/src/document-links.ts index 8bc17cb..cd739c0 100644 --- a/actions-languageservice/src/document-links.ts +++ b/actions-languageservice/src/document-links.ts @@ -1,5 +1,6 @@ import {convertWorkflowTemplate, parseWorkflow} from "@github/actions-workflow-parser"; import {ErrorPolicy} from "@github/actions-workflow-parser/model/convert"; +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"; @@ -27,7 +28,10 @@ export async function documentLinks(document: TextDocument): Promise Promise; +}; diff --git a/actions-workflow-parser/.prettierignore b/actions-workflow-parser/.prettierignore new file mode 100644 index 0000000..ce98533 --- /dev/null +++ b/actions-workflow-parser/.prettierignore @@ -0,0 +1,5 @@ +node_modules +dist +*.md +*.js +*.json diff --git a/actions-workflow-parser/src/model/converter/base-job.ts b/actions-workflow-parser/src/model/converter/base-job.ts new file mode 100644 index 0000000..0b1d02e --- /dev/null +++ b/actions-workflow-parser/src/model/converter/base-job.ts @@ -0,0 +1,66 @@ +import {TemplateContext} from "../../templates/template-context"; +import {StringToken, MappingToken, BasicExpressionToken} from "../../templates/tokens"; +import {isSequence, isString} from "../../templates/tokens/type-guards"; +import {BaseJob} from "../workflow-template"; +import {convertConcurrency} from "./concurrency"; +import {handleTemplateTokenErrors} from "./handle-errors"; +import {IdBuilder} from "./id-builder"; + +export function convertBaseJob( + context: TemplateContext, + jobKey: StringToken, + token: MappingToken +): Omit { + const error = new IdBuilder().tryAddKnownId(jobKey.value); + if (error) { + context.error(jobKey, error); + } + + const result: Omit = { + id: jobKey, + name: undefined, + needs: undefined, + if: new BasicExpressionToken(undefined, undefined, "success()", undefined, undefined, undefined), + concurrency: undefined, + strategy: undefined + }; + + for (const item of token) { + const propertyName = item.key.assertString("job property name"); + switch (propertyName.value) { + case "concurrency": + handleTemplateTokenErrors(item.value, context, undefined, () => convertConcurrency(context, item.value)); + result.concurrency = item.value; + break; + + case "name": + result.name = item.value.assertScalar("job name"); + break; + + case "needs": + result.needs = []; + if (isString(item.value)) { + const jobNeeds = item.value.assertString("job needs id"); + result.needs.push(jobNeeds); + } + + if (isSequence(item.value)) { + for (const seqItem of item.value) { + const jobNeeds = seqItem.assertString("job needs id"); + result.needs.push(jobNeeds); + } + } + break; + + case "strategy": + result.strategy = item.value; + break; + } + } + + if (!result.name) { + result.name = result.id; + } + + return result; +} diff --git a/actions-workflow-parser/src/model/converter/job.ts b/actions-workflow-parser/src/model/converter/job.ts new file mode 100644 index 0000000..4fda96b --- /dev/null +++ b/actions-workflow-parser/src/model/converter/job.ts @@ -0,0 +1,70 @@ +import {TemplateContext} from "../../templates/template-context"; +import {StringToken, MappingToken} from "../../templates/tokens"; +import {Job} from "../workflow-template"; +import {convertBaseJob} from "./base-job"; +import {convertToJobContainer, convertToJobServices} from "./container"; +import {handleTemplateTokenErrors} from "./handle-errors"; +import {convertToActionsEnvironmentRef} from "./job/environment"; +import {convertRunsOn} from "./job/runs-on"; +import {convertSteps} from "./steps"; + +export function convertJob(context: TemplateContext, jobKey: StringToken, token: MappingToken): Job { + const base = convertBaseJob(context, jobKey, token); + const result: Job = { + type: "job", + id: base.id, + name: base.name, + needs: base.needs, + if: base.if, + env: undefined, + concurrency: base.concurrency, + environment: undefined, + strategy: base.strategy, + "runs-on": undefined, + container: undefined, + services: undefined, + outputs: undefined, + steps: [] + }; + + for (const item of token) { + const propertyName = item.key.assertString("job property name"); + switch (propertyName.value) { + case "container": + convertToJobContainer(context, item.value); + result.container = item.value; + break; + + case "env": + result.env = item.value.assertMapping("job env"); + break; + + case "environment": + handleTemplateTokenErrors(item.value, context, undefined, () => + convertToActionsEnvironmentRef(context, item.value) + ); + result.environment = item.value; + break; + + case "outputs": + result.outputs = item.value.assertMapping("job outputs"); + break; + + case "runs-on": + handleTemplateTokenErrors(item.value, context, undefined, () => convertRunsOn(context, item.value)); + result["runs-on"] = item.value; + break; + + case "services": + convertToJobServices(context, item.value); + result.services = item.value; + break; + + case "steps": + result.steps = convertSteps(context, item.value); + break; + } + } + + return result; +} diff --git a/actions-workflow-parser/src/model/converter/job/runs-on.ts b/actions-workflow-parser/src/model/converter/job/runs-on.ts new file mode 100644 index 0000000..c96da91 --- /dev/null +++ b/actions-workflow-parser/src/model/converter/job/runs-on.ts @@ -0,0 +1,102 @@ +import {TemplateContext} from "../../../templates/template-context"; +import {TemplateToken} from "../../../templates/tokens"; +import {isMapping, isString, isSequence} from "../../../templates/tokens/type-guards"; + +type RunsOn = { + labels: Set; + group: string; +}; + +export function convertRunsOn(context: TemplateContext, token: TemplateToken): RunsOn { + const labels = convertRunsOnLabels(token); + + if (!isMapping(token)) { + return { + labels, + group: "" + }; + } + + let group = ""; + + for (const item of token) { + const key = item.key.assertString("job runs-on property name"); + switch (key.value) { + case "group": { + if (item.value.isExpression) { + continue; + } + + const groupName = item.value.assertString("job runs-on group name").value; + const names = groupName.split("/"); + switch (names.length) { + case 1: { + group = groupName; + break; + } + case 2: { + if (!["org", "organization", "ent", "enterprise"].includes(names[0])) { + context.error( + item.value, + `Invalid runs-on group name '${groupName}. Please use 'organization/' or 'enterprise/' prefix to target a single runner group.'` + ); + continue; + } + if (!names[1]) { + context.error(item.value, `Invalid runs-on group name '${groupName}'.`); + continue; + } + + group = groupName; + break; + } + default: { + context.error( + item.value, + `Invalid runs-on group name '${groupName}. Please use 'organization/' or 'enterprise/' prefix to target a single runner group.'` + ); + break; + } + } + break; + } + case "labels": { + const mapLabels = convertRunsOnLabels(item.value); + for (const label of mapLabels) { + labels.add(label); + } + break; + } + } + } + + return { + labels, + group + }; +} + +function convertRunsOnLabels(token: TemplateToken): Set { + const labels = new Set(); + if (token.isExpression) { + return labels; + } + + if (isString(token)) { + labels.add(token.value); + return labels; + } + + if (isSequence(token)) { + for (const item of token) { + if (item.isExpression) { + continue; + } + + const label = item.assertString("job runs-on label sequence item"); + labels.add(label.value); + } + } + + return labels; +} diff --git a/actions-workflow-parser/src/model/converter/jobs.ts b/actions-workflow-parser/src/model/converter/jobs.ts index ac31142..5aab4c5 100644 --- a/actions-workflow-parser/src/model/converter/jobs.ts +++ b/actions-workflow-parser/src/model/converter/jobs.ts @@ -1,14 +1,10 @@ import {TemplateContext} from "../../templates/template-context"; -import {BasicExpressionToken, MappingToken, StringToken} from "../../templates/tokens"; +import {StringToken} from "../../templates/tokens"; import {TemplateToken} from "../../templates/tokens/template-token"; -import {isMapping, isSequence, isString} from "../../templates/tokens/type-guards"; +import {isMapping} from "../../templates/tokens/type-guards"; import {Job} from "../workflow-template"; -import {convertConcurrency} from "./concurrency"; -import {convertToJobContainer, convertToJobServices} from "./container"; import {handleTemplateTokenErrors} from "./handle-errors"; -import {IdBuilder} from "./id-builder"; -import {convertToActionsEnvironmentRef} from "./job/environment"; -import {convertSteps} from "./steps"; +import {convertJob} from "./job"; type nodeInfo = { name: string; @@ -100,200 +96,3 @@ function validateNeeds( } } } - -function convertJob(context: TemplateContext, jobKey: StringToken, token: MappingToken): Job { - const error = new IdBuilder().tryAddKnownId(jobKey.value); - if (error) { - context.error(jobKey, error); - } - const result: Job = { - type: "job", - id: jobKey, - name: undefined, - needs: undefined, - if: new BasicExpressionToken(undefined, undefined, "success()", undefined, undefined, undefined), - env: undefined, - concurrency: undefined, - environment: undefined, - strategy: undefined, - "runs-on": undefined, - container: undefined, - services: undefined, - outputs: undefined, - steps: [] - }; - - for (const item of token) { - const propertyName = item.key.assertString("job property name"); - switch (propertyName.value) { - case "concurrency": - handleTemplateTokenErrors(item.value, context, undefined, () => convertConcurrency(context, item.value)); - result.concurrency = item.value; - break; - - case "container": - // Do early validation, but don't convert - convertToJobContainer(context, item.value); - result.container = item.value; - break; - - case "env": - result.env = item.value.assertMapping("job env"); - break; - - case "environment": - handleTemplateTokenErrors(item.value, context, undefined, () => - convertToActionsEnvironmentRef(context, item.value) - ); - result.environment = item.value; - break; - - case "name": - result.name = item.value.assertScalar("job name"); - break; - - case "needs": - result.needs = []; - if (isString(item.value)) { - const jobNeeds = item.value.assertString("job needs id"); - result.needs.push(jobNeeds); - } - - if (isSequence(item.value)) { - for (const seqItem of item.value) { - const jobNeeds = seqItem.assertString("job needs id"); - result.needs.push(jobNeeds); - } - } - break; - - case "outputs": - result.outputs = item.value.assertMapping("job outputs"); - break; - - case "runs-on": - handleTemplateTokenErrors(item.value, context, undefined, () => convertRunsOn(context, item.value)); - result["runs-on"] = item.value; - break; - - case "services": - // Do early validation, but don't convert - convertToJobServices(context, item.value); - result.services = item.value; - break; - - case "steps": - result.steps = convertSteps(context, item.value); - break; - - case "strategy": - result.strategy = item.value; - break; - } - } - - if (!result.name) { - result.name = result.id; - } - - return result; -} - -type RunsOn = { - labels: Set; - group: string; -}; - -function convertRunsOn(context: TemplateContext, token: TemplateToken): RunsOn { - const labels = convertRunsOnLabels(token); - - if (!isMapping(token)) { - return { - labels, - group: "" - }; - } - - let group = ""; - - for (const item of token) { - const key = item.key.assertString("job runs-on property name"); - switch (key.value) { - case "group": { - if (item.value.isExpression) { - continue; - } - - const groupName = item.value.assertString("job runs-on group name").value; - const names = groupName.split("/"); - switch (names.length) { - case 1: { - group = groupName; - break; - } - case 2: { - if (!["org", "organization", "ent", "enterprise"].includes(names[0])) { - context.error( - item.value, - `Invalid runs-on group name '${groupName}. Please use 'organization/' or 'enterprise/' prefix to target a single runner group.'` - ); - continue; - } - if (!names[1]) { - context.error(item.value, `Invalid runs-on group name '${groupName}'.`); - continue; - } - - group = groupName; - break; - } - default: { - context.error( - item.value, - `Invalid runs-on group name '${groupName}. Please use 'organization/' or 'enterprise/' prefix to target a single runner group.'` - ); - break; - } - } - break; - } - case "labels": { - const mapLabels = convertRunsOnLabels(item.value); - for (const label of mapLabels) { - labels.add(label); - } - break; - } - } - } - - return { - labels, - group - }; -} - -function convertRunsOnLabels(token: TemplateToken): Set { - const labels = new Set(); - if (token.isExpression) { - return labels; - } - - if (isString(token)) { - labels.add(token.value); - return labels; - } - - if (isSequence(token)) { - for (const item of token) { - if (item.isExpression) { - continue; - } - - const label = item.assertString("job runs-on label sequence item"); - labels.add(label.value); - } - } - - return labels; -} diff --git a/actions-workflow-parser/src/model/converter/reusable-job.ts b/actions-workflow-parser/src/model/converter/reusable-job.ts new file mode 100644 index 0000000..3eef8f3 --- /dev/null +++ b/actions-workflow-parser/src/model/converter/reusable-job.ts @@ -0,0 +1,34 @@ +import {TemplateContext} from "../../templates/template-context"; +import {MappingToken, StringToken} from "../../templates/tokens"; +import {ReusableWorkflowJob} from "../workflow-template"; +import {convertBaseJob} from "./base-job"; + +export function convertResuableWorkflowJob( + context: TemplateContext, + jobKey: StringToken, + token: MappingToken +): ReusableWorkflowJob { + const base = convertBaseJob(context, jobKey, token); + + const result: ReusableWorkflowJob = { + type: "reusableWorkflowJob", + id: base.id, + name: base.name, + needs: base.needs, + if: base.if, + concurrency: base.concurrency, + strategy: base.strategy, + uses: new StringToken(undefined, undefined, "undefined", undefined, undefined) + }; + + for (const item of token) { + const propertyName = item.key.assertString("job property name"); + switch (propertyName.value) { + case "uses": + result.uses = item.value.assertString("job item uses"); + break; + } + } + + return result; +} diff --git a/actions-workflow-parser/src/model/type-guards.ts b/actions-workflow-parser/src/model/type-guards.ts index a1e09c0..60923f2 100644 --- a/actions-workflow-parser/src/model/type-guards.ts +++ b/actions-workflow-parser/src/model/type-guards.ts @@ -1,4 +1,4 @@ -import {ActionStep, RunStep, Step} from "./workflow-template"; +import {ActionStep, Job, ReusableWorkflowJob, RunStep, Step, WorkflowJob} from "./workflow-template"; export function isRunStep(step: Step): step is RunStep { return (step as RunStep).run !== undefined; @@ -7,3 +7,11 @@ export function isRunStep(step: Step): step is RunStep { export function isActionStep(step: Step): step is ActionStep { return (step as ActionStep).uses !== undefined; } + +export function isJob(job: WorkflowJob): job is Job { + return job.type === "job"; +} + +export function isReusableWorkflowJob(job: WorkflowJob): job is ReusableWorkflowJob { + return job.type === "reusableWorkflowJob"; +} diff --git a/actions-workflow-parser/src/model/workflow-template.ts b/actions-workflow-parser/src/model/workflow-template.ts index 2ede8f5..508b424 100644 --- a/actions-workflow-parser/src/model/workflow-template.ts +++ b/actions-workflow-parser/src/model/workflow-template.ts @@ -9,7 +9,7 @@ import { export type WorkflowTemplate = { events: EventsConfig; - jobs: Job[]; + jobs: WorkflowJob[]; concurrency: TemplateToken; env: TemplateToken; @@ -28,16 +28,25 @@ export type ActionsEnvironmentReference = { url?: TemplateToken; }; -export type Job = { - type: string; +export type WorkflowJob = Job | ReusableWorkflowJob; + +export type JobType = "job" | "reusableWorkflowJob"; + +export type BaseJob = { + type: JobType; id: StringToken; name?: ScalarToken; needs?: StringToken[]; if: BasicExpressionToken; - env?: MappingToken; concurrency?: TemplateToken; - environment?: TemplateToken; strategy?: TemplateToken; +}; + +// `job-factory` in the schema +export type Job = BaseJob & { + type: "job"; + env?: MappingToken; + environment?: TemplateToken; "runs-on"?: TemplateToken; container?: TemplateToken; services?: TemplateToken; @@ -45,6 +54,12 @@ export type Job = { steps: Step[]; }; +// `workflow-job` in the schema +export type ReusableWorkflowJob = BaseJob & { + type: "reusableWorkflowJob"; + uses: StringToken; +}; + export type Container = { image: StringToken; credentials?: Credential; diff --git a/browser-playground/.prettierignore b/browser-playground/.prettierignore new file mode 100644 index 0000000..ce98533 --- /dev/null +++ b/browser-playground/.prettierignore @@ -0,0 +1,5 @@ +node_modules +dist +*.md +*.js +*.json