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]>
7.7 KiB
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
- 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:
- rejects
stackunless it matches[a-z0-9_-]+, andpullunless it is one ofpolicy,always,missing,never, - fails if
ansible/playbooks/<stack>.ymldoes not exist, - materialises the SSH key with
scripts/load-ssh-key.sh, - 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.
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.ymlcreated 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:
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.
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
- Allowlist the caller under Job token permissions, or the trigger 404s.
- 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.
- 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
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:
refis 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.modefalls back tocheck. 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 forgettingpull=always, and it fails just as quietly. (The workflow applies that fallback itself: an input's declareddefault:belongs to the dispatch form, not to the API.)- The job's own
GITEA_TOKENcannot do this. It is scoped to the repository the job runs in.CONFIG_REPO_TOKENis an access token belonging to a user with write access to the config repository — Settings → Applications → Access tokens, with thewrite:repositoryscope — 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.