Files
config-public/docs/triggering-deploys.md
T
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

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:

  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.

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:

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

  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

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.