actions/github-script runs the body of an async JavaScript function inside a workflow step, with an authenticated Octokit client (github), the run's context and the toolkit packages core, glob, io and exec already in scope. v9.0.0 injects getOctokit for a second token, and it breaks scripts that require('@actions/github') or declare their own const getOctokit.

Three of the five majors are only a Node runtime bump. v5 and v9 are the two that change how a script is written.
You reach for actions/github-script when a step needs the GitHub API and a gh api call would turn into string escaping: labeling a pull request, posting a comment, dispatching a workflow in another repository, reading a diff. The script is a function body, so await works at the top level and whatever you return becomes the step's result output.
Scripts pasted from older answers are where it breaks. A call to github.issues.createComment was written for v4, before v5 moved every REST method under github.rest, and a script that builds its own client with require('@actions/github') stops working on v9. Everything here was read from the v9.0.0 tag on 2026-11-12.
What your script receives
The action calls your script with these names already bound, so you never import them. github and octokit are the same object: a REST and GraphQL client authenticated with the github-token input, with the pagination, retry and request-log plugins attached.
require is a wrapper, not Node's own: it resolves relative paths against the working directory and finds packages installed there, which is how a script loads ./scripts/triage.js after a checkout. If a library needs the real one, __original_require__ is it. exec is the @actions/exec module rather than a function, so the call is exec.exec('make', ['lint']) or exec.getExecOutput().
Pass event data through env and read it from process.env, as below. The README warns that expressions are evaluated before the script runs, so ${{ github.event.issue.title }} pasted into the script becomes JavaScript source: a title with a quote in it is a syntax error at best and script injection at worst.
name: Triage
on:
issues:
types: [opened]
permissions:
issues: write
jobs:
label:
runs-on: ubuntu-latest
steps:
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env:
TITLE: ${{ github.event.issue.title }}
with:
result-encoding: string
script: |
const labels = /crash|panic/i.test(process.env.TITLE) ? ['bug'] : ['triage']
await github.rest.issues.addLabels({
...context.repo,
issue_number: context.issue.number,
labels,
})
return labels.join(',')
| Name | What it is |
|---|---|
github (also octokit) |
Octokit client authenticated with github-token. REST methods live under github.rest; github.graphql, github.paginate and github.request sit beside it |
context |
The workflow run context: context.repo, context.issue, context.payload, context.sha
|
core |
@actions/core: core.setOutput, core.setFailed, core.exportVariable, core.info
|
glob |
@actions/glob: match files on the runner |
io |
@actions/io: copy, move, remove and locate files |
exec |
@actions/exec: exec.exec() returns the exit code, exec.getExecOutput() also returns stdout and stderr |
getOctokit |
v9: getOctokit(token, opts) returns a second client with the same plugins and retry settings |
require |
Wrapped require that resolves relative paths and packages from the working directory; __original_require__ is the unwrapped one |
Bound names read from
src/main.tsat the v9.0.0 tag on 2026-11-12. Theoctokitalias is present from v7.1.0 onward.
Nine inputs, one required
Only script is required. The defaults below are the literal values in action.yml at v9.0.0, and the action runs on node24.
| Input | Default | What it does |
|---|---|---|
script |
required | The function body to run |
github-token |
${{ github.token }} |
Token for the github client |
result-encoding |
json |
json or string; any other value fails the step |
retries |
0 |
Retries per request, through @octokit/plugin-retry
|
retry-exempt-status-codes |
400,401,403,404,422 |
Status codes never retried; no effect unless retries is set |
debug |
${{ runner.debug == '1' }} |
Log each request; on whenever step debug logging is on |
user-agent |
actions/github-script |
User agent string; v9 appends the orchestration ID when the runner provides one |
previews |
none | GraphQL API previews, comma separated; REST previews are ignored since v7 |
base-url |
none | REST API URL for a GitHub Enterprise Server instance |
Output:
result, the return value encoded perresult-encoding. Read fromaction.ymlat v9.0.0 on 2026-11-12.
result-encoding: what lands in the output
The default json encoding runs JSON.stringify on whatever you return. That suits objects and arrays, which a later step reads back with fromJSON(), and it surprises people with strings: return 'v1.2.3' sets the output to "v1.2.3", quotes included, and a shell step comparing it to v1.2.3 never matches.
string encoding runs String() instead, which has its own trap. A script that returns nothing does not get an empty output: String() turns the missing value into a word, so result is a non-empty string that a shell test for an empty output does not catch. Under json the same script does produce an empty output, because JSON.stringify of no value returns no string and @actions/core writes that as empty. Return something on every path, and pick the encoding by who reads it: string for a shell, json for fromJSON.
Any other value fails the step with "result-encoding" must be either "string" or "json". An exception or a rejected promise inside the script fails it with Unhandled error: followed by the error.
- id: pr
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const { data } = await github.rest.pulls.get({
...context.repo,
pull_number: context.issue.number,
})
return { draft: data.draft, files: data.changed_files }
- if: fromJSON(steps.pr.outputs.result).draft == false
env:
FILES: ${{ fromJSON(steps.pr.outputs.result).files }}
run: echo "Ready for review, $FILES files changed"
Retries, and the status codes they skip
Requests are not retried by default: retries is 0, so a single 502 from the API fails the step. Setting retries: 3 turns on @octokit/plugin-retry, which waits with exponential backoff between attempts. The default exempt list, 400,401,403,404,422, is deliberate: those answers will be the same on the next attempt, and retrying a missing permission only makes the failure slower.
In v9 the clients you create with getOctokit inherit the same retry configuration, request logging and user agent as github, so moving a call to a second token does not quietly lose the retries you set on the step.
github.rest in v5, getOctokit in v9
Most upgrades are a Node runtime bump. Two are not: v5 moved every REST method under github.rest, and v9 changed how a script gets a second client. A script that still calls github.issues.createComment was written for v4.
| Major | Released | What changed | What breaks |
|---|---|---|---|
| v5 | 2021-09-24 | REST methods moved to github.rest.*
|
github.issues.createComment becomes github.rest.issues.createComment; request, paginate and graphql unchanged |
| v6 | 2022-02-11 | Runtime Node 16 | Anything relying on Node 12 behavior |
| v7 | 2023-11-13 | Runtime Node 20; base-url input; previews applies to GraphQL only |
Node 16 to 20 differences |
| v8 | 2025-09-04 | Runtime Node 24 | Self-hosted runners older than v2.327.1 |
| v9 | 2026-04-09 |
@actions/github v9 (ESM only), @octokit/core v7, injected getOctokit, orchestration ID in the user agent |
require('@actions/github') fails at runtime; const getOctokit = ... or let getOctokit is a SyntaxError
|
Dates are the GitHub release dates. Changes are from the release notes and the README's Breaking Changes section, read 2026-11-12.
The token, and the permissions each call needs
By default the client authenticates with github.token, the job's GITHUB_TOKEN. That token carries the scopes in the workflow's permissions: block and can only reach the repository the workflow runs in. When a call needs a scope the token lacks, the API answers 403 with Resource not accessible by integration, the message in issue #42 on the action's repository. Grant the narrowest scope that covers the call: issues: write to comment on an issue, pull-requests: write to label a pull request, contents: write to create a release, actions: write to cancel a run.
Two cases a permissions block cannot fix. A pull_request run from a fork gets a read-only token whatever you declare. And no setting lets GITHUB_TOKEN reach a second repository, which surfaces as a 404 rather than a 403 (GITHUB_TOKEN cannot access another repository). For the second case, mint a GitHub App token with actions/create-github-app-token and hand it to getOctokit, as below, or to github-token. A fine-grained personal access token also works, with its own failure mode: resource not accessible by personal access token.
permissions:
issues: write
jobs:
dispatch:
runs-on: ubuntu-latest
steps:
- id: app
uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0
with:
client-id: ${{ vars.APP_CLIENT_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
repositories: deploy-config
permission-contents: write
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env:
APP_TOKEN: ${{ steps.app.outputs.token }}
with:
retries: 3
script: |
// v8 built this client with require('@actions/github').
// v9 injects getOctokit; redeclaring it with const or let is a SyntaxError.
const other = getOctokit(process.env.APP_TOKEN)
await other.rest.repos.createDispatchEvent({
owner: context.repo.owner,
repo: 'deploy-config',
event_type: 'deploy',
})
await github.rest.issues.createComment({
...context.repo,
issue_number: context.issue.number,
body: 'Deploy dispatched',
})
FAQ
Why is exec not a function in actions/github-script?
Because exec is the whole @actions/exec module, not a function. Call await exec.exec('echo', ['hello']) for the exit code, or exec.getExecOutput() when you also want stdout and stderr. That is the answer the maintainers gave in issue #503 on the action's repository.
Why do the github-script examples fail with Resource not accessible by integration?
Either the token lacks the scope the call needs, or the run is a pull_request from a fork, where the token is read-only whatever the workflow declares. Add the scope to permissions: for the first. For the second, the workaround suggested in issue #42 was pull_request_target, which carries its own risk: never check out or run the fork's code in that job.
How do I use the result of github-script in a later step?
Give the step an id and read steps.<id>.outputs.result. The value is JSON encoded unless you set result-encoding: string, so parse objects with fromJSON() and choose string when a shell compares the value directly.
Can I use npm packages in actions/github-script?
Yes. Install them in the working directory in an earlier step, with npm ci or npm install <package>, then require them; the wrapped require resolves packages from there. For an ES module, use await import() with an absolute path built from ${{ github.workspace }} and a package.json that sets "type": "module".
Top comments (0)