Files
config-public/docs/triggering-deploys.md
Lucas WintherandClaude Opus 5 e1f152ec3e
lint / yamllint + ansible-lint + syntax (push) Successful in 2m52s
lint / shellcheck (push) Successful in 7s
lint / secret scan (push) Failing after 3s
Add the documentation and the agent instructions
README for humans, AGENTS.md for agents and contributors, and the docs that
outlive any one platform: architecture, secrets, connectivity, triggering a
deploy from another repository. CLAUDE.md, GEMINI.md and .claude/skills/ are
pointers rather than copies, so every agent and every human reads the same
text.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-16 03:58:24 +02:00

196 lines
7.7 KiB
Markdown

# Triggering a deploy from another repository
The case: an application repository builds a new image, pushes it, and then asks
*this* repository to redeploy the stack that runs it.
```
application repo config repo
────────────────────── ──────────────────────────
build → push :latest ─── trigger ───► deploy
stack=webapp └─ ansible-playbook
pull=always playbooks/webapp.yml
-e pull=always
```
`pull=always` is not optional here. The image tag has not changed — only its
contents have — and Compose is perfectly happy with the image it already has. A
trigger that forgets it produces a deploy that does nothing and reports success.
## GitHub Actions
`deploy.yml` accepts a `repository_dispatch` event of type `deploy`.
### Caller
```yaml
- name: Redeploy the stack
env:
GH_TOKEN: ${{ secrets.CONFIG_REPO_TOKEN }}
run: |
gh api repos/OWNER/config/dispatches \
--field event_type=deploy \
--field 'client_payload[stack]=webapp' \
--field 'client_payload[pull]=always'
```
`CONFIG_REPO_TOKEN` is a fine-grained personal access token, or a GitHub App
installation token, with **Contents: read and write** on the config repository.
The default `GITHUB_TOKEN` cannot dispatch to another repository.
### What happens downstream
`deploy.yml` validates the payload before doing anything:
1. rejects `stack` unless it matches `[a-z0-9_-]+`, and `pull` unless it is one
of `policy`, `always`, `missing`, `never`,
2. fails if `ansible/playbooks/<stack>.yml` does not exist,
3. materialises the SSH key with `scripts/load-ssh-key.sh`,
4. runs the playbook.
The values arrive from another repository and end up on a command line, so they
are treated as hostile. See "Why untrusted input is validated" in
[ci-github-actions.md](ci-github-actions.md).
### The caller cannot wait
A `repository_dispatch` returns as soon as GitHub accepts the event. The response
contains no run id, so the caller cannot poll for a result either. If the build
pipeline must go red when the deploy fails, you have two options:
- **Put the deploy in the same repository** as the build and use `workflow_call`,
which does propagate failure.
- **Poll.** After dispatching, list workflow runs for `deploy.yml` created after
your dispatch timestamp and wait on the newest. This is fiddly and racy; only
do it if you genuinely need the coupling.
Most of the time you do not. A deploy that fails is visible in the config
repository's Actions tab and can notify on failure there.
## GitLab CI
`deploy:triggered` exists only in pipelines that were triggered from outside, so
it can never fire on a push here.
### Multi-project trigger (preferred)
In the upstream project:
```yaml
deploy-config:
stage: deploy
trigger:
project: your-group/config
branch: main # must be a protected branch — see below
strategy: depend # upstream waits and mirrors the result
variables:
STACK: webapp
PULL: always
```
`strategy: depend` is what makes the build pipeline report the *deployment's*
result. Drop it and the trigger job goes green the moment the request is
accepted, whether or not the deploy worked.
This authenticates with `CI_JOB_TOKEN`, so **the config project must allowlist
the upstream project** under Settings → CI/CD → Job token permissions. Recent
GitLab versions deny cross-project job-token access by default, and the failure
is a **404** rather than a permissions error — which is the most common cause of
"the trigger does nothing".
### Trigger token
For callers that are not GitLab CI — a webhook, a cron box, a person with
`curl`. Create the token under Settings → CI/CD → Pipeline trigger tokens.
```bash
curl --request POST \
--form token="$DEPLOY_TRIGGER_TOKEN" \
--form ref=main \
--form "variables[STACK]=webapp" \
--form "variables[PULL]=always" \
"https://gitlab.example.com/api/v4/projects/your-group%2Fconfig/trigger/pipeline"
```
The project path must be URL-encoded (`%2F` for the slash), or use the numeric
project ID. The response is the created pipeline as JSON; `.web_url` takes you
straight to it.
### One-time setup
1. **Allowlist the caller** under Job token permissions, or the trigger 404s.
2. **Protect the target branch**, and mark the deploy variables Protected.
Protected variables only exist on protected refs; trigger a feature branch and
the key comes back empty.
3. **Give the upstream project Developer access**, or the job token is refused
regardless of the allowlist.
## Gitea Actions
Gitea has no `repository_dispatch`. The way in is the **workflow dispatch API**,
which fills in the same form a person would use under Actions → deploy → Run
workflow. It needs Gitea 1.24 or newer.
### Caller
```bash
curl -X POST \
-H "Authorization: token $CONFIG_REPO_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"ref": "main",
"inputs": {"stack": "webapp", "mode": "deploy", "pull": "always"}
}' \
https://gitea.example.com/api/v1/repos/OWNER/config/actions/workflows/deploy.yml/dispatches
```
Three things are easy to get wrong here:
- **`ref` is required**, and it is the ref the playbooks are read from as well as
the one the run is attributed to. Point it at your default branch.
- **`mode` falls back to `check`.** Omit it and the trigger runs
`--check --diff`, reports what it would have done, and goes green having
deployed nothing. This is the Gitea equivalent of forgetting `pull=always`, and
it fails just as quietly. (The workflow applies that fallback itself: an
input's declared `default:` belongs to the dispatch form, not to the API.)
- **The job's own `GITEA_TOKEN` cannot do this.** It is scoped to the repository
the job runs in. `CONFIG_REPO_TOKEN` is an access token belonging to a user
with write access to the config repository — Settings → Applications → Access
tokens, with the `write:repository` scope — stored as a secret in the calling
repository.
The workflow name in the path is the file name, `deploy.yml`.
### The caller can wait, if it wants to
Add `?return_run_details=true` and the response carries the run's id and URL, so
a caller can poll `/api/v1/repos/OWNER/config/actions/runs/<id>` until it
finishes and fail its own pipeline on the result. That is more than GitHub's
`repository_dispatch` offers, and more than most callers need: a deploy that
fails is visible in the config repository's Actions tab and can notify from
there.
### What happens downstream
Exactly what happens on GitHub — the same validation is in
`.gitea/workflows/deploy.yml`, and it matters more here. A `type: choice` input
constrains the *form* and not the API, so a dispatch call may put any string in
`stack`.
## Before you enable this
**A trigger is a deploy.** Anyone holding the token, and any repository on the
allowlist, can deploy any stack in this repository. The validation guard stops
them running *arbitrary commands*; it does not stop them deploying a stack they
had no business touching. Scope the allowlist deliberately, and rotate the token
when someone leaves.
**Confirm the playbook exists first.** The downstream job fails the pipeline by
design when it does not, which is correct but makes for a confusing first
experience if you enable the trigger before converting the stack.
## Checking it worked
The Ansible recap at the end of the job log is the real answer. `changed=0` means
the images had not moved — which, for a trigger that fired after a successful
build, almost always means `pull=always` was not passed.