Skip to content
This repository was archived by the owner on Nov 14, 2025. It is now read-only.
Open
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
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,34 @@ This output can then used on the `runs-on` property of subsequent jobs.

Note: In order to support an array of labels for the `runs-on` field, the output is formatted as a JSON string and needs to be parsed using `fromJson`. See example usage below.



## Usage

### ✏️ Inputs

#### Required

| Name | Description |
| ----------------- | ---------------------------------------------------------------------------------------------------------- |
| `github-token` | A token that can access the `list action runners` for the given context (e.g. user repo, org, enterprise). |
| `primary-runner` | A comma separated list of labels for the _primary_ runner (e.g. 'self-hosted,linux'). |
| `fallback-runner` | A comma separated list of labels for the _fallback_ runner (e.g. 'self-hosted,linux'). |

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

better to suggest a different fallback vs primary? even though this is just nitpicking on an example blurb
then as a concrete suggestion, I suspect the typical use case is to fallback to a public runner if self-hosted is offline

Suggested change
| `fallback-runner` | A comma separated list of labels for the _fallback_ runner (e.g. 'self-hosted,linux'). |
| `fallback-runner` | A comma separated list of labels for the _fallback_ runner (e.g. 'ubuntu-latest'). |



#### Optional
---

There are three ways runners can be allowed to run against a repo: User, Organization, Enterprise. The following options allow you to switch the implementation to use one of the other specified levels. **_Note:_** You can only provide one of the values.

| Name | Description |
| ---------------- | ------------------------------------------------------------------ |
| `organization` | The name of the github organization (e.g. `My-Github-Org`) |
| `enterprise` | The name of the github enterprise (e.g. `My-Github-Ent`) |



### Example
```yaml
jobs:
# Job to
Expand Down
6 changes: 6 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@ inputs:
fallback-runner:
description: 'Name or labels of the fallback runner, e.g. ubuntu-latest'
required: true
organization:
description: "GitHub organization name to check for self-hosted runners (mutually exclusive with enterprise), if not supplied we will use user's personal runners"
required: false
enterprise:
description: "GitHub enterprise name to check for self-hosted runners (mutually exclusive with organization), if not supplied we will use user's personal runners"
required: false
outputs:
use-runner:
description: 'The runner to use, either the primary or the fallback runner, based on availability'
Expand Down
46 changes: 34 additions & 12 deletions dist/index.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion dist/index.js.map

Large diffs are not rendered by default.

46 changes: 34 additions & 12 deletions index.js
Original file line number Diff line number Diff line change
@@ -1,44 +1,66 @@
const core = require('@actions/core');
const httpClient = require('@actions/http-client');

async function checkRunner({ token, owner, repo, primaryRunnerLabels, fallbackRunner }) {
const http = new httpClient.HttpClient('http-client');
async function checkRunner({
token,
primaryRunnerLabels,
fallbackRunner,
apiPath,
}) {
const http = new httpClient.HttpClient("http-client");
const headers = {
'Authorization': `Bearer ${token}`,
Authorization: `Bearer ${token}`,
};
const response = await http.getJson(`https://api.github.com/repos/${owner}/${repo}/actions/runners`, headers);

const response = await http.getJson(
`https://api.github.com/${apiPath}`,
headers
);

if (response.statusCode !== 200) {
return { error: `Failed to get runners. Status code: ${response.statusCode}` };
return {
error: `Failed to get runners. Status code: ${response.statusCode}`,
};
}

const runners = response.result.runners || [];
let useRunner = fallbackRunner;
let primaryIsOnline = false;

for (const runner of runners) {
if (runner.status === 'online') {
const runnerLabels = runner.labels.map(label => label.name);
if (primaryRunnerLabels.every(label => runnerLabels.includes(label))) {
if (runner.status === "online") {
const runnerLabels = runner.labels.map((label) => label.name);
if (primaryRunnerLabels.every((label) => runnerLabels.includes(label))) {
primaryIsOnline = true;
useRunner = primaryRunnerLabels.join(',');
useRunner = primaryRunnerLabels.join(",");
break;
}
}
}

// return a JSON string so that it can be parsed using `fromJson`, e.g. fromJson('["self-hosted", "linux"]')
return { useRunner: JSON.stringify(useRunner.split(',')), primaryIsOnline };
return { useRunner: JSON.stringify(useRunner.split(",")), primaryIsOnline };
}

async function main() {
const githubRepository = process.env.GITHUB_REPOSITORY;
const organization = core.getInput('organization', { required: false });
const enterprise = core.getInput('enterprise', { required: false });
const [owner, repo] = githubRepository.split("/");
if (organization && enterprise) {
throw new Error('You cannot specify both organization and enterprise inputs. Please choose one.');
}
let apiPath = `repos/${owner}/${repo}/actions/runners`;
if (organization) {
apiPath = `orgs/${organization}/actions/runners`;
} else if (enterprise) {
apiPath = `enterprises/${enterprise}/actions/runners`;
}


try {
const inputs = {
owner,
repo,
apiPath,
token: core.getInput('github-token', { required: true }),
primaryRunnerLabels: core.getInput('primary-runner', { required: true }).split(','),
fallbackRunner: core.getInput('fallback-runner', { required: true }),
Expand Down
53 changes: 49 additions & 4 deletions index.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ jest.mock('@actions/http-client', () => {
});

describe('checkRunner', () => {
beforeEach(() => {
mockGetJson.mockClear();
});

it('should use the primary runner if it is online', async () => {
mockGetJson.mockResolvedValue({
statusCode: 200,
Expand All @@ -31,8 +35,7 @@ describe('checkRunner', () => {

const result = await checkRunner({
token: 'fake-token',
owner: 'fake-owner',
repo: 'fake-repo',
apiPath: 'repos/fake-owner/fake-repo/actions/runners',
primaryRunnerLabels: ['self-hosted', 'linux'],
fallbackRunner: 'ubuntu-latest',
});
Expand Down Expand Up @@ -61,8 +64,7 @@ describe('checkRunner', () => {

const result = await checkRunner({
token: 'fake-token',
owner: 'fake-owner',
repo: 'fake-repo',
apiPath: 'repos/fake-owner/fake-repo/actions/runners',
primaryRunnerLabels: ['self-hosted', 'linux'],
fallbackRunner: 'ubuntu-latest',
});
Expand All @@ -72,4 +74,47 @@ describe('checkRunner', () => {
primaryIsOnline: false,
});
});

describe('alternative api handling', () => {
it('should query organization runners if organization is provided', async () => {
mockGetJson.mockResolvedValue({
statusCode: 200,
result: {
runners: [],
},
});

await checkRunner({
token: "fake-token",
apiPath: 'orgs/call-me-ishmael/actions/runners',

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sailing forbidden seas, and landing on barbarous coasts, I see

a gentleman and a scholar :-)

primaryRunnerLabels: ["self-hosted", "linux"],
fallbackRunner: "ubuntu-latest",
});

expect(mockGetJson).toHaveBeenCalledWith(
"https://api.github.com/orgs/call-me-ishmael/actions/runners",
expect.anything()
);
});
it('should query enterprise runners if enterprise is provided', async () => {
mockGetJson.mockResolvedValue({
statusCode: 200,
result: {
runners: [],
},
});

await checkRunner({
token: 'fake-token',
apiPath: 'enterprises/i-am-the-enterprise-now/actions/runners',
primaryRunnerLabels: ['self-hosted', 'linux'],
fallbackRunner: 'ubuntu-latest',
});

expect(mockGetJson).toHaveBeenCalledWith(
"https://api.github.com/enterprises/i-am-the-enterprise-now/actions/runners",
expect.anything()
);
});
});
});
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.