# actions/github-script: what your script gets, and what v9 changed

> actions/github-script runs JavaScript against the GitHub API in a step. Get every v9 input, result encoding, retries, the major changes and tokens.

Source: https://latchkey.dev/learn/actions/github-script  
Updated: 2026-11-12

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

```.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(',')
```

| 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.ts` at the v9.0.0 tag on 2026-11-12. The `octokit` alias 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 per `result-encoding`. Read from `action.yml` at 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.

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

| 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](/learn/github-actions/github-actions-github-token-cannot-access-another-repo)). For the second case, mint a GitHub App token with [actions/create-github-app-token](/learn/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](/learn/github-actions/github-actions-resource-not-accessible-fine-grained-pat).

```.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',
            })
```

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

## References

- [actions/github-script README (read 2026-11-12)](https://github.com/actions/github-script)
- [actions/github-script action.yml at v9.0.0](https://github.com/actions/github-script/blob/v9.0.0/action.yml)
- [actions/github-script v9.0.0 release notes](https://github.com/actions/github-script/releases/tag/v9.0.0)
- [GITHUB_TOKEN, docs.github.com](https://docs.github.com/en/actions/concepts/security/github_token)
- [Issue #42: Resource not accessible by integration when trying examples](https://github.com/actions/github-script/issues/42)

---

Latchkey runs CI/CD that repairs its own failures. Agent entry points: https://latchkey.dev/agent.txt, https://latchkey.dev/openapi.json, https://latchkey.dev/llms.txt
