Skip to content

Commit c01c5a1

Browse files
committed
feat: add script-file input for lint-friendly external JS scripts
Closes #714 ## Problem Inline `script` is a YAML string — invisible to linters and IDEs. The common workaround was wrapping a `require` call inside the inline script, which still needs boilerplate and assumes a path convention. ## Solution New optional `script-file` input that accepts a path to a JS file. The file must `module.exports` an async function receiving the standard IoC dependency bag (`github`, `octokit`, `getOctokit`, `context`, `core`, `exec`, `glob`, `io`, `require`). ```yaml - uses: actions/checkout@v4 - uses: actions/github-script@v9 with: script-file: .github/scripts/my-script.js ``` `script` and `script-file` are mutually exclusive — exactly one must be provided. Relative paths resolve against `$GITHUB_WORKSPACE`; absolute paths are used as-is. ## Changes - `action.yml` — adds `script-file` input; makes `script` optional - `src/script-file.ts` — path resolution and script loading logic - `src/args.ts` — `AsyncFunctionArguments` extracted from `async-function.ts` so neither execution path depends on the other - `src/main.ts` — mutual-exclusion validation; dispatches to the right execution path - `types/non-webpack-require.ts` — corrects `__non_webpack_require__` type from deprecated `NodeRequire` / wrong `NodeJS.RequireResolve` to `NodeJS.Require` - `__test__/script-file.test.ts` — 10 tests covering path resolution, arg forwarding, error cases - `README.md` — new `## Script file` section with usage, IoC bag table, path resolution rules
1 parent 3a2844b commit c01c5a1

9 files changed

Lines changed: 297 additions & 42 deletions

File tree

README.md

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,9 @@ You are welcome to still raise bugs in this repo.
2727

2828
### This action
2929

30-
To use this action, provide an input named `script` that contains the body of an asynchronous JavaScript function call.
31-
The following arguments will be provided:
30+
To use this action, provide either a `script` input (the body of an async function, inline in your workflow YAML) or a `script-file` input (a path to a JS file that `module.exports` an async function). Exactly one of the two must be provided.
31+
32+
The following arguments are available to both forms:
3233

3334
- `github` A pre-authenticated
3435
[octokit/rest.js](https://octokit.github.io/rest.js) client with pagination plugins
@@ -201,6 +202,42 @@ By default, the following status codes will not be retried: `400, 401, 403, 404,
201202

202203
These retries are implemented using the [octokit/plugin-retry.js](https://github.com/octokit/plugin-retry.js) plugin. The retries use [exponential backoff](https://en.wikipedia.org/wiki/Exponential_backoff) to space out retries. ([source](https://github.com/octokit/plugin-retry.js/blob/9a2443746c350b3beedec35cf26e197ea318a261/src/error-request.ts#L13))
203204

205+
## Script file
206+
207+
Instead of providing the `script` inline, you can use `script-file` to point to a JS file in your repository. The file must `module.exports` an async function — making it a proper module that linters and IDEs can fully analyse, with no boilerplate `require` scaffold needed.
208+
209+
```yaml
210+
- uses: actions/checkout@v4
211+
- uses: actions/github-script@v9
212+
with:
213+
script-file: .github/scripts/my-script.js
214+
```
215+
216+
```js
217+
// .github/scripts/my-script.js
218+
module.exports = async ({github, context, core}) => {
219+
// destructure only what you need
220+
}
221+
```
222+
223+
The function receives a single [IoC](https://en.wikipedia.org/wiki/Inversion_of_control) dependency bag (defined in [`src/args.ts`](src/args.ts)). Its members are the same as those available to the inline `script`:
224+
225+
| Name | Description |
226+
|---|---|
227+
| `github` | Pre-authenticated [octokit/rest.js](https://octokit.github.io/rest.js) client |
228+
| `octokit` | Alias for `github` |
229+
| `getOctokit` | Factory for additional authenticated Octokit clients (see [Creating additional clients](#creating-additional-clients-with-getoctokit)) |
230+
| `context` | [Workflow run context](https://github.com/actions/toolkit/blob/main/packages/github/src/context.ts) |
231+
| `core` | [@actions/core](https://github.com/actions/toolkit/tree/main/packages/core) |
232+
| `exec` | [@actions/exec](https://github.com/actions/toolkit/tree/main/packages/exec) |
233+
| `glob` | [@actions/glob](https://github.com/actions/toolkit/tree/main/packages/glob) |
234+
| `io` | [@actions/io](https://github.com/actions/toolkit/tree/main/packages/io) |
235+
| `require` | Wrapped `require` that resolves relative paths and local `node_modules` |
236+
237+
**Path resolution:** relative paths are resolved against `$GITHUB_WORKSPACE`; absolute paths are used as-is. The `file://` protocol is not supported.
238+
239+
`script` and `script-file` are mutually exclusive — exactly one must be provided.
240+
204241
## Examples
205242

206243
Note that `github-token` is optional in this action, and the input is there

__test__/script-file.test.ts

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
import * as path from 'node:path'
2+
import * as os from 'node:os'
3+
import * as fs from 'node:fs'
4+
import {callScriptFile, resolveScriptFilePath} from '../src/script-file'
5+
6+
describe('resolveScriptFilePath', () => {
7+
test('rejects file:// protocol', () => {
8+
expect(() => resolveScriptFilePath('file:///some/path.js')).toThrow(
9+
'"script-file" must not use the "file://" protocol'
10+
)
11+
})
12+
13+
test('returns absolute path as-is', () => {
14+
const abs = '/absolute/path/to/script.js'
15+
expect(resolveScriptFilePath(abs)).toEqual(abs)
16+
})
17+
18+
test('resolves relative path against GITHUB_WORKSPACE when set', () => {
19+
const original = process.env['GITHUB_WORKSPACE']
20+
process.env['GITHUB_WORKSPACE'] = '/workspace'
21+
try {
22+
expect(resolveScriptFilePath('scripts/run.js')).toEqual(
23+
'/workspace/scripts/run.js'
24+
)
25+
} finally {
26+
if (original === undefined) {
27+
delete process.env['GITHUB_WORKSPACE']
28+
} else {
29+
process.env['GITHUB_WORKSPACE'] = original
30+
}
31+
}
32+
})
33+
34+
test('resolves relative path against cwd when GITHUB_WORKSPACE is unset', () => {
35+
const original = process.env['GITHUB_WORKSPACE']
36+
delete process.env['GITHUB_WORKSPACE']
37+
try {
38+
expect(resolveScriptFilePath('scripts/run.js')).toEqual(
39+
path.resolve(process.cwd(), 'scripts/run.js')
40+
)
41+
} finally {
42+
if (original !== undefined) {
43+
process.env['GITHUB_WORKSPACE'] = original
44+
}
45+
}
46+
})
47+
})
48+
49+
describe('callScriptFile', () => {
50+
let tmpDir: string
51+
52+
beforeEach(() => {
53+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'github-script-test-'))
54+
})
55+
56+
afterEach(() => {
57+
fs.rmSync(tmpDir, {recursive: true, force: true})
58+
})
59+
60+
test('calls the exported function with args', async () => {
61+
const scriptPath = path.join(tmpDir, 'script.js')
62+
fs.writeFileSync(
63+
scriptPath,
64+
'module.exports = async ({core}) => core.value'
65+
)
66+
67+
// eslint-disable-next-line @typescript-eslint/no-require-imports
68+
const result = await callScriptFile(scriptPath, require, {
69+
core: {value: 42}
70+
} as never)
71+
72+
expect(result).toEqual(42)
73+
})
74+
75+
test('forwards all injected args to the exported function', async () => {
76+
const scriptPath = path.join(tmpDir, 'all-args.js')
77+
fs.writeFileSync(
78+
scriptPath,
79+
`module.exports = async (args) => Object.keys(args).sort()`
80+
)
81+
82+
const args = {
83+
github: {},
84+
octokit: {},
85+
getOctokit: () => null,
86+
context: {},
87+
core: {},
88+
exec: {},
89+
glob: {},
90+
io: {},
91+
require: require,
92+
__original_require__: require
93+
}
94+
95+
// eslint-disable-next-line @typescript-eslint/no-require-imports
96+
const result = await callScriptFile(scriptPath, require, args as never)
97+
98+
expect(result).toEqual(Object.keys(args).sort())
99+
})
100+
101+
test('throws when file does not export a function', async () => {
102+
const scriptPath = path.join(tmpDir, 'not-a-fn.js')
103+
fs.writeFileSync(scriptPath, 'module.exports = 42')
104+
105+
await expect(
106+
// eslint-disable-next-line @typescript-eslint/no-require-imports
107+
callScriptFile(scriptPath, require, {} as never)
108+
).rejects.toThrow('"script-file" must export a function, got number')
109+
})
110+
111+
test('throws when file does not exist', async () => {
112+
const scriptPath = path.join(tmpDir, 'nonexistent.js')
113+
114+
await expect(
115+
// eslint-disable-next-line @typescript-eslint/no-require-imports
116+
callScriptFile(scriptPath, require, {} as never)
117+
).rejects.toThrow()
118+
})
119+
120+
test('propagates rejection from the exported function', async () => {
121+
const scriptPath = path.join(tmpDir, 'throws.js')
122+
fs.writeFileSync(
123+
scriptPath,
124+
"module.exports = async () => { throw new Error('boom') }"
125+
)
126+
127+
await expect(
128+
// eslint-disable-next-line @typescript-eslint/no-require-imports
129+
callScriptFile(scriptPath, require, {} as never)
130+
).rejects.toThrow('boom')
131+
})
132+
133+
test('rejects file:// path before loading', async () => {
134+
await expect(
135+
// eslint-disable-next-line @typescript-eslint/no-require-imports
136+
callScriptFile('file:///some/path.js', require, {} as never)
137+
).rejects.toThrow('"script-file" must not use the "file://" protocol')
138+
})
139+
})

action.yml

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,11 @@ branding:
66
icon: code
77
inputs:
88
script:
9-
description: The script to run
10-
required: true
9+
description: The body of an async function to run (mutually exclusive with script-file)
10+
required: false
11+
script-file:
12+
description: Path to a JS file that module.exports an async function receiving {github, octokit, getOctokit, context, core, exec, glob, io, require} (mutually exclusive with script)
13+
required: false
1114
github-token:
1215
description: The GitHub token used to create an authenticated client
1316
default: ${{ github.token }}

dist/index.js

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -65029,6 +65029,25 @@ function parseNumberArray(listString) {
6502965029

6503065030
// EXTERNAL MODULE: external "path"
6503165031
var external_path_ = __nccwpck_require__(1017);
65032+
;// CONCATENATED MODULE: ./src/script-file.ts
65033+
65034+
function resolveScriptFilePath(scriptFile) {
65035+
if (scriptFile.startsWith('file://')) {
65036+
throw new Error('"script-file" must not use the "file://" protocol');
65037+
}
65038+
return external_path_.isAbsolute(scriptFile)
65039+
? scriptFile
65040+
: external_path_.resolve(process.env['GITHUB_WORKSPACE'] || process.cwd(), scriptFile);
65041+
}
65042+
async function callScriptFile(scriptFile, requireFn, args) {
65043+
const resolvedPath = resolveScriptFilePath(scriptFile);
65044+
const scriptFn = requireFn(resolvedPath);
65045+
if (typeof scriptFn !== 'function') {
65046+
throw new Error(`"script-file" must export a function, got ${typeof scriptFn}`);
65047+
}
65048+
return scriptFn(args);
65049+
}
65050+
6503265051
;// CONCATENATED MODULE: ./src/wrap-require.ts
6503365052

6503465053
const wrapRequire = new Proxy(require, {
@@ -65065,6 +65084,7 @@ const wrapRequire = new Proxy(require, {
6506565084

6506665085

6506765086

65087+
6506865088
process.on('unhandledRejection', handleError);
6506965089
main().catch(handleError);
6507065090
async function main() {
@@ -65091,13 +65111,20 @@ async function main() {
6509165111
opts.baseUrl = baseUrl;
6509265112
}
6509365113
const github = getOctokit(token, opts, retry, requestLog);
65094-
const script = core.getInput('script', { required: true });
65114+
const scriptInline = core.getInput('script');
65115+
const scriptFile = core.getInput('script-file');
65116+
if (scriptInline && scriptFile) {
65117+
throw new Error('Only one of "script" or "script-file" may be provided, not both');
65118+
}
65119+
if (!scriptInline && !scriptFile) {
65120+
throw new Error('One of "script" or "script-file" must be provided');
65121+
}
6509565122
// Wrap getOctokit so secondary clients inherit retry, logging,
6509665123
// orchestration ID, and the action's retries input.
6509765124
// Deep-copy opts to prevent shared references with the primary client.
6509865125
const configuredGetOctokit = createConfiguredGetOctokit(getOctokit, { ...opts, retry: { ...opts.retry }, request: { ...opts.request } }, retry, requestLog);
6509965126
// Using property/value shorthand on `require` (e.g. `{require}`) causes compilation errors.
65100-
const result = await callAsyncFunction({
65127+
const args = {
6510165128
require: wrapRequire,
6510265129
__original_require__: require,
6510365130
github,
@@ -65108,7 +65135,10 @@ async function main() {
6510865135
exec: exec,
6510965136
glob: glob,
6511065137
io: io
65111-
}, script);
65138+
};
65139+
const result = scriptFile
65140+
? await callScriptFile(scriptFile, require, args)
65141+
: await callAsyncFunction(args, scriptInline);
6511265142
let encoding = core.getInput('result-encoding');
6511365143
encoding = encoding ? encoding : 'json';
6511465144
let output;

src/args.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import * as core from '@actions/core'
2+
import * as exec from '@actions/exec'
3+
import type {context, getOctokit} from '@actions/github'
4+
import * as glob from '@actions/glob'
5+
import * as io from '@actions/io'
6+
7+
export type AsyncFunctionArguments = {
8+
context: typeof context
9+
core: typeof core
10+
github: ReturnType<typeof getOctokit>
11+
octokit: ReturnType<typeof getOctokit>
12+
getOctokit: typeof getOctokit
13+
exec: typeof exec
14+
glob: typeof glob
15+
io: typeof io
16+
require: NodeJS.Require
17+
__original_require__: NodeJS.Require
18+
}

src/async-function.ts

Lines changed: 2 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,8 @@
1-
import * as core from '@actions/core'
2-
import * as exec from '@actions/exec'
3-
import type {context, getOctokit} from '@actions/github'
4-
import * as glob from '@actions/glob'
5-
import * as io from '@actions/io'
1+
import type {AsyncFunctionArguments} from './args'
2+
export type {AsyncFunctionArguments}
63

74
const AsyncFunction = Object.getPrototypeOf(async () => null).constructor
85

9-
export declare type AsyncFunctionArguments = {
10-
context: typeof context
11-
core: typeof core
12-
github: ReturnType<typeof getOctokit>
13-
octokit: ReturnType<typeof getOctokit>
14-
getOctokit: typeof getOctokit
15-
exec: typeof exec
16-
glob: typeof glob
17-
io: typeof io
18-
require: NodeRequire
19-
__original_require__: NodeRequire
20-
}
21-
226
export function callAsyncFunction<T>(
237
args: AsyncFunctionArguments,
248
source: string

src/main.ts

Lines changed: 31 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {RequestRequestOptions} from '@octokit/types'
1010
import {callAsyncFunction} from './async-function'
1111
import {createConfiguredGetOctokit} from './create-configured-getoctokit'
1212
import {RetryOptions, getRetryOptions, parseNumberArray} from './retry-options'
13+
import {callScriptFile, resolveScriptFilePath} from './script-file'
1314
import {wrapRequire} from './wrap-require'
1415

1516
process.on('unhandledRejection', handleError)
@@ -58,7 +59,17 @@ async function main(): Promise<void> {
5859
}
5960

6061
const github = getOctokit(token, opts, retry, requestLog)
61-
const script = core.getInput('script', {required: true})
62+
const scriptInline = core.getInput('script')
63+
const scriptFile = core.getInput('script-file')
64+
65+
if (scriptInline && scriptFile) {
66+
throw new Error(
67+
'Only one of "script" or "script-file" may be provided, not both'
68+
)
69+
}
70+
if (!scriptInline && !scriptFile) {
71+
throw new Error('One of "script" or "script-file" must be provided')
72+
}
6273

6374
// Wrap getOctokit so secondary clients inherit retry, logging,
6475
// orchestration ID, and the action's retries input.
@@ -71,21 +82,25 @@ async function main(): Promise<void> {
7182
)
7283

7384
// Using property/value shorthand on `require` (e.g. `{require}`) causes compilation errors.
74-
const result = await callAsyncFunction(
75-
{
76-
require: wrapRequire,
77-
__original_require__: __non_webpack_require__,
78-
github,
79-
octokit: github,
80-
getOctokit: configuredGetOctokit,
81-
context,
82-
core,
83-
exec,
84-
glob,
85-
io
86-
},
87-
script
88-
)
85+
const args = {
86+
require: wrapRequire,
87+
__original_require__: __non_webpack_require__,
88+
github,
89+
octokit: github,
90+
getOctokit: configuredGetOctokit,
91+
context,
92+
core,
93+
exec,
94+
glob,
95+
io
96+
}
97+
98+
const result = scriptFile
99+
? await callScriptFile(
100+
__non_webpack_require__(resolveScriptFilePath(scriptFile)),
101+
args
102+
)
103+
: await callAsyncFunction(args, scriptInline)
89104

90105
let encoding = core.getInput('result-encoding')
91106
encoding = encoding ? encoding : 'json'

0 commit comments

Comments
 (0)