Skip to content

Deploy from GitHub Actions

A GitHub workflow can deploy an app with no token or password stored anywhere. Each run asks GitHub for a signed token that says which repository, branch and commit it is, and trades it with Jiayang Cloud for a deploy credential. That credential lasts 15 minutes, deploys that one app, and can’t do anything else.

It works on every plan, for static sites, Workers and container apps alike. An owner of the app, or an owner or admin of the workspace, sets it up once. After that, every push to the branch you pick deploys. Turn on previews of pull requests too, and each pull request gets a preview address of its own, commented on the pull request.

The app has to exist already. Deploy it once from your machine first, so you know it builds.

  1. In the dashboard, open the app, then Deploy, then GitHub Actions.
  2. Under Repository, enter it as owner/name, the way GitHub shows it, like acme-corp/payroll. Its address on github.com works too.
  3. Fill in Only this branch, Only this environment, or both:
    • A branch, like main, so only runs on that branch deploy.
    • An environment, like production, so only jobs that run in that GitHub environment deploy. Use this when you want GitHub’s own reviewers or wait timers in front of a deploy. With no branch beside it, the environment needs a reviewer (see below).
  4. Select Save.

One of the two is required. Without either, anyone who can push a branch to the repository could change the workflow on it and deploy.

With an environment and no branch, a run on any branch deploys if its job runs in that environment. Anyone who can push a branch writes the workflow on it, and so names the environment its job runs in, and GitHub makes up an environment it doesn’t know with no rules at all. So we hold these runs to what we hold previews of pull requests to:

  • In GitHub, under the repository’s Settings, Environments, give the environment at least one required reviewer and turn on Prevent self-review. We check it when you save, for a public repository, and on every run, for any repository, and refuse it without them.
  • Before a run gets a credential, we read its own review history: someone has to have approved it in that environment who didn’t start the run, and didn’t write or commit any of the commits it brings in since the app was last deployed from GitHub. A reviewer’s own commit under a teammate’s still counts as the reviewer’s, and so does one they pushed straight to the default branch that no run has deployed yet. A commit whose author or committer GitHub can’t match to an account refuses the run, since we can’t tell who wrote it. Who wrote a commit is what the commit says, though, and GitHub doesn’t check it: someone can still write theirs under a teammate’s address. Only checking signatures would stop that, and we don’t yet. An admin skipping the rules, or a bot, doesn’t count.
  • So the app needs a deploy from GitHub to read the first run against. Until a run has deployed it, runs on any branch are refused: name a branch, deploy once from it, then take the branch out. A deploy from GitHub from before we kept each run’s commit doesn’t count.

So every deploy waits for a reviewer on GitHub. To deploy pushes without one, name a branch. This needs the deploy action from action-v1.2.0 on, which sends the run’s own token for these checks when we ask for it, and only then. A setting with a branch works as before.

Only the app’s owners, and the workspace’s owners and admins, can change this, from a browser where they signed in in the last 15 minutes. A CLI session can’t, so a leaked one can’t leave a way in behind it.

We look the repository up on GitHub when you save a new one, and keep GitHub’s numeric ids for it and for its owner. A run has to match those ids as well as the name. Saving the same repository again, to change the branch or environment, keeps the ids it has. If the repository is renamed, its runs stop deploying until an owner saves the new name. If it’s deleted and made again under the same name, remove the setting and save it again. A private repository’s id isn’t public, so we keep it from its first run, which has to come from the same GitHub account or organization. The setting then says GitHub doesn’t show us the repository. If yours isn’t private, check the name: a misspelled one saves, but no run of the real repository deploys.

Under The workflow, the dashboard hands you this file ready to paste, with your app, branch and environment filled in. Copy it and save it as .github/workflows/deploy.yml in the repository:

name: Deploy
on:
push:
branches: [main]
workflow_dispatch:
permissions:
contents: read
id-token: write
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: ss2d22/jiayang/actions/deploy@d474fc67342b7c76e59b04ab83407a95a89f978d # action-v1.2.0
with:
app: acme/site

If you saved an environment, the job has environment: production under runs-on. With an environment and no branch, it also has actions: read under permissions, so we can read the environment’s rules and who approved the run with the job’s own token.

id-token: write is what lets the job ask GitHub for its token. It gives the job no other access to anything.

Commit the file and push it to the branch. The run’s log shows the deploy as it goes and ends with the app’s address. The new version is on the app’s Versions tab, and the GitHub Actions tab lists the latest runs that got a credential, by run number and commit. You can also start it by hand from the repository’s Actions tab, since the workflow has workflow_dispatch.

A pull request can get a preview of its own: the action puts its head up at a preview address, comments the address on the pull request, and keeps that one comment up to date as you push. When the pull request closes, merged or not, the preview ends and the comment says so. The app’s live version never changes. Previews of pull requests need the deploy action from action-v1.1.0 on, and CLI 0.1.11 or later.

They work for Worker apps and static sites that already have a version live, as any preview does.

  1. In the dashboard, open the app, then Deploy, then GitHub Actions. The repository has to be saved there already.
  2. Tick Put up a preview of each pull request.
  3. Fill in Pull request jobs deploy in this environment, for example previews. In GitHub, under the repository’s Settings, Environments, give that environment at least one required reviewer and turn on Prevent self-review. It isn’t optional: a pull request’s workflow is the pull request’s own, so its author picks the environment its job names, and GitHub makes up one it doesn’t know with no rules at all. So we ask GitHub about the environment, when you save for a public repository and on every pull request’s run for any repository, and refuse it unless a reviewer other than whoever started the job has to approve it. Its job waits for one of them before it runs, and so does the run that ends a closed pull request’s preview. Before a preview goes up we also read the run’s own review history: someone has to have approved it in that environment who didn’t write the pull request’s code, open it or start the run, so an admin skipping the rules, or a reviewer approving their own code, puts nothing up. If nobody approves that one, the preview answers until its 7 days run out, or until someone selects End for it on the GitHub Actions tab.
  4. Select Turn on previews.

Anyone who can push a branch to the repository can then run code as the app at a preview address, even when only one branch deploys to production, and even if they aren’t in your workspace. A preview uses the app’s production data, as any preview does: its environment variables, secrets, database, files, key-value storage and queue, Postgres connections, private services and its budget of the company AI key. Branch protection on your live branch doesn’t hold these runs back, since a pull request’s run deploys from its own branch. The environment’s reviewers are what stand between a push and its preview, so give it reviewers, or only turn this on for a repository where you’d let everyone who can push deploy. A bot’s code, such as Dependabot’s or Renovate’s, gets no preview, since nobody has read it yet: the action skips a bot’s push, and we refuse a pull request a bot opened, or any of whose commits a bot wrote or committed, whoever starts the run. Open a pull request of your own, with only your own commits, to put one up. A pull request with 250 commits or more, more than GitHub lists, gets no preview either. A commit’s author is only what git was told, so these checks keep honest bots out; the environment’s reviewer is what keeps out code nobody has read. Turning it on or off, or changing the environment, ends every credential runs hold now, like any other save. Turning previews off, saving another repository, or stopping deploys from GitHub also ends every pull request’s preview that’s up, since no run can end them any more.

With previews on, the dashboard hands you this file instead, with a second job for pull requests:

name: Deploy
on:
push:
branches: [main]
pull_request:
types: [opened, synchronize, reopened, closed]
workflow_dispatch:
permissions: {}
jobs:
deploy:
if: github.event_name != 'pull_request'
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@v5
- uses: ss2d22/jiayang/actions/deploy@d474fc67342b7c76e59b04ab83407a95a89f978d # action-v1.2.0
with:
app: acme/site
preview:
# A fork's pull request gets no preview.
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-latest
environment: previews
permissions:
actions: read
contents: read
id-token: write
pull-requests: write
concurrency:
group: preview-${{ github.event.pull_request.number }}
cancel-in-progress: ${{ github.event.action != 'closed' }}
steps:
- uses: actions/checkout@v5
with:
ref: ${{ github.event.pull_request.head.sha }}
persist-credentials: false
- uses: ss2d22/jiayang/actions/deploy@d474fc67342b7c76e59b04ab83407a95a89f978d # action-v1.2.0
with:
app: acme/site

The preview job checks out the pull request’s head, so the preview is the branch as pushed. pull-requests: write is for the comment, and for checking the pull request: the action sends the job’s own token with the trade, and we use it to ask GitHub where the pull request’s code comes from and who wrote each of its commits. actions: read lets us read, with the same token, whether the job’s environment needs a reviewer and who approved the run, and contents: read that the run built the pull request’s latest push. We never keep it, and we only read with it: it can write because the comment needs that. To send one that can only read, set comment: false and give the job pull-requests: read. A newer push to the same pull request cancels a run still going for it, and its preview replaces the last one.

A fork’s pull request never gets a preview. Three things stop it, each on its own:

  • The workflow’s if: skips the job for a pull request whose head is in another repository.
  • GitHub gives a fork’s run no OIDC token. The action sees a fork’s pull request too, says so in the run, and stops without asking for one.
  • We ask GitHub where the pull request’s head is, and refuse any pull request whose head isn’t in the repository itself, even if a private repository’s settings send write tokens to forks’ workflows.

Use pull_request, never pull_request_target. A pull_request_target run has the repository’s own rights whatever code it checks out, so the action refuses to run in one, and we refuse its token.

A pull request’s preview answers until the pull request closes, until its next push replaces it, or for 7 days, whichever comes first. A pull request with no push for a week loses its preview, and its next push brings one back. Each pull request has one preview at a time, and they don’t replace each other or the app’s own preview. How many pull requests have one at a time is your plan’s, counted across the workspace’s apps: 3 on Free, 10 on Team and 25 on Business (see limits). The next one is told so, and gets one once another closes or its preview ends.

A pull request’s previews aren’t counted in the newest 20 versions we keep, so a busy repository never pushes out the versions you can roll back to. Each one is pruned once it stops answering.

The GitHub Actions tab lists the pull requests’ previews still up, with an End button for each. From a terminal:

Terminal window
jiayang preview end acme/site --pull-request 7
Ended pull request #7's preview, version 12. Its address no longer answers.

The action runs that itself when the pull request closes. Anyone who can deploy the app can run it.

Less than a push’s. It puts up previews of its own pull request, and ends that pull request’s preview, and that’s all: it reads the app, its versions and its configuration, uploads files, and reads back what its preview’s scan found, as a deploy does. It can’t make anything live, push an image, or touch another pull request’s preview or the app’s own. A credential for a closed pull request can only end its preview. The database holds the same rules, whatever a request asks for. The audit log names the run and the pull request, as app.preview and app.preview_end.

It installs one version of the CLI from npm, without running any install scripts. Then it asks GitHub for a token with https://api.jiayang.cloud as its audience, trades it, and runs jiayang deploy. Detection, the install, the build and the upload work the same as on your machine: see how a directory is deployed.

Input What it does Default
app The app, as workspace/app. It has to exist already. required
directory What to deploy, from the root of the checkout. .
args More flags for jiayang deploy, such as --no-build. none
cli-version The CLI version to install, such as 0.1.12. GitHub deploys need 0.1.8 or later, previews of pull requests 0.1.11 or later, and container.instances in jiayang.json 0.1.12 or later. the version the action was released with
api The API, which is also the token’s audience. https://api.jiayang.cloud
github-token The token we check a pull request, or a run’s environment and who approved it, with, and the action comments on a pull request with. A run that isn’t a pull request’s sends it only when we ask for it, for an app that deploys from an environment on any branch. We never keep it. the job’s own token
comment Whether to comment a pull request’s preview address on it. true

The action sets url and version as outputs, so a later step can link to what went live, or for a pull request to its preview. mode says what it did: deploy, preview, end when the pull request closed, or skip for a fork’s pull request. It also sets scan to what the deploy’s scan found, clean or warnings, and adds a warning to the run when it found something. A deploy the scan refused fails the step, with what it found in the log.

The runner has Node already, and the CLI installs your packages from your lockfile before it builds. A project that needs another tool, such as pnpm, Bun or a pinned Node version, sets it up in a step before the action, as for any other build:

steps:
- uses: actions/checkout@v5
- uses: pnpm/action-setup@v4
with:
version: 10
- uses: ss2d22/jiayang/actions/deploy@d474fc67342b7c76e59b04ab83407a95a89f978d # action-v1.2.0
with:
app: acme/site
directory: web

The action is named by the commit that its release, action-v1.2.0, points at, the way GitHub recommends for any action. A tag can be moved by whoever can push to the repository it’s in, and the next run of every workflow naming it would run whatever it pointed at then. A commit can’t be moved. The workflow the dashboard hands you names the newest release the same way, so copy it from there when we release a new one.

A run deploys when all of these hold:

  • the repository’s name, its id and its owner’s id are the ones saved,
  • the branch is the one saved, when one is,
  • the job runs in the environment saved, when one is,
  • with an environment and no branch saved, the environment needs a reviewer other than whoever starts the job, and someone approved this run in it who didn’t start it or write any commit it brings in since the app’s last deploy from GitHub, and
  • the run was started by push, workflow_dispatch, release, schedule, deployment or repository_dispatch.

A run started by a pull request never deploys, whichever event it is. Its code can come from a fork, and so can what a workflow_run follows, so neither can deploy either. With previews of pull requests on, a pull_request run from the repository itself puts up a preview instead, and nothing goes live. pull_request_target never trades at all: it runs with the repository’s own rights whatever code it checks out. A deployment runs the commit being deployed, which can be a fork’s, so it deploys only when the setting names a branch and the deployment is of that branch. A release is on a tag, so it deploys only when no branch is saved.

A run that is turned away from an app with a setting is in the workspace’s audit log as github.refused, with the repository, the run, the event and why. Anyone can make a GitHub token for us from a repository of their own, so an app logs one refused run a minute at most, whichever repository it came from. The next one logged says how many more were turned away in between, as also_refused.

The credential deploys the one app it was made for. It reads the app, its versions and its configuration, uploads files, and makes new versions, and that’s all. Anyone who can push to the branch can change the workflow to print what the credential reads, so don’t put anything in the app’s configuration you wouldn’t show them: secrets stay out of its reach. It is told “a member of the workspace” where a person’s address would be, such as who deployed each version. It can’t share the app, open paths to the internet, read secrets or logs, roll back, or reach any other app. It never opens the app either.

A run deploys with the permissions of the person who saved the setting, and is logged as itself: the audit log names the repository, the run and the commit, beside who set it up. A credential stops working at once, and any image push access it got for a container app ends within a minute, when:

  • 15 minutes have passed,
  • an owner saves the setting again or removes it,
  • the person who saved it can no longer share the app, or their account ends,
  • the app is deleted, even if it’s restored later, or
  • the workspace is suspended or deleted, even if the suspension is lifted or the workspace is restored later.

Saving the setting chooses who else may deploy, so it takes the right to share the app. If the person who saved it loses that right, the setting is deleted. That happens when they leave the workspace, are made a plain member, lose their owner grant on the app or have it made an editor’s, or end up with a custom role without apps.share: given to them, given to a group they join, or edited. The audit log has a github.remove that says why. Getting the right back doesn’t bring the setting back: an owner sets it up again under their own name.

Under Deploy, GitHub Actions, select Stop deploys from GitHub and confirm. Credentials already handed out stop working at once. Removing it doesn’t ask you to sign in again first, so you can do it straight away.

id-token: write is the job’s, so every step in the job can ask GitHub for a token, and code your build runs is code in the job. The same goes for the pull request’s token in a preview job. The action keeps what it can out of the build’s reach: the CLI is installed before the token is asked for, the credential goes to the CLI in a file it deletes before the build starts, and the deploy step drops the variables that ask GitHub for a token before the build runs. But a build script or a dependency runs as the same user as everything else in the job, so treat it the way you would any build that can deploy: it’s trusted with deploying the app. Keep code you don’t trust, such as a fork’s pull request, out of any job with id-token: write. The preview job’s if: does that for forks.

The trade and the deploy happen in one step, and the credential’s file is deleted when the step ends, however it ends. On a self-hosted runner, use ephemeral runners, one job each, so nothing a job leaves behind is there for the next one.

The action prints our answer as the run’s error.

The run doesn’t match the setting, the app doesn’t exist, or nothing is saved for it. We answer the same in each case, so a token from someone else’s repository can’t find out which apps you have:

This GitHub run can't deploy acme/site. An owner of the app names the repository, and a branch or environment, under Deploy, GitHub Actions. A run started by a pull request puts up a preview only once they turn on previews of pull requests there.

Check the repository, the branch and the environment on the GitHub Actions tab against the run’s.

The setting names an environment and no branch, and the run didn’t send its own token when asked. The answer’s code is github_run_token, which the deploy action from action-v1.2.0 on answers by trading again with the job’s token. Use it, or name a branch:

acme/site deploys from runs on any branch in the production environment, so the run sends its own GITHUB_TOKEN for us to check that someone else approved it there. Use the deploy action from action-v1.2.0 on, and give the job actions: read and contents: read. Or have an owner of the app name a branch under Deploy, GitHub Actions.

The setting names an environment and no branch, and the environment lets a job through without a reviewer, lets whoever started it approve it, or isn’t there. This is what a setting saved before we checked says too:

The production environment of acme-gh/site lets a job run without a reviewer, or lets whoever started it approve it. With no branch named, anyone who can push a branch can change the workflow and deploy from it. Name a branch under Deploy, GitHub Actions, or on GitHub, under Settings, Environments, production, add a required reviewer and turn on Prevent self-review.

Nobody but whoever pushed, or wrote or committed one of the run’s commits, approved the run, or an admin skipped the reviewers. Have someone else approve the next run, or name a branch:

Nobody approved this run in the production environment but someone who wrote its commits or started it. With no branch named, anyone who can push a branch can deploy from it, so someone else has to approve the job on GitHub first. Or have an owner of the app name a branch under Deploy, GitHub Actions.

The run’s token couldn’t read the commit it built. Give the job contents: read, as the workflow above does:

GitHub didn't show us the commit this run built with the run's GITHUB_TOKEN, so we can't tell who wrote it. Give the job contents: read, as the workflow the dashboard hands you does.

Or it couldn’t read how the run’s commit compares with the one the app was last deployed from:

GitHub didn't show us the commits this run brings in with the run's GITHUB_TOKEN, so we can't tell who wrote them. Give the job contents: read, as the workflow the dashboard hands you does.

We have no commit a run deployed the app from, so there’s nothing to read the run’s commits against. No run has deployed it, or the last one did before we kept the commit each run deploys. Name a branch, deploy once from it, then take the branch out:

We have no commit a run deployed this app from, so we can't tell which commits this run brings in. Have an owner of the app name a branch under Deploy, GitHub Actions, and deploy once from it. Runs on any branch are read against that deploy from then on.

The run’s commit brings in 250 commits or more since the app was last deployed from GitHub, more than we read. Name a branch and deploy once from it, so the next run is read from there:

This run brings in 250 commits or more since 3f9c2ab, the commit the app was last deployed from by a run, more than we read, so we can't check who wrote them all. Have an owner of the app name a branch under Deploy, GitHub Actions, and deploy once from it.

Or what it brings in changes so much that GitHub’s comparison is too big for us to read:

GitHub's comparison of this run's commit with 3f9c2ab, the commit the app was last deployed from by a run, is too big for us to read, so we can't check who wrote its commits. Have an owner of the app name a branch under Deploy, GitHub Actions, and deploy once from it.

Or GitHub doesn’t have the commit the app was last deployed from: it was deleted, or the setting names another repository now. Name a branch and deploy once from it:

GitHub doesn't have 3f9c2ab, the commit the app was last deployed from by a run, in acme-gh/site, so we can't tell which commits this run brings in. Have an owner of the app name a branch under Deploy, GitHub Actions, and deploy once from it.

The run was started by a pull request, or by another event that can’t deploy:

A run started by pull_request can't deploy. Deploy from push, workflow_dispatch, release, schedule, deployment or repository_dispatch. A pull_request run can put up a preview once previews of pull requests are turned on; pull_request_target and workflow_run never can, since their code can come from a fork.

For a pull_request run, that also means previews of pull requests aren’t turned on for the app, or the run isn’t from the repository saved.

A pull request from a fork:

Pull request #9 comes from a fork, and a fork's code never gets a preview or a credential here. Its previews come from a branch in acme-gh/site itself.

The run didn’t send its own token, or GitHub wouldn’t show the pull request to it. Use action-v1.1.0 or later, and give the job pull-requests: write:

GitHub didn't show us pull request #7 with the run's GITHUB_TOKEN. Give the job pull-requests: read (the comment on the pull request needs write).

Your plan’s number of pull requests have a preview already:

3 other pull requests in this workspace have a preview up, and the Free plan allows 3 at once. Close one or end its preview, then run this again. Team allows 10 pull requests' previews at once.

A bot’s push to a pull request, such as Dependabot’s. The action skips these, so you only see this from a workflow of your own:

Pull request #9's run was started by a bot, and a bot's push never gets a preview. Push to its branch yourself to put one up.

A pull request a bot opened, or with a commit a bot wrote or committed, even when a person starts the run, say by selecting Update branch or reopening it:

Pull request #9 was opened by a bot, and a bot's code never gets a preview. Open a pull request of your own from the branch to put one up.
A commit in pull request #9 was written or committed by a bot, and a bot's code never gets a preview. Open a pull request of your own with only your commits to put one up.

The run’s token couldn’t read the pull request’s commits. Give the job pull-requests: write, as the workflow above does:

GitHub didn't show us the commits of pull request #9 with the run's GITHUB_TOKEN. Give the job pull-requests: read (the comment on the pull request needs write).

A pull request with more commits than GitHub lists:

Pull request #9 has 250 commits or more, more than GitHub will list, so we can't check who wrote them all. Squash it into fewer to put a preview up.

The environment the job ran in lets a job through without a reviewer, lets whoever started it approve it, or isn’t there. Give it a required reviewer and turn on Prevent self-review (see Turn them on):

The previews environment of acme-gh/site lets a job run without a reviewer, or lets whoever started it approve it. A preview runs as the app, with its data and secrets, and anyone who can push a branch can open a pull request. On GitHub, under Settings, Environments, previews, add a required reviewer and turn on Prevent self-review.

The run’s token couldn’t read the environment. Give the job actions: read, as the workflow above does:

GitHub didn't show us the previews environment with the run's GITHUB_TOKEN, so we can't tell whether a reviewer approved the job. Give the job actions: read, as the workflow the dashboard hands you does.

Nobody but the pull request’s author, someone whose commit is in it, or whoever started the run approved it, or an admin skipped the reviewers. Have someone else approve the next run:

Nobody approved pull request #7's run in the previews environment but someone who wrote its code, opened it or started the run. A preview runs as the app, with its data and secrets, so someone else has to approve the job on GitHub first.

The pull request was pushed to after the run started, so the run built older code than the pull request has now. The run for the latest push puts the preview up:

Pull request #7 has moved on since this run started, so it gets no preview. The run for its latest push puts one up.

A run for a pull request that’s closed, run again by hand:

Pull request #7 is closed, so it gets no preview. Its run can end the one it had.

If the run says the job can’t ask GitHub for an OIDC token, add id-token: write under permissions, as in the workflow above.

The person who saved the setting can’t share the app any more, or the workspace is suspended. An owner saves it again:

GitHub deploys of acme/site were set up by someone who can't share it any more, or the workspace is suspended. An owner of the app can set them up again under Deploy, GitHub Actions.

The token was used already, has expired, or isn’t one GitHub Actions made. Run the job again:

That isn't a GitHub Actions token we can take: it was traded for a credential already, and each one is traded once. Ask GitHub for a new one in the same job.

Too many runs at once:

Twenty runs already hold a live credential for this app. Try again once some have finished.

When you save the setting, GitHub has to know the repository:

There's no GitHub account or organization called acme-crop.
Name a branch, an environment, or both. Without one, a run on any branch of the repository could deploy.

With an environment and no branch, a public repository’s environment has to need a reviewer when you save, as in Name the repository.

Once the trade works, a deploy fails the same ways it does on your machine, with the same messages. See Troubleshooting.

  • Each GitHub token is traded once.
  • At most 20 credentials can be live for one app at a time.
  • One repository can make 60 trades a minute.
  • Each person, and each workspace, can save 10 new repositories an hour.
  • Pull requests’ previews up at once in a workspace: 3 on Free, 10 on Team, 25 on Business.

The action is a short script. Any job that can get a GitHub OIDC token can do the same: POST it as {"token": "..."} to /v1/workspaces/<workspace>/apps/<app>/github/token on the API, then run jiayang deploy with the token from the answer in JIAYANG_DEPLOY_TOKEN, or in a file named by JIAYANG_DEPLOY_TOKEN_FILE, which the CLI reads and deletes.

For CI outside GitHub, there’s no way to deploy without a person’s session yet.