Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Adding support for waiting on specific job/step completion in turnstyle #98

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 27 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,30 @@ jobs:
run: sleep 30
```

you can also wait on a specific job (and step) to complete in a run by using the `jobs.<job_id>.steps.with.job-to-wait-for`
and `jobs.<job_id>.steps.with.step-to-wait-for` inputs. Specify the name of the job/step to wait for.

```diff
name: Main

on: push

jobs:
main:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Turnstyle
uses: softprops/turnstyle@v2
with:
+ job-to-wait-for: "main"
+ step-to-wait-for: "Deploy"
- name: Deploy
run: sleep 30
```


Finally, you can use the `force_continued` output to skip only a subset of steps
by setting `continue-after-seconds` and conditioning future steps with
`if: ! steps.<step id>.outputs.force_continued`
Expand Down Expand Up @@ -137,12 +161,14 @@ jobs:
#### inputs

| Name | Type | Description |
| ------------------------ | ------- | -------------------------------------------------------------------------------------------------------------------------------------- |
| ------------------------ | ------- |----------------------------------------------------------------------------------------------------------------------------------------|
| `continue-after-seconds` | number | Maximum number of seconds to wait before moving forward (unbound by default). Mutually exclusive with abort-after-seconds |
| `abort-after-seconds` | number | Maximum number of seconds to wait before aborting the job (unbound by default). Mutually exclusive with continue-after-seconds |
| `poll-interval-seconds` | number | Number of seconds to wait in between checks for previous run completion (defaults to 60) |
| `same-branch-only` | boolean | Only wait on other runs from the same branch (defaults to true) |
| `initial-wait-seconds` | number | Total elapsed seconds within which period the action will refresh the list of current runs, if no runs were found in the first attempt |
| `job-to-wait-for` | string | Name of the workflow's job to wait for (unbound by default). |
| `step-to-wait-for` | string | Name of the step to wait for (unbound by default). Required if job-to-wait-for is set. |

#### outputs

Expand Down
25 changes: 25 additions & 0 deletions __tests__/input.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ describe("input", () => {
"INPUT_POLL-INTERVAL-SECONDS": "5",
"INPUT_SAME-BRANCH-ONLY": "false",
"INPUT_INITIAL-WAIT-SECONDS": "5",
"INPUT_JOB-TO-WAIT-FOR": "job-name",
"INPUT_STEP-TO-WAIT-FOR": "step-name",
}),
{
githubToken: "s3cr3t",
Expand All @@ -27,6 +29,8 @@ describe("input", () => {
abortAfterSeconds: undefined,
pollIntervalSeconds: 5,
sameBranchOnly: false,
jobToWaitFor: "job-name",
stepToWaitFor: "step-name",
initialWaitSeconds: 5,
},
);
Expand Down Expand Up @@ -56,6 +60,8 @@ describe("input", () => {
abortAfterSeconds: 10,
pollIntervalSeconds: 5,
sameBranchOnly: false,
jobToWaitFor: undefined,
stepToWaitFor: undefined,
initialWaitSeconds: 0,
},
);
Expand All @@ -75,6 +81,19 @@ describe("input", () => {
);
});

it("rejects env with stepToWaitFor but no jobToWaitFor", () => {
assert.throws(() =>
parseInput({
GITHUB_REF: "refs/heads/foo",
GITHUB_REPOSITORY: "softprops/turnstyle",
GITHUB_WORKFLOW: "test",
GITHUB_RUN_ID: "1",
INPUT_TOKEN: "s3cr3t",
"INPUT_STEP-TO-WAIT-FOR": "step-name",
}),
);
});

it("parses config from env with defaults", () => {
assert.deepEqual(
parseInput({
Expand All @@ -87,6 +106,8 @@ describe("input", () => {
"INPUT_POLL-INTERVAL-SECONDS": "",
"INPUT_SAME-BRANCH-ONLY": "",
"INPUT_INITIAL-WAIT-SECONDS": "",
"INPUT_JOB-TO-WAIT-FOR": "",
"INPUT_STEP-TO-WAIT-FOR": "",
}),
{
githubToken: "s3cr3t",
Expand All @@ -99,6 +120,8 @@ describe("input", () => {
abortAfterSeconds: undefined,
pollIntervalSeconds: 60,
sameBranchOnly: true,
jobToWaitFor: "",
stepToWaitFor: "",
initialWaitSeconds: 0,
},
);
Expand All @@ -125,6 +148,8 @@ describe("input", () => {
abortAfterSeconds: undefined,
pollIntervalSeconds: 60,
sameBranchOnly: true,
jobToWaitFor: undefined,
stepToWaitFor: undefined,
initialWaitSeconds: 0,
},
);
Expand Down
163 changes: 163 additions & 0 deletions __tests__/wait.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ describe("wait", () => {
runId: 2,
workflowName: workflow.name,
sameBranchOnly: true,
jobToWaitFor: undefined,
stepToWaitFor: undefined,
initialWaitSeconds: 0,
};
});
Expand Down Expand Up @@ -311,6 +313,167 @@ describe("wait", () => {
"✋Awaiting run 1 ...",
]);
});

it("will wait for a specific job to complete if wait-for-job is defined", async () => {
input.jobToWaitFor = "test-job";
input.pollIntervalSeconds = 1;
const run = {
id: 1,
status: "in_progress",
html_url: "1",
};
const job = {
id: 1,
name: "test-job",
status: "in_progress",
html_url: "job-url",
};

const githubClient = {
runs: async (
owner: string,
repo: string,
branch: string | undefined,
workflowId: number,
) => Promise.resolve([run]),
jobs: jest
.fn()
.mockResolvedValueOnce([job])
.mockResolvedValue([{ ...job, status: "completed" }]),
workflows: async (owner: string, repo: string) =>
Promise.resolve([workflow]),
};

const messages: Array<string> = [];
const waiter = new Waiter(
workflow.id,
// @ts-ignore
githubClient,
input,
(message: string) => {
messages.push(message);
},
() => {},
);
await waiter.wait();

assert.deepEqual(messages, [
"✋Awaiting job run completion from job job-url ...",
"Job test-job completed from run 1",
]);
});

it("will wait for a specific step to complete if wait-for-step is defined", async () => {
input.jobToWaitFor = "test-job";
input.stepToWaitFor = "test-step";
input.pollIntervalSeconds = 1;
const run = {
id: 1,
status: "in_progress",
html_url: "1",
};
const job = {
id: 1,
name: "test-job",
status: "in_progress",
html_url: "job-url",
};
const step = {
id: 1,
name: "test-step",
status: "in_progress",
html_url: "step-url",
};

const githubClient = {
runs: async (
owner: string,
repo: string,
branch: string | undefined,
workflowId: number,
) => Promise.resolve([run]),
jobs: jest.fn().mockResolvedValue([job]),
steps: jest
.fn()
.mockResolvedValueOnce([step])
.mockResolvedValue([{ ...step, status: "completed" }]),
workflows: async (owner: string, repo: string) =>
Promise.resolve([workflow]),
};

const messages: Array<string> = [];
const waiter = new Waiter(
workflow.id,
// @ts-ignore
githubClient,
input,
(message: string) => {
messages.push(message);
},
() => {},
);
await waiter.wait();

assert.deepEqual(messages, [
"✋Awaiting step completion from job job-url ...",
"Step test-step completed from run 1",
]);
});

it("will await the full run if the job is not found", async () => {
input.runId = 2;
input.jobToWaitFor = "test-job";
input.pollIntervalSeconds = 1;
const run = {
id: 1,
status: "in_progress",
html_url: "run1-url",
};
const run2 = {
id: 2,
status: "in_progress",
html_url: "run2-url",
};
const notOurTestJob = {
id: 1,
name: "another-job",
status: "in_progress",
html_url: "job-url",
};

const githubClient = {
// On the first call have both runs in progress, on the second call have the first run completed
runs: jest
.fn()
.mockResolvedValueOnce([run, run2])
.mockResolvedValue([
{ ...run, conclusion: "success", status: "success" },
run2,
]),
// This workflow's jobs is not the one we are looking for (should be fine, we fall back to waiting the full run)
jobs: jest.fn().mockResolvedValue([notOurTestJob]),
workflows: async (owner: string, repo: string) =>
Promise.resolve([workflow]),
};

const infoMessages: Array<string> = [];
const waiter = new Waiter(
workflow.id,
// @ts-ignore
githubClient,
input,
(message: string) => {
infoMessages.push(message);
},
() => {},
);

await waiter.wait();
assert.deepEqual(infoMessages, [
`Job ${input.jobToWaitFor} not found in run ${run.id}, awaiting full run for safety`,
`✋Awaiting run ${run.html_url} ...`,
]);
});
});
});
});
4 changes: 4 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ inputs:
description: "Maximum number of seconds to wait before failing the step (unbound by default). Mutually exclusive with continue-after-seconds"
same-branch-only:
description: "Only wait on other runs from the same branch (defaults to true)"
job-to-wait-for:
description: "Name of the workflow run's job to wait for (unbound by default)"
step-to-wait-for:
description: "Name of the job's step to wait for (unbound by default). Requires job-to-wait-for to be set"
initial-wait-seconds:
description: "Total elapsed seconds within which period the action will refresh the list of current runs, if no runs were found in the first poll (0 by default, ie doesn't retry)"
outputs:
Expand Down
6 changes: 3 additions & 3 deletions dist/index.js

Large diffs are not rendered by default.

29 changes: 29 additions & 0 deletions src/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,4 +86,33 @@ export class OctokitGitHub {
(values) => values.flat(),
);
};

jobs = async (owner: string, repo: string, run_id: number) => {
const options: Endpoints["GET /repos/{owner}/{repo}/actions/runs/{run_id}/jobs"]["parameters"] =
{
owner,
repo,
run_id,
per_page: 100,
};

return this.octokit.paginate(
this.octokit.actions.listJobsForWorkflowRun,
options,
);
};

steps = async (owner: string, repo: string, job_id: number) => {
const options: Endpoints["GET /repos/{owner}/{repo}/actions/jobs/{job_id}"]["parameters"] =
{
owner,
repo,
job_id,
};
const job = await this.octokit.paginate(
this.octokit.actions.getJobForWorkflowRun,
options,
);
return job?.steps || [];
};
}
10 changes: 10 additions & 0 deletions src/input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ export interface Input {
continueAfterSeconds: number | undefined;
abortAfterSeconds: number | undefined;
sameBranchOnly: boolean;
jobToWaitFor: string | undefined;
stepToWaitFor: string | undefined;
initialWaitSeconds: number;
}

Expand Down Expand Up @@ -39,6 +41,12 @@ export const parseInput = (env: Record<string, string | undefined>): Input => {

const sameBranchOnly =
env["INPUT_SAME-BRANCH-ONLY"] === "true" || !env["INPUT_SAME-BRANCH-ONLY"]; // true if not specified

const jobToWaitFor = env["INPUT_JOB-TO-WAIT-FOR"];
const stepToWaitFor = env["INPUT_STEP-TO-WAIT-FOR"];
if (stepToWaitFor && !jobToWaitFor) {
throw new Error("step-to-wait-for requires job-to-wait-for to be defined");
}
return {
githubToken,
owner,
Expand All @@ -50,6 +58,8 @@ export const parseInput = (env: Record<string, string | undefined>): Input => {
continueAfterSeconds,
abortAfterSeconds,
sameBranchOnly,
jobToWaitFor,
stepToWaitFor,
initialWaitSeconds,
};
};
Loading