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:
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¶
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:
- Work out the next version.
- Generate the changelog.
- Update files.
- 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. - 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:
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:
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:
# 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:
- 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. - A fine-grained PAT, stored as the
SHIP_TOKENsecret. - 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:
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:
- Register a GitHub App, granting it the repository permissions in the GitHub App tab below.
- Install it on the repository you release.
- Store its Client ID as the
APP_CLIENT_IDvariable — it is not a secret — and its private key as theAPP_PRIVATE_KEYsecret. Put both in thereleaseenvironment, 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 throughgh api, so enterprise hosts andGH_HOSTbehave 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_TOKENon everyghit runs, overriding anyGH_TOKENalready 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_REPOor, in Actions,GITHUB_REPOSITORY. Outside those, setSHIP_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 apias 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.tokenis 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:
- 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. - 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 firesworkflow_runagain once it succeeds, and the release is then made visible. --no-waitor exit code 75? Waiting with a loweredSHIP_RUN_TIMEOUTexits with code75when 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-waitis 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 releasealso works if the publish workflow was started by hand. - Draft stays a draft until then. If
release.draftisfalsethe release is already visible, and--no-waitonly skips the wait. workflow_runis matched on the publish workflow'sname:, 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-minutesis safe here only because the token is replaced before it expires. Never raiseSHIP_RUN_TIMEOUTitself 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 preparealso exits with code75, but its retries start over and may delete the previous run's staging ref.previewalso 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, notPull requests. GitHub listsPUT /repos/{owner}/{repo}/pulls/{number}/mergeunder Contents. - Label creation may already be covered.
POST /repos/{owner}/{repo}/labelsis listed under both Issues and Pull requests, soPull requests: writemay suffice. GrantIssues: writeif labels still fail — gh-ship degrades gracefully and opens the PR without them either way. Workflows: writemay be needed too. GitHub lists the ref-creating and release endpoints under a separateWorkflowspermission. 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:
The publish workflow uploads release assets:
Checking your work¶
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:
- The ref. Every dispatch goes to a ref that identifies the work.
preparecuts a throwaway staging branch,ship/prepare-<token>, and dispatches on it;releasedispatches on the tag. Neither is shared with anything else. - The event. Only
workflow_dispatchruns are candidates. This is what stops a workflow that also declareson: pushfrom matching the run that creating the staging branch triggered. - 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.