CI/CD pipeline¶
Python and uv-workspace templates ship a GitHub Actions pipeline that wires test → version bump → publish → docs → (optional) container release together. The electron-workspace template uses a separate centralized pipeline for quality checks, version bumping, and unsigned cross-platform Electron artifacts.
Electron workspace pipeline¶
The generated Electron project keeps only thin caller workflows:
ci.yamlcallsreusable-electron-ci.yamlfor format checks, builds, and tests.version-bump.yamlcallsreusable-version-bump.yamlfor Cocogitto releases.release.yamlcallsreusable-electron-release.yamlon a SemVer tag or manual dispatch.
The release workflow packages Windows, macOS, and Linux artifacts and uploads them to GitHub Releases. Code signing, notarization, and certificate management are intentionally outside the current template scope.
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 (or explicit --version) │
│ - 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). A manual dispatch may provide release-version (for example 1.0.0) to run cog bump --version explicitly; this is the GitOps path for the initial 0.x → 1.0.0 transition.
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.