Skip to content

✨ feat(workflows): shared CI, release and update workflows - #46

Merged
gaborbernat merged 38 commits into
tox-dev:mainfrom
gaborbernat:feat/shared-ci
Nov 4, 2026
Merged

gaborbernat merged 38 commits into
tox-dev:mainfrom
gaborbernat:feat/shared-ci

Conversation

@gaborbernat

@gaborbernat gaborbernat commented Nov 4, 2026 •

Copy link
Copy Markdown
Member

Callers of these workflows currently write the gate, release, dependency and build jobs themselves. This PR offers them as reusable workflows and composite actions. A caller adds one uses: line, pins a release SHA and receives later fixes at its next pin. 🧩

The change covers pull request gates and CodeQL, the release tail, automated dependency and maintenance pull requests, and builds for maturin extensions, JetBrains plugins, pre-commit mirrors and LaTeX documents.

Write tokens are minted only in jobs that run no project code. Read-only jobs check out the pull request and produce a patch or an artifact, and they check it against the paths the caller allows. A separate writer job verifies the digest of that output and then mints the GitHub App token that pushes or publishes. The gate is one composite action, required-checks, so every gate applies the same rule to skipped jobs. The trade-off is that callers keep their own triggers, environments and publish steps, and pin a full commit SHA for each release.

Caller-facing changes

  • CodeQL check names change. Callers of the reusable CodeQL workflow see checks named <caller job> / 🛡️ Analyze <language>. Branch protection that requires the old names must require the new ones.
  • gw's own CI is ci.yaml. check.yaml is now the reusable tox check for libraries, with one job per include row.
  • Cutoff inputs. New reusable workflows that install tools take exclude-newer and exclude-newer-package, with the same defaults and meaning as the existing Actions. The default cutoff is 7 days.
  • Cutoff exceptions expire. A [tool.nab] exception carries an expiry date. After it passes, refresh-release-lock and upgrade-release-tools fail on it.
  • Maturin gate. build-maturin-wheels passes when its sdist job is skipped, which happens when sdist is off.
  • Upgrade commit message. upgrade-release-tools no longer takes commit-message. The pull request title is the commit subject.
  • Release patch paths. validate-release-patch takes a caller-supplied allowed-paths list, so a caller can admit paths beyond the default.

Temporary bump-deps-index exception

update-python-dependencies requires bump-deps-index>=1.19. That release, which carries the per-package and per-group cutoffs the workflow needs, was uploaded on 2026-11-04, inside the seven-day cutoff. The bump step exempts that one package until 2026-11-11 so callers can use the workflow now. The exemption is fixed in the step, so callers cannot remove it by changing their inputs.

CI cost

  • Maintenance pull requests check paths and modes once, in the read-only validate job. The writer applies the bytes whose digest validate recorded. Kept: that read-only check, the only check of what the writer may change, and the digest check before the App token is minted.
  • update-python-dependencies runs cooled-prek once. Hooks that rewrite files pass into the pull request, whose checks run the hooks on the result. Kept: a hook failure that changed nothing still fails the job, so a real error does not open a pull request.
  • upgrade-release-tools hands its pull request to open-maintenance-pr instead of keeping its own writer. Kept: the same validation and token boundary.
  • Release patch paths and file modes run in one step. Kept: both checks.
  • Independent installs run as background steps and join before the step that needs them: tools and hooks in update-python-dependencies, and apt, tox and Python in check.yaml.
  • stage-jetbrains-marketplace no longer repeats the mode check. release-creator rejects an unknown mode before any upload.
  • Kept on purpose: the ui-test build, which needs the plugin's own build configuration, the latex Pages verification that the served bytes match, and the mirror tag comparison and atomic push, which guard writes.

Every gate repeated a jq expression over the needs context. The new
action fails unless each needed job succeeded, and it accepts named jobs
that may be skipped, so a gate names only what it requires.

gw's own CI moves from check.yaml to ci.yaml so the name is free for the
reusable tox check workflow. Job names do not change, so required check
names stay as they are.
Library repositories repeat one tox matrix: install tox, build each
environment with --notest, run it with --skip-pkg-install, and gate on
the results. The reusable workflow takes the matrix as JSON rows and
keeps the install under the same upload cutoff the Actions use, while
tox environments keep fresh dependencies.

Coverage data is an opt-in artifact per job, so a caller that measures
coverage across runners still merges it in its own job.
CodeQL is the same analysis in each repository and differs only in the
languages it covers, the paths it skips, and its query suite. The
reusable workflow takes those as inputs, and each caller keeps its own
triggers and schedule.

The workflow pins CodeQL Action v4.39.1. gw's own analysis still ran
v4.39.0, so it moves to the same release through a thin caller.
`release-notes.yaml` renders the GitHub Release body in a read-only job
and uploads it as an artifact. Its changelog defaults to the path in the
committed Towncrier configuration, and the previous tag comes from
`render-release-notes`, so callers drop their own previous-tag and upload
steps.

`render-release-notes` gains a `package` input that renders one package
section of a shared Markdown changelog, for repositories that release
several packages from one tag namespace.
`github-release.yaml` checks the PyPI distributions with
`reconcile-pypi-artifacts`, then publishes the GitHub Release with
`publish-github-release`. The job runs in the caller's `release`
environment with `contents: write`, so a caller cannot forget the
environment, which several callers did. The caller keeps its PyPI upload
job, because trusted publishing cannot run in a reusable workflow.

Callers pass `title-prefix` for tags such as `v1.2.3`, and
`assets-directory` to attach the distributions. The README gains the
caller examples for both workflows.
A `[tool.nab]` cutoff exception outlives its release unless someone
removes it, so the lock check now requires an expiry date for each one.
Nab rejects unknown keys in a package table, so the date sits in a
comment directly above the table header or dotted key:

    # expires 2026-11-09
    [tool.nab.packages.release-creator]
    uploaded-prior-to = 2026-11-02T21:45:00Z

`refresh-release-lock` in `check` mode runs `check_nab_exceptions.py`
before it installs Nab. It fails, and names the package, when an
exception has no comment, a malformed date, a date already past, or a
`[[tool.nab.package-rules]]` cutoff, which the check does not read.
A standalone `nab.toml` is checked too. Dates are inclusive: the
exception is valid through its date.
The expiry check was a step inside `refresh-release-lock`, reachable only
through its check mode. Tools run through actions in this repository, so
the check moves into `check-nab-exceptions`, a composite Action that
callers and the lock actions can call. `refresh-release-lock` calls it in
check mode.
The weekly lock refresh now runs the cutoff exception check before it
refreshes the lock. A caller whose `[tool.nab]` exception has passed its
date, or has no date, fails the scheduled run and sees the package name,
even when no release input changed. The check-mode call in
`refresh-release-lock` stays.

The README describes the three places the check runs.
Callers that generate a maintenance change in a read-only job can now
open or update its pull request without peter-evans/create-pull-request.
The read-only validate job applies the patch to a scratch index and
accepts only paths that match allowed-paths as regular non-executable
files or deletions. The App writer job, in the release-auth
environment, checks the patch digest and paths again, and mints the App
token only after both checks pass. It force-pushes the branch with a
lease and edits or opens the pull request.
Add a reusable workflow that bumps the direct requirements of the files
a caller names, under a seven-day upload cooldown. A run can expedite
named packages past the cooldown, and cooldown 0 days turns it off.
The lock is refreshed with Nab, the repository's prek hooks run twice
to confirm the result is stable, and the change goes through
open-maintenance-pr.

The bump-deps-index bootstrap always waits out the seven-day cooldown,
since tooling is cooled. kind library is refused until bump-deps-index
can apply a cutoff to one dependency section; it cannot yet, so
libraries cannot use this workflow.
Describe the step mapping with a TypedDict instead of Any, and read the workflow-level env so a step test sees the same variables as the runner.
kind library keeps the groups in fresh-groups (default "project test")
at their newest release by passing --exclude-newer-group GROUP=false,
and cools every other group. kind app passes no group. Other groups,
the build backend, and hooks stay cooled.

The bump-deps-index install takes the new exclude-newer and
exclude-newer-package inputs, seven days by default, instead of a
bootstrap cutoff hard-coded in the workflow. The bump-deps-index
requirement is one BUMP_DEPS_INDEX constant in the workflow env, set to
the release that adds the group and package cutoffs.
The maintenance writer pushes a branch and opens a pull request from a
patch that a read-only job of the caller produced. Record that
threat, its control in open-maintenance-pr, and the verdict for a caller
whose allowlist admits workflow files. Update the untrusted-input row,
which named only the release writer's paths.
build-maturin-wheels.yaml builds a maturin extension for a JSON build
matrix. A source job runs an optional pre-build command once, each
matrix entry builds in its own job, and an sdist job builds from the
hash-checked build group. Artifacts keep the names callers already
publish, and no job requests a write token.
validate-release-patch takes allowed-paths, glob patterns for the only
paths a release patch may change. When it is set, the Towncrier
configuration is not read and the changelog and fragment rule does not
apply. Executable and symlink modes still fail.
prepare-release-creator.yaml follows prepare-towncrier-release: the
assemble job selects the tag, links fragments, runs the caller's
changelog command, and uploads a patch without a token; the push job
checks the patch against release-paths and pushes the commit and tag
with the release App. Repositories with several packages scope tags
and fragments with package and tag-format.
Add a reusable workflow that builds the unsigned plugin ZIP and records its SHA256,
runs verifyPlugin for each entry of a matrix input, runs the Xvfb UI tests with a
configurable start timeout, and gates on all of them. Release callers pass the tag as
plugin-version and sign the recorded ZIP.

The build selects exactly one archive matching the version, and keeps the Gradle cache
off because the ZIP feeds a signed release. UI-test reports upload on failure.
Add a composite that checks a signed ZIP against the Marketplace with
release-creator before upload (mode stage) and against the public version afterwards
(mode verify). The channel is derived from the tag suffix, so callers pass it to the
publish action instead of computing it.
Add a composite that commits the next -dev plugin version without an App token.
It requires a plain X.Y.Z tag and writes the manifest that publish-post-release-pr
consumes, so the App token stays in the writer job.
Add a reusable workflow for pyproject-fmt and tox-toml-fmt style mirrors.

- Pin pre-commit-mirror-maker and prek, and setup-uv to an exact version.
- Run prek twice: exit 1 means files changed, the second run must pass. Other
  statuses fail the job instead of being masked.
- Refuse to move a published tag. The push creates the branch and tag in one
  atomic push, with no force and no token in the remote URL.
- Serialize runs per repository with cancel-in-progress disabled.
Add a reusable workflow that compiles a LaTeX root file with SOURCE_DATE_EPOCH pinned
to the commit time, uploads the PDF, deploys it to gh-pages from main, and waits until
Pages serves the built SHA256. Build and deploy jobs have timeouts, and the workflow
grants no permissions at top level.
The daily mirror run failed whenever the package version had already been
tagged, even when the regenerated content matched the tag. Compare the
regenerated tree with the published tag's tree instead: identical content
ends the run with a notice and no push, and different content fails with
both tree IDs so the maintainer knows to bump the version.
Replace the Any-typed YAML dicts in the workflow tests with a recursive
JsonValue alias. load_yaml validates the parsed document once with a
TypeIs guard, and small accessors (at, mapping, text, steps, step) narrow
each read, so no test body touches Any. The guards get direct tests.
The LaTeX deploy writes gh-pages and the pre-commit mirror writes a
branch and tag from a caller repository. Name the new untrusted input, the
write each one holds, and the controls, so reports can be classified.
Every gate calls required-checks, including the maturin and JetBrains
gates that used inline jq. Maturin allows only a skipped sdist job.

Tool installs in the new reusable workflows take exclude-newer and
exclude-newer-package like the existing Actions. Inputs use kebab-case,
jobs declare permissions explicitly, and checkouts do not persist
credentials. The mirror workflow authenticates pushes with gh auth
setup-git instead of the persisted checkout token.
conftest keeps one typed YAML path (load_yaml with BaseLoader, at,
mapping, text, steps, step) and one step runner (run_workflow_step).
The runner expands ${{ }} from explicit values unless the env override
supplies the variable. Tests drop Any, cast and local loaders.
Inline the module-level pattern and parse arguments through a typed
Namespace subclass. Output is unchanged.
Document the shared workflows and their gates, correct the Validate a
Python release example, and rename the allowed-paths fragment to a
feature because it adds an optional input.
bump-deps-index 1.19.0, which carries the per-package and per-group
cutoffs the update workflow needs, was uploaded on 2026-11-04. The
seven-day cutoff would reject it, so the bump step exempts that one
package until 2026-11-11. The exemption sits in the step rather than an
input so callers cannot drop it while the release is young.
@gaborbernat gaborbernat added the enhancement New feature or request label Nov 4, 2026
Comment on lines +50 to +55
- name: 📥 Check out repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.sha }}
persist-credentials: false
- name: 📩 Download maintenance patch
Comment on lines +147 to +153
- name: 📥 Check out repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.sha }}
fetch-depth: 0
persist-credentials: false
- name: 📩 Download maintenance patch
Comment on lines +74 to +80
- name: 📥 Check out repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
persist-credentials: false
ref: ${{ github.sha }}
- name: 🐍 Install uv and Python
gaborbernat and others added 9 commits November 3, 2026 18:54
The same path and mode check ran twice in maintenance pull requests, and
update-python-dependencies ran the hooks twice to confirm stability. The
writer now applies the exact bytes the read-only job verified by digest,
and one hook run accepts the files it rewrites, since the pull request
checks run the hooks on the result. Release patch paths and modes are one
check in validate-release-patch.

Upgrading release tools hands its pull request to open-maintenance-pr
instead of keeping a second writer, so the commit subject is the pull
request title and the commit-message input goes away.

Independent installs and downloads run in background steps and join
before the step that needs them.
Describe the single path and mode check, the single hook pass, and the
writer's digest check. Remove the commit-message input from the upgrade
section.
Writer jobs hold a release App token or contents write. A reusable
workflow runs with its caller's event, so a pull_request,
pull_request_target, workflow_run, issue_comment or merge_group caller
could hand a writer pull request code. Each writer job now runs a first
step that fails with an error naming the event, before any checkout or
token step runs. Read-only jobs and the SHA pins are unchanged.
Each writer section says which caller events it refuses and which
triggers to use. The threat model rows that called these triggers
caller configuration now name the refusal, and a changelog fragment
records the behaviour change for callers.
pre-commit-mirror-maker commits and tags each upstream version itself, so the
job needs a Git identity before it runs. The old workflow also decided "nothing
to commit" by diffing against its own commit, which skipped any version whose
formatting was a no-op, and pushed only the last tag when one run mirrored
several versions.

The job now records the base commit, folds the README and formatting changes
into the newest mirror commit, checks each new tag against origin, and pushes
the branch with every new tag in one atomic push. It creates a release per tag.
The inline shell check runs shfmt with two-space indentation and does not indent case patterns. Reformat the case blocks of the six writer guards to match.
The step-order test predates the writer guard, which now runs before the checkout in the mirror job.
@gaborbernat
gaborbernat merged commit 0225642 into tox-dev:main Nov 4, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants