CI/CD pipeline¶
Every generated project ships a self-contained GitHub Actions pipeline that wires test → version bump → publish → docs → (optional) container release together. Both templates use the same five-workflow skeleton; the python template adds a sixth workflow when Podman is enabled.
This page documents what each workflow does, how they trigger one another, and what secrets / variables you need to configure on the repository.
Pipeline at a glance¶
push to master/main ──▶ ci-tests (lint + matrix tests)
└──▶ version-bump (cocogitto release)
│
└─push tag X.Y.Z──▶ package-release (build → publish → GitHub release)
└─▶ docs-deploy (mkdocs gh-deploy)
└─▶ container-release (python + use_podman only)
pull_request ────────▶ ci-tests
| Workflow | Trigger | Purpose | python |
uv-workspace |
|---|---|---|---|---|
ci-tests.yaml |
push / pull_request | Lint + multi-Python matrix tests | ✅ | ✅ |
version-bump.yaml |
push to master / main |
Cocogitto computes next version, updates files, tags | ✅ | ✅ (monorepo mode) |
package-release.yaml |
tag matching [0-9]+.[0-9]+.[0-9]+ |
Build → publish (public + private PyPI) → GitHub Release | ✅ | ✅ |
docs-deploy.yaml |
tag or manual dispatch | mkdocs gh-deploy to the gh-pages branch |
only with use_github_actions=yes |
only with use_github_actions=yes |
container-release.yaml |
GitHub Release published | Multi-arch container build → push to GHCR | only with use_podman=yes |
— |
Required secrets and variables¶
Set these once in Settings → Secrets and variables → Actions before the first push:
| Name | Type | Used by |
|---|---|---|
PERSONAL_ACCESS_TOKEN |
Secret | version-bump (push tags), container-release (push to GHCR), package-release (create GitHub Release) |
PYPI_TOKEN |
Secret | package-release → public PyPI |
PYPI_SERVER_USERNAME / PYPI_SERVER_PASSWORD |
Secret | package-release → private PyPI server, plus uv index auth across all workflows |
PUBLISH_TO_PUBLIC_PYPI |
Variable | Set to 'true' to enable the public PyPI publish job. Leave unset for private-only releases. |
PERSONAL_ACCESS_TOKEN must be a fine-grained PAT (or classic) with contents: write at minimum. The reason it is needed instead of the default GITHUB_TOKEN is so that the bump commit + tag pushed by version-bump actually triggers downstream workflows — pushes made with the default token do not re-trigger workflows by design.
ci-tests.yaml — code quality gate¶
┌──────────┐ ┌────────────────────────────┐
│ lint │──▶ │ test (matrix: 3.x, ...) │
└──────────┘ └────────────────────────────┘
Runs on every push to master / main and every pull request.
lint job
setup-uv@v5withenable-cache: true.just initto bootstrap the env (installsrust-justas a uv tool,uv sync, installs pre-commit hooks).just lint— Ruff fix + format + final verification check.just lint-pre-commit— runs every pre-commit hook (uv-lock,yamlfmt,check-github-workflows,actionlint) so workflow YAML errors are caught in PRs, not onmaster.
test job (matrix)
strategy.matrix.python-versionis rendered statically by Cookiecutter frommin_python_version..max_python_version. Picking3.10..3.13produces["3.10", "3.11", "3.12", "3.13"], so each version runs in its own runner in parallel.fail-fast: falseso one version's failure doesn't kill the rest — useful for spotting compatibility issues at a glance.setup-uv@v5withpython-version: ${{ matrix.python-version }}provisions the right interpreter directly through uv (no separateactions/setup-pythonstep).pythontemplate:uv sync --all-extrasthenjust test-version <ver>.uv-workspacetemplate:uv sync --all-groupsthenjust test-version <ver>— pytest is pointed atpackages/so all members run together.
Concurrency
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
PR pushes cancel superseded runs to save minutes; pushes to master / main are queued (never cancelled), so failed runs on the default branch always show up.
Container build smoke test (python only, use_podman=yes)
When the python template is generated with use_podman=yes, an extra container-build-test job runs alongside the test matrix:
- Sets up QEMU.
- Builds the container image for
linux/amd64andlinux/arm64viaredhat-actions/buildah-build@v2. - Does not push — the build itself is the assertion.
This catches Dockerfile / dependency regressions on PRs, before they would otherwise blow up in container-release.
version-bump.yaml — cocogitto release¶
┌─────────────────────────────────────────────────┐
│ release (cocogitto/cocogitto-action@v3) │
│ - cog bump --auto │
│ - run pre_bump_hooks (uv version, uv lock) │
│ - update CHANGELOG.md │
│ - commit + tag (cog-bot identity) │
│ - run post_bump_hooks → git push --follow-tags│
└─────────────────────────────────────────────────┘
Triggers on every push to master / main (and workflow_dispatch).
Skip guard
Cocogitto's own bump commits are prefixed with chore(version):, so the workflow ignores them and avoids re-triggering itself.
Why a PAT, not the default token
Checkout uses token: ${{ secrets.PERSONAL_ACCESS_TOKEN }}. When cocogitto's post_bump_hooks runs git push --follow-tags it reuses the auth that checkout configured, so the push must come from a token GitHub recognizes as "real activity" — pushes made with the default GITHUB_TOKEN are explicitly prevented from triggering downstream workflows, which would mean the X.Y.Z tag push never fires package-release or docs-deploy.
Push is a cog hook, not a workflow step
cocogitto-action only runs cog bump --auto locally on the runner — creating the bump commit and tag — and does not push. Following the Cocogitto bump guide, cog.toml declares post_bump_hooks = ["git push --follow-tags"] so the same cog bump invocation handles the push whether you run it locally or in CI. The workflow itself doesn't have a separate push step.
Concurrency
Two pushes in quick succession are queued, never run in parallel — important because cocogitto computes the next version from current refs and concurrent runs would race each other.
Monorepo difference (uv-workspace)
The workflow file itself is identical to the python template's. The monorepo behavior comes entirely from cog.toml:
generate_mono_repository_global_tag = true→ oneX.Y.Ztag for the repo state.generate_mono_repository_package_tags = true+monorepo_version_separator = "-"→ per-package tags like<pkg>-X.Y.Z.[packages.<slug>]blocks tell cocogitto which paths "belong" to each package, so a commit only bumps the packages whose paths it touched.
The downstream package-release workflow only listens for the global X.Y.Z tag, so a single bump cycle produces one publish run.
package-release.yaml — build & publish¶
┌─────────┐
│ build │ ── upload-artifact dist/
└────┬────┘
├──────────────────┬───────────────────┐
▼ ▼ ▼
publish-pypi publish-private-pypi (gate)
(if PUBLISH_ (always) │
TO_PUBLIC_PYPI │
== 'true') │
└──────────────────┴───────────────────┘
▼
┌──────────────┐
│ release │ softprops/action-gh-release
└──────────────┘
Triggers on tag pushes matching [0-9]+.[0-9]+.[0-9]+.
build job
just build (which is uv build for the python template, uv build --all-packages for uv-workspace). The dist/ directory is uploaded as an artifact named dist, so downstream publishers don't recompile.
publish-pypi (public)
- Gated on the
PUBLISH_TO_PUBLIC_PYPIrepository variable so closed-source projects can release to private only. - Uses PyPI trusted publishing (
id-token: write) plusUV_PUBLISH_TOKEN. uv-workspace:just publish-pypiinvokesuv publish --check-url https://pypi.org/simple/, so packages whose version is already on PyPI are silently skipped — important when only some workspace members were bumped.
publish-private-pypi
- Always runs.
- Calls
just publish-pypi-server, which forwardsPYPI_SERVER_USERNAME/PYPI_SERVER_PASSWORDand the configuredPYPI_SERVER_URLtouv publish --username --password --publish-url. uv-workspace: same--check-urlskip behavior, scoped to the private index.
release job
Runs after both publish jobs as long as nothing actually failed — a skipped publish-pypi (private-only mode) does not block the GitHub Release. Downloads the dist/ artifact and creates a GitHub Release with auto-generated notes plus the wheel and sdist attached.
docs-deploy.yaml — documentation¶
A single job; both templates ship the same content.
push tag X.Y.Z (or workflow_dispatch)
│
▼
┌─────────────────────────────────────────────┐
│ deploy │
│ - actions/checkout │
│ - setup-uv (cache) │
│ - just deploy-gh-pages │
│ ↳ uv run mkdocs gh-deploy --force │
│ ↳ pushes to gh-pages branch │
└─────────────────────────────────────────────┘
- Uses the default
GITHUB_TOKENwithpermissions: contents: write— sufficient to push thegh-pagesbranch, no PAT needed. concurrency: { group: pages, cancel-in-progress: false }queues two close-together releases instead of letting them race the gh-pages branch.- Repository setting: Pages must use Source → Deploy from a branch →
gh-pages/(root).repo-scaffold gh-initconfigures this automatically; set it manually under Settings → Pages only if you bootstrapped without it (or used--no-pages).
The workflow is gated on the same use_github_actions=yes cookiecutter prompt as the rest of the docs setup; opting out removes the workflow file along with mkdocs.yml and the docs/ directory.
container-release.yaml — Podman / GHCR¶
Generated only for the python template when use_podman=yes. Skipped (the file is removed by post_gen_project.py) otherwise.
release (Published)
│
▼
┌────────────────────────────────────────────┐
│ push_to_registry │
│ - actions/checkout │
│ - QEMU │
│ - podman-login (ghcr.io, PAT) │
│ - buildah-build (linux/amd64,arm64) │
│ tags: latest, ${sha}, ${ref_name} │
│ - push-to-registry │
└────────────────────────────────────────────┘
- Triggers on
release: { types: [published] }, so it runs afterpackage-releasefinishes successfully. permissions: { contents: read, packages: write }— thepackages: writeline is what authorizes the push to GHCR.- Multi-arch builds use buildah with QEMU emulation; the
redhat-actions/*action stack is small and doesn't need BuildKit. - Tags pushed every release:
latest, the commit${sha}, and the version${ref_name}.latestalways points at the most recent published release.
End-to-end timeline¶
A push of feat: … to master produces, in order:
ci-tests— lint passes, every Python version in the matrix passes.version-bump— cocogitto computes a minor bump, updatespyproject.toml+uv.lock+CHANGELOG.md, commits ascog-bot, pushes tag0.2.0.package-release— builds, publishes to public + private PyPI, attaches the wheel/sdist to a GitHub Release. The release itself becomes "published".docs-deploy—mkdocs gh-deployupdatesgh-pagesfrom the freshly tagged source.container-release(python + Podman only) — multi-arch container image pushed to GHCR.
The whole sequence runs without manual intervention as long as commits follow Conventional Commits. The only setup is the initial repository configuration — secrets, the PUBLISH_TO_PUBLIC_PYPI variable, and the Pages source pointed at gh-pages — all of which repo-scaffold gh-init applies for you.