The shared CI of Postulo's plugin repositories: one composite action, referenced by full address (postulo/postulo#1170)
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Tiago Águeda adb5ef504e
Add the shared CI of Postulo's Python plugins as a composite action
The seven Python plugin repositories each carried a hand-copied ci.yml and
release.yml, and the copies drifted: the dependency audit a step in some and a
job in others, a weekly schedule that ran the whole suite in two, a tag that
ran the full CI beside the release gate in six, and a uv bump that was about
fifteen edits in eight repositories. None kept uv's cache, so every run cloned
the core again, about 90 s of a 3-minute job.

action.yml is that job once. In check mode it installs the plugin's Debian
packages (WeasyPrint's by default, more by input), uv at the pinned version
after checking the installer's sha256, restores uv's cache, runs
`uv sync --locked`, saves the cache (never from a pull request, which also
restores by exact key alone), then ruff, the catalogue checks and guard,
Weblate's gate on its own pull request, compile and pytest, and on request the
release gate. In audit mode it exports the lock and runs pip-audit over it,
without installing anything, so a plugin runs it as a job of its own and
weekly. What differs between plugins is an input. The uv pin is here alone.

The steps of a composite action cannot carry timeout-minutes, so every
network command runs under coreutils' timeout and the caller puts a step
timeout on the uses: line. The README lists the inputs, shows the workflows a
plugin needs, and says how to bump uv and cut a release behind the moving v1
tag. AGPL-3.0-or-later, as the plugins are.

Refs postulo/postulo#1170

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 09:36:20 +01:00
action.yml Add the shared CI of Postulo's Python plugins as a composite action 2026-11-12 09:36:20 +01:00
LICENSE Add the shared CI of Postulo's Python plugins as a composite action 2026-11-12 09:36:20 +01:00
README.md Add the shared CI of Postulo's Python plugins as a composite action 2026-11-12 09:36:20 +01:00

plugin-ci

The continuous integration of a Postulo Python plugin, as one composite action for Forgejo Actions (postulo/postulo#1170).

Every plugin repository used to carry its own copy of the same workflow, and the copies drifted: the dependency audit a step in some and a job in others, a weekly schedule that ran the whole suite in two, a tag that ran the full CI beside the release gate in six, and a uv bump that was about fifteen hand edits across eight repositories. A plugin's workflow now checks the repository out and calls this action. What differs between plugins is an input, never a copy, and the pinned uv lives here alone.

What it does

In check mode (the default), in this order:

  1. Refuses a mode it does not know, and a shallow checkout when the catalogue guard is to run (the guard reads the history).
  2. Installs the Debian packages the plugin needs: WeasyPrint's libraries by default, which Postulo's settings load through its PDF backend, plus any the plugin adds.
  3. Installs uv at the pinned version, after checking the installer's sha256.
  4. Restores uv's cache (its downloads, the core's git checkout, the Python it fetched) with actions/cache/restore.
  5. uv sync --locked --python <python-version>: the plugin, Postulo at the pinned commit or tag, and the test tools.
  6. Saves uv's cache with actions/cache/save, unless the run is a pull request or the exact key was already restored.
  7. ruff check --output-format=full . and ruff format --check .
  8. postulo-messages extract --check, check and guard --base <base>, which refuses a translation written outside Weblate.
  9. On Weblate's own pull request (head branch weblate), postulo-messages gate for the gated languages.
  10. postulo-messages compile (the .mo files are not committed), then pytest.
  11. When asked, the release gate: pytest -m release.

In audit mode it installs uv, exports the lock (uv export, without the project and without what audit-exclude names) and runs pip-audit --strict over it through uvx. Nothing is installed and nothing is synced, so the audit is a job of its own, fast, and the one a weekly schedule runs.

The two actions/cache steps are continue-on-error: a cache that is down costs time and fails nothing. A pull request restores by exact key only and never saves; a push, a tag or a schedule may restore an older cache by prefix and saves a new one, as Postulo's own CI does (postulo/postulo#827).

uv is installed to ~/.local/bin and called by that path; the action does not put it on PATH. A step of the plugin's own after the action calls ~/.local/bin/uv.

Inputs

Every input is a string, as the inputs of an action are; the booleans are "true" or "false".

Input Default Mode What it does
mode check check or audit, as above.
python-version 3.14 check The Python uv installs the plugin on. Also part of the cache key.
system-packages libpango-1.0-0 libpangoft2-1.0-0 libharfbuzz0b libfontconfig1 check Debian packages, separated by spaces; empty installs none.
extra-system-packages (none) check Installed beside system-packages. postulo-templates: libharfbuzz-subset0.
sync-args (none) check Further arguments to uv sync --locked, such as --extra chromium.
lint true check Run ruff's check and format check.
catalogues true check Run postulo-messages extract --check, check and guard. Needs fetch-depth: 0.
weblate-gate fr-FR pt-PT check Languages postulo-messages gate holds Weblate's pull request to; empty skips it.
tests true check Compile the catalogues and run pytest.
pytest-args (none) check Further arguments to that pytest.
release-gate false check Run pytest -m release after the tests.
cache true check Keep uv's cache between runs.
audit-exclude postulo audit Locked packages left out of the audit because they are not on PyPI. Postulo is a git dependency the core audits itself. Empty for a project that does not depend on Postulo.

How a plugin uses it

The action is named by its full address. A bare Postulo/plugin-ci@v1 would be looked up on the instance's default actions host (data.forgejo.org), not on this Forgejo. It is named by its major tag, v1, which moves forward with every compatible release (see below).

Time limits go on the steps: this Forgejo honours a step's timeout-minutes and ignores a job's, and the steps inside a composite action cannot carry one, so the caller's timeout-minutes on the uses: step bounds the whole action. Inside it, every command that reaches the network runs under coreutils' timeout as well. Leave out permissions:: this Forgejo does not support it and warns. A job-level if: fails its job here instead of skipping it, which is why the weekly audit is a workflow of its own rather than a schedule in ci.yml.

.forgejo/workflows/ci.yml. No tags: trigger: a tag is a commit main already tested, and release.yml is the only workflow a tag starts.

name: CI

on:
  push:
    branches: [main]
  pull_request:

jobs:
  test:
    runs-on: ubuntu-latest
    container:
      # Node, because Forgejo runs actions/checkout as JavaScript inside the container.
      image: node:22-bookworm
    steps:
      - uses: actions/checkout@v4
        with:
          persist-credentials: false
          # The whole history: the translation guard reads every commit it is given.
          fetch-depth: 0
        timeout-minutes: 5
      - name: Install, lint, check the catalogues and test
        uses: https://source.tiagoagueda.com/Postulo/plugin-ci@v1
        timeout-minutes: 30

  audit:
    name: Audit the locked dependencies
    runs-on: ubuntu-latest
    container:
      image: node:22-bookworm
    steps:
      - uses: actions/checkout@v4
        with:
          persist-credentials: false
        timeout-minutes: 5
      - name: Audit the Python dependencies
        uses: https://source.tiagoagueda.com/Postulo/plugin-ci@v1
        with:
          mode: audit
        timeout-minutes: 15

.forgejo/workflows/weekly.yml: the audit alone, every Monday, so an advisory published in a quiet week is still seen. It is the same audit job as in ci.yml, on:

on:
  schedule:
    - cron: "17 6 * * 1"
  workflow_dispatch:

.forgejo/workflows/release.yml, on push: tags: ["v*"]: the install and the release gate alone, since lint, the catalogues and the suite already ran on main.

      - name: The release gate
        uses: https://source.tiagoagueda.com/Postulo/plugin-ci@v1
        with:
          lint: "false"
          catalogues: "false"
          tests: "false"
          release-gate: "true"
        timeout-minutes: 30

.forgejo/workflows/every-python.yml, for a plugin that tests the older Pythons its requires-python admits (postulo-imap, postulo-apprise): a matrix over python-version, the tests alone, since lint and the catalogues read the same on every Python.

    strategy:
      fail-fast: false
      matrix:
        python-version: ["3.12", "3.13"]
    steps:
      - uses: actions/checkout@v4
        with:
          persist-credentials: false
        timeout-minutes: 5
      - name: Test
        uses: https://source.tiagoagueda.com/Postulo/plugin-ci@v1
        with:
          python-version: ${{ matrix.python-version }}
          lint: "false"
          catalogues: "false"
        timeout-minutes: 30

postulo-templates adds extra-system-packages: libharfbuzz-subset0 to its test and release steps.

The action is made for the plugins: a repository with Postulo in its dev group and its own catalogues. postulo-mcp, which has neither, keeps its own workflow; its audit could still call this action with mode: audit and audit-exclude: "".

Bumping uv

uv's version and the sha256 of that version's install.sh are in one place: the env: of the Install uv step in action.yml, UV_VERSION and UV_INSTALLER_SHA256. They are the same two values as Postulo's own workflows. Bump them when the core does, to the core's values:

  1. In Postulo's repository, read the new pair from .forgejo/workflows/ci.yml. Its CONTRIBUTING.md says how they are found and checked; the sha256 is that of https://astral.sh/uv/<version>/install.sh:

    curl -LsSf "https://astral.sh/uv/0.12.17/install.sh" | sha256sum
    
  2. Change the two lines in action.yml, open a pull request here, and merge it once a plugin has run it (point a plugin's branch at the branch by @<branch> to try it).

  3. Cut a new release (below). The plugins pick it up through v1 with no change of their own.

Cutting a release

Every release gets its own tag, v1.0.0, v1.0.1, v1.1.0, which never moves, and the major tag v1 is moved to the newest of them. A change that keeps every input working (a uv bump, a new input with a default that keeps today's behaviour) is a minor or patch release under v1. A change that removes or renames an input, or changes a default so that a plugin would check less or fail, is v2.0.0, and each plugin moves to @v2 in its own pull request.

git switch main && git pull --ff-only
git tag -s v1.0.1 -m "plugin-ci 1.0.1: uv 0.12.18"
git tag -s -f v1 -m "plugin-ci v1 -> 1.0.1"
git push origin v1.0.1
git push --force origin v1

Moving v1 re-points every plugin at once, which is the purpose of it and also the risk. Try the new commit from one plugin first, by its tag or commit (@v1.0.1) in a branch, and only then move v1. A plugin that needs to hold still pins an exact tag or a commit hash.

Licence

AGPL-3.0-or-later, as Postulo's plugins are. See LICENSE.