Skip to content

Commit

Permalink
feat: adding support for waiting on specific job/step completion in t…
Browse files Browse the repository at this point in the history
…urnstyle (#98)

This PR adds in support for two new input fields:
* job-to-wait-for
* step-to-wait-for

The idea of these new inputs allows the turnstyle waiter to examin a
previous run's jobs->steps and allows for more granular control of what
phase of the workflow to wait on.

The use case of this is my company currently needs to split our
deployment workflow (gitops commit + argocd sync) across two workflows
as we want to make sure the gitops commit runs sequentally. Using a
turnstyle at the workflow level means that a subsequent sync from argo
would delay the next workflow execution until the subsequent job/steps
complete.

This PR updates the waiter to, if a previous run is found, and the above
inputs are set, make subsequent requests to the ghAPI and check to see
if the desired job/step is has finished executing
  • Loading branch information
selecsosi authored Dec 18, 2024
1 parent e347851 commit 30a2cbb
Show file tree
Hide file tree
Showing 8 changed files with 315 additions and 4 deletions.
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

0 comments on commit 30a2cbb

Please sign in to comment.