Skip to content
Latchkey LogoLatchkey home

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.

Timeline of github-script majors v5 to v9 with what each changed and what breaks in a script
Three of the five majors are only a Node runtime bump. v5 and v9 are the two that change how a script is written.

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.

.github/workflows/triage.yml
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(',')
NameWhat 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
contextThe 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
getOctokitv9: getOctokit(token, opts) returns a second client with the same plugins and retry settings
requireWrapped 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.

InputDefaultWhat it does
scriptrequiredThe function body to run
github-token${{ github.token }}Token for the github client
result-encodingjsonjson or string; any other value fails the step
retries0Retries per request, through @octokit/plugin-retry
retry-exempt-status-codes400,401,403,404,422Status codes never retried; no effect unless retries is set
debug${{ runner.debug == '1' }}Log each request; on whenever step debug logging is on
user-agentactions/github-scriptUser agent string; v9 appends the orchestration ID when the runner provides one
previewsnoneGraphQL API previews, comma separated; REST previews are ignored since v7
base-urlnoneREST 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.

.github/workflows/pr.yml
- 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.

MajorReleasedWhat changedWhat breaks
v52021-09-24REST methods moved to github.rest.*github.issues.createComment becomes github.rest.issues.createComment; request, paginate and graphql unchanged
v62022-02-11Runtime Node 16Anything relying on Node 12 behavior
v72023-11-13Runtime Node 20; base-url input; previews applies to GraphQL onlyNode 16 to 20 differences
v82025-09-04Runtime Node 24Self-hosted runners older than v2.327.1
v92026-04-09@actions/github v9 (ESM only), @octokit/core v7, injected getOctokit, orchestration ID in the user agentrequire('@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.

.github/workflows/dispatch.yml
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

  • github and octokit are the same pre-authenticated client; REST calls go through github.rest since v5.
  • Default json encoding wraps a returned string in quotes; string turns 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 any const getOctokit; use the injected getOctokit.

Frequently asked questions

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".

References

Small automation jobs, one runs-on label. Latchkey spend limits cap the total. Start free → 30-day trial · No credit card