Skip to content

fix: declare one version, in one place per package, enforced by CI (closes #31) - #33

Merged
snad1 merged 1 commit into
docs/user-quickstartfrom
fix/version-single-source
Jul 30, 2026
Merged

fix: declare one version, in one place per package, enforced by CI (closes #31)#33
snad1 merged 1 commit into
docs/user-quickstartfrom
fix/version-single-source

Conversation

@snad1

@snad1 snad1 commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

What & why

Closes #31.

/health reported 0.0.0 against a v0.1.1 tag. The endpoint was never the bug — it reads
app.__version__ correctly. The bug was that eight sites each declared a version and nothing
held them equal, so bumping was a manual, silent, partial operation. Every Python package declared
it twice, in the pyproject and in a dunder, with no link between the two.

Provx versions as one product. Not a new position — the one already implied everywhere: both
tags are whole-product milestones, CHANGELOG.md carries a single SemVer stream, ROADMAP §7 /
CONTRIBUTING §1 / PROJECT_SETUP describe one release train, and REPOSITORY_STRATEGY.md names a
per-package "version matrix" as the cost it rejected polyrepo to avoid. The only internal
dependency edge (backend → provx-sdk) is deliberately unpinned and path-installed, so independent
versions would have meant inventing a pin matrix to solve a problem the repo does not have.

Source of truth is each package's own __init__.py, read by hatchling via
dynamic = ["version"]. Deliberately not a root VERSION file and not git describe: each
package must stay buildable from its own directory — backend/Dockerfile copies
packages/adapters alone into the image, pipx takes packages/cli alone, and neither build
context contains a .git. An air-gapped build has no git metadata either, and for this product
that is a supported case, not an edge one. So equality across packages is enforced by an
assertion rather than by file layout — the same shape as assert_gate_results.sh,
assert_workflow_gating.py, and check_licenses.py.

What ships:

  • version-consistency, a new required gate. Checks two things, the second being how the
    first would quietly come undone: that every declaration site agrees, and that each pyproject
    still delegates rather than having regrown a static version =. Unconditional like
    license-check — a skew is a property of the tree, not of the paths a PR touched.
  • 17-vector truth table, run by the gate itself before it asserts anything real. Blocking
    vectors must also emit an ::error:: line, so a crash cannot pass as a refusal — the fail-open
    shape assert_workflow_gating.test.sh learned to catch, carried forward rather than
    rediscovered. assert_gate_results.test.sh grew to the 11-gate required set with both new
    directions covered.
  • release-check — a tag push asserts the tag matches the code. Its own workflow, because
    ci.yml opens with a paths-filter job that has no base ref to diff on a tag push. Honest about
    its limits: a detector, not a gate, since a tag already pushed cannot be blocked.
  • make version-set VERSION=x.y.z writes all five sites at once. Its writer lives inside the
    assertion script, because a second copy of the list of version sites is the bug being fixed.
  • provx --version, answering from the package without contacting a server — it works
    precisely when the server is unreachable, which is when an operator is asking. Retires a
    __version__ that was defined, exported in __all__, and read by nobody.

Two things found along the way and not fixed here, each noted in STATUS with a land-with
condition: the CHANGELOG's pre-existing [Unreleased] bullets were not re-filed into the
backfilled [0.1.0]/[0.1.1] sections (deciding which shipped in which tag would be
reconstruction, and a changelog that guesses is worse than one visibly incomplete — stated in the
file itself), and CONTRIBUTING §1/§3 promise release-please-style version automation that was
never built
. This PR made the version correct and enforced; it did not make it automatic. Filed
separately.

Refs #29, #30.

Definition of Done

  • Safetypassive. No adapter, no egress, no target-facing code path touched. Nothing
    here can send a request to anything.
  • Signal quality — n/a: emits no findings.
  • Fixture test added — n/a as an adapter fixture; the equivalent ships as the 17-vector
    truth table driving the real script over mutated copies of the real tree.
  • Accuracy gate passes — n/a as a gate for this diff (no detection logic changed);
    make test including the lab and CI self-tests ran green.
  • Docs / manifest updateddocs/STATUS.md gains a ## Versioning section recording the
    decision, the two constraints that forced it, and its reversal path; CHANGELOG.md gains the
    entry plus the backfilled release sections.
  • Platform security — no secret logged or read. The gate is offline and file-based: no
    network, no git describe, same verdict on every machine including air-gapped
    (PX-DETERMINISM). No new dependency (PX-FREE) — hatchling was already all three build
    backends, and the script is stdlib + tomllib.
  • DCO — commit signed off.

Safety classification

  • passive (read-only, safe in test/production)

How I tested

The decisive check is the image build, because it is exactly where hatch-vcs would have
failed — pip install --no-deps /opt/provx-sdk with no .git in the build context:

$ curl -sS localhost:8000/health
{"status":"ok","service":"provx-backend","version":"0.1.1"}

$ curl -sS localhost:8000/openapi.json | jq -r .info.version
0.1.1

$ docker compose exec backend python -c "from importlib.metadata import version; print(version('provx-sdk'))"
0.1.1

That last line is the design proven at the packaging layer: the distribution built inside the image
carries the version read from the dunder.

$ pipx install --force ./packages/cli
  installed package provx-cli 0.1.1
$ provx --version
provx 0.1.1

Negative proof, not just positive — one site skewed by hand on the live tree:

::error::version declarations disagree (0.1.1, 0.2.0):
    backend/app/__init__.py: 0.2.0
    frontend/package.json: 0.1.1
    ...
    Run `make version-set VERSION=x.y.z` rather than editing these by hand.

make version-set VERSION=0.9.9 then back to 0.1.1 round-trips to a clean tree.

Gates: make lint clean (ruff, ruff format, mypy --strict over 55 files, eslint, prettier, tsc).
make test green — 516 backend tests (513 + 3 new), 11 frontend, and all four CI self-test
truth tables including the new 17-vector one. actionlint 1.7.7 (the version ci.yml pins) clean
over all three workflows including the new release-check.yml.

Closes #31.

/health reported 0.0.0 against a v0.1.1 tag. The endpoint was never the
bug - it reads app.__version__ correctly. The bug was that eight sites each
declared a version and nothing held them equal, so bumping was a manual,
silent, partial operation. Every Python package declared it twice, in the
pyproject and in a dunder, with no link between the two.

Provx versions as one product. That is not a new position: both tags are
whole-product milestones, CHANGELOG carries one SemVer stream, and
REPOSITORY_STRATEGY names a per-package version matrix as the cost it
rejected polyrepo to avoid. The one internal edge, backend -> provx-sdk, is
deliberately unpinned and path-installed, so independent versions would have
meant inventing a pin matrix to solve a problem the repo does not have.

The source of truth is each package's own __init__.py, read by hatchling via
dynamic = ["version"]. Not a root VERSION file and not git describe: each
package must stay buildable from its own directory - the image copies
packages/adapters alone and pipx takes packages/cli alone, and neither build
context contains a .git. An air-gapped build has no git metadata either, and
for this product that is a supported case. Equality across packages is
therefore enforced by an assertion rather than by file layout.

assert_version_consistency.py checks two things, the second being how the
first would quietly come undone: that every site agrees, and that each
pyproject still delegates rather than having regrown a static version. It is
unconditional in ci-required, like license-check - a skew is a property of
the tree, not of the paths a PR touched. 17-vector truth table, run by the
gate before it asserts anything real; blocking vectors must emit ::error::,
so a crash cannot pass as a refusal. release-check asserts a tag matches the
code, in its own workflow because ci.yml's paths-filter has no base ref on a
tag push.

make version-set writes every site at once; its writer lives inside the
assertion script, because a second copy of the list of version sites is the
bug being fixed. provx --version answers from the package without contacting
a server, retiring a __version__ that was exported and read by nobody.

Signed-off-by: Solomon Nii Amu Darku <snad1@users.noreply.github.com>
@snad1
snad1 merged commit 77906bb into docs/user-quickstart Jul 30, 2026
13 checks passed
@snad1

snad1 commented Jul 30, 2026

Copy link
Copy Markdown
Contributor Author

Superseded by #35. This PR merged into docs/user-quickstart rather than main: it was stacked on that branch, #32 merged the branch to main first, and because the branch still existed afterwards GitHub never retargeted this one. The result landed on an already-merged feature branch and never reached main — which is why #31 correctly stayed open.

No work is lost: #35 cherry-picks the same commit onto main and the tree is byte-identical (git diff origin/docs/user-quickstart <head> is empty).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant