Merge pull request #124 from github/joshmgross/reusable-wf-jobs

Add initial support for converting reusable workflow jobs
This commit is contained in:
Josh Gross
2023-02-06 10:38:41 -05:00
committed by GitHub
16 changed files with 368 additions and 219 deletions
+5
View File
@@ -0,0 +1,5 @@
node_modules
dist
*.md
*.js
*.json
+5
View File
@@ -0,0 +1,5 @@
node_modules
dist
*.md
*.js
*.json
@@ -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) {
@@ -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());
@@ -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;
}
@@ -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<DocumentLin
const gitHubBaseUri = "https://www.github.com/";
for (const job of template?.jobs || []) {
for (const step of job?.steps || []) {
if (!job || !isJob(job)) {
continue;
}
for (const step of job.steps || []) {
if ("uses" in step) {
const actionRef = parseActionReference(step.uses.value);
if (!actionRef) {
@@ -0,0 +1,5 @@
import {File} from "@github/actions-workflow-parser/workflows/file";
export type WorkflowProvider = {
getWorkflow: (name: string) => Promise<File | undefined>;
};
+5
View File
@@ -0,0 +1,5 @@
node_modules
dist
*.md
*.js
*.json
@@ -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<BaseJob, "type"> {
const error = new IdBuilder().tryAddKnownId(jobKey.value);
if (error) {
context.error(jobKey, error);
}
const result: Omit<BaseJob, "type"> = {
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;
}
@@ -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;
}
@@ -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<string>;
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<string> {
const labels = new Set<string>();
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;
}
@@ -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<string>;
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<string> {
const labels = new Set<string>();
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;
}
@@ -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;
}
@@ -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";
}
@@ -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;
+5
View File
@@ -0,0 +1,5 @@
node_modules
dist
*.md
*.js
*.json