# 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/.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/` 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.