Skip to content

Commit 67c280d

Browse files
committed
feat: add script-file input for lint-friendly external JS scripts
Closes #714 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. 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. - `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 - `.github/fixtures/script-file/` — fixture JS files for integration tests - `.github/workflows/integration.yml` — 10 new integration test jobs: happy path (relative path, absolute path, all IoC args, json/string encoding, require-in-file) and error cases (both inputs set, neither set, nonexistent file, non-function export, file:// protocol)
1 parent 3a2844b commit 67c280d

18 files changed

Lines changed: 519 additions & 97 deletions
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
module.exports = async ({github, octokit, getOctokit, context, core, exec, glob, io, require}) => {
2+
return [github, octokit, getOctokit, context, core, exec, glob, io, require]
3+
.map(arg => typeof arg)
4+
.every(t => t === 'function' || t === 'object')
5+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
module.exports = async () => 'hello from script-file'
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
module.exports = async ({context}) => ({repo: context.repo.repo, owner: context.repo.owner})
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
module.exports = 42
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
module.exports = async ({require}) => require('./sibling-module').value
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
module.exports = {value: 'loaded-by-require'}

.github/workflows/integration.yml

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -361,3 +361,174 @@ jobs:
361361
echo $'::error::\u274C' "Expected base-url to equal '$expected', got $actual"
362362
exit 1
363363
fi
364+
365+
test-script-file-basic:
366+
name: 'Integration test: script-file - relative path, string return'
367+
runs-on: ubuntu-latest
368+
steps:
369+
- uses: actions/checkout@v4
370+
- id: act
371+
uses: ./
372+
with:
373+
script-file: .github/fixtures/script-file/basic.js
374+
result-encoding: string
375+
- run: |
376+
expected="hello from script-file"
377+
if [[ "${{ steps.act.outputs.result }}" != "$expected" ]]; then
378+
echo $'::error::❌' "Expected '$expected', got ${{ steps.act.outputs.result }}"
379+
exit 1
380+
fi
381+
echo $'✅ Test passed' | tee -a $GITHUB_STEP_SUMMARY
382+
383+
test-script-file-absolute-path:
384+
name: 'Integration test: script-file - absolute path'
385+
runs-on: ubuntu-latest
386+
steps:
387+
- uses: actions/checkout@v4
388+
- id: act
389+
uses: ./
390+
with:
391+
script-file: ${{ github.workspace }}/.github/fixtures/script-file/basic.js
392+
result-encoding: string
393+
- run: |
394+
expected="hello from script-file"
395+
if [[ "${{ steps.act.outputs.result }}" != "$expected" ]]; then
396+
echo $'::error::❌' "Expected '$expected', got ${{ steps.act.outputs.result }}"
397+
exit 1
398+
fi
399+
echo $'✅ Test passed' | tee -a $GITHUB_STEP_SUMMARY
400+
401+
test-script-file-all-ioc-args:
402+
name: 'Integration test: script-file - all IoC args available'
403+
runs-on: ubuntu-latest
404+
steps:
405+
- uses: actions/checkout@v4
406+
- id: act
407+
uses: ./
408+
with:
409+
script-file: .github/fixtures/script-file/all-args.js
410+
- run: |
411+
if [[ "${{ steps.act.outputs.result }}" != "true" ]]; then
412+
echo $'::error::❌' "Expected all IoC args to be present, got ${{ steps.act.outputs.result }}"
413+
exit 1
414+
fi
415+
echo $'✅ Test passed' | tee -a $GITHUB_STEP_SUMMARY
416+
417+
test-script-file-result-encoding-json:
418+
name: 'Integration test: script-file - result-encoding json'
419+
runs-on: ubuntu-latest
420+
steps:
421+
- uses: actions/checkout@v4
422+
- id: act
423+
uses: ./
424+
with:
425+
script-file: .github/fixtures/script-file/json-return.js
426+
- run: |
427+
expected='{"repo":"${{ github.event.repository.name }}","owner":"${{ github.repository_owner }}"}'
428+
if [[ "${{ steps.act.outputs.result }}" != "$expected" ]]; then
429+
echo $'::error::❌' "Expected '$expected', got ${{ steps.act.outputs.result }}"
430+
exit 1
431+
fi
432+
echo $'✅ Test passed' | tee -a $GITHUB_STEP_SUMMARY
433+
434+
test-script-file-require-in-file:
435+
name: 'Integration test: script-file - require inside script file'
436+
runs-on: ubuntu-latest
437+
steps:
438+
- uses: actions/checkout@v4
439+
- id: act
440+
uses: ./
441+
with:
442+
script-file: .github/fixtures/script-file/sibling-caller.js
443+
result-encoding: string
444+
- run: |
445+
expected="loaded-by-require"
446+
if [[ "${{ steps.act.outputs.result }}" != "$expected" ]]; then
447+
echo $'::error::❌' "Expected '$expected', got ${{ steps.act.outputs.result }}"
448+
exit 1
449+
fi
450+
echo $'✅ Test passed' | tee -a $GITHUB_STEP_SUMMARY
451+
452+
test-script-file-conflict-both:
453+
name: 'Integration test: script-file - fails when both script and script-file are set'
454+
runs-on: ubuntu-latest
455+
steps:
456+
- uses: actions/checkout@v4
457+
- id: act
458+
continue-on-error: true
459+
uses: ./
460+
with:
461+
script: return 1
462+
script-file: .github/fixtures/script-file/basic.js
463+
- run: |
464+
if [[ "${{ steps.act.outcome }}" != "failure" ]]; then
465+
echo $'::error::❌' "Expected step to fail when both inputs are set"
466+
exit 1
467+
fi
468+
echo $'✅ Test passed' | tee -a $GITHUB_STEP_SUMMARY
469+
470+
test-script-file-conflict-neither:
471+
name: 'Integration test: script-file - fails when neither script nor script-file is set'
472+
runs-on: ubuntu-latest
473+
steps:
474+
- uses: actions/checkout@v4
475+
- id: act
476+
continue-on-error: true
477+
uses: ./
478+
- run: |
479+
if [[ "${{ steps.act.outcome }}" != "failure" ]]; then
480+
echo $'::error::❌' "Expected step to fail when no input is set"
481+
exit 1
482+
fi
483+
echo $'✅ Test passed' | tee -a $GITHUB_STEP_SUMMARY
484+
485+
test-script-file-nonexistent-file:
486+
name: 'Integration test: script-file - fails on nonexistent file'
487+
runs-on: ubuntu-latest
488+
steps:
489+
- uses: actions/checkout@v4
490+
- id: act
491+
continue-on-error: true
492+
uses: ./
493+
with:
494+
script-file: .github/fixtures/script-file/does-not-exist.js
495+
- run: |
496+
if [[ "${{ steps.act.outcome }}" != "failure" ]]; then
497+
echo $'::error::❌' "Expected step to fail for nonexistent file"
498+
exit 1
499+
fi
500+
echo $'✅ Test passed' | tee -a $GITHUB_STEP_SUMMARY
501+
502+
test-script-file-non-function-export:
503+
name: 'Integration test: script-file - fails when file does not export a function'
504+
runs-on: ubuntu-latest
505+
steps:
506+
- uses: actions/checkout@v4
507+
- id: act
508+
continue-on-error: true
509+
uses: ./
510+
with:
511+
script-file: .github/fixtures/script-file/not-a-function.js
512+
- run: |
513+
if [[ "${{ steps.act.outcome }}" != "failure" ]]; then
514+
echo $'::error::❌' "Expected step to fail for non-function export"
515+
exit 1
516+
fi
517+
echo $'✅ Test passed' | tee -a $GITHUB_STEP_SUMMARY
518+
519+
test-script-file-file-protocol-rejected:
520+
name: 'Integration test: script-file - fails for file:// protocol'
521+
runs-on: ubuntu-latest
522+
steps:
523+
- uses: actions/checkout@v4
524+
- id: act
525+
continue-on-error: true
526+
uses: ./
527+
with:
528+
script-file: file://${{ github.workspace }}/.github/fixtures/script-file/basic.js
529+
- run: |
530+
if [[ "${{ steps.act.outcome }}" != "failure" ]]; then
531+
echo $'::error::❌' "Expected step to fail for file:// protocol"
532+
exit 1
533+
fi
534+
echo $'✅ Test passed' | tee -a $GITHUB_STEP_SUMMARY

README.md

Lines changed: 65 additions & 39 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,56 @@ 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+
Coding long JS logic in yaml is not linted as JS/TS.
208+
Instead of providing the `script` inline, you can use `script-file` to point to a JS file in your repository. The file must proide `module.exports` as an function (that may be async) — making it a proper module that linters and IDEs can fully analyse.
209+
210+
The action handler is called with 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`:
211+
212+
| Name | Description |
213+
| --- | --- |
214+
| `github` | Pre-authenticated [octokit/rest.js](https://octokit.github.io/rest.js) client |
215+
| `octokit` | Alias for `github` |
216+
| `getOctokit` | Factory for additional authenticated Octokit clients (see [Creating additional clients](#creating-additional-clients-with-getoctokit)) |
217+
| `context` | [Workflow run context](https://github.com/actions/toolkit/blob/main/packages/github/src/context.ts) |
218+
| `core` | [@actions/core](https://github.com/actions/toolkit/tree/main/packages/core) |
219+
| `exec` | [@actions/exec](https://github.com/actions/toolkit/tree/main/packages/exec) |
220+
| `glob` | [@actions/glob](https://github.com/actions/toolkit/tree/main/packages/glob) |
221+
| `io` | [@actions/io](https://github.com/actions/toolkit/tree/main/packages/io) |
222+
| `require` | Wrapped `require` that resolves relative paths and local `node_modules` |
223+
224+
**Path resolution:** relative paths are resolved against `$GITHUB_WORKSPACE`; absolute paths are used as-is. The `file://` protocol is not supported.
225+
226+
`script` and `script-file` are mutually exclusive — exactly one must be provided.
227+
228+
```yaml
229+
- uses: actions/checkout@v4
230+
- uses: actions/github-script@v9
231+
with:
232+
script-file: .github/scripts/my-script.js
233+
```
234+
235+
The action handler:
236+
237+
JS: `.github/scripts/my-script.js`
238+
239+
```js
240+
module.exports = async ({github, context, core /* destructure what you need */}) => {
241+
// your logic here
242+
}
243+
```
244+
245+
or TS: `.github/scripts/my-script.ts`
246+
247+
```ts
248+
import type {AsyncFunctionArguments} from '@actions/github-script'
249+
250+
module.exports = async ({github, context, core /* destructure what you need */}: AsyncFunctionArguments) => {
251+
// your logic here
252+
}
253+
```
254+
204255
## Examples
205256

206257
Note that `github-token` is optional in this action, and the input is there
@@ -377,52 +428,19 @@ jobs:
377428
- uses: actions/checkout@v4
378429
- uses: actions/github-script@v9
379430
with:
380-
script: |
381-
const script = require('./path/to/script.js')
382-
console.log(script({github, context}))
431+
script-file: ./path/to/script.js
432+
383433
```
384434

385435
And then export a function from your module:
386436

387437
```javascript
388-
module.exports = ({github, context}) => {
438+
module.exports = ({github, context }) => {
389439
return context.payload.client_payload.value
390440
}
391441
```
392442

393-
Note that because you can't `require` things like the GitHub context or
394-
Actions Toolkit libraries, you'll want to pass them as arguments to your
395-
external function.
396-
397-
Additionally, you'll want to use the [checkout
398-
action](https://github.com/actions/checkout) to make sure your script file is
399-
available.
400-
401-
### Run a separate file with an async function
402-
403-
You can also use async functions in this manner, as long as you `await` it in
404-
the inline script.
405-
406-
In your workflow:
407-
408-
```yaml
409-
on: push
410-
411-
jobs:
412-
echo-input:
413-
runs-on: ubuntu-latest
414-
steps:
415-
- uses: actions/checkout@v4
416-
- uses: actions/github-script@v9
417-
env:
418-
SHA: '${{env.parentSHA}}'
419-
with:
420-
script: |
421-
const script = require('./path/to/script.js')
422-
await script({github, context, core})
423-
```
424-
425-
And then export an async function from your module:
443+
The exported function may be async if you like:
426444

427445
```javascript
428446
module.exports = async ({github, context, core}) => {
@@ -436,6 +454,14 @@ module.exports = async ({github, context, core}) => {
436454
}
437455
```
438456

457+
Note that because you can't `require` things like the GitHub context or
458+
Actions Toolkit libraries, you'll want to accept them as arguments to your
459+
external function: Your action is called with an [IoC](https://en.wikipedia.org/wiki/Inversion_of_control) dependency bag - destructure from it whatever you need. Check the docs above in the **Script file** section.
460+
461+
Additionally, you'll want to use the [checkout
462+
action](https://github.com/actions/checkout) to make sure your script file is
463+
available.
464+
439465
### Use npm packages
440466

441467
Like importing your own files above, you can also use installed modules.

0 commit comments

Comments
 (0)