Skip to content

Workflows

gh-ship dispatches workflows you own. This page is the contract they must satisfy.

gh ship validate checks rules 1 and 2 below, so you never have to discover those violations during a release. Rule 3 — uploading the artifact — is the one thing it cannot check, because it depends on what your job steps actually do at runtime.

The contract

name: prepare-release

on:
  # 1. Dispatchable. Required.
  workflow_dispatch:
    inputs:
      # 2. The dry-run input. Required for the prepare workflow.
      dry_run:
        required: false
        type: boolean
        default: false

  # Optional, recommended: keeps the workflow reusable too.
  workflow_call:
    inputs:
      dry_run: { required: false, type: boolean, default: false }

That is the whole contract. gh-ship asks for nothing that would stop this being an ordinary reusable workflow — no run-name convention, no correlation input.

0. gh-ship refers to it by filename slug

.github/ship.yml names workflows by their filename without the extension:

.github/workflows/prepare-release.yml    ->   prepare: prepare-release
.github/workflows/prepare-release.yaml   ->   prepare: prepare-release

Either extension works, and both resolve to the same slug — which is why gh ship init writing .yml costs you nothing if your repository uses .yaml.

Not by the name: in the workflow. That means the display name is yours to decorate:

name: 🚀 Prepare Release

and renaming it never breaks a release. gh ship validate prints the slug, with the display name beside it when it differs.

1. It must be dispatchable

gh-ship starts workflows through the GitHub API, which can only start workflows declaring on: workflow_dispatch.

A reusable workflow is not enough

A workflow declaring only on: workflow_call — what people usually mean by "reusable" — cannot be started through the API at all. This is the single most common setup mistake.

Declare both triggers to have it both ways: dispatchable by gh-ship, and reusable by your other workflows.

2. It must accept and respect dry_run

on:
  workflow_dispatch:
    inputs:
      dry_run: { required: false, type: boolean, default: false }

When dry_run is true, the workflow must produce the artifact but not commit or push. This is what makes gh ship preview safe.

Declaring the input is checked; respecting it is up to you. The declaration matters on its own, because GitHub refuses a dispatch carrying an input the workflow does not declare — so without it gh ship preview fails mid-command.

This rule applies to the prepare workflow only, which is the one preview dispatches.

3. It must upload the artifact

Not checked by gh ship validate

This is the one rule gh-ship cannot verify statically: whether an artifact is produced depends on what your steps do, not on what the workflow declares. A missing upload surfaces during gh ship prepare instead.

- uses: actions/upload-artifact@v7
  with:
    name: ship-release
    path: ship.release.json
    if-no-files-found: error

Both names are part of the protocol.

The prepare workflow

Responsible for everything gh-ship refuses to know about:

  1. Work out the next version.
  2. Generate the changelog.
  3. Update files.
  4. Commit and push to the staging branch the workflow was dispatched on (git push origin HEAD). gh-ship then promotes it to the release branch.
  5. Write and upload ship.release.json.
- name: Write the release artifact
  run: |
    jq -n \
      --arg version "$VERSION" \
      --arg tag "v$VERSION" \
      --rawfile notes NOTES.md \
      '{
        "$schema": "https://noirbizarre.github.io/gh-ship/schema/release/v1.json",
        schemaVersion: 1,
        changed: true,
        version: $version,
        tag: $tag,
        release: { notes: $notes }
      }' > ship.release.json

- uses: actions/upload-artifact@v7
  with:
    name: ship-release
    path: ship.release.json
    if-no-files-found: error

gh-ship validates the artifact after downloading it, before updating the Release PR. Keep builds and tests in PR CI so preparation stays fast.

Nothing to release? Say so, and gh-ship stops cleanly with exit 0:

jq -n '{schemaVersion: 1, changed: false}' > ship.release.json

Your versioning tool does not read GH_TOKEN

GH_TOKEN is the gh CLI's variable. The tools that compute the version and write the changelog have their own, and they usually look for GITHUB_TOKEN. git-cliff does, as soon as cliff.toml declares [remote.github]: it calls the GitHub API even for --bumped-version.

Setting GH_TOKEN is not enough. The tool then sends anonymous requests, which are limited to 60 per hour per IP — and GitHub-hosted runners share IPs, so the budget can be spent before your job starts. It fails intermittently, and a re-run on another runner passes, which makes it look like a fluke. git-cliff reports it as a panic about GitHub metadata, not as an authentication problem.

Pass the token to every step that runs the tool:

- name: Compute the next version
  env:
    GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}
  run: git cliff --bumped-version

With a GitHub App, set it per step for the same reason as GH_TOKEN: the token is a step output, not available in a job-level env:. With the default token, use ${{ github.token }}.

The publish workflow

Optional. Dispatched by gh ship release after the release exists as a draft and before it becomes visible, so it can attach assets to a release nobody has been notified about yet.

By default it receives the tag as an input, and should check that out rather than a branch — you want to build exactly what is being released. This tag contract remains fully supported; generated workflows use it too. A ref-based publisher can be selected explicitly as described below.

Rule 1 applies here exactly as it does to the prepare workflow: it must be dispatchable. Rule 2 (dry_run) is prepare-only, since gh ship preview never dispatches publish.

on:
  workflow_dispatch:
    inputs:
      tag: { required: true, type: string }
  # Optional, recommended: keeps the workflow reusable too. The generated
  # template declares both.
  workflow_call:
    inputs:
      tag: { required: true, type: string }

jobs:
  publish:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
        with:
          ref: ${{ inputs.tag }}

      - run: ./build.sh

      - env:
          GH_TOKEN: ${{ github.token }}
        run: gh release upload "${{ inputs.tag }}" dist/* --clobber

Opting into ref inputs

For a publisher shared with development builds, select the ref contract in .github/ship.yml:

release:
  publish_input: ref

For release v1.4.0, gh-ship dispatches with --ref v1.4.0 -f ref=refs/tags/v1.4.0. The execution ref stays the release tag; the named input is a fully qualified tag ref. The default (or explicit publish_input: tag) instead sends --ref v1.4.0 -f tag=v1.4.0, preserving the original tag value.

Configuration controls the input: gh-ship does not infer it from the workflow and never sends both. If you declare both inputs, make the unselected one optional or give it a default. gh ship validate checks that the selected input exists under workflow_dispatch.inputs; declaring it only under workflow_call does not satisfy the dispatch contract.

on:
  workflow_dispatch:
    inputs:
      ref: { required: true, type: string }
  workflow_call:
    inputs:
      ref: { required: true, type: string }

permissions:
  contents: write

jobs:
  publish:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
        with:
          ref: ${{ inputs.ref }}

      - run: ./build.sh

      # Checkout takes the qualified ref, but release uploads and asset names
      # take the bare tag. Development builds do not upload to a stable release.
      - if: startsWith(inputs.ref, 'refs/tags/')
        env:
          BUILD_REF: ${{ inputs.ref }}
          GH_TOKEN: ${{ github.token }}
        run: |
          tag="${BUILD_REF#refs/tags/}"
          tar -czf "package-${tag}.tar.gz" -C dist .
          gh release upload "$tag" "package-${tag}.tar.gz" --clobber

Reusable callers can supply branch refs or commit SHAs for development builds and choose their own development artifact policy. gh ship release always publishes version tags and supplies refs/tags/<tag> in this mode. The prepare artifact's tag field remains a bare tag in either mode.

The release environment

The generated workflows both run in an environment named release, which is where SHIP_TOKEN lives. GitHub creates the environment the first time a workflow references it.

They declare it differently, on purpose:

# prepare-release: secrets, but no deployment record.
environment:
  name: release
  deployment: false
# publish-release: this one really is a deployment.
environment:
  name: release
  url: ${{ github.server_url }}/${{ github.repository }}/releases/tag/${{ inputs.tag }}

By default, referencing an environment creates a GitHub deployment object. deployment: false opts out while still granting the environment's secrets and still honouring wait timers and required reviewers — see Control deployments.

Preparing a release is not a deployment, so it should not appear in your deployment history. Publishing one is, so it does — and its url links straight to the release.

Custom deployment protection rules

deployment: false is incompatible with custom deployment protection rules (the GitHub App kind), which need a deployment object to function. A job combining the two fails immediately. Wait timers and required reviewers are unaffected.

Releasing on every push

Use gh ship run in a single job to prepare on pushes and release when the Release PR merges. The command detects the phase from the Actions event and your configured release branches. Your prepare and publish workflows still own version changes, changelog generation, and asset builds.

name: 🚢 Ship

on:
  push:
    branches: [main]
  pull_request:
    types: [closed]
    branches: [main]

permissions:
  contents: write
  actions: write
  pull-requests: write
  issues: write

concurrency:
  group: ship
  cancel-in-progress: false

jobs:
  ship:
    # Skip unmerged and fork PRs before starting credential setup. gh-ship
    # checks the configured release branch itself.
    if: >-
      github.event_name == 'push'
      || (github.event.pull_request.merged
          && github.event.pull_request.head.repo.full_name == github.repository)
    runs-on: ubuntu-latest
    # `gh ship run` blocks on the dispatched run until `SHIP_RUN_TIMEOUT`
    # expires, 60 minutes by default. Capping below that makes a stuck run
    # fail the job visibly. See [Waiting](cli.md#waiting).
    timeout-minutes: 45
    environment:
      name: release
      deployment: false
    # Set once so every `gh` invocation picks up SHIP_TOKEN when configured.
    # With a GitHub App token this has to move to each step instead — see
    # [Using a GitHub App](#using-a-github-app).
    env:
      GH_TOKEN: ${{ secrets.SHIP_TOKEN || secrets.GITHUB_TOKEN }}
    steps:
      - uses: actions/checkout@v7
      - run: gh extension install noirbizarre/gh-ship
      - run: gh ship run

The resulting lifecycle:

Event What happens
push to your default branch gh ship run prepares: opens or updates the Release PR
the Release PR merges gh ship run releases: tags, drafts, attaches assets, publishes
another PR closes gh ship run skips successfully

For multiple release lines, include their base branches in both event filters. The shared ship concurrency group serializes all lines; to allow independent lines to run concurrently, use ship-${{ github.base_ref || github.ref_name }}. The base branch then gives both events for a release line the same group. Do not use the PR head branch as the group key. GitHub concurrency does not guarantee FIFO ordering or retain an unlimited queue of pending runs.

A push with nothing to release costs one prepare-release run reporting changed: false, and exits 0.

Merging the Release PR does not start another one

The merge is itself a push to your default branch, so this workflow runs again — but gh ship prepare detects that a merged Release PR is still awaiting gh ship release and stops. See gh ship prepare.

cancel-in-progress: false matters: both commands block on a dispatched run, and cancelling would orphan that run rather than stop it.

Merging fires both triggers, since the merge is also a push. The concurrency group keeps active runs from interleaving, and either order is correct — if prepare goes first it stops at the pending-release guard, and if release goes first the next prepare finds nothing to release.

Tokens

The default token cannot trigger workflows

GitHub deliberately prevents GITHUB_TOKEN from triggering further workflow runs, to avoid infinite loops. A commit or PR authored with it will not start your CI.

If the Release PR must be tested before merging, you need a different token.

Options, best first:

  1. A GitHub App token. Scoped, rotatable, attributable, and nothing long-lived is stored. Minted per job with actions/create-github-app-token, or by gh-ship itself when a step has to outlive the token's one hour.
  2. A fine-grained PAT, stored as the SHIP_TOKEN secret.
  3. Nothing. Accept that the Release PR shows no CI results.

gh ship init asks which of the three you want and generates the matching workflow.

With a PAT, the generated template prefers SHIP_TOKEN when present:

- uses: actions/checkout@v7
  with:
    token: ${{ secrets.SHIP_TOKEN || secrets.GITHUB_TOKEN }}

With a PAT, gh-ship never sees, stores, or manages the secret: gh reads GH_TOKEN and that is the end of it. It is between you and GitHub.

Using a GitHub App

An App uses short-lived installation tokens: the workflow mints one at the start of the job, and the action revokes it when the job ends. The private key you store is still a durable secret capable of minting new tokens, including outside a workflow run. Protect it as the App's credential; the installation tokens themselves need not be stored between runs.

Setting one up:

  1. Register a GitHub App, granting it the repository permissions in the GitHub App tab below.
  2. Install it on the repository you release.
  3. Store its Client ID as the APP_CLIENT_ID variable — it is not a secret — and its private key as the APP_PRIVATE_KEY secret. Put both in the release environment, alongside your other release secrets.
jobs:
  prepare:
    runs-on: ubuntu-latest
    # Well below the token's one-hour lifetime. See the warning below.
    timeout-minutes: 30
    # Load-bearing twice over: it gates the secret, and an environment
    # variable resolves only in a job that declares its environment.
    environment:
      name: release
      deployment: false
    steps:
      # An installation token, minted for this job and revoked when it ends.
      # The private key never leaves the secret.
      - uses: actions/create-github-app-token@v3
        id: app-token
        with:
          client-id: ${{ vars.APP_CLIENT_ID }}
          private-key: ${{ secrets.APP_PRIVATE_KEY }}

      - uses: actions/checkout@v7
        with:
          token: ${{ steps.app-token.outputs.token }}

      - run: gh extension install noirbizarre/gh-ship
        env:
          GH_TOKEN: ${{ steps.app-token.outputs.token }}

      - run: gh ship prepare
        env:
          GH_TOKEN: ${{ steps.app-token.outputs.token }}

The App token cannot go in the job's env:

Everywhere else these docs set GH_TOKEN once at the job level, because a per-step GH_TOKEN is easy to forget on one step and silently fall back to the default token.

An App token cannot be set that way. It is a step output, and a job-level env: block is evaluated before any step has run, so ${{ steps.app-token.outputs.token }} there resolves to the empty string — and gh falls back to GITHUB_TOKEN without complaining. Set it on every step that runs gh, and check you have not missed one.

Installation tokens expire after one hour

This applies to a token minted by actions/create-github-app-token and handed to gh-ship as GH_TOKEN. To lift the ceiling instead, let gh-ship mint and refresh its own token.

gh ship prepare and gh ship release both block on a dispatched workflow run, for up to SHIP_RUN_TIMEOUT — 60 minutes by default. A job that runs that long outlives its own token, and fails at whichever call happens to come after the hour.

Cap timeout-minutes well below 60. skip-token-revoke does not help: it stops the action revoking the token early, it does not extend its lifetime.

gh-ship warns when SHIP_RUN_TIMEOUT is at or above 60 minutes inside Actions. When the timeout expires with the run still going, the command exits with code 75 rather than 1. Re-running release adopts its publish run; prepare starts a new run and sweeps the previous staging ref. For publication, a second job can mint a fresh token and retry on that code alone, leaving real failures (exit 1) alone.

A publish workflow that takes more than about 50 minutes cannot be waited on with a single App token. The job is cancelled by its timeout-minutes (or fails on an expired token), and the release is left as a draft. Nothing is lost: re-run the Release job. gh ship release is idempotent — it adopts the publish run, in flight or succeeded, and only makes the release public.

To avoid holding a token for the whole build, skip the wait entirely with gh ship release --no-wait, or retry with a fresh token as in Retrying with a fresh token.

An environment variable is invisible outside its environment

Keeping APP_CLIENT_ID in the release environment rather than at repository level means a job that does not declare environment: release cannot see it. ${{ vars.APP_CLIENT_ID }} there expands to the empty string, and the mint step fails with a confusing error rather than "variable not set".

So the environment: key is not only about gating: it is what makes the credentials resolve at all. Every job that mints a token needs it.

app-id also works

actions/create-github-app-token accepts the legacy app-id input, which takes the App's numeric ID rather than its Client ID. client-id is what the action recommends, and what these examples use.

Letting gh-ship mint the token

Give gh-ship the App's credentials instead of a token, and it mints the installation token itself and replaces it a few minutes before it expires. A gh ship release that waits two hours on the publish build then keeps working the whole time: there is no one-hour ceiling.

jobs:
  release:
    runs-on: ubuntu-latest
    # No longer bound by the token: allow the publish build, plus a margin.
    timeout-minutes: 150
    environment:
      name: release
      deployment: false
    steps:
      - uses: actions/checkout@v7

      - run: gh extension install noirbizarre/gh-ship
        env:
          GH_TOKEN: ${{ github.token }}

      - run: gh ship release
        env:
          SHIP_APP_CLIENT_ID: ${{ vars.APP_CLIENT_ID }}
          SHIP_APP_PRIVATE_KEY: ${{ secrets.APP_PRIVATE_KEY }}
          # Also raise the wait: it defaults to 60 minutes.
          SHIP_RUN_TIMEOUT: 7200

The variables:

Variable Meaning
SHIP_APP_CLIENT_ID The App's Client ID. Its numeric App ID works too.
SHIP_APP_PRIVATE_KEY The full contents of the App's .pem file. A key flattened onto one line with literal \n is accepted.
SHIP_APP_INSTALLATION_ID Optional. Skips the installation lookup.

Set both of the first two, or neither: one alone is an error rather than a quiet fallback to another credential. With neither set nothing changes, and gh authenticates as it always did.

How it works:

  • gh-ship signs a short-lived JWT with the key, finds the installation on the repository (GET /repos/{owner}/{repo}/installation), and exchanges the JWT for an installation token (POST /app/installations/{id}/access_tokens). Both calls go through gh api, so enterprise hosts and GH_HOST behave as for any other call.
  • The token is scoped to the one repository gh-ship operates on, and carries the permissions granted to the installation, as the action's default does.
  • That token is set as GH_TOKEN on every gh it runs, overriding any GH_TOKEN already in the environment, and replaced five minutes before it expires. If GitHub rejects a token anyway, a new one is minted and the call repeated once.
  • Inside Actions, a minted token is registered with the runner for log masking.
  • The repository is taken from --repo, SHIP_REPO or, in Actions, GITHUB_REPOSITORY. Outside those, set SHIP_APP_INSTALLATION_ID.

Things to know:

  • The private key is in the step's environment. With the action, the key is read by the action alone and the token it mints is revoked when the job ends. Here the key is visible to the step that runs gh ship, and to the processes that step starts. Set the variables on that step, not at the job level.
  • gh-ship never stores the key. It is read from the environment, held in memory for the length of the command, and not logged.
  • The JWT is passed to gh api as an argument, so it is visible in the process list on the runner for the moment each call takes. It lasts nine minutes at most, and it can only call the App-level API. That includes minting tokens, so treat it as sensitive for those minutes.
  • The action is still useful for anything that is not gh-ship. A checkout whose pushes must trigger CI, for instance. The default github.token is enough for installing the extension and for a read-only checkout.
  • gh-ship has to be new enough. A workflow that installs the extension from a release older than this feature ignores these variables, and fails with gh's own authentication error.

Long publish builds

With gh-ship minting its own token

This section is for a token handed to gh-ship from outside. When gh-ship mints its own, a single job can wait for any build, and splitting it is optional.

When the publish workflow can take close to an hour or more, do not hold a token for the whole build. Split release in two:

  1. The job on the merged Release PR runs gh ship release --no-wait: it tags, creates the draft release, dispatches the publish workflow and returns. The release stays a draft.
  2. A second job, triggered when the publish workflow completes successfully, runs a plain gh ship release. It finds the successful run on the tag, skips the dispatch, and only makes the release visible.
on:
  pull_request:
    types: [closed]
  workflow_run:
    # The `name:` of your publish workflow.
    workflows: ["📦 Publish Release"]
    types: [completed]

jobs:
  release:
    if: >-
      github.event_name == 'pull_request'
      && github.event.pull_request.merged
      && github.event.pull_request.head.ref == 'release/next'
    runs-on: ubuntu-latest
    timeout-minutes: 15
    environment:
      name: release
      deployment: false
    steps:
      # ... mint a token, install gh-ship
      - run: gh ship release --no-wait
        env:
          GH_TOKEN: ${{ steps.app-token.outputs.token }}

  publish:
    # A failed publish run must not make the release visible.
    if: >-
      github.event_name == 'workflow_run'
      && github.event.workflow_run.conclusion == 'success'
    runs-on: ubuntu-latest
    timeout-minutes: 15
    environment:
      name: release
      deployment: false
    steps:
      # ... mint a *fresh* token, install gh-ship
      - run: gh ship release
        env:
          GH_TOKEN: ${{ steps.app-token.outputs.token }}

Things to know:

  • The second job mints its own token. That is the point: nothing is held while the build runs, so build time is no longer bound to the token lifetime.
  • A failed publish run is skipped, not retried. The conclusion == 'success' guard keeps the second job from running; the failure is visible on the publish run itself. Re-running that run from the GitHub UI fires workflow_run again once it succeeds, and the release is then made visible.
  • --no-wait or exit code 75? Waiting with a lowered SHIP_RUN_TIMEOUT exits with code 75 when the run is still in flight, and a retry job can resume it with a fresh token. That still keeps a runner idle for the whole build; --no-wait is the choice when you do not want that.
  • The second run is idempotent. The publish run is looked up on the tag, so gh ship release also works if the publish workflow was started by hand.
  • Draft stays a draft until then. If release.draft is false the release is already visible, and --no-wait only skips the wait.
  • workflow_run is matched on the publish workflow's name:, and it triggers from the workflow file on your default branch.

Retrying with a fresh token

--no-wait needs a second trigger and keeps no runner busy. If you would rather keep a single job that waits for the build, make two gh ship release attempts in it, each with a freshly minted token. No single token has to outlive an hour, so the job itself may run longer — at the cost of a runner idling for the whole build.

jobs:
  release:
    runs-on: ubuntu-latest
    # Two attempts of about 45 minutes each, plus headroom.
    timeout-minutes: 100
    environment:
      name: release
      deployment: false
    steps:
      - uses: actions/create-github-app-token@v3
        id: app-token
        with:
          client-id: ${{ vars.APP_CLIENT_ID }}
          private-key: ${{ secrets.APP_PRIVATE_KEY }}

      - uses: actions/checkout@v7

      - run: gh extension install noirbizarre/gh-ship
        env:
          GH_TOKEN: ${{ steps.app-token.outputs.token }}

      # Give up waiting before the token expires. Exit 75 means the publish
      # run is still going; any other failure is real and fails the step.
      - id: attempt-1
        run: |
          status=0
          gh ship release || status=$?
          echo "status=$status" >> "$GITHUB_OUTPUT"
          if [ "$status" -ne 0 ] && [ "$status" -ne 75 ]; then exit "$status"; fi
        env:
          GH_TOKEN: ${{ steps.app-token.outputs.token }}
          SHIP_RUN_TIMEOUT: "2700" # 45 minutes

      # Only when the first attempt timed out: a fresh token, and the same
      # command, which adopts the run that is still going.
      - uses: actions/create-github-app-token@v3
        id: app-token-retry
        if: steps.attempt-1.outputs.status == '75'
        with:
          client-id: ${{ vars.APP_CLIENT_ID }}
          private-key: ${{ secrets.APP_PRIVATE_KEY }}

      - run: gh ship release
        if: steps.attempt-1.outputs.status == '75'
        env:
          GH_TOKEN: ${{ steps.app-token-retry.outputs.token }}
          SHIP_RUN_TIMEOUT: "2700"
  • Each attempt waits about 45 minutes, which leaves room for the calls around the wait. Two attempts cover a publish workflow of roughly 90 minutes. A third attempt follows the same shape.
  • Raising timeout-minutes is safe here only because the token is replaced before it expires. Never raise SHIP_RUN_TIMEOUT itself to 60 minutes or more with an App token.
  • The attempts can also be separate jobs. Each job that mints a token needs environment: release, as above.
  • gh ship prepare also exits with code 75, but its retries start over and may delete the previous run's staging ref. preview also dispatches anew. Budget enough time for a complete prepare, using a PAT or gh-ship's built-in App token refresh for long waits; repeated short waits do not resume it.

Committing as the App

Your prepare workflow commits the version bump. By default that commit is attributed to whichever identity git config names — usually github-actions[bot], copied from an example — which is misleading once the push is authenticated as your App.

The App's bot user has a numeric id, and the pair makes the commit attribute to the App:

- name: Get the App's user id
  id: bot
  run: echo "id=$(gh api "/users/${{ steps.app-token.outputs.app-slug }}[bot]" --jq .id)" >> "$GITHUB_OUTPUT"
  env:
    GH_TOKEN: ${{ steps.app-token.outputs.token }}

- name: Commit and push
  run: |
    git config user.name  '${{ steps.app-token.outputs.app-slug }}[bot]'
    git config user.email '${{ steps.bot.outputs.id }}+${{ steps.app-token.outputs.app-slug }}[bot]@users.noreply.github.com'
    git add -A
    git commit -m "chore(release): ${{ steps.version.outputs.version }}"
    git push origin HEAD

That is attribution, not signing. The commit above shows as authored by your App and carries no Verified badge. See Getting a verified release commit.

Getting a verified release commit

A commit created on the runner is never signed, whichever token pushes it. Git signs with a key, and there is no key on the runner; the token only authenticates the push. So git commit + git push yields an unsigned commit under a GitHub App token exactly as it does under GITHUB_TOKEN.

GitHub will sign a commit it creates itself on behalf of a bot — but only when the request carries no author, committer or signature of its own. Adding the identity is what suppresses the signature, which makes the two goals mutually exclusive in a single call. Measured, four routes to the same tree:

How the commit is made Verified Committer
git commit + git push no, unsigned the identity you configured
POST /git/commits with author/committer no, unsigned the identity you sent
POST /git/commits without them yes, valid GitHub
GraphQL createCommitOnBranch yes, valid GitHub

To take the third route, commit and push as usual, then re-create that commit through the API and move the branch onto it. gh ship sign does exactly that; install the extension before signing:

- name: Commit and push
  run: |
    git add -A
    git commit -m "chore(release): ${{ steps.version.outputs.version }}"
    git push origin HEAD

- name: Sign the release commit
  run: |
    gh extension install noirbizarre/gh-ship
    gh ship sign
  env:
    GH_TOKEN: ${{ steps.app-token.outputs.token }}

With no argument it signs the tip of the branch the workflow was dispatched on, which is the staging branch gh-ship cut. Pass one — gh ship sign release/next — if your workflow pushes somewhere else. A commit that is already signed is left alone, so this is safe to add to a workflow that imports its own signing key.

It exits non-zero, without moving the branch, when GitHub returns the re-created commit unsigned. That happens whenever the token is not a bot, and the diagnostic says so rather than leaving you with a silently rewritten commit.

What the re-created commit costs

The signed commit is a new commit, so the committer becomes GitHub and the author becomes the bot the token belongs to — you cannot choose either, since choosing is what stops GitHub signing. That also makes the git config user.name/user.email lines and the Get the App's user id step above unnecessary: the identity comes from the token either way.

Only the tip is re-created. Its parents keep their SHAs, so a workflow that makes several commits signs the last one alone.

Without the extension

The same thing by hand. The push has to come first: the API can only reference a tree that already exists on the server, and pushing is what uploads it.

- name: Commit, then re-create the commit so GitHub signs it
  run: |
    git add -A
    git commit -m "chore(release): ${{ steps.version.outputs.version }}"
    git push origin HEAD

    signed=$(gh api "repos/$GITHUB_REPOSITORY/git/commits" -X POST \
      -f message="$(git log -1 --format=%B)" \
      -f tree="$(git rev-parse 'HEAD^{tree}')" \
      -f "parents[]=$(git rev-parse HEAD^)" \
      --jq .sha)

    gh api "repos/$GITHUB_REPOSITORY/git/refs/heads/$GITHUB_REF_NAME" -X PATCH \
      -f sha="$signed" -F force=true --silent
  env:
    GH_TOKEN: ${{ steps.app-token.outputs.token }}

gh-ship needs nothing from you here: it promotes whatever the staging branch points at, so moving the tip is enough.

HEAD^ assumes one commit

The snippet takes the tip's parent as the new commit's parent, so it assumes your workflow made exactly one commit on top of the ref it was dispatched on. If it makes several, replay them or squash them deliberately.

gh ship sign reads the real parents from the API instead, so it does not have this problem — it simply signs the last commit and leaves the rest.

If you would rather not install the extension or write the calls, iarekylew00t/verified-bot-commit and planetscale/ghcommit-action do the same job as third-party actions, replacing the commit step rather than following it.

For a release commit attributed to a person or an organisation rather than a bot, import a GPG or SSH signing key and set commit.gpgsign instead. That verifies without the API round trip, at the cost of a signing key in your secrets.

What the token must be allowed to do

Repository permissions:

Permission Access Why
Metadata Read-only Resolve the repository
Contents Read and write Create the release branch, read refs, merge the Release PR, create and edit the release
Actions Read and write Dispatch your workflow and download its artifact
Pull requests Read and write List, create and update the Release PR
Issues Read and write Create missing labels
Scope Why
repo Branch, PR, release and label access
workflow Dispatch workflows, and push commits that touch .github/workflows/

Repository permissions:

Permission Access Why
metadata read Resolve the repository
contents write Create the release branch, read refs, merge the Release PR, create and edit the release
actions write Dispatch your workflow and download its artifact
pull_requests write List, create and update the Release PR
issues write Create missing labels

Installation permissions are not the App's permissions

They are fixed when the App is installed. Adding a permission to the App later does not grant it to existing installations until an account administrator approves it — so an App that looks correctly configured can still mint a token that lacks actions: write.

Actions: write is the one people miss

Without it, gh ship prepare fails at the very first step:

× `gh workflow run prepare-release.yaml --ref ship/prepare-8f2c1a9e4b07` failed:
  HTTP 403: Resource not accessible by personal access token

Dispatching a workflow is an Actions write, not a Contents write. A token with full repository content access will still fail here.

Three of these are counter-intuitive enough to be worth stating outright:

  • Merging the Release PR needs Contents, not Pull requests. GitHub lists PUT /repos/{owner}/{repo}/pulls/{number}/merge under Contents.
  • Label creation may already be covered. POST /repos/{owner}/{repo}/labels is listed under both Issues and Pull requests, so Pull requests: write may suffice. Grant Issues: write if labels still fail — gh-ship degrades gracefully and opens the PR without them either way.
  • Workflows: write may be needed too. GitHub lists the ref-creating and release endpoints under a separate Workflows permission. Grant it if a release commit could ever modify a file under .github/workflows/.

The authoritative mapping is GitHub's permissions required for fine-grained personal access tokens.

Permissions

The prepare workflow pushes commits:

permissions:
  contents: write

The publish workflow uploads release assets:

permissions:
  contents: write

Checking your work

$ gh ship validate

Rules 1 and 2 are checked, with an explanation of why each exists:

× workflow `prepare-release` does not declare `on: workflow_dispatch`
  help: gh-ship starts workflows through the API, which can only start workflows
        declaring `on: workflow_dispatch`. A `workflow_call`-only workflow — what
        is usually called a reusable workflow — cannot be started this way.
        Declare both triggers to keep it reusable *and* dispatchable.

How gh-ship finds the run it started

You do not have to do anything for this to work — it is documented because it explains the shape of the contract, and because a workflow that also triggers on push interacts with it.

gh workflow run, and the REST endpoint behind it, returns 204 No Content. No run id, no URL. There is no API that says "the dispatch you just made became run 12345", and taking the newest run on the branch is wrong whenever a schedule fires, a push lands at the same moment, or a teammate dispatches concurrently.

So gh-ship correlates on three things it controls:

  1. The ref. Every dispatch goes to a ref that identifies the work. prepare cuts a throwaway staging branch, ship/prepare-<token>, and dispatches on it; release dispatches on the tag. Neither is shared with anything else.
  2. The event. Only workflow_dispatch runs are candidates. This is what stops a workflow that also declares on: push from matching the run that creating the staging branch triggered.
  3. Novelty. The run ids on the ref are recorded immediately before dispatching. The run that was not there before is ours.

The same ref-based reasoning makes gh ship release idempotent: a publish run that failed and was re-run from the GitHub UI is on the tag, so it is found and waited on rather than duplicated.

gh ship preview is the one loose end

preview dispatches on the base branch, which is shared. Two people running gh ship preview at the same moment may attach to each other's run. It is a dry run producing the same changelog either way, so nothing is corrupted.

Migrating from the ship_id contract

Earlier versions required a ship_id input and a run-name stamping ship:${{ inputs.ship_id }}. Neither is used any more.

Nothing breaks if you do nothing: gh-ship detects a workflow that still requires ship_id and supplies a placeholder, warning as it goes. That shim is removed in the next release, so tidy up when convenient:

-run-name: prepare-release (ship:${{ inputs.ship_id }})
-
 on:
   workflow_dispatch:
     inputs:
-      ship_id:
-        required: true
-        type: string
       dry_run: { required: false, type: boolean, default: false }

Keeping a plain run-name is fine — it is yours to decorate. gh ship validate points out a leftover ship_id input without failing.