actions/github-script: what your script gets, and what v9 changed
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.
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 |
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 |
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 |
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',
})Key takeaways
githubandoctokitare the same pre-authenticated client; REST calls go throughgithub.restsince v5.- Default
jsonencoding wraps a returned string in quotes;stringturns a missing return into text, not an empty output. - Requests are not retried unless you set
retries, and 400, 401, 403, 404 and 422 are never retried. - v9 breaks
require('@actions/github')and anyconst getOctokit; use the injectedgetOctokit.
Frequently asked questions
Why is exec not a function in actions/github-script?
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?
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?
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?
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".