Reusable workflows and a standalone CLI (gha-utils) that let you release Python packages multiple times a day with only 2-clicks. Designed for uv-based Python projects, but usable for other projects too.
Maintainer-in-the-loop: nothing is done behind your back. A PR or issue is created every time a change is proposed or action is needed.
Automates:
- Version bumping
- Changelog management
- Formatting autofix for: Python, Markdown, JSON, typos
- Linting: Python types with
mypy, YAML,zsh, GitHub Actions, URLS & redirects, Awesome lists, secrets - Compiling of Python binaries for Linux / macOS / Windows on
x86_64&arm64 - Building of Python packages and upload to PyPI
- Produce attestations
- Git version tagging and GitHub release creation
- Synchronization of:
uv.lock,.gitignore,.mailmapand Mermaid dependency graph - Auto-locking of inactive closed issues
- Static image optimization
- Sphinx documentation building & deployment, and
autodocupdates - Label management, with file-based and content-based rules
- Awesome list template synchronization
- Address GitHub Actions limitations
GitHub Actions has several design limitations. This repository works around most of them:
| Limitation | Status | Addressed by |
|---|---|---|
| No conditional step groups | ✅ Addressed | project-metadata job + gha-utils metadata |
| Workflow inputs only accept strings | ✅ Addressed | String parsing in gha-utils |
| Matrix outputs not cumulative | ✅ Addressed | project-metadata pre-computes matrices |
cancel-in-progress evaluated on new run, not old |
✅ Addressed | SHA-based concurrency groups in release.yaml |
| Cross-event concurrency cancellation | ✅ Addressed | event_name in changelog.yaml concurrency group |
| PR close doesn't cancel runs | ✅ Addressed | cancel-runs.yaml |
GITHUB_TOKEN can't modify workflow files |
✅ Addressed | WORKFLOW_UPDATE_GITHUB_PAT fine-grained PAT |
| Tag pushes from Actions don't trigger workflows | ✅ Addressed | Custom PAT for tag operations |
| Default input values not propagated across events | ✅ Addressed | Manual defaults in env: section |
head_commit only has latest commit in multi-commit pushes |
✅ Addressed | gha-utils metadata extracts full commit range |
actions/checkout uses merge commit for PRs |
✅ Addressed | Explicit ref: github.event.pull_request.head.sha |
| Multiline output encoding fragile | ✅ Addressed | Random delimiters in gha_utils/github.py |
| Branch deletion doesn't cancel runs | ❌ Not addressed | Same root cause as PR close; partially mitigated by cancel-runs.yaml since branch deletion typically follows PR closure |
| No native way to depend on all matrix jobs completing | ❌ Not addressed | GitHub limitation; use needs: with a summary job as workaround |
actionlint false positives for runtime env vars |
🚫 Not addressable | Linter limitation, not GitHub's |
$ cd my-project
$ uvx -- gha-utils init
$ git add . && git commit -m "Bootstrap reusable workflows" && git push
That's it. The workflows will start running and guide you through any remaining setup (like creating a WORKFLOW_UPDATE_GITHUB_PAT secret) via issues and PRs in your repository.
Run gha-utils init --help to see available components and options.
gha-utils stands for GitHub Actions workflows utilities.
Thanks to uv, you can run it in one command, without installation or venv:
$ uvx -- gha-utils
Usage: gha-utils [OPTIONS] COMMAND [ARGS]...
Options:
--time / --no-time Measure and print elapsed execution time. [default:
no-time]
--color, --ansi / --no-color, --no-ansi
Strip out all colors and all ANSI codes from output.
[default: color]
--config CONFIG_PATH Location of the configuration file. Supports local path
with glob patterns or remote URL. [default:
~/Library/Application Support/gha-
utils/*.toml|*.yaml|*.yml|*.json|*.ini]
--no-config Ignore all configuration files and only use command
line parameters and environment variables.
--show-params Show all CLI parameters, their provenance, defaults and
value, then exit.
--table-format [aligned|asciidoc|csv|csv-excel|csv-excel-tab|csv-unix|double-grid|double-outline|fancy-grid|fancy-outline|github|grid|heavy-grid|heavy-outline|html|jira|latex|latex-booktabs|latex-longtable|latex-raw|mediawiki|mixed-grid|mixed-outline|moinmoin|orgtbl|outline|pipe|plain|presto|pretty|psql|rounded-grid|rounded-outline|rst|simple|simple-grid|simple-outline|textile|tsv|unsafehtml|vertical|youtrack]
Rendering style of tables. [default: rounded-outline]
--verbosity LEVEL Either CRITICAL, ERROR, WARNING, INFO, DEBUG.
[default: WARNING]
-v, --verbose Increase the default WARNING verbosity by one level for
each additional repetition of the option. [default: 0]
--version Show the version and exit.
-h, --help Show this message and exit.
Commands:
broken-links Manage broken links issue lifecycle
changelog Maintain a Markdown-formatted changelog
check-renovate Check Renovate migration prerequisites
collect-artifacts Collect and rename artifacts for release
deps-graph Generate dependency graph from uv lockfile
git-tag Create and push a Git tag
init Bootstrap a repository to use reusable workflows
lint-changelog Check changelog dates against release dates
lint-repo Run repository consistency checks
mailmap-sync Update Git's .mailmap file with missing contributors
metadata Output project metadata
pr-body Generate PR body with workflow metadata
release-prep Prepare files for a release
setup-guide Manage setup guide issue lifecycle
sponsor-label Label issues/PRs from GitHub sponsors
sync-uv-lock Re-lock and revert if only timestamp noise changed
test-plan Run a test plan from a file against a binary
update-checksums Update SHA-256 checksums for binary downloads
update-gitignore Generate .gitignore from gitignore.io templates
verify-binary Verify binary architecture using exiftool
version-check Check if a version bump is allowed
workflow Manage downstream workflow caller files
$ uvx -- gha-utils --version
gha-utils, version 5.9.1
That's the best way to get started with gha-utils and experiment with it.
Tip
Development versions use a .devN suffix per PEP 440. When running from a Git clone, the short commit hash is appended as a local version identifier (e.g., 5.9.2.dev0+abc1234).
To ease deployment, standalone executables of gha-utils's latest version are available as direct downloads for several platforms and architectures:
That way you have a chance to try it out without installing Python or uv. Or embed it in your CI/CD pipelines running on minimal images. Or run it on old platforms without worrying about dependency hell.
Note
ABI targets:
$ file ./gha-utils-*
./gha-utils-linux-arm64.bin: ELF 64-bit LSB pie executable, ARM aarch64, version 1 (SYSV), dynamically linked, interpreter /lib/ld-linux-aarch64.so.1, BuildID[sha1]=520bfc6f2bb21f48ad568e46752888236552b26a, for GNU/Linux 3.7.0, stripped
./gha-utils-linux-x64.bin: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=56ba24bccfa917e6ce9009223e4e83924f616d46, for GNU/Linux 3.2.0, stripped
./gha-utils-macos-arm64.bin: Mach-O 64-bit executable arm64
./gha-utils-macos-x64.bin: Mach-O 64-bit executable x86_64
./gha-utils-windows-arm64.exe: PE32+ executable (console) Aarch64, for MS Windows
./gha-utils-windows-x64.exe: PE32+ executable (console) x86-64, for MS Windows
To play with the latest development version of gha-utils, you can run it directly from the repository:
$ uvx --from git+https://github.com/kdeldycke/workflows -- gha-utils --version
gha-utils, version 5.9.2.dev0+3eb8894
This repository contains workflows to automate most of the boring tasks in the form of reusable GitHub Actions workflows.
The fastest way to adopt these workflows is with gha-utils init (see Quick start). It generates all the thin-caller workflow files for you.
If you prefer to set up a single workflow manually, create a .github/workflows/lint.yaml file using the uses syntax:
name: Lint
on:
push:
pull_request:
jobs:
lint:
uses: kdeldycke/workflows/.github/workflows/lint.yaml@v5.9.1Important
Concurrency is already configured in the reusable workflows—you don't need to re-specify it in your calling workflow.
Downstream projects can customize workflow behavior by adding a [tool.gha-utils] section in their pyproject.toml:
[tool.gha-utils]
nuitka = false
unstable-targets = ["linux-arm64", "windows-arm64"]
test-plan-file = "./tests/cli-test-plan.yaml"
timeout = 120
test-plan = "- args: --version"
gitignore-location = "./.gitignore"
gitignore-extra-categories = ["terraform", "go"]
gitignore-extra-content = '''
junit.xml
# Claude Code
.claude/
'''
dependency-graph-output = "./docs/assets/dependencies.mmd"
extra-label-files = ["https://example.com/my-labels.toml"]
extra-file-rules = "docs:\n - docs/**"
extra-content-rules = "security:\n - '(CVE|vulnerability)'"| Option | Type | Default | Description |
|---|---|---|---|
nuitka |
bool | true |
Enable Nuitka binary compilation. Set to false for projects with [project.scripts] that don't need binaries. |
unstable-targets |
list[str] | [] |
Nuitka build targets allowed to fail without blocking the release (e.g., ["linux-arm64"]). |
test-plan-file |
str | "./tests/cli-test-plan.yaml" |
Path to the YAML test plan file for binary testing. Read directly by test-plan subcommand; CLI args override. |
timeout |
int | (none) | Timeout in seconds for each binary test. Read directly by test-plan subcommand; CLI --timeout overrides. |
test-plan |
str | (none) | Inline YAML test plan for binary testing. Read directly by test-plan subcommand; CLI --plan-file/--plan-envvar override. |
gitignore-location |
str | "./.gitignore" |
File path of the .gitignore to update. |
gitignore-extra-categories |
list[str] | [] |
Additional categories to add to the .gitignore file (e.g., ["terraform", "go"]). |
gitignore-extra-content |
str | See example above | Additional content to append to the generated .gitignore. Supports TOML multi-line literal strings ('''...'''). |
dependency-graph-output |
str | "./docs/assets/dependencies.mmd" |
Location of the generated dependency graph file. Read directly by deps-graph subcommand; CLI --output overrides. |
extra-label-files |
list[str] | [] |
URLs of additional label definition files (JSON, JSON5, TOML, or YAML) downloaded and applied by labelmaker. |
extra-file-rules |
str | "" |
Additional YAML rules appended to the bundled file-based labeller configuration. |
extra-content-rules |
str | "" |
Additional YAML rules appended to the bundled content-based labeller configuration. |
Setup — guide new users through initial configuration:
-
Setup guide (
setup-guide)- Detects missing
WORKFLOW_UPDATE_GITHUB_PATsecret and opens an issue with step-by-step setup instructions - Automatically closes the issue once the secret is configured
- Skip: upstream
kdeldycke/workflowsrepo,workflow_callevents
- Detects missing
Formatters — rewrite files to enforce canonical style:
-
Format Python (
format-python) -
Format
pyproject.toml(format-pyproject)- Auto-formats
pyproject.tomlusingpyproject-fmt - Requires:
- Python package with a
pyproject.tomlfile
- Python package with a
- Auto-formats
-
Format Markdown (
format-markdown)- Auto-formats Markdown files using
mdformat - Requires:
- Markdown files (
**/*.{markdown,mdown,mkdn,mdwn,mkd,md,mdtxt,mdtext,mdx}) in the repository
- Markdown files (
- Auto-formats Markdown files using
-
Format JSON (
format-json)- Auto-formats JSON, JSONC, and JSON5 files using Biome
- Requires:
- JSON files (
**/*.{json,jsonc,json5},**/.code-workspace,!**/package-lock.json) in the repository
- JSON files (
Fixers — correct or improve existing content in-place:
-
Fix typos (
fix-typos)- Automatically fixes typos in the codebase using
typos
- Automatically fixes typos in the codebase using
-
Lint changelog (
lint-changelog)- Checks and fixes changelog dates and admonitions using
gha-utils lint-changelog
- Checks and fixes changelog dates and admonitions using
-
Optimize images (
optimize-images)- Compresses images in the repository using
image-actions - Requires:
- Image files (
**/*.{jpeg,jpg,png,webp,avif}) in the repository
- Image files (
- Compresses images in the repository using
Syncers — regenerate files from external sources or project state:
-
Update .gitignore (
update-gitignore)- Regenerates
.gitignorefrom gitignore.io templates usinggha-utils update-gitignore - Requires:
- A
.gitignorefile in the repository
- A
- Regenerates
-
Sync bumpversion config (
sync-bumpversion)- Syncs the
[tool.bumpversion]configuration inpyproject.tomlusinggha-utils init bumpversion - Skipped if:
[tool.bumpversion]section already exists inpyproject.toml
- Syncs the
-
Sync workflows (
sync-workflows)- Syncs thin-caller workflow files from the upstream
kdeldycke/workflowsrepository usinggha-utils workflow sync - Skipped if:
- Repository is
kdeldycke/workflowsitself (the upstream source)
- Repository is
- Syncs thin-caller workflow files from the upstream
-
Update
.mailmap(update-mailmap)- Keeps
.mailmapfile up to date with contributors usinggha-utils mailmap-sync - Requires:
- A
.mailmapfile in the repository root
- A
- Keeps
-
Update dependency graph (
update-deps-graph)- Generates a Mermaid dependency graph of the Python project using
gha-utils deps-graph - Requires:
- Python package with a
uv.lockfile
- Python package with a
- Generates a Mermaid dependency graph of the Python project using
-
Update docs (
update-docs)- Regenerates Sphinx autodoc files using
sphinx-apidoc - Runs
docs/docs_update.pyif present to generate dynamic content (tables, diagrams, Sphinx directives) - Requires:
- Python package with a
pyproject.tomlfile docsdependency group- Sphinx autodoc enabled (checks for
sphinx.ext.autodocindocs/conf.py)
- Python package with a
- Regenerates Sphinx autodoc files using
-
Sync awesome template (
sync-awesome-template)- Syncs awesome list projects from the
awesome-templaterepository usingactions-template-sync - Requires:
- Repository name starts with
awesome- - Repository is not
awesome-templateitself
- Repository name starts with
- Syncs awesome list projects from the
-
Lock inactive threads (
lock)- Automatically locks closed issues and PRs after 90 days of inactivity using
lock-threads
- Automatically locks closed issues and PRs after 90 days of inactivity using
-
Dump context (
dump-context)- Dumps GitHub Actions context and runner environment info across all build targets using
ghaction-dump-context - Useful for debugging runner differences and CI environment issues
- Runs on:
- Push to
main(only whendebug.yamlitself changes) - Monthly schedule
- Manual dispatch
workflow_callfrom downstream repositories
- Push to
- Dumps GitHub Actions context and runner environment info across all build targets using
-
Cancel PR runs (
cancel-runs)- Cancels all in-progress and queued workflow runs for a PR's branch when the PR is closed
- Prevents wasted CI resources from long-running jobs (e.g. Nuitka binary builds) that continue after a PR is closed
- GitHub Actions does not natively cancel runs on PR close — the
concurrencymechanism only triggers cancellation when a new run enters the same group
-
Bump versions (
bump-versions)- Creates PRs for minor and major version bumps using
bump-my-version - Syncs
uv.lockto include the new version in the same commit - Uses commit message parsing as fallback when tags aren't available yet
- Requires:
bump-my-versionconfiguration inpyproject.toml- A
changelog.mdfile
- Runs on:
- Schedule (daily at 6:00 UTC)
- Manual dispatch
- After
release.yamlworkflow completes successfully (viaworkflow_runtrigger, to ensure tags exist before checking bump eligibility). Checks out the latestmainHEAD, not the triggering workflow's commit.
- Creates PRs for minor and major version bumps using
-
Prepare release (
prepare-release)- Creates a release PR with two commits: a freeze commit that freezes everything to the release version, and an unfreeze commit that reverts to development references and bumps the patch version
- Uses
bump-my-versionandgha-utils changelog - Must be merged with "Rebase and merge" (not squash) — the auto-tagging job needs both commits separate
- Requires:
bump-my-versionconfiguration inpyproject.toml- A
changelog.mdfile
- Runs on:
- Push to
main(whenchangelog.md,pyproject.toml, or workflow files change) - Manual dispatch
workflow_callfrom downstream repositories
- Push to
These jobs require a docs dependency group in pyproject.toml so they can determine the right Sphinx version to install and its dependencies:
[dependency-groups]
docs = [
"furo",
"myst-parser",
"sphinx",
…
]-
Deploy Sphinx doc (
deploy-docs) -
Sphinx linkcheck (
check-sphinx-links)- Runs Sphinx's built-in
linkcheckbuilder to detect broken auto-generated links (intersphinx, autodoc, type annotations) that Lychee cannot see - Creates/updates issues for broken documentation links found
- Requires:
- Python package with a
pyproject.tomlfile docsdependency group- Sphinx configuration file at
docs/conf.py
- Python package with a
- Skipped for:
- Pull requests
prepare-releasebranch- Post-release version bump commits
- Runs Sphinx's built-in
-
Check broken links (
check-broken-links)- Checks for broken links in documentation using
lychee - Creates/updates issues for broken links found
- Requires:
- Documentation files (
**/*.{markdown,mdown,mkdn,mdwn,mkd,md,mdtxt,mdtext,mdx,rst,tex}) in the repository
- Documentation files (
- Skipped for:
- All PRs (only runs on push to main)
prepare-releasebranch- Post-release bump commits
- Checks for broken links in documentation using
-
Sync labels (
sync-labels)- Synchronizes repository labels using
labelmaker - Uses
labels.tomlwith multiple profiles:defaultprofile applied to all repositoriesawesomeprofile additionally applied toawesome-*repositories
- Synchronizes repository labels using
-
File-based PR labeller (
file-labeller)- Automatically labels PRs based on changed file paths using
labeler - Skipped for:
prepare-releasebranch- Bot-created PRs
- Automatically labels PRs based on changed file paths using
-
Content-based labeller (
content-labeller)- Automatically labels issues and PRs based on title and body content using
issue-labeler - Skipped for:
prepare-releasebranch- Bot-created PRs
- Automatically labels issues and PRs based on title and body content using
-
Tag sponsors (
sponsor-labeller)- Adds a
💖 sponsorslabel to issues and PRs from sponsors using the GitHub GraphQL API - Skipped for:
prepare-releasebranch- Bot-created PRs
- Adds a
-
Lint repository metadata (
lint-repo)- Validates repository metadata (package name, Sphinx docs, project description) using
gha-utils lint-repo. Readspyproject.tomldirectly. - Requires:
- Python package (with a
pyproject.tomlfile)
- Python package (with a
- Validates repository metadata (package name, Sphinx docs, project description) using
-
Lint types (
lint-types)- Type-checks Python code using
mypy - Requires:
- Python files (
**/*.{py,pyi,pyw,pyx,ipynb}) in the repository
- Python files (
- Skipped for:
prepare-releasebranch
- Type-checks Python code using
-
Lint YAML (
lint-yaml)- Lints YAML files using
yamllint - Requires:
- YAML files (
**/*.{yaml,yml}) in the repository
- YAML files (
- Skipped for:
prepare-releasebranch- Bot-created PRs
- Lints YAML files using
-
Lint Zsh (
lint-zsh)- Syntax-checks Zsh scripts using
zsh --no-exec - Requires:
- Zsh files (
**/*.zsh) in the repository
- Zsh files (
- Skipped for:
prepare-releasebranch- Bot-created PRs
- Syntax-checks Zsh scripts using
-
Lint GitHub Actions (
lint-github-actions)- Lints workflow files using
actionlintandshellcheck - Requires:
- Workflow files (
.github/workflows/**/*.{yaml,yml}) in the repository
- Workflow files (
- Skipped for:
prepare-releasebranch- Bot-created PRs
- Lints workflow files using
-
Lint Awesome list (
lint-awesome)- Lints awesome lists using
awesome-lint - Requires:
- Repository name starts with
awesome- - Repository is not
awesome-templateitself
- Repository name starts with
- Skipped for:
prepare-releasebranch
- Lints awesome lists using
-
Lint secrets (
lint-secrets)- Scans for leaked secrets using
gitleaks - Skipped for:
prepare-releasebranch- Bot-created PRs
- Scans for leaked secrets using
Release Engineering is a full-time job, and full of edge-cases that nobody wants to deal with. This workflow automates most of it for Python projects.
Cross-platform binaries — Targets 6 platform/architecture combinations (Linux/macOS/Windows × x86_64/arm64). Unstable targets use continue-on-error so builds don't fail on experimental platforms. Job names are prefixed with ✅ (stable, must pass) or
-
Detect squash merge (
detect-squash-merge)- Detects squash-merged release PRs, opens a GitHub issue to notify the maintainer, and fails the workflow
- The release is effectively skipped:
create-tagonly matches commits with the[changelog] Release vprefix, so no tag, PyPI publish, or GitHub release is created from a squash merge - The net effect of squashing freeze + unfreeze leaves
mainin a valid state for the next development cycle; the maintainer just releases the next version when ready - Runs on:
- Push to
mainonly
- Push to
-
Build package (
build-package)- Builds Python wheel and sdist packages using
uv build - Requires:
- Python package with a
pyproject.tomlfile
- Python package with a
- Builds Python wheel and sdist packages using
-
Compile binaries (
compile-binaries)- Compiles standalone binaries using
Nuitkafor Linux/macOS/Windows onx64/arm64 - Requires:
- Python package with CLI entry points defined in
pyproject.toml
- Python package with CLI entry points defined in
- Skipped if
[tool.gha-utils] nuitka = falseis set inpyproject.toml(for projects with CLI entry points that don't need standalone binaries) - Skipped for branches that don't affect code:
update-mailmap(.mailmapchanges)format-markdown(documentation formatting)format-json(JSON formatting)update-gitignore(.gitignoreupdates)optimize-images(image optimization)update-deps-graph(dependency graph docs)
- Compiles standalone binaries using
-
Test binaries (
test-binaries)- Runs test plans against compiled binaries using
gha-utils test-plan - Requires:
- Compiled binaries from
compile-binariesjob - Test plan file (default:
./tests/cli-test-plan.yaml)
- Compiled binaries from
- Skipped for:
- Same branches as
compile-binaries
- Same branches as
- Runs test plans against compiled binaries using
-
Create tag (
create-tag)- Creates a Git tag for the release version
- Requires:
- Push to
mainbranch - Release commits matrix from
gha-utils metadata
- Push to
-
Publish to PyPI (
publish-pypi)- Uploads packages to PyPI with attestations using
uv publish - Requires:
PYPI_TOKENsecret- Built packages from
build-packagejob
- Uploads packages to PyPI with attestations using
-
Create release (
create-release)- Creates a GitHub release with all artifacts attached using
action-gh-release - Requires:
- Successful
create-tagjob
- Successful
- Creates a GitHub release with all artifacts attached using
-
Sync bundled config (
sync-bundled-config)- Keeps the bundled
gha_utils/data/renovate.json5in sync with the rootrenovate.json5 - Only runs in:
- The
kdeldycke/workflowsrepository
- The
- Keeps the bundled
-
Migrate to Renovate (
migrate-to-renovate)- Automatically migrates from Dependabot to Renovate by creating a PR that:
- Exports
renovate.json5configuration file (if missing) - Removes
.github/dependabot.yamlor.github/dependabot.yml(if present)
- Exports
- PR body includes a prerequisites status table showing:
- What this PR fixes (config file creation, Dependabot removal)
- What needs manual action (security updates settings, token permissions)
- Links to relevant settings pages for easy access
- Uses
peter-evans/create-pull-requestfor consistent PR creation - Skipped if:
- No changes needed (
renovate.json5already exists and no Dependabot config is present)
- No changes needed (
- Automatically migrates from Dependabot to Renovate by creating a PR that:
-
Renovate (
renovate)- Validates prerequisites before running (fails if not met):
renovate.json5configuration exists- No Dependabot config file present
- Dependabot security updates disabled
- Runs self-hosted Renovate to update dependencies
- Creates PRs for outdated dependencies with stabilization periods
- Handles security vulnerabilities via
vulnerabilityAlerts - Requires:
WORKFLOW_UPDATE_GITHUB_PATsecret with Dependabot alerts permission
- Validates prerequisites before running (fails if not met):
-
Sync
uv.lock(sync-uv-lock)- Runs
uv lock --upgradeto update transitive dependencies to their latest allowed versions usinggha-utils sync-uv-lock - Only creates a PR when the lock file contains real dependency changes (timestamp-only noise is detected and skipped)
- Replaces Renovate's
lockFileMaintenance, which cannot reliably revert noise-only changes - Requires:
- Python package with a
pyproject.tomlfile
- Python package with a
- Runs
Most jobs in this repository depend on a shared parent job called project-metadata. It runs first to extract contextual information, reconcile and combine it, and expose it for downstream jobs to consume.
This expands the capabilities of GitHub Actions, since it allows to:
- Share complex data across jobs (like build matrix)
- Remove limitations of conditional jobs
- Allow for runner introspection
- Fix quirks (like missing environment variables, events/commits mismatch, merge commits, etc.)
This job relies on the gha-utils metadata command to gather data from multiple sources:
- Git: current branch, latest tag, commit messages, changed files
- GitHub: event type, actor, PR labels
- Environment: OS, architecture
pyproject.toml: project name, version, entry points
Important
This flexibility comes at the cost of:
- Making the whole workflow a bit more computationally intensive
- Introducing a small delay at the beginning of the run
- Preventing child jobs to run in parallel before its completion
But is worth it given how GitHub Actions can be frustrating.
All Python dependencies and CLIs are installed via uv for speed and reproducibility.
Jobs are guarded by conditions to skip unnecessary steps: file type detection (only lint Python if .py files exist), branch filtering (prepare-release skipped for most linting), and bot detection.
Workflows never commit directly or act silently. Every proposed change creates a PR; every action needed opens an issue. You review and decide — nothing lands without your approval.
Workflows accept inputs for customization while providing defaults that work out of the box. Downstream projects can further customize behavior via [tool.gha-utils] configuration in pyproject.toml.
Safe to re-run: tag creation skips if already exists, version bumps have eligibility checks, PRs update existing branches.
Fallback tokens (secrets.WORKFLOW_UPDATE_GITHUB_PAT || secrets.GITHUB_TOKEN) and continue-on-error for unstable targets. Job names use emoji prefixes for at-a-glance status: ✅ for stable jobs that must pass,
This repository uses these workflows for itself.
All dependencies are pinned to specific versions for stability, reproducibility, and security.
| Mechanism | What it pins | How it's updated |
|---|---|---|
uv.lock |
Project dependencies | Renovate PRs |
| Hard-coded versions in YAML | GitHub Actions, npm, Python | Renovate PRs |
uv --exclude-newer option |
Transitive dependencies | Time-based window |
| Tagged workflow URLs | Remote workflow references | Release process |
--from . gha-utils |
CLI from local source | Release freeze |
GitHub Actions and npm packages are pinned directly in YAML files:
- uses: actions/checkout@v6.0.1 # Pinned action
- run: npm install eslint@9.39.1 # Pinned npm packageRenovate's github-actions manager handles action updates, and a custom regex manager handles npm packages pinned inline in workflow files.
To avoid update fatigue, and mitigate supply chain attacks, renovate.json5 uses stabilization periods (with prime numbers to stagger updates).
This ensures major updates get more scrutiny while patches flow through faster.
The uv.lock file pins all project dependencies, and Renovate keeps it in sync.
The --exclude-newer flag ignores packages released in the last 7 days, providing a buffer against freshly-published broken releases.
Workflows in this repository are self-referential. The prepare-release job's freeze commit rewrites workflow URL references from main to the release tag, ensuring released versions reference immutable URLs. The unfreeze commit reverts them back to main for development.
This repository updates itself via GitHub Actions. But updating its own YAML files in .github/workflows is forbidden by default, and we need extra permissions.
Usually, to grant special permissions to some jobs, you use the permissions parameter in workflow files:
on: (…)
jobs:
my-job:
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps: (…)But contents: write doesn't allow write access to workflow files in .github/. The actions: write permission only covers workflow runs, not their YAML source files. Even permissions: write-all doesn't work.
You will always end up with this error:
! [remote rejected] branch_xxx -> branch_xxx (refusing to allow a GitHub App to create or update workflow `.github/workflows/my_workflow.yaml` without `workflows` permission)
error: failed to push some refs to 'https://github.com/kdeldycke/my-repo'
Note
The Settings → Actions → General → Workflow permissions setting on your repository has no effect on this issue. Even with "Read and write permissions" enabled, the default GITHUB_TOKEN cannot modify workflow files—that's a hard security boundary enforced by GitHub:

Modifying workflow files is the primary reason for the PAT, but it serves additional purposes. Events triggered by GITHUB_TOKEN don't start new workflow runs, so operations like tag pushes also need the PAT to trigger downstream workflows. Renovate requires several additional permissions for its full feature set.
Jobs that use WORKFLOW_UPDATE_GITHUB_PAT:
| Workflow | Job | Reason |
|---|---|---|
autofix.yaml |
Fix typos | Create PR touching .github/workflows/ files |
autofix.yaml |
Sync workflows | Create PR updating workflow caller files |
autofix.yaml |
Sync awesome template | Checkout and sync including workflow files |
changelog.yaml |
Prepare release | Create release PR freezing versions in workflow files |
release.yaml |
Create tag | Push tag that triggers on.push.tags workflows |
release.yaml |
Publish GitHub release | Create release that triggers downstream workflows |
renovate.yaml |
Renovate | Manage dependency PRs, status checks, dashboard, and vulnerability alerts |
Each token permission maps to specific needs:
| Permission | Needed for |
|---|---|
| Workflows | All PR-creating jobs that touch .github/workflows/ files |
| Contents | Tag pushes, release publishing, PR branch creation |
| Pull requests | All PR-creating jobs (sync-workflows, fix-typos, prepare-release, Renovate) |
| Commit statuses | Renovate stability-days status checks |
| Dependabot alerts | Renovate vulnerability alert reading |
| Issues | Renovate Dependency Dashboard |
| Metadata | Required for all fine-grained token API operations |
All jobs fall back to GITHUB_TOKEN when the PAT is unavailable (secrets.WORKFLOW_UPDATE_GITHUB_PAT || secrets.GITHUB_TOKEN), but operations requiring the workflows permission or workflow triggering will silently fail.
To bypass these limitations, create a custom access token called WORKFLOW_UPDATE_GITHUB_PAT. It replaces the default secrets.GITHUB_TOKEN in steps that need elevated permissions.
-
Go to GitHub → Settings → Developer Settings → Personal Access Tokens → Fine-grained tokens
-
Click Generate new token
-
Configure:
Field Value Token name workflow-self-update(or similar descriptive name)Expiration Choose based on your security policy Repository access Select Only select repositories and choose the repos that need workflow self-updates -
Click Add permissions:
Permission Access Commit statuses Read and Write Contents Read and Write Dependabot alerts Read-only Issues Read and Write Metadata Read-only (mandatory) Pull requests Read and Write Workflows Read and Write [!IMPORTANT] The Workflows permission is the key. This is the only place where you can grant it—it's not available via the
permissions:parameter in YAML files.The Commit statuses permission is required by Renovate to set status checks (e.g.,
renovate/stability-days) on commits.The Dependabot alerts permission allows Renovate to read vulnerability alerts and create security update PRs, replacing Dependabot security updates.
The Issues permission is required by Renovate to create and update the Dependency Dashboard issue.
-
Click Generate token and copy the
github_pat_XXXXvalue
- Go to your repository → Settings → Security → Secrets and variables → Actions
- Click New repository secret
- Set:
- Name:
WORKFLOW_UPDATE_GITHUB_PAT - Secret: paste the
github_pat_XXXXtoken
- Name:
Go to your repository → Settings → Advanced Security → Dependabot and configure:
| Setting | Status | Reason |
|---|---|---|
| Dependabot alerts | ✅ Enabled | Renovate reads these alerts to detect vulnerabilities |
| Dependabot security updates | ❌ Disabled | Renovate creates security PRs instead |
| Grouped security updates | ❌ Disabled | Not needed when security updates are disabled |
| Dependabot version updates | ❌ Disabled | Renovate handles all version updates |
Warning
Keep Dependabot alerts enabled—these are passive notifications that Renovate reads via the API. Disable all other Dependabot features since Renovate handles both security and version updates.
Re-run your workflow. It should now update files in .github/workflows/ without the error.
Tip
For organizations: Consider using a machine user account or a dedicated service account to own the PAT, rather than tying it to an individual's account.
Warning
Token expiration: Fine-grained PATs expire. Set a calendar reminder to rotate the token before expiration, or your workflows will fail silently.
All workflows use a concurrency directive to prevent redundant runs and save CI resources. When a new commit is pushed, any in-progress workflow runs for the same branch or PR are automatically cancelled.
Workflows are grouped by:
- Pull requests:
{workflow-name}-{pr-number}— Multiple commits to the same PR cancel previous runs - Branch pushes:
{workflow-name}-{branch-ref}— Multiple pushes to the same branch cancel previous runs
release.yaml uses a stronger protection: release commits get a unique concurrency group based on the commit SHA, so they can never be cancelled. This ensures tagging, PyPI publishing, and GitHub release creation complete successfully.
Additionally, cancel-runs.yaml actively cancels in-progress and queued runs when a PR is closed. This complements passive concurrency groups, which only trigger cancellation when a new run enters the same group — closing a PR doesn't produce such an event.
Tip
For implementation details on how concurrency groups are computed and why release.yaml needs special handling, see claude.md § Concurrency implementation.
Check these projects to get real-life examples of usage and inspiration:
Awesome Falsehood - Falsehoods Programmers Believe in.
Awesome Engineering Team Management - How to transition from software development to engineering management.
Awesome IAM - Identity and Access Management knowledge for cloud platforms.
Awesome Billing - Billing & Payments knowledge for cloud platforms.
Meta Package Manager - A unifying CLI for multiple package managers.
Mail Deduplicate - A CLI to deduplicate similar emails.
dotfiles - macOS dotfiles for Python developers.
Click Extra - Extra colorization and configuration loading for Click.
workflows - Itself. Eat your own dog-food.
Extra Platforms - Detect platforms and group them by family.
Feel free to send a PR to add your project in this list if you are relying on these scripts.
See claude.md for development commands, code style, testing guidelines, and design principles.