Skip to content

Version pinning and drift

A documentation build has more inputs than its own source, which creates dependency drift when declared or installed versions diverge. zensical renders the site, weasyprint lays out the PDF, and both are ordinary Python packages that resolve to whatever is newest unless you say otherwise.

That matters more than it first appears, because the way an upgrade shows up here is not a failed build. It is a published document that quietly differs from the one you reviewed.

What this looks like in practice

Zensical 0.0.52 bumped its bundled Font Awesome from 7.2.0 to 7.3.1, which redrew the GitHub brand icon. Every page of this project's own website changed. The PDF was byte-identical. Nothing failed, nothing was committed, and the site simply looked slightly different the next time it deployed.

A weasyprint upgrade is sharper still: it decides pagination, so a release that lays out one paragraph differently shifts every page number after it - and those page numbers are content, resolved into the back-of-book index and the table of contents.

The prodockit pins command supports two halves that only work together:

  1. Pin the build inputs, so output changes when someone decides it should.
  2. Watch for newer releases, so pinning does not mean going quietly stale.

Pins uses Prodockit's shared field and decision language, but presents its short package inventory without the full phase-and-activity frame.

Figure 17.1 shows the two controls in one cycle. The top row finds declarations or shared files that disagree and aligns the release inputs. The lower row compares the pinned output with newer dependencies and reports the difference for a maintainer to accept or reject.

Version declarations are checked for agreement while scheduled drift checks compare pinned and newer rendered outputs before a deliberate upgrade

1. Version pinning and drift workflow

Maintain a dependency safely

Check agreement first, inspect newer versions separately, and update only after the resulting output difference has been reviewed.

  1. Check that the repository agrees with itself

    prodockit pins --check --offline
    

    This does not contact PyPI. It answers the pull-request question: do the version declarations already present in pyproject.toml and the workflows agree? It is the form used by ci.yml, because a new upstream release should not make an unrelated pull request fail.

  2. Review drift information

    The scheduled drift.yml workflow asks the separate maintenance question: are newer releases available, and would they change the website or PDF? Read the issue it opens or run the workflow manually before selecting versions.

    For a quick inventory from a terminal:

    prodockit pins
    

    This reports the newest releases from PyPI, but offers the complete version combination tested by the installed Prodockit release as its defaults. It does not compare rendered artifacts for you.

    Restore the supported software combination

    If a build starts failing, its output changes unexpectedly, or dependency versions have drifted, run prodockit pins. Review the inventory, then press Enter at each prompt to bring every declared build input back to the combination supported by the installed Prodockit release. This covers Zensical, WeasyPrint, Prodockit, Markdown, PyMdown Extensions, Pandoc, and Python wherever the project declares them.

    A newer version shown by PyPI is information, not the default. Type a different version only when you intend to test that combination, or use --latest to opt into the newest PyPI releases explicitly.

  3. Move every declaration together

    Set an reviewed version explicitly:

    prodockit pins --set zensical=0.0.57
    

    The tool preserves the role of each declaration: a library floor remains a floor and a publishing workflow's exact pin remains exact.

  4. Rebuild in publishing order

    zensical build --clean --strict
    prodockit pdf
    pytest
    

    Compare the website and PDF with the pinned baseline recorded by the drift issue. Review pagination, generated indexes, diagrams, code blocks, and icons—not only whether the commands returned zero.

  5. Confirm consistency before committing

    prodockit pins --check --offline
    git diff --check
    git status --short
    

    The final offline check prevents a partial version bump from reaching the pull request. The pull request then runs the same consistency gate in ci.yml.

Where a version gets declared

Pinning creates its own problem: the same version ends up written in several files at once, and nothing keeps them in step.

Table 17.1 identifies every file in which a build-input version can be declared.

1. Where a version gets declared

File Typically declares Why that form
pyproject.toml zensical>=0.0.57 A floor. An exact pin in a library's metadata propagates to every consumer and conflicts with any project needing a different Zensical.
CI docs/build job zensical==0.0.57 An exact pin. The site and PDF are artifacts; they should change deliberately.
Ordinary CI test job Package dependency floors A latest-version canary; resolved versions can advance independently of publishing pins.
Drift job both, exactly The baseline it compares the newest release against.

Both forms are correct in their own place. What is not correct is them disagreeing, which is easy to do by hand and invisible when it happens.

prodockit pins

Reads every declaration across all of those files, shows what is currently set against what is newest on PyPI, and moves them together.

prodockit pins
zensical
  pyproject.toml:34  zensical>=0.0.53
  .github/workflows/docs.yml:164  zensical==0.0.53
  .github/workflows/drift.yml:69  zensical==0.0.53
  newest on PyPI: 0.0.60  <- newer available
  tested with installed prodockit 0.58.0: 0.0.59  <- interactive default

weasyprint
  .github/workflows/ci.yml:59  weasyprint==69.0
  .github/workflows/docs.yml:164  weasyprint==69.0
  newest on PyPI: 70.0  <- newer available
  tested with installed prodockit 0.58.0: 69.0  <- interactive default

markdown
  pyproject.toml:25  markdown>=3.10.3
  .github/workflows/docs.yml:164  markdown==3.10.3
  .github/workflows/drift.yml:69  markdown==3.10.3
  newest on PyPI: 3.10.3
  tested with installed prodockit 0.58.0: 3.10.3  <- interactive default

pymdown-extensions
  .github/workflows/docs.yml:164  pymdown-extensions==11.0.2
  .github/workflows/drift.yml:69  pymdown-extensions==11.0.2
  newest on PyPI: 11.0.2
  tested with installed prodockit 0.58.0: 11.0.2  <- interactive default

zensical: version to set [0.0.59]:

Markdown and pymdown-extensions are in that list even though nothing installs them directly - they arrive under Zensical, which declares only floors for them. See the limitations below for why pinning Zensical alone left them free to move.

Press Enter to take the version tested by the installed Prodockit release, or type another version deliberately. Each site keeps its own operator - the floor stays a floor, the pins stay pinned - so one answer updates every file correctly. The supported defaults are carried inside the installed wheel, so restoring them does not depend on the template or on PyPI being available.

The pdk pins command reference lists every reporting, write, network, and package-selection option. This task guide concentrates on choosing and validating a version change.

Keep shared files with the pinned release

Some documentation assets belong to the Prodockit release rather than to one site. The managed pdk.css and pdk-pdf.css files are two of them: extensions, template and user-guide use identical defaults, then select site-specific behaviour through configuration switches. Author-owned extra.css and print.css are deliberately absent from this manifest and remain free for local customisation. Copying managed files by hand allowed one site to retain a duplicated older rule without any build failing.

Prodockit therefore carries the canonical file in its wheel. A repository opts in with .prodockit-shared-files.toml:

version = 1

[[files]]
source = "pdk.css"
target = "docs/stylesheets/pdk.css"

[[files]]
source = "pdk-pdf.css"
target = "docs/stylesheets/pdk-pdf.css"

Check without writing:

prodockit shared-files --check

Restore a missing or different file, then review it:

prodockit shared-files --apply
git diff -- docs/stylesheets/pdk.css docs/stylesheets/pdk-pdf.css

When the manifest is present, prodockit pins --check --offline performs the same content check after checking version declarations. This makes the normal CI gate protect the versions and managed files supplied by that installed version without treating author-owned extra.css or print.css as drift. It reads only the installed wheel and local project: no sibling checkout, GitHub branch, checksum list or network request is involved.

Use prodockit shared-files --verbose when investigating a mismatch; it adds the expected and actual SHA-256 values to the ordinary author-facing report.

--set is the unattended form, so it suppresses the prompt for every package, not only the one it names:

prodockit pins --set zensical=0.0.53
  pyproject.toml:34  zensical>=0.0.52 -> zensical>=0.0.53
  .github/workflows/docs.yml:136  zensical==0.0.52 -> zensical==0.0.53

Left untouched (no version given): weasyprint

Updated 2 declaration(s). Rebuild and diff before committing.

A package it was not given is reported and its files are not opened - name it with its own --set to move it, or leave it where it is. That is what makes the command safe in a script: it can neither hang on a prompt nor half-finish because one appeared.

--check is the one to put in CI:

prodockit pins --check --offline

It fails when a package is behind PyPI or when the files disagree with each other - the second being the failure that pinning across several files invites.

Add --offline when it gates a pull request

Those two failures belong in different places. Files disagreeing is a property of the repository: a real mistake, introduced by a commit, fixable by its author. Behind PyPI is a property of the world, and turns every open pull request red the day upstream ships a release, with nothing in the branch having changed and nothing the author can do about it. A gate that fails for reasons outside the contributor's control is one people learn to ignore.

--offline keeps the first check and drops the second, and needs no network. Leave "is there something newer" to a drift job, which reports on a schedule rather than failing a build. This project's own ci.yml runs the offline form for exactly this reason.

What it scans

Both CI hosts, so the same command works either way:

  • pyproject.toml, setup.cfg
  • .python-version — the exact Python used for artifact builds
  • .github/workflows/*.yml — GitHub Actions
  • .gitlab-ci.yml and .gitlab/**/*.yml — GitLab CI
  • requirements*.txt, constraints*.txt at the project root

Build output and virtualenvs (site/, public/, .venv/, node_modules/, …) are skipped, so a stale copy of a workflow inside one is not mistaken for a declaration.

Five shapes of declaration are recognised, because a build input is not always a pip package:

Table 17.2 lists the declaration forms recognised by the drift scanner.

2. What it scans

Shape Example Where
pip specifier zensical==0.0.52, zensical>=0.0.52 anywhere
runner label runs-on: ubuntu-24.04 GitHub Actions
image tag image: python:3.14 GitLab CI, or any container
CI variable PANDOC_VERSION: "3.10.1" a GitHub env: block, a GitLab variables: block
version file 3.14 .python-version

Why Python is managed without a release check

.python-version gives local tooling and non-matrix CI jobs one exact build interpreter. A GitLab image: python:... declaration is checked against the same value and moves with it when you run, for example, prodockit pins --set python=3.14.

Python is not looked up on PyPI: its release lifecycle is not represented by the unrelated package with that name. pins checks that the repository agrees with itself; choosing a newer Python remains a deliberate project decision. A library's supported-version test matrix is separate and is not rewritten from .python-version.

Why prodockit is managed by default

It was not, for a long time, and the omission had exactly the consequence the managed set exists to prevent. prodockit-template pinned prodockit==0.35.0 and drifted two releases behind with nothing noticing, because the one command that looks at pins was not looking at this one - moving it needed -p prodockit typed by hand, which is the step nobody remembers (prodockit-template#173).

It belongs there on the merits too: prodockit renders the PDF and generates the back-of-book index, so its version changes a project's published output as directly as Zensical's does.

Including it is safe even in prodockit's own repository, where the name appears in pyproject.toml as the project's identity rather than as a dependency. The specifier pattern requires a version operator after the name, so name = "prodockit" and the adjacent version = "..." are not declarations - without that, the command would offer to rewrite the release number of the package being built.

Why pandoc is managed by default

Pandoc is not a Python package, so it never appears as a pip specifier - it is a build-provided binary, pinned as a <PACKAGE>_VERSION variable the way prodockit pdf's publishing workflow does. It earns a place in the default set for the same reason Zensical and WeasyPrint do: pandoc is not always compatible with itself across releases, and one of its changes broke every fenced code block in this project's own PDF while the build kept reporting success.

The CI variable's name keeps its case on rewrite - PANDOC_VERSION, not pandoc_VERSION - since a workflow step reading ${{ env.PANDOC_VERSION }} needs the name unchanged, only the value.

Pandoc version drift

Distribution Pandoc packages can lag several major versions behind upstream. Ubuntu 24.04, for example, supplies an older release than the one this repository currently tests and publishes with. Installing the distribution package locally while CI downloads a current release means the two builds can parse identical HTML differently.

That happened here when one Pandoc release accepted highlighted <pre><code> content that a newer release interpreted differently. Every fenced code block reflowed as ordinary justified prose in one environment, while the other environment continued publishing a correct PDF. Both builds reported success.

Pin Pandoc as a PANDOC_VERSION workflow variable and move it through prodockit pins, then compare complete PDF and website artifacts before and after the change. Pinning does not prevent incompatibility; it makes the change arrive in a reviewed commit instead of with an unannounced runner update.

Only versioned declarations are found

A dependency installed with no version at all is not a declaration site, so it will not appear - pin it once by hand and the tool manages it from then on. The same applies to runs-on: ubuntu-latest, which names no version to move.

Watching for drift

Pinning trades one risk for another: nothing tells you a newer release exists, or what it would do to your output.

The check worth running is not "is there a new version" - PyPI can answer that - but "would taking it change what we publish?" That needs a real build, twice.

The shape is the same on either host:

  1. Build the docs with the pinned versions. Keep the result.
  2. Upgrade to the newest and build again.
  3. Diff the two, byte for byte.
  4. Run your built-output checks against the newer build.
  5. Report - do not fail.

Both builds run in the same job, so pandoc, Chrome, fonts and the runner image are identical between them and any difference is attributable to the upgraded packages alone.

Two things make or break this

Build order. zensical build copies the PDF into the site directory, so it must run after prodockit pdf. Reversed, the copied PDF lags a generation and every comparison is a false positive that looks exactly like nondeterminism.

Determinism. The diff only means something if identical inputs give identical output. Verify that first - two builds of the same version should produce a byte-identical site and PDF. They do for this project; confirm it for yours before trusting a diff.

Reporting, not failing

A newer release is information, not a broken build. A scheduled job that goes red every week trains everyone to ignore it, so open an issue instead - and keep one open at a time, updating it in place rather than filing a fresh one every Monday until somebody acts.

GitHub Actions

This project ships drift.yml doing exactly this. The essentials:

name: Dependency drift
on:
  schedule:
    - cron: "0 6 * * 1"   # Mondays, 06:00 UTC
  workflow_dispatch:
permissions:
  contents: read
  issues: write           # needed to open the issue
jobs:
  drift:
    runs-on: ubuntu-latest
    steps:
      # ... same build tooling as your docs job ...
      - name: Build with the pinned versions
        run: |
          pip install -e ".[testing]" "zensical==0.0.57" "weasyprint==69.0" "Markdown==3.10.3" "pymdown-extensions==11.0.2"
          zensical build --clean --strict    # Build the site first ...
          prodockit pdf                      # ... then consume it for the PDF
          cp -R site /tmp/pinned-site
          cp docs/site_documentation.pdf /tmp/pinned.pdf

      - name: Build with the newest versions
        run: |
          pip install -qU zensical weasyprint
          zensical build --clean --strict
          prodockit pdf

      - name: Compare
        env:
          GH_TOKEN: ${{ github.token }}
        run: |
          cmp -s /tmp/pinned.pdf docs/site_documentation.pdf \
            && echo "PDF identical" || echo "PDF differs"
          diff -rq /tmp/pinned-site site | wc -l
          # ... then gh issue create / gh issue comment

GitLab CI

The same job, with GitLab's own scheduling and API. Add a pipeline schedule running weekly, and a project access token with the api scope exposed as DRIFT_TOKEN so the job can open an issue.

drift:
  image: python:3.14
  rules:
    # Only on the schedule - never on a normal push pipeline.
    - if: $CI_PIPELINE_SOURCE == "schedule"
  before_script:
    - apt-get update && apt-get install -y pandoc libpango-1.0-0 libpangoft2-1.0-0 libharfbuzz-subset0 jq curl
  script:
    - pip install -e ".[testing]" "zensical==0.0.57" "weasyprint==69.0" "Markdown==3.10.3" "pymdown-extensions==11.0.2"
    - zensical build --clean --strict   # Build the site first ...
    - prodockit pdf                     # ... then consume it for the PDF
    - cp -R site /tmp/pinned-site && cp docs/site_documentation.pdf /tmp/pinned.pdf
    - pip install -qU zensical weasyprint
    - PINNED=$(pip show zensical | awk '/^Version:/{print $2}')
    - zensical build --clean --strict
    - prodockit pdf
    - LATEST=$(pip show zensical | awk '/^Version:/{print $2}')
    - |
      if [ "$PINNED" = "$LATEST" ]; then
        echo "Pins are current."; exit 0
      fi
      CHANGED=$(diff -rq /tmp/pinned-site site | wc -l)
      cmp -s /tmp/pinned.pdf docs/site_documentation.pdf && PDF=identical || PDF=differs
      # One open issue at a time.
      EXISTING=$(curl -sf --header "PRIVATE-TOKEN: $DRIFT_TOKEN" \
        "$CI_API_V4_URL/projects/$CI_PROJECT_ID/issues?state=opened&search=Dependency+drift" \
        | jq -r '.[0].iid // empty')
      BODY="zensical $PINNED -> $LATEST. PDF: $PDF. Website: $CHANGED files differ."
      if [ -n "$EXISTING" ]; then
        curl -sf --request POST --header "PRIVATE-TOKEN: $DRIFT_TOKEN" \
          --data-urlencode "body=$BODY" \
          "$CI_API_V4_URL/projects/$CI_PROJECT_ID/issues/$EXISTING/notes" > /dev/null
      else
        curl -sf --request POST --header "PRIVATE-TOKEN: $DRIFT_TOKEN" \
          --data-urlencode "title=Dependency drift: newer build inputs than the docs pins" \
          --data-urlencode "description=$BODY" \
          "$CI_API_V4_URL/projects/$CI_PROJECT_ID/issues" > /dev/null
      fi
  allow_failure: true

allow_failure: true keeps the pipeline green: the job's purpose is the issue it opens, not its own exit status.

The input pip cannot reach

prodockit pins manages Python packages. The CI runner image is the other half, and nothing in pyproject.toml or a workflow's pip install line touches it:

  • pandoc comes from the image's own package archive. Distribution packages lag upstream far enough that some Markdown edge cases parse differently—see Pandoc version drift.
  • Fonts the PDF embeds (fonts-inter, fonts-jetbrains-mono).
  • Chrome, which rasterises Mermaid diagrams.

On runs-on: ubuntu-latest all three move the day the label migrates to a new LTS, with nothing committed - the same silent change the package pins exist to prevent, one layer down. Naming the image freezes them together:

jobs:
  deploy:
    runs-on: ubuntu-24.04   # not ubuntu-latest

GitLab's equivalent is the job image:, which most projects already pin by habit - python:3.14 rather than python:latest.

prodockit pins manages both, so the image is inventoried and moved the same way as everything else. Python is in the default managed set; -p narrows a run to one named input:

prodockit pins -p ubuntu       # runs-on: ubuntu-24.04, across every workflow
prodockit pins -p python       # .python-version and image: python:3.14
ubuntu
  .github/workflows/ci.yml:11  ubuntu-24.04
  .github/workflows/docs.yml:69  ubuntu-24.04
  .github/workflows/drift.yml:36  ubuntu-24.04
  .github/workflows/publish.yml:10  ubuntu-24.04
  not on PyPI - set the version yourself

ubuntu: version to set [24.04]:

There is no suggested version for these: PyPI has nothing to say about a runner image, and asking it for "ubuntu" would at best miss and at worst find an unrelated package of that name and propose a nonsense upgrade. The default is what is currently set, so Enter is a no-op and you type the new one deliberately - which suits a change you make once every couple of years.

--check still applies, and catches the failure that matters here: some jobs left on the old image after a partial migration.

What this costs

ubuntu-latest already is 24.04 today, so pinning changes nothing immediately. It takes effect at the migration - which is the point.

Pinned images are retired roughly a year after the following LTS, and the job then fails outright rather than drifting. That is the better failure: loud, and at a time you choose. Treat a retirement notice as the prompt to rebuild, diff, and move up deliberately.

Taking an upgrade

When drift reports something worth having, use the complete maintenance flow above. The short command sequence is:

prodockit pins --set zensical=0.0.57
zensical build --clean --strict
prodockit pdf
pytest
prodockit pins --check --offline

Substitute the package and reviewed version reported by drift. Then diff the built output against the previous version before committing. That is the step the whole arrangement exists to make possible: seeing what an upgrade does to your document before your readers do.

Limitations and workarounds

See Implementation limitations for the general list. Specific to pinning:

  • A floor still floats. zensical>=0.0.57 in pyproject.toml records a version; it does not control one. Only the exact pin in the build job does. Both exist deliberately - see Table 17.1.
  • A pinned package's own dependencies float too, which is the sharper version of the same trap: pinning a direct dependency exactly does nothing for the transitive ones underneath it, because their versions come from floors in its metadata. Zensical is pinned exactly here, and still declares only floors for Markdown and pymdown-extensions - the two packages that actually turn every page into HTML. A build pinning Zensical alone therefore rendered with whatever those two resolved to on the morning it ran. Both are now pinned alongside it, and both are managed by prodockit pins (#178). If your project pins something whose rendering you depend on, check what it pulls in: pip show <package> lists its requirements, and a floor there is a version you are not controlling.
  • Only pip packages are watched. pandoc and Chrome arrive from the runner image, so a drift job that installs both builds in one job cannot see them change between weeks. Pinning the image is the lever for those - see below.
  • Comments are not scanned. A version specifier written in prose - explaining why something is pinned, say - is deliberately not treated as a declaration, so it is neither reported nor rewritten by --set. Only the part of a line before a # is read (#184); a trailing comment after a real declaration still leaves that declaration findable.
  • The supported defaults work offline. prodockit pins only needs network to report newer PyPI releases or use --latest. The combination tested by the installed Prodockit release is carried in its wheel, so --offline still offers those versions interactively.

Zensical release qualification

Use .github/workflows/zensical-compatibility.yml for an isolated Zensical comparison. Ordinary ci.yml resolves library dependency floors and is a latest-version canary; docs.yml pins publishing inputs. The older drift job upgrades several dependencies together and cannot attribute a change to Zensical alone.

The compatibility workflow accepts exact baseline and candidate releases. Its configuration matrix covers Python 3.10–3.14 on Ubuntu, macOS and Windows. The Windows macro fixture uses an extended-length include path. A manual run with full enabled additionally builds both complete extensions documents, both complete template documents and both template source bundles on Ubuntu. It tests browser redirect navigation and feature PDFs separately. Choose an explicit template commit for repeatable qualification; the resolved commit and working-tree state are recorded in the evidence.

Run the same gate locally from a checkout with the normal PDF prerequisites:

python tools/zensical_compatibility.py pair \
  --baseline 0.0.63 --candidate 0.0.64 \
  --output /tmp/zensical-config-review

python tools/zensical_compatibility.py pair \
  --baseline 0.0.63 --candidate 0.0.64 --full \
  --template ../prodockit-template \
  --browser-script tools/compatibility/redirect_browser.cjs \
  --output /tmp/zensical-full-review

Use a fresh output directory. The runner creates two private virtual environments, freezes the candidate dependencies, and changes only Zensical in the historical baseline. The baseline installs the same local Prodockit build without dependency resolution so the new supported floor does not prevent testing the previous release. All other dependencies retain their frozen versions; the report rejects any other dependency difference. The baseline may record only the exact Prodockit/Zensical metadata-floor conflict as an expected diagnostic failure; any other diagnostic failure still blocks qualification. Candidate diagnostics receive no exemption. Only Zensical declarations in disposable baseline projects are aligned to the historical version under test; runtime source and documentation stay unchanged. It never installs into the invoking environment. Complete builds use disposable Git copies and the same template citation-style bytes; supply harvard-cite-them-right.csl in the template checkout as its publishing workflow does. Node, Chrome, Pandoc and fonts must already be available for full builds.

Read analysis.md, result.json, command logs and the comparison JSON artifacts. Configuration checks report pass/fail, strict expected failure, unsupported feature and not-run counts separately. JUnit reports retain executed, passed, failed, error and skipped suite counts. Configuration-only results do not qualify an upgrade. Build failure or incomplete output fails the gate. Byte differences are recorded for review, not automatically classified as regressions.

All pages of each generated PDF are compared for dimensions, text, raster output at 144 dpi, bookmarks and link annotations. Navigation coverage and published PDF copies are checked independently. A short feature-fixture PDF never substitutes for the full extensions/template documents. Website redirect success does not imply JavaScript aliases work inside a PDF.

Known limitations

Zensical 0.0.61 rejects Mike's documented version_selector option. The gate retains the real CLI reproduction as a strict expected failure for exactly that version and diagnostic. An unrelated failure still fails; an unexpected pass also fails so the exception must be reviewed. This is an upstream limitation, not a passing Prodockit test. See the compatibility issue. New candidate versions are not automatically exempted.

The full link audit also exposes existing defects such as absolute checkout paths in PDFs. A defect present on both releases is not a new Zensical regression, but it still prevents a clean full qualification until repaired. No result from one local OS/Python run substitutes for the workflow matrix.