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]>
196 lines
7.7 KiB
Markdown
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.
|