Skip to content

ts publisher ​

Show, register, or delete a trusted publisher: a GitHub Actions workflow that publishes releases of one app to this server's Update API with the token GitHub mints for its run, so no credential is stored in the repository or on the server. Register one when a CI job, rather than an administrator on the server, publishes builds.

Usage ​

text
ts publishers [--csv] [--no-trunc]
ts publisher <name>
ts publisher new <name> --repository=OWNER/NAME --repository-id=ID --workflow=PATH --ref=REF --app=APP --channel=CHANNEL [--channel=CHANNEL]... [--environment=NAME] [--audience=AUDIENCE]
ts publisher delete <name> [--force]

Description ​

A trusted publisher names one workflow in one GitHub repository, the branch or tag its runs must be for, and what those runs may publish: one app, to the channels listed. A run of that workflow exchanges the OIDC token GitHub mints for it at this server's Controller, and publishes with the token it gets back.

ts publishers lists the publishers with their repository, ref, app, and channels, when each last exchanged a token, and its ID. ts publisher <name> prints one, named exactly as it was registered. Publishers are managed with ts; the Administration site has no page for them.

What a Publisher Trusts ​

The options of new are the rules a run's token must meet. Each is matched exactly and case-sensitively against a claim GitHub signs, with no wildcards:

  • --repository — owner/name, matched against repository, so a transfer to another owner stops the rule.
  • --repository-id — the repository's number, matched against repository_id. It survives a rename, so a repository that later takes the old name is not trusted.
  • --workflow — the workflow file's path, such as .github/workflows/build.yml. With the repository and the ref it makes the job_workflow_ref a run must carry, which print shows as Workflow:, such as example-org/client/.github/workflows/build.yml@refs/heads/main.
  • --ref — the branch or tag runs must be for, as refs/heads/<branch> or refs/tags/<tag>. A run for a pull request has another ref, and is refused.
  • --environment — optional. Runs must use this GitHub environment, so its protection rules, such as required reviewers, apply too.
  • --audience — optional. The audience the run requests its token for. It defaults to https:// and this server's domain setting, so a token minted for one server is refused by every other. With no domain set, new requires it.

What the runs may publish:

  • --app — the one app, by its ID, such as turbo-client.
  • --channel — a channel it may publish to. Give one for each, up to 16. Channel names are stored in lowercase.

A publisher's name is letters, digits, dots, dashes, and underscores, at most 64. new refuses a name already taken and a value of the wrong shape, with a sentence saying which.

What the CI Job Does ​

new prints the publisher's ID, also the last column of ts publishers. The job names it as client_id:

  1. It requests the run's OIDC token from GitHub for the publisher's audience, which needs permissions: id-token: write in the workflow.

  2. It posts that token to the Controller's token endpoint, /oauth/token, as a token exchange:

    text
    grant_type=urn:ietf:params:oauth:grant-type:token-exchange
    client_id=<publisher id>
    subject_token=<the run's OIDC token>
    subject_token_type=urn:ietf:params:oauth:token-type:id_token
    resource=turbo:service:update-api
    scope=update:releases:publish
  3. It calls the Update API with the access_token it receives as a bearer token.

The token lasts five minutes; a job that runs longer requests another OIDC token and exchanges again. Each OIDC token is exchanged once, within ten minutes of being issued. The token reaches the Update API's catalog and uploads alone, and there only the publisher's app and channels: another app's build or another channel's slot answers 403 with the code outside-publisher-limit.

An exchange whose OIDC token breaks a rule answers 400 with invalid_grant and the first rule it broke, for example The subject token's ref is 'refs/pull/42/merge', which this publisher does not trust. Compare it with ts publisher <name>. A 503 with temporarily_unavailable means GitHub's signing keys could not be fetched; try again.

Neither the token endpoint nor the Update API is served through the gateway. The job reaches them on the server itself, for example through an SSH tunnel to the Controller (port 8089 by default) and the Update API (port 8086). The token endpoint takes the exchange only over HTTPS or from loopback.

Deleting and Auditing ​

delete asks first, then deletes the publisher: its jobs get no new token from then on, and a token already issued lives out its five minutes.

Registering and deleting a publisher are recorded in the operator audit log with the acting administrator, as TrustedPublisherAdded and TrustedPublisherRemoved. Each release change made with a publisher's token is written to the Update API's service log as an AUDIT: line naming TrustedPublisher <name> and the run: repository, run number, attempt, and commit.

Options ​

FlagDescriptionDefault/ValuesNotes
--repositoryThe repository, as owner/name.new only, and required there.
--repository-idThe repository's number, which survives a rename.Digitsnew only, and required there.
--workflowThe workflow file..github/workflows/<file>.yml or .yamlnew only, and required there.
--refThe branch or tag runs must be for.refs/heads/<branch>, refs/tags/<tag>new only, and required there.
--appThe one app it may publish.App ID: lowercase letters, digits, and dashesnew only, and required there.
--channelA channel it may publish to.Channel namenew only, and required there. Repeat for each channel, up to 16.
--environmentThe GitHub environment runs must use.new only.
--audienceThe audience the run's token names.https:// and the server's domain settingnew only. Required when no domain is set.
--force, -fDelete without confirming.delete only. Required when there is no terminal.
--csvTab-separated output, nothing truncated.ts publishers only.
--no-truncDo not truncate columns.ts publishers only.

Examples ​

bash
# Which workflows may publish, and when did each last do so?
ts publishers

# One publisher's rules, to compare with a refused exchange
ts publisher client-ci

# Find the repository's number
gh api repos/example-org/client --jq .id

# Let the build workflow on main publish turbo-client to stable and beta
ts publisher new client-ci \
  --repository=example-org/client --repository-id=812734561 \
  --workflow=.github/workflows/build.yml --ref=refs/heads/main \
  --app=turbo-client --channel=stable --channel=beta

# Require the run to use the GitHub environment "release", with its reviewers
ts publisher new client-release \
  --repository=example-org/client --repository-id=812734561 \
  --workflow=.github/workflows/release.yml --ref=refs/heads/main \
  --environment=release --app=turbo-client --channel=stable

# Stop a workflow publishing
ts publisher delete client-ci --force

# Who registered or deleted a publisher?
ts audit --event=TrustedPublisherAdded
ts audit --event=TrustedPublisherRemoved
bash
# A publisher trusts one branch or one tag, exactly. A pattern is refused:
# "The ref must be a branch or a tag, such as refs/heads/master."
ts publisher new client-tags \
  --repository=example-org/client --repository-id=812734561 \
  --workflow=.github/workflows/release.yml --ref='refs/tags/*' \
  --app=turbo-client --channel=stable

Output ​

text
NAME       REPOSITORY          REF              APP           CHANNELS     LAST USED         ID
client-ci  example-org/client  refs/heads/main  turbo-client  stable beta  2026-10-21 07:58  5d0c1f7a-3b2e-4c69-a8d4-0e7f91b26c35

new:

text
Registered trusted publisher client-ci
Id:          5d0c1f7a-3b2e-4c69-a8d4-0e7f91b26c35
Workflow:    example-org/client/.github/workflows/build.yml@refs/heads/main
Repository:  example-org/client (812734561)
Environment: -
Audience:    https://turbo.example.net
Publishes:   turbo-client to stable, beta
Last used:   never

Its jobs exchange their token with client_id=5d0c1f7a-3b2e-4c69-a8d4-0e7f91b26c35.

ts publisher <name> prints the same lines, under Name: in place of the first and without the last. With --format=json, the publisher object also carries the issuer, created, and createdBy.

Exit Codes ​

0 on success. -1 when the name is missing; when new lacks a required option, has a value of the wrong shape, names a publisher that exists, or needs --audience because the server has no domain set; and when you answer no at the prompt, or there is no terminal and --force was not passed. -3 when the server cannot be reached. -4 when no account is signed in. -5 when the account is not a Server Administrator. -6 when no publisher has the name.

  • ts release — The releases the server offers, managed on the server itself.
  • ts client — A credential for a job that administers the server rather than publishing releases.
  • ts settings — domain, which the default audience is made from.
  • ts audit — Who registered or deleted a publisher, and when.