Add a context provider for action outputs
This commit is contained in:
@@ -1,7 +1,9 @@
|
|||||||
import {data} from "@github/actions-expressions";
|
import {data} from "@github/actions-expressions";
|
||||||
import {ContextProviderConfig} from "@github/actions-languageservice/.";
|
import {ContextProviderConfig} from "@github/actions-languageservice/.";
|
||||||
|
import {WorkflowContext} from "@github/actions-languageservice/context/workflow-context";
|
||||||
import {Octokit} from "@octokit/rest";
|
import {Octokit} from "@octokit/rest";
|
||||||
import {getSecrets} from "./context-providers/secrets";
|
import {getSecrets} from "./context-providers/secrets";
|
||||||
|
import {getStepsContext} from "./context-providers/steps";
|
||||||
import {RepositoryContext} from "./initializationOptions";
|
import {RepositoryContext} from "./initializationOptions";
|
||||||
import {TTLCache} from "./utils/cache";
|
import {TTLCache} from "./utils/cache";
|
||||||
|
|
||||||
@@ -18,15 +20,23 @@ export function contextProviders(
|
|||||||
auth: sessionToken
|
auth: sessionToken
|
||||||
});
|
});
|
||||||
|
|
||||||
const getContext = async (name: string, defaultContext: data.Dictionary | undefined) => {
|
const getContext = async (
|
||||||
|
name: string,
|
||||||
|
defaultContext: data.Dictionary | undefined,
|
||||||
|
workflowContext: WorkflowContext
|
||||||
|
) => {
|
||||||
switch (name) {
|
switch (name) {
|
||||||
case "secrets":
|
case "secrets": {
|
||||||
const secrets = await getSecrets(octokit, cache, repo.owner, repo.name);
|
const secrets = await getSecrets(octokit, cache, repo.owner, repo.name);
|
||||||
|
|
||||||
defaultContext = defaultContext || new data.Dictionary();
|
defaultContext = defaultContext || new data.Dictionary();
|
||||||
secrets.forEach(secret => defaultContext!.add(secret.value, new data.StringData("***")));
|
secrets.forEach(secret => defaultContext!.add(secret.value, new data.StringData("***")));
|
||||||
return defaultContext;
|
return defaultContext;
|
||||||
}
|
}
|
||||||
|
case "steps": {
|
||||||
|
return await getStepsContext(octokit, cache, defaultContext, workflowContext);
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return {getContext};
|
return {getContext};
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import {ActionReference, ActionOutputs} from "@github/actions-languageservice/action";
|
||||||
|
import {Octokit} from "@octokit/rest";
|
||||||
|
import {fetchActionMetadata} from "../utils/action-metadata";
|
||||||
|
import {TTLCache} from "../utils/cache";
|
||||||
|
|
||||||
|
export async function getActionOutputs(
|
||||||
|
octokit: Octokit,
|
||||||
|
cache: TTLCache,
|
||||||
|
action: ActionReference
|
||||||
|
): Promise<ActionOutputs | undefined> {
|
||||||
|
return (await fetchActionMetadata(octokit, cache, action))?.outputs;
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import {data} from "@github/actions-expressions";
|
||||||
|
import {isDictionary} from "@github/actions-expressions/data/dictionary";
|
||||||
|
import {parseActionReference} from "@github/actions-languageservice/action";
|
||||||
|
import {WorkflowContext} from "@github/actions-languageservice/context/workflow-context";
|
||||||
|
import {isActionStep} from "@github/actions-workflow-parser/model/type-guards";
|
||||||
|
import {Octokit} from "@octokit/rest";
|
||||||
|
import {TTLCache} from "../utils/cache";
|
||||||
|
import {getActionOutputs} from "./action-outputs";
|
||||||
|
|
||||||
|
export async function getStepsContext(
|
||||||
|
octokit: Octokit,
|
||||||
|
cache: TTLCache,
|
||||||
|
defaultContext: data.Dictionary | undefined,
|
||||||
|
workflowContext: WorkflowContext
|
||||||
|
): Promise<data.Dictionary | undefined> {
|
||||||
|
if (!defaultContext || !workflowContext.job) {
|
||||||
|
return defaultContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The default context includes the set of valid
|
||||||
|
// step ids that can be used in expressions
|
||||||
|
const contextSteps = new Set<string>();
|
||||||
|
for (const {key} of defaultContext.pairs()) {
|
||||||
|
contextSteps.add(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy the default context for each step
|
||||||
|
// If the step is an action, add the action outputs to the context
|
||||||
|
const stepsContext = new data.Dictionary();
|
||||||
|
for (const step of workflowContext.job.steps) {
|
||||||
|
if (!contextSteps.has(step.id)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultStepContext = defaultContext.get(step.id);
|
||||||
|
if (!defaultStepContext) {
|
||||||
|
stepsContext.add(step.id, new data.Null());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isActionStep(step) || !isDictionary(defaultStepContext)) {
|
||||||
|
stepsContext.add(step.id, defaultStepContext);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const action = parseActionReference(step.uses.value);
|
||||||
|
if (!action) {
|
||||||
|
stepsContext.add(step.id, defaultStepContext);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stepContext = new data.Dictionary();
|
||||||
|
for (const {key, value} of defaultStepContext.pairs()) {
|
||||||
|
switch (key) {
|
||||||
|
case "outputs":
|
||||||
|
const outputs = await getActionOutputs(octokit, cache, action);
|
||||||
|
if (!outputs) {
|
||||||
|
stepContext.add(key, value);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const outputsDict = new data.Dictionary();
|
||||||
|
for (const [key, value] of Object.entries(outputs)) {
|
||||||
|
outputsDict.add(key, new data.StringData(value.description));
|
||||||
|
}
|
||||||
|
stepContext.add("outputs", outputsDict);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
stepContext.add(key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stepsContext.add(step.id, stepContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
return stepsContext;
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import {ActionReference, ActionInputs, ActionOutputs, actionIdentifier} from "@github/actions-languageservice/action";
|
||||||
|
import {Octokit, RestEndpointMethodTypes} from "@octokit/rest";
|
||||||
|
import {parse} from "yaml";
|
||||||
|
import {TTLCache} from "./cache";
|
||||||
|
|
||||||
|
export type ActionMetadata = {
|
||||||
|
inputs?: ActionInputs;
|
||||||
|
outputs?: ActionOutputs;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function fetchActionMetadata(
|
||||||
|
client: Octokit,
|
||||||
|
cache: TTLCache,
|
||||||
|
action: ActionReference
|
||||||
|
): Promise<ActionMetadata | undefined> {
|
||||||
|
const metadata = await cache.get(`${actionIdentifier(action)}/action-metadata`, undefined, () =>
|
||||||
|
getActionMetadata(client, action)
|
||||||
|
);
|
||||||
|
if (!metadata) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return parseActionMetadata(metadata);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getActionMetadata(client: Octokit, action: ActionReference): Promise<string | undefined> {
|
||||||
|
let resp: RestEndpointMethodTypes["repos"]["getContent"]["response"];
|
||||||
|
try {
|
||||||
|
resp = await client.repos.getContent({
|
||||||
|
owner: action.owner,
|
||||||
|
repo: action.name,
|
||||||
|
ref: action.ref,
|
||||||
|
path: action.path ? `${action.path}/action.yml` : "action.yml"
|
||||||
|
});
|
||||||
|
} catch (e: any) {
|
||||||
|
// If action.yml doesn't exist, try action.yaml
|
||||||
|
if (e.status === 404) {
|
||||||
|
resp = await client.repos.getContent({
|
||||||
|
owner: action.owner,
|
||||||
|
repo: action.name,
|
||||||
|
ref: action.ref,
|
||||||
|
path: action.path ? `${action.path}/action.yaml` : "action.yaml"
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// https://docs.github.com/en/rest/repos/contents?apiVersion=2022-11-28
|
||||||
|
// Ignore directories (array of files) and non-file content
|
||||||
|
if (resp.data === undefined || Array.isArray(resp.data) || resp.data.type !== "file") {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resp.data.content === undefined) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = Buffer.from(resp.data.content, "base64").toString("utf8");
|
||||||
|
// Remove any null bytes
|
||||||
|
return text.replace(/\0/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
// https://docs.github.com/en/actions/creating-actions/metadata-syntax-for-github-actions
|
||||||
|
async function parseActionMetadata(content: string): Promise<ActionMetadata> {
|
||||||
|
const metadata: ActionMetadata = parse(content);
|
||||||
|
return metadata;
|
||||||
|
}
|
||||||
@@ -1,14 +1,9 @@
|
|||||||
import {
|
import {ActionInputs, ActionReference, parseActionReference} from "@github/actions-languageservice/action";
|
||||||
actionIdentifier,
|
|
||||||
ActionInputs,
|
|
||||||
ActionReference,
|
|
||||||
parseActionReference
|
|
||||||
} from "@github/actions-languageservice/action";
|
|
||||||
import {WorkflowContext} from "@github/actions-languageservice/context/workflow-context";
|
import {WorkflowContext} from "@github/actions-languageservice/context/workflow-context";
|
||||||
import {Value} from "@github/actions-languageservice/value-providers/config";
|
import {Value} from "@github/actions-languageservice/value-providers/config";
|
||||||
import {isActionStep} from "@github/actions-workflow-parser/model/type-guards";
|
import {isActionStep} from "@github/actions-workflow-parser/model/type-guards";
|
||||||
import {Octokit, RestEndpointMethodTypes} from "@octokit/rest";
|
import {Octokit} from "@octokit/rest";
|
||||||
import {parse} from "yaml";
|
import {fetchActionMetadata} from "../utils/action-metadata";
|
||||||
import {TTLCache} from "../utils/cache";
|
import {TTLCache} from "../utils/cache";
|
||||||
|
|
||||||
export async function getActionInputs(
|
export async function getActionInputs(
|
||||||
@@ -16,11 +11,7 @@ export async function getActionInputs(
|
|||||||
cache: TTLCache,
|
cache: TTLCache,
|
||||||
action: ActionReference
|
action: ActionReference
|
||||||
): Promise<ActionInputs | undefined> {
|
): Promise<ActionInputs | undefined> {
|
||||||
const inputs = await cache.get(`${actionIdentifier(action)}/action-inputs`, undefined, () =>
|
return (await fetchActionMetadata(client, cache, action))?.inputs;
|
||||||
fetchActionInputs(client, action)
|
|
||||||
);
|
|
||||||
|
|
||||||
return inputs;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getActionInputValues(
|
export async function getActionInputValues(
|
||||||
@@ -49,60 +40,3 @@ export async function getActionInputValues(
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchActionInputs(client: Octokit, action: ActionReference): Promise<ActionInputs | undefined> {
|
|
||||||
const metadata = await getActionMetadata(client, action);
|
|
||||||
if (!metadata) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
return parseActionMetadata(metadata);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getActionMetadata(client: Octokit, action: ActionReference): Promise<string | undefined> {
|
|
||||||
let resp: RestEndpointMethodTypes["repos"]["getContent"]["response"];
|
|
||||||
try {
|
|
||||||
resp = await client.repos.getContent({
|
|
||||||
owner: action.owner,
|
|
||||||
repo: action.name,
|
|
||||||
ref: action.ref,
|
|
||||||
path: action.path ? `${action.path}/action.yml` : "action.yml"
|
|
||||||
});
|
|
||||||
} catch (e: any) {
|
|
||||||
// If action.yml doesn't exist, try action.yaml
|
|
||||||
if (e.status === 404) {
|
|
||||||
resp = await client.repos.getContent({
|
|
||||||
owner: action.owner,
|
|
||||||
repo: action.name,
|
|
||||||
ref: action.ref,
|
|
||||||
path: action.path ? `${action.path}/action.yaml` : "action.yaml"
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// https://docs.github.com/en/rest/repos/contents?apiVersion=2022-11-28
|
|
||||||
// Ignore directories (array of files) and non-file content
|
|
||||||
if (resp.data === undefined || Array.isArray(resp.data) || resp.data.type !== "file") {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (resp.data.content === undefined) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
const text = Buffer.from(resp.data.content, "base64").toString("utf8");
|
|
||||||
// Remove any null bytes
|
|
||||||
return text.replace(/\0/g, "");
|
|
||||||
}
|
|
||||||
|
|
||||||
type ActionMetadata = {
|
|
||||||
inputs?: ActionInputs;
|
|
||||||
};
|
|
||||||
|
|
||||||
// https://docs.github.com/en/actions/creating-actions/metadata-syntax-for-github-actions
|
|
||||||
async function parseActionMetadata(content: string): Promise<ActionInputs> {
|
|
||||||
const metadata: ActionMetadata = parse(content);
|
|
||||||
return metadata.inputs ?? {};
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -8,6 +8,14 @@ export type ActionInput = {
|
|||||||
|
|
||||||
export type ActionInputs = Record<string, ActionInput>;
|
export type ActionInputs = Record<string, ActionInput>;
|
||||||
|
|
||||||
|
// https://docs.github.com/en/actions/creating-actions/metadata-syntax-for-github-actions#outputs-for-docker-container-and-javascript-actions
|
||||||
|
export type ActionOutput = {
|
||||||
|
description: string;
|
||||||
|
value?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ActionOutputs = Record<string, ActionOutput>;
|
||||||
|
|
||||||
export type ActionReference = {
|
export type ActionReference = {
|
||||||
owner: string;
|
owner: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
import {data} from "@github/actions-expressions";
|
import {data} from "@github/actions-expressions";
|
||||||
import {Dictionary} from "@github/actions-expressions/data/dictionary";
|
import {Dictionary} from "@github/actions-expressions/data/dictionary";
|
||||||
import {ExpressionData, Pair} from "@github/actions-expressions/data/expressiondata";
|
import {ExpressionData, Pair} from "@github/actions-expressions/data/expressiondata";
|
||||||
|
import {WorkflowContext} from "../context/workflow-context";
|
||||||
|
|
||||||
export type ContextProviderConfig = {
|
export type ContextProviderConfig = {
|
||||||
getContext: (name: string, defaultContext: data.Dictionary | undefined) => Promise<data.Dictionary | undefined>;
|
getContext: (
|
||||||
|
name: string,
|
||||||
|
defaultContext: data.Dictionary | undefined,
|
||||||
|
workflowContext: WorkflowContext
|
||||||
|
) => Promise<data.Dictionary | undefined>;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ export async function getContext(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
value = (await config?.getContext(contextName, value)) || value;
|
value = (await config?.getContext(contextName, value, workflowContext)) || value;
|
||||||
|
|
||||||
context.add(contextName, value);
|
context.add(contextName, value);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user