# ADR-0003: Composite Actions for CI Workflow Reuse Date: 2026-08-12 Status: Accepted ## Context The devx repository's Gitea Actions workflows (`.gitea/workflows/ci.yml`, `post-merge.yml`, `build-images.yml`) repeated multi-step sequences across jobs: 1. **Set up environment** — `make setup-image` (optionally with `EXTRAS=`). Appeared verbatim in 4 jobs across `ci.yml` and `post-merge.yml`, each with the same `CI_GITEA_API_TOKEN` env wiring. 2. **Notify on failure** — `python3 -m devx.ci.notify_failure ... --auto-login` with venv activation, PATH export, and 5 fixed CLI args. Appeared in 4 jobs (`ci/validate`, `post-merge/detect-and-configure`, `post-merge/release-and-maintain`, `build-images/build-and-push`), each differing only in the `--workflow` string. 3. **Quality checks** — a 6-step sequence (lint-all, pytest-cov, check-test-speed, devx-docs-check, check-translations, pip-audit) with venv activation boilerplate on every step. Appeared once in `ci.yml` validate job, but the same sequence is needed by `grm` and `infra` (Phase 2b/2c of the cross-repo refactoring plan). This duplication had the following costs: - **Drift risk**: a fix to notify-failure (for example, new flag, different env var) had to be applied to 4 places; missing one caused inconsistent failure notifications. - **Workflow YAML noise**: the 6-step quality block obscured the validate job's actual structure (detect-changes, pr-review, release-dry-run). - **Cross-repo reuse blocked**: `grm` and `infra` could not adopt the same quality-checks sequence without copy-pasting the inline steps, which would amplify the drift problem across 3 repos. - **Gitea 1.27 constraints**: every `run` step needs explicit `shell:`; composite actions cannot access `secrets` directly (only `env:`). These constraints had to be re-discovered and re-applied per step. ## Decision Introduce three Gitea composite actions in `.gitea/actions/`: ### 1. `setup-env/action.yml` Wraps the `make setup-image` call. Single input `extras` (default empty) forwarded to `make setup-image EXTRAS=`. Reads `CI_GITEA_API_TOKEN` and `CI_GITEA_USERNAME` from the calling workflow's `env:` context. ### 2. `notify-failure/action.yml` Wraps the `devx.ci.notify_failure` invocation. Single required input `workflow` (the workflow/job name for the Gitea issue title). Step is gated by `if: failure()` so it only runs on job failure. Reads `CI_GITEA_API_TOKEN` from the calling workflow's `env:` context. ### 3. `quality-checks/action.yml` Wraps the 6-step quality sequence. Inputs: - `package` (default empty) — sets `DEVX_DOC_VERSIONS_PKG` for doc version checks (for example, `devx`, `grm`). - `test-speed-max` (default `15`) — total test seconds threshold. - `test-speed-max-single` (default `0.5`) — per-test seconds threshold. - `translations-file` (default empty) — path to `translations.json` for repos whose translations live outside `src/devx/`. Each step activates the venv defensively (`. .venv/bin/activate 2>/dev/null || true`) so the action works with both pre-built CI images (which symlink `/opt/venv` to `.venv`) and fresh `make setup-image` runs. ### Adoption Scope - **`ci.yml` validate job**: `setup-env` + `quality-checks` + `notify-failure`. - **`ci.yml` auto-merge job**: `setup-env` only (no quality checks, no notify-failure — auto-merge failure is surfaced by the validate job's notify-failure). - **`post-merge.yml` detect-and-configure**: `setup-env` + `notify-failure`. - **`post-merge.yml` release-and-maintain**: `setup-env` (with `extras: "release"`) + `notify-failure`. - **`build-images.yml` build-and-push**: `notify-failure` only. The setup steps use `make setup-release` and `make setup-ci` (not `make setup-image`), so `setup-env` does not apply. The cleanup job has no notify-failure step (it only runs on build-and-push success). ### Same-Repo Copies (No Cross-Repo References) Each consumer repo (`devx`, `grm`, `infra`) gets its own copy of the composite actions under its `.gitea/actions/` directory. There is no `uses: oblachno-oss/devx/.gitea/actions/...@vX.Y.Z` reference. This avoids a single-point-of-failure where a bad `devx` master commit would break all three repos' CI simultaneously. The cost is three copies of ~30 lines of YAML each, updated manually when a composite action changes. Given the stability of these patterns (the inline versions were unchanged for months), this cost is acceptable. ## Consequences ### Positive - **Workflow YAML is shorter and clearer**: the validate job's quality block collapses from 32 lines to 5 lines. The intent (`uses: ./.gitea/actions/quality-checks`) is more legible than 6 individually wrapped steps. - **Drift eliminated**: a change to notify-failure (new flag, different env var) is applied in one file. All 4 calling sites pick it up. - **Cross-repo reuse enabled**: Phase 2b (`grm`) and Phase 2c (`infra`) copy the same `action.yml` files and adopt the same `uses:` pattern. The quality-checks sequence is now portable. - **Gitea 1.27 constraints centralized**: the `shell: bash` and `env:` (not `secrets`) patterns are encoded once per action, not re-derived per step. - **No release triggered**: changes to `.gitea/**` are classified as workflow-only by `devx.ci.classify_changes`. Phase 2a does not produce a new devx version. `grm`/`infra` bump to the Phase 1 release (v0.50.4), not a Phase 2a version. ### Negative - **Three copies of each action**: when a composite action changes, the change must be applied to `devx`, `grm`, and `infra` independently. This is intentional (see Same-Repo Copies preceding) but is a maintenance cost. - **Composite action debugging is harder**: Gitea's log output for composite action steps is nested under the action name. Finding the failing step requires reading one more level of indentation. - **`env:` propagation is implicit**: the calling workflow's top-level `env:` block must define `CI_GITEA_API_TOKEN` for the composite action to read it. A workflow that omits this will see an empty token at runtime, not at lint time. actionlint does not catch this. - **`quality-checks` is devx-shaped**: the `package` and `translations-file` inputs exist because consumer repos (for example, `grm`) have translations files outside the default `src/devx/translations.json` location and need doc version checks targeting their own package name. A repo with a different translations path or package layout would need a new input or a different action. This is acceptable for the current 3-repo scope. ### Neutral - **`if: failure()` is preserved**: the `notify-failure` composite action's step has `if: failure()`, which is evaluated in the calling workflow's job-status context. This is the standard Gitea Actions pattern for post-failure notification. - **Venv activation is defensive**: `. .venv/bin/activate 2>/dev/null || true` does not fail if the venv is missing (pre-built image path) or already active. This matches the inline pattern's behavior.