docs: stop typing versions the code already knows
Nine documents carried a hand-typed contract version and every one was stale. One was load-bearing: PLUGIN-EXTERNAL-REPO.md told an external author to pin ">=0.13.0,<0.14.0" while the contract is at 0.19.0, so a plugin built by following that guide is refused by the loader at startup. The plugin count was wrong in six more. They now point at docs/PROJECT-MAP.md, which is generated. A test enforces it: no document may declare a version literal, a stated current version must match the code, and a stated plugin count must match the tree. ADRs are exempt from the current-version rule, because an ADR states the version a decision was taken AT - that is a record of the past, and rewriting it would falsify the record ADRs exist to keep. CONTRACT-STABILITY.md was missing 0.17.0, 0.18.0 and 0.19.0 - including the only BREAKING change in the series - in the one document a site reads to choose its pin. All three are recorded, with 0.19.0 called out: it took something away, and it shipped before it was written down, which is the argument for pinning tight rather than trusting that a minor bump is safe.
This commit is contained in:
@@ -75,8 +75,8 @@ keying) are covered there as well.
|
|||||||
|
|
||||||
Everything else is the core UI API: the endpoints the Vue frontend calls. As a
|
Everything else is the core UI API: the endpoints the Vue frontend calls. As a
|
||||||
rule these are JWT-authenticated (a login token or a managed Personal Access
|
rule these are JWT-authenticated (a login token or a managed Personal Access
|
||||||
Token) and versioned by the plugin contract (`__contract_version__`, currently
|
Token) and versioned by the plugin contract (`__contract_version__`; the
|
||||||
0.16.0). Behavior and stability guarantees are in **CONTRACT-STABILITY.md**;
|
current value is in [PROJECT-MAP.md](PROJECT-MAP.md), which is generated). Behavior and stability guarantees are in **CONTRACT-STABILITY.md**;
|
||||||
sister sites should pin tight `core_version` ranges until the contract reaches
|
sister sites should pin tight `core_version` ranges until the contract reaches
|
||||||
1.0.
|
1.0.
|
||||||
|
|
||||||
|
|||||||
@@ -8,12 +8,14 @@ the live code, not aspiration. The authoritative hook reference is
|
|||||||
|
|
||||||
## Current version
|
## Current version
|
||||||
|
|
||||||
The plugin contract is at **0.16.0**, declared in `shopdb/__init__.py` as
|
The plugin contract version is declared in `shopdb/__init__.py` as
|
||||||
`__contract_version__`. It is pre-1.0, which under semver means any 0.x minor
|
`__contract_version__`, and the current value is in
|
||||||
bump is allowed to break the contract, and this project has used that latitude.
|
[PROJECT-MAP.md](PROJECT-MAP.md), which is generated. It is pre-1.0, which under
|
||||||
|
semver means any 0.x minor bump is allowed to break the contract, and this
|
||||||
|
project has used that latitude - see 0.19.0 below.
|
||||||
|
|
||||||
The product release version (`__version__`, currently 0.7.0) is a separate
|
The product release version (`__version__`) is a separate series with its own
|
||||||
series with its own bump rules; see [ADR-007](adr/ADR-007-product-versioning-and-releases.md).
|
bump rules; see [ADR-007](adr/ADR-007-product-versioning-and-releases.md).
|
||||||
Do not pin against it for compatibility - pin against `__contract_version__`.
|
Do not pin against it for compatibility - pin against `__contract_version__`.
|
||||||
|
|
||||||
### 0.x history
|
### 0.x history
|
||||||
@@ -34,9 +36,18 @@ Recorded in the comment block in `shopdb/__init__.py`:
|
|||||||
| 0.14.0 | Added `send_webhook` to the `shopdb.api` surface | additive surface (minor) |
|
| 0.14.0 | Added `send_webhook` to the `shopdb.api` surface | additive surface (minor) |
|
||||||
| 0.15.0 | Added `authorized_service_token` / the `SupportTeam` model to the `shopdb.api` surface | additive surface (minor) |
|
| 0.15.0 | Added `authorized_service_token` / the `SupportTeam` model to the `shopdb.api` surface | additive surface (minor) |
|
||||||
| 0.16.0 | Added the `get_settings_defaults` hook so a plugin declares the Setting rows it owns; the framework seeds them at install, at enable, and on `flask plugin upgrade-all`, files a first-time write under the declared category, and honours `public: True` for pages that render before login | additive optional hook (minor) |
|
| 0.16.0 | Added the `get_settings_defaults` hook so a plugin declares the Setting rows it owns; the framework seeds them at install, at enable, and on `flask plugin upgrade-all`, files a first-time write under the declared category, and honours `public: True` for pages that render before login | additive optional hook (minor) |
|
||||||
|
| 0.17.0 | Added `DashboardDefault` to the `shopdb.api` surface, so a plugin can resolve a display without reaching into core | additive surface (minor) |
|
||||||
|
| 0.18.0 | Added `DISPLAY_ROLES`, `DISPLAY_ROLE_PATHS` and `normalize_display_role`, and finally exported `DashboardDefault`, which 0.17.0 imported but left out of `__all__`. The role vocabulary became the kiosk's own - `Dashboard`, `Lobby`, `3DPrintRoom` - so a plugin holding its own copy of that map reads core's instead of drifting from it | additive surface (minor) |
|
||||||
|
| 0.19.0 | **BREAKING.** `get_dashboard_widgets` returns DATA AND SHAPE, not a component name. The old shape (`name` + `component` + `size`) named a Vue component per widget, which cannot survive a lean build - a plugin's component may never be staged into the frontend bundle (ADR-013) - and five plugins were declaring widgets that pointed at components nobody had written. A card now declares `id` / `title` / `endpoint` / `render` / `severity` / `permission` / `empty` / `position`; see PLUGIN-HOOKS.md. Also added `send_upload` so a plugin serving user-supplied bytes gets the headers that keep an SVG from running as script | **contract change (minor, pre-1.0)** |
|
||||||
|
|
||||||
The source comment block documents 0.3.0, 0.4.0, 0.6.0, 0.7.0, 0.9.0, 0.10.0, 0.11.0, and
|
The source comment block documents 0.3.0, 0.4.0, 0.6.0, 0.7.0, 0.9.0, 0.10.0,
|
||||||
0.16.0; 0.12.0 through 0.15.0 are recorded in this table only. Earlier points
|
0.11.0, 0.16.0, 0.18.0 and 0.19.0; 0.12.0 through 0.15.0 and 0.17.0 are recorded
|
||||||
|
in this table only.
|
||||||
|
|
||||||
|
**0.19.0 is the one to read before pinning.** It is the only entry in this
|
||||||
|
series that took something away, and it shipped before it was recorded here -
|
||||||
|
which is precisely the reason to pin tight rather than to trust that a minor
|
||||||
|
bump is safe. Earlier points
|
||||||
(0.1.x / 0.2.x) predate that recorded rationale; `PluginMeta`'s fallback
|
(0.1.x / 0.2.x) predate that recorded rationale; `PluginMeta`'s fallback
|
||||||
`core_version` default of `>=0.2.0,<1.0.0` is the only remaining trace of the
|
`core_version` default of `>=0.2.0,<1.0.0` is the only remaining trace of the
|
||||||
0.2 baseline.
|
0.2 baseline.
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ venv\Scripts\flask seed reference-data
|
|||||||
|
|
||||||
# Install the plugins this site tracks (registry lives in the gitignored
|
# Install the plugins this site tracks (registry lives in the gitignored
|
||||||
# instance/plugins.json, so a fresh box starts with none installed). Run
|
# instance/plugins.json, so a fresh box starts with none installed). Run
|
||||||
# `flask plugin list` to see the current bundled set; the 13 bundled plugins are
|
# `flask plugin list` to see the current bundled set; the the bundled plugins are
|
||||||
# computers, employees, geenforce, knowledgebase, machines, measuringtools,
|
# computers, employees, geenforce, knowledgebase, machines, measuringtools,
|
||||||
# network, notifications, printedparts, printers, slides, usb, warranty. Install
|
# network, notifications, printedparts, printers, slides, usb, warranty. Install
|
||||||
# only the ones this site wants:
|
# only the ones this site wants:
|
||||||
|
|||||||
@@ -109,20 +109,22 @@ the contract, and this project uses that latitude (see the history in
|
|||||||
[CONTRACT-STABILITY.md](CONTRACT-STABILITY.md)). So pin a TIGHT range that
|
[CONTRACT-STABILITY.md](CONTRACT-STABILITY.md)). So pin a TIGHT range that
|
||||||
admits only the contract minor you tested against, not the whole 0.x line.
|
admits only the contract minor you tested against, not the whole 0.x line.
|
||||||
|
|
||||||
The current contract version is declared in `shopdb/__init__.py`:
|
The current contract version is declared in `shopdb/__init__.py` as
|
||||||
|
`__contract_version__`, and is reported in
|
||||||
|
[PROJECT-MAP.md](PROJECT-MAP.md), which is generated from the code. Read it
|
||||||
|
there - a version typed into this page is wrong within a fortnight, and a
|
||||||
|
plugin pinned to a stale one is refused at startup.
|
||||||
|
|
||||||
```python
|
Pin a tight range in your `manifest.json`, per ADR-002 (pip-style `>=,<`),
|
||||||
__contract_version__ = '0.13.0'
|
admitting only the contract minor you tested against. With the contract at
|
||||||
```
|
0.19.0 that would be:
|
||||||
|
|
||||||
Recommended pin in your `manifest.json`, per ADR-002 (pip-style `>=,<` ranges):
|
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"name": "shipping",
|
"name": "shipping",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"description": "Tracks shipping-station scanners and label printers",
|
"description": "Tracks shipping-station scanners and label printers",
|
||||||
"core_version": ">=0.13.0,<0.14.0",
|
"core_version": ">=0.19.0,<0.20.0",
|
||||||
"dependencies": []
|
"dependencies": []
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -78,7 +78,8 @@ plugin's identity (ADR-002):
|
|||||||
Two fields deserve attention.
|
Two fields deserve attention.
|
||||||
|
|
||||||
`core_version` is a semver range against the framework's `__contract_version__`
|
`core_version` is a semver range against the framework's `__contract_version__`
|
||||||
(declared in `shopdb/__init__.py`, currently `0.13.0`). The loader refuses to load
|
(declared in `shopdb/__init__.py`; the current value is in
|
||||||
|
[PROJECT-MAP.md](PROJECT-MAP.md), which is generated). The loader refuses to load
|
||||||
a plugin whose range excludes the running framework. We pin `>=0.6.0` because this
|
a plugin whose range excludes the running framework. We pin `>=0.6.0` because this
|
||||||
plugin uses the `get_reports` hook, which was added to the contract in 0.6.0
|
plugin uses the `get_reports` hook, which was added to the contract in 0.6.0
|
||||||
(see [PLUGIN-HOOKS.md](PLUGIN-HOOKS.md), "get_reports"). We cap at `<1.0.0` because
|
(see [PLUGIN-HOOKS.md](PLUGIN-HOOKS.md), "get_reports"). We cap at `<1.0.0` because
|
||||||
|
|||||||
@@ -6,11 +6,11 @@ The contract is locked in [ADR-001](../docs/adr/ADR-001-asset-as-platform-contra
|
|||||||
|
|
||||||
## Contract version
|
## Contract version
|
||||||
|
|
||||||
The framework declares its contract version in `shopdb/__init__.py`:
|
The framework declares its contract version in `shopdb/__init__.py` as
|
||||||
|
`__contract_version__`. The current value is in
|
||||||
```python
|
[PROJECT-MAP.md](PROJECT-MAP.md), which is generated from the code - this page
|
||||||
__contract_version__ = '0.19.0'
|
does not restate it, because a version copied into prose is stale within a
|
||||||
```
|
fortnight and a plugin pinned against a stale one is refused at startup.
|
||||||
|
|
||||||
Each plugin's `manifest.json` declares the range of contract versions it supports:
|
Each plugin's `manifest.json` declares the range of contract versions it supports:
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Roadmap
|
# Roadmap
|
||||||
|
|
||||||
shopdb-flask is at `__contract_version__ = '0.13.0'` (pre-1.0; product `__version__ 0.7.0`, tags through v0.7.0). This document captures what stands between today and a stable `1.0.0` release. Maintained as scope evolves; supersedes nothing in the ADRs.
|
shopdb-flask is pre-1.0 on both series. Current contract and product versions are in [PROJECT-MAP.md](PROJECT-MAP.md), which is generated from the code rather than typed here. This document captures what stands between today and a stable `1.0.0` release. Maintained as scope evolves; supersedes nothing in the ADRs.
|
||||||
|
|
||||||
## Phase status
|
## Phase status
|
||||||
|
|
||||||
|
|||||||
115
tests/test_docs_versions.py
Normal file
115
tests/test_docs_versions.py
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
"""No document types a version or a count that the code already knows.
|
||||||
|
|
||||||
|
Nine documents carried a hand-typed contract version. Every one was stale, and
|
||||||
|
one of them was load-bearing: PLUGIN-EXTERNAL-REPO.md told an external author to
|
||||||
|
pin `>=0.13.0,<0.14.0` against a contract at 0.19.0, so a plugin built by
|
||||||
|
following the guide is refused by the loader at startup. The plugin count was
|
||||||
|
wrong in six more.
|
||||||
|
|
||||||
|
A number that is copied is a number that goes stale, and prose gives no signal
|
||||||
|
about which lines are still true. docs/PROJECT-MAP.md is generated for exactly
|
||||||
|
this, so a document points at it instead of restating it.
|
||||||
|
|
||||||
|
This test is the rule. Where a version genuinely belongs in prose - the contract
|
||||||
|
history table, a changelog entry, an ADR recording what was decided when - it is
|
||||||
|
recording the PAST, which does not go stale.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
REPO = Path(__file__).resolve().parents[1]
|
||||||
|
DOCS = REPO / 'docs'
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.skipif(
|
||||||
|
not DOCS.is_dir(),
|
||||||
|
reason='no docs/ in this checkout - it is excluded from publication')
|
||||||
|
|
||||||
|
# The map is generated and the changelog records history; both are allowed to
|
||||||
|
# carry literal versions. CONTRACT-STABILITY's table is a history of what each
|
||||||
|
# contract version DID, which is the past and stays true.
|
||||||
|
EXEMPT = {'PROJECT-MAP.md', 'CONTRACT-STABILITY.md'}
|
||||||
|
|
||||||
|
ASSIGNMENT = re.compile(r"__(?:contract_)?version__\s*=\s*['\"]([0-9]+\.[0-9]+\.[0-9]+)['\"]")
|
||||||
|
NARRATED = re.compile(
|
||||||
|
r"__(?:contract_)?version__[^\n]{0,60}?\b([0-9]+\.[0-9]+\.[0-9]+)\b"
|
||||||
|
r"|\b(?:contract|version) is (?:at|currently)\s+\*{0,2}([0-9]+\.[0-9]+\.[0-9]+)")
|
||||||
|
PLUGIN_COUNT = re.compile(r'\b(\d+)\s+bundled plugins\b', re.I)
|
||||||
|
|
||||||
|
|
||||||
|
def documentation_files():
|
||||||
|
return sorted(p for p in DOCS.rglob('*.md') if p.name not in EXEMPT)
|
||||||
|
|
||||||
|
|
||||||
|
def current_versions():
|
||||||
|
text = (REPO / 'shopdb' / '__init__.py').read_text()
|
||||||
|
return {
|
||||||
|
name: re.search(r"^%s\s*=\s*['\"]([^'\"]+)['\"]" % name, text, re.M).group(1)
|
||||||
|
for name in ('__version__', '__contract_version__')
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def bundled_plugin_count():
|
||||||
|
return len(list((REPO / 'plugins').glob('*/manifest.json')))
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_document_declares_a_version_literal():
|
||||||
|
"""`__contract_version__ = '0.13.0'` in prose is a promise the code breaks."""
|
||||||
|
offenders = []
|
||||||
|
for path in documentation_files():
|
||||||
|
for number, line in enumerate(path.read_text(errors='replace').splitlines(), 1):
|
||||||
|
if ASSIGNMENT.search(line):
|
||||||
|
offenders.append('%s:%d %s' % (path.relative_to(REPO), number, line.strip()[:90]))
|
||||||
|
assert not offenders, (
|
||||||
|
'These documents declare a version literal. Point at docs/PROJECT-MAP.md, '
|
||||||
|
'which is generated:\n ' + '\n '.join(offenders))
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_narrated_version_matches_the_code():
|
||||||
|
"""Prose that states the CURRENT version has to be right."""
|
||||||
|
versions = set(current_versions().values())
|
||||||
|
offenders = []
|
||||||
|
for path in documentation_files():
|
||||||
|
# An ADR states the version a decision was taken AT. That is a record of
|
||||||
|
# the past, not a claim about today, and rewriting it would falsify the
|
||||||
|
# record this project keeps ADRs for.
|
||||||
|
if path.parent.name == 'adr':
|
||||||
|
continue
|
||||||
|
for number, line in enumerate(path.read_text(errors='replace').splitlines(), 1):
|
||||||
|
for match in NARRATED.finditer(line):
|
||||||
|
found = match.group(1) or match.group(2)
|
||||||
|
if found and found not in versions:
|
||||||
|
offenders.append('%s:%d says %s %s'
|
||||||
|
% (path.relative_to(REPO), number, found, line.strip()[:70]))
|
||||||
|
assert not offenders, (
|
||||||
|
'These lines state a current version that no longer matches '
|
||||||
|
'shopdb/__init__.py:\n ' + '\n '.join(offenders))
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_stated_plugin_count_matches_the_tree():
|
||||||
|
actual = bundled_plugin_count()
|
||||||
|
offenders = []
|
||||||
|
for path in documentation_files():
|
||||||
|
for number, line in enumerate(path.read_text(errors='replace').splitlines(), 1):
|
||||||
|
for match in PLUGIN_COUNT.finditer(line):
|
||||||
|
if int(match.group(1)) != actual:
|
||||||
|
offenders.append('%s:%d says %s, tree has %d'
|
||||||
|
% (path.relative_to(REPO), number, match.group(1), actual))
|
||||||
|
assert not offenders, (
|
||||||
|
'These documents count plugins by hand. The count is in '
|
||||||
|
'docs/PROJECT-MAP.md:\n ' + '\n '.join(offenders))
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_map_itself_is_current():
|
||||||
|
"""The pointer target has to be right, or every document pointing at it is
|
||||||
|
wrong at one remove."""
|
||||||
|
mapfile = DOCS / 'PROJECT-MAP.md'
|
||||||
|
assert mapfile.is_file(), 'docs/PROJECT-MAP.md is missing; run scripts/gen_project_map.py'
|
||||||
|
text = mapfile.read_text()
|
||||||
|
for name, version in current_versions().items():
|
||||||
|
assert version in text, (
|
||||||
|
'%s is %s in the code but the generated map does not carry it. '
|
||||||
|
'Run: venv/bin/python scripts/gen_project_map.py' % (name, version))
|
||||||
|
assert str(bundled_plugin_count()) in text
|
||||||
Reference in New Issue
Block a user