Skip to content

Commit ff4e1d0

Browse files
committed
docs: differential release + upstream-watch contracts
1 parent 8faf867 commit ff4e1d0

3 files changed

Lines changed: 288 additions & 56 deletions

File tree

AGENTS.md

Lines changed: 91 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -77,9 +77,32 @@ do not reach up the tree.
7777

7878
SemVer, **not** CalVer — this repo is the documented exception (alongside `srtla-send-rs`).
7979
ONE unified tag `vX.Y.Z` releases **both** artifacts: `@ceralive/modem-control@X.Y.Z` on
80-
npm and the `.deb` set. `.deb` versions encode the tag as `<upstream>-<rev>~ceralive<X.Y.Z>`
81-
(upstream-ordered, apt-safe; injected with `dch --force-bad-version`). Non-tag CI builds use
82-
`~ceralive0.0.0~dev`. Full contract: `docs/VERSIONING.md`.
80+
npm and the `.deb` set. Non-tag CI builds use `~ceralive0.0.0~dev`. Full contract:
81+
`docs/VERSIONING.md`.
82+
83+
**`.deb` versions no longer encode the tag.** Releases are DIFFERENTIAL, so each upstream
84+
source carries its own rebuild counter: `<upstream>-<rev>~ceralive.N` (upstream-ordered,
85+
apt-safe; injected with `dch --force-bad-version`). A REBUILT source takes its previous
86+
counter + 1, derived from the previous release manifest's rows for that source; an
87+
UNTOUCHED source is carried forward byte-identically and keeps the counter it already had,
88+
never re-stamped with the new tag. Two sources at different counters in one release is the
89+
normal shape, not drift — coherence is a PER-SOURCE property, and a source disagreeing with
90+
ITSELF fails closed naming that source. Derivation reads every row of a source (both arches,
91+
runtime and aux) and refuses on a counter disagreement, a counter/legacy mixture, or a
92+
malformed suffix; entirely-legacy rows and an absent previous manifest bootstrap at `.1`.
93+
94+
The pre-`v1.0.0` releases WERE built with one uniform `~ceralive<X.Y.Z>` suffix shared by all
95+
four sources; those published artifacts are unchanged. No release mixes the two schemes — the
96+
first differential release force-rebuilds every source at `.1`, because this effort's own
97+
`packaging/ci/**` changes are a shared build input and force-all on their own. The
98+
migration-continuity chain `~ceralive0.2.0 < ~ceralive1.0.0 < ~ceralive1.1.0 < ~ceralive.1 <
99+
~ceralive.2 < ~ceralive.10 < <upstream>-<rev>` is proven with real `dpkg --compare-versions`
100+
by the ONE sourced library `packaging/ci/suffix-contract.sh`, from both
101+
`test-suffix-coherence-manifest.sh` (host) and `test-package-contract.sh` CHECK 5/6
102+
(container). The release manifest states `suffix_scheme: per-source-counter` and carries **no
103+
`deb_version_suffix:`** — under per-source counters no single suffix value is truthful. The
104+
companion `ceralive-modem-support` stays outside this entirely: bare SemVer tag version, no
105+
`~ceralive` suffix, and always rebuilt.
83106

84107
## FROZEN V1.1 DOMAIN CONTRACTS
85108

@@ -249,6 +272,17 @@ arch-dependent stanzas + enumerated `-dbgsym`) for exact per-source set **equali
249272
`packaging/ci/check-package-sets.sh` (add/remove/rename fails closed). Full detail:
250273
`packaging/README.md`.
251274

275+
**The pins are watched, never auto-bumped.** `.github/workflows/upstream-watch.yml` runs
276+
weekly (plus `workflow_dispatch`) and calls `packaging/ci/check-upstream-freshness.sh`, which
277+
enumerates each source's upstream release tags and salsa `debian/*` packaging tags via
278+
`git ls-remote --tags`, filters the development series out, and compares the survivors to the
279+
four pins above. On `behind` it opens **or updates** ONE issue labelled `upstream-freshness`,
280+
and closes it when everything is current again. It is **issue-only**: it never edits
281+
`upstream-pins.yaml` and never dispatches a build, which is why it is the only workflow here
282+
holding `issues: write` and no dispatch token. A newer upstream release with no matching
283+
Debian packaging tag reports the distinct `upstream-ahead-no-packaging` — there is no
284+
`<upstream>-<rev>` pair to pin, so there is no bump to recommend.
285+
252286
## MUTATION ADMISSION + EXCLUSIVE OWNERSHIP
253287

254288
`control/src/ports/mutation-admission.ts` defines `MutationAdmissionPort`. It is an injected
@@ -1350,13 +1384,44 @@ major action versions, per-manager caches, weekly grouped Dependabot, test-befor
13501384
before any other job). Exports `version` + `sha`.
13511385
2. **test** (needs tag-guard) — full bun lane + packaging contract lane
13521386
(test-before-publish).
1353-
3. **build-deb** (needs [tag-guard, test]) — injects `<upstream>-<rev>~ceralive<X.Y.Z>`
1354-
(non-tag runs `~ceralive0.0.0~dev`) via `packaging/ci/inject-deb-version.sh`, builds both
1355-
arches, runs the package contract suite + daemon smoke, builds the `Architecture: all`
1356-
companion ONCE (`packaging/ci/build-companion.sh`) and runs its clean-chroot contract
1357-
(`packaging/ci/test-companion-chroot.sh`), generates the manifest-complete
1358-
release manifest (`packaging/ci/generate-release-manifest.sh`), and uploads the `.deb`
1359-
artifacts + manifest.
1387+
3. **build-deb** (needs [tag-guard, test]) — the **DIFFERENTIAL** `.deb` job. Steps, in file
1388+
order: **Checkout the resolved commit** (`fetch-depth: 0` — load-bearing here and nowhere
1389+
else, since the detector diffs `<prev-tag>..HEAD`; a shallow checkout would silently
1390+
force-all forever) → **Assert checkout is pinned to the resolved SHA****Set up QEMU
1391+
(arm64 emulation)****Resolve previous release + fetch its manifest (once)**
1392+
(`id: prev-release`; `gh release list`, never `git describe` — the previous release is the
1393+
latest PUBLISHED one; no release or no manifest asset leaves both outputs empty, which is
1394+
the bootstrap case, not an error) → **Detect changed sources (per-source verdicts)**
1395+
(`packaging/ci/detect-changed-sources.sh --out verdicts.txt`) → **Stage carry-forward debs
1396+
(unchanged sources, sha256-verified)** (`packaging/ci/stage-carryforward-debs.sh`) →
1397+
**Build the MM 1.24 stack (.deb) — amd64 + arm64** (`build-bookworm.sh amd64` /`arm64`,
1398+
with `VERDICTS_FILE` + the already-resolved `PREV_MANIFEST_FILE`) → **Package contract
1399+
suite** (amd64 full, arm64 metadata) → **Daemon smoke (amd64)****Build the first-party
1400+
companion .deb (Architecture: all)** (UNCONDITIONAL — the companion is never detected and
1401+
never carried) → **Companion package contract (clean Debian chroot)****Generate release
1402+
manifest** (`packaging/ci/generate-release-manifest.sh`) → **Upload .deb artifacts +
1403+
release manifest**.
1404+
1405+
Three things about that order are load-bearing. The previous release is resolved and its
1406+
manifest downloaded **exactly once**, and that one path feeds all three consumers
1407+
(detection, carry-forward staging, per-source counter derivation). Carry-forward staging
1408+
runs **strictly before any `build-bookworm.sh` call**, because carried debs are a build
1409+
INPUT — `build-bookworm.sh` seeds its Pin-Priority-1001 local apt repo from
1410+
`packaging/build/<arch>/`, so a changed source resolves its build-deps and gir typelibs
1411+
against the carried `-dev`/`gir1.2-*` packages rather than stock bookworm; staging late
1412+
still goes green and silently reintroduces stock dependencies, which is why
1413+
`packaging/ci/test-release-workflow-wiring.sh` pins the ordering statically. And a
1414+
zero-build run starts no container at all yet still asserts the merged runtime closure
1415+
over the carried set.
1416+
1417+
Detection is **fail-SAFE toward rebuilding**: an absent previous release, a manifest with
1418+
no `closure_version:` header (an absent header IS closure version 1), a shared-input
1419+
change under `packaging/ci/**` or `packaging/BOOKWORM-ADAPTATIONS.md`, or the operator's
1420+
escape hatch all yield `mode=force-all`. That escape hatch is the `force_rebuild`
1421+
`workflow_dispatch` boolean input (default `false`), mapped to the script's
1422+
`FORCE_REBUILD=all` env via
1423+
`${{ github.event.inputs.force_rebuild == 'true' && 'all' || '' }}` — defense in depth, since
1424+
a shared-input change force-alls on its own.
13601425
4. **publish-npm** (needs [tag-guard, build-deb]) — OIDC trusted publishing
13611426
(`id-token: write`), verifies `control/package.json` version === tag, then an
13621427
**integrity-idempotent** publish: `npm pack` → classify registry state (404 → publish;
@@ -1379,6 +1444,22 @@ major action versions, per-manager caches, weekly grouped Dependabot, test-befor
13791444
the release exists. An operator's own `gh api` call would test the operator's CLI token and
13801445
prove nothing about the repository secret — which is exactly why this lives here.
13811446
`cancel-in-progress: false` (never cancel a release/publish mid-run).
1447+
- **`.github/workflows/upstream-watch.yml`** — the weekly **upstream freshness watch**
1448+
(schedule + `workflow_dispatch`, `cancel-in-progress: false`). Runs
1449+
`packaging/ci/check-upstream-freshness.sh`, which enumerates each source's upstream release
1450+
tags and salsa `debian/*` packaging tags via `git ls-remote --tags`, filters out the
1451+
development series, and compares the survivors to the pins. On `behind` it opens **or
1452+
updates** ONE issue labelled `upstream-freshness`; when everything is current again it closes
1453+
it. **Issue-only** — it never edits `packaging/upstream-pins.yaml` and never dispatches a
1454+
build, which is why it is the ONLY workflow here that escalates `issues: write` and why it
1455+
holds no dispatch token. The stable filter is the substance: all four projects publish their
1456+
unstable train on the same tag namespace (ModemManager `1.25.95` → Debian *experimental*), so
1457+
`-rc`/`-dev`, non-`X.Y.Z`, **odd-minor** and `.9x`-micro tags are rejected, as are `~`-bearing
1458+
Debian revisions. A newer upstream release with no Debian packaging tag reports the distinct
1459+
`upstream-ahead-no-packaging` — NOT `behind`, because with no `<upstream>-<rev>` pair there is
1460+
no bump to recommend. `packaging/ci/test-check-upstream-freshness.sh` pins all of it offline
1461+
through a fixture seam. NOTE: GitHub disables scheduled workflows after 60 days of repository
1462+
inactivity — a silent watch reads exactly like an up-to-date one.
13821463

13831464
Action pins track the latest stable **major** (resolved via the `gh api` releases/latest
13841465
endpoint); Dependabot keeps them current. JS/TS CI runs on **Node 26** — the CeraLive CI

docs/VERSIONING.md

Lines changed: 107 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -31,53 +31,131 @@ Do **not** apply the CalVer scheme here. The rationale, and the parallel excepti
3131
convention. Everything in this repo — the git tag, the npm version, and the `~ceralive`
3232
suffix below — is SemVer.
3333

34-
## `.deb` version encoding
34+
## `.deb` version encoding — per-source rebuild counters
3535

3636
The `.deb` internal `Version:` field must stay **upstream-ordered** so `apt` compares
37-
releases correctly, while still encoding which repo tag produced the rebuild. The
38-
encoding is:
37+
releases correctly, while still recording that this is a CeraLive rebuild and how many
38+
times that particular source has been rebuilt. The encoding is:
3939

4040
```
41-
<upstream>-<rev>~ceralive<X.Y.Z>
41+
<upstream>-<rev>~ceralive.N
4242
```
4343

44-
For the current pins at repo tag `v0.2.0` (the latest release — the ModemManager revision
45-
is `-2`, the other three `-1`):
46-
47-
| Source | Upstream | Encoded `.deb` version |
44+
`N` is that **source's own rebuild counter**, not the release tag. Releases are
45+
differential: only the sources whose inputs actually moved are rebuilt, and only a rebuilt
46+
source's counter advances. Release provenance lives in the release manifest; the package
47+
version records the rebuild count.
48+
49+
- A **rebuilt** source takes its previous counter **+ 1**. The previous counter is derived
50+
from the previous release manifest's rows for that source, by
51+
`packaging/ci/inject-deb-version.sh`.
52+
- An **untouched (carried-forward)** source keeps whatever counter it already had. Its
53+
`.deb`s are the byte-identical artifacts from the release that last built them; they are
54+
**not** re-stamped with the new release's tag. Builds are not reproducible, so reusing
55+
recorded bytes is the only honest way to keep a release self-contained.
56+
- Different sources therefore legitimately carry **different** counters within one release.
57+
That is the normal shape of a differential release, not drift.
58+
59+
Counter derivation is **coherence-checked and fail-closed**. Every previous-manifest row
60+
for the source is read — both arches, `role=runtime` and `role=aux` alike — and the counter
61+
is accepted only if all of them agree. Three conditions refuse the release outright, each
62+
naming the offending source:
63+
64+
| Condition | Why it refuses |
65+
|-----------|----------------|
66+
| rows disagree on the counter (`.2` vs `.3`) | picking either risks publishing a downgrade |
67+
| rows mix counter and legacy `~ceraliveX.Y.Z` suffixes | the source's history is ambiguous |
68+
| a row's suffix is neither `~ceralive.[1-9][0-9]*` nor `~ceraliveX.Y.Z` | malformed input |
69+
70+
Two cases **bootstrap at `.1`** instead: a source whose previous-manifest rows are entirely
71+
legacy, and a rebuild with no previous manifest at all (the force-all bootstrap).
72+
73+
The current pins are ModemManager 1.24.2 (revision `-2`), libmbim 1.34.0, libqmi 1.38.0 and
74+
libqrtr-glib 1.4.0 (all `-1`), so a release that rebuilt only libqmi while the other three
75+
carried forward from `.1` would produce:
76+
77+
| Source | Rebuilt? | Encoded `.deb` version |
4878
|--------|----------|------------------------|
49-
| ModemManager | 1.24.2 | `1.24.2-2~ceralive0.2.0` |
50-
| libmbim | 1.34.0 | `1.34.0-1~ceralive0.2.0` |
51-
| libqmi | 1.38.0 | `1.38.0-1~ceralive0.2.0` |
52-
| libqrtr-glib | 1.4.0 | `1.4.0-1~ceralive0.2.0` |
79+
| ModemManager | carried | `1.24.2-2~ceralive.1` |
80+
| libmbim | carried | `1.34.0-1~ceralive.1` |
81+
| libqmi | rebuilt | `1.38.0-1~ceralive.2` |
82+
| libqrtr-glib | carried | `1.4.0-1~ceralive.1` |
83+
84+
> The authoritative pin manifest is `packaging/upstream-pins.yaml`; the release workflow
85+
> derives `<upstream>-<rev>` from each source's `debian/changelog`, never from a value
86+
> hardcoded in the version script.
87+
88+
### The legacy suffix, and the migration into counters
89+
90+
Every release through `v0.2.0` used a different scheme: one `~ceralive<X.Y.Z>` suffix,
91+
identical across all four sources, taken from the release tag. Those artifacts are
92+
published and unchanged — `1.24.2-2~ceralive0.2.0` is still exactly what a fleet device has
93+
installed. What changed is what the pipeline produces from here on.
5394

54-
> The upstream versions above are the current provenance-verified pins. The authoritative
55-
> manifest is `packaging/upstream-pins.yaml`; the release workflow derives `<upstream>-<rev>`
56-
> from each source's `debian/changelog`, never from a value hardcoded in the version script.
95+
There is no release that mixes the two schemes. The first release built from the
96+
differential pipeline **force-rebuilds every source at `.1`**, because this effort's own
97+
changes under `packaging/ci/**` are a shared build input, and a shared-input change in the
98+
diff against the last published release force-alls on its own. So the transition happens in
99+
one release, for all four sources at once, and no manifest ever carries a legacy suffix
100+
beside a counter suffix.
57101

58-
### Why the tilde (`~`)
102+
### Why the tilde (`~`), and why the chain still orders
59103

60-
`dpkg` orders a `~` suffix **lower** than the un-suffixed version:
104+
`dpkg` orders a `~` suffix **lower** than the un-suffixed version, and orders the counter
105+
suffix above every legacy one. The full **migration-continuity chain**, exactly as
106+
`packaging/ci/suffix-contract.sh` proves it:
61107

62108
```
63-
1.24.2-2~ceralive0.1.0 < 1.24.2-2~ceralive0.2.0 < 1.24.2-2
109+
<base>~ceralive0.2.0 < <base>~ceralive1.0.0 < <base>~ceralive1.1.0
110+
< <base>~ceralive.1 < <base>~ceralive.2 < <base>~ceralive.10 < <base>
64111
```
65112

66-
So every CeraLive rebuild sorts **below** a hypothetical stock Debian `1.24.2-2`, and a
67-
newer repo tag (`0.2.0`) sorts **above** an older one (`0.1.0`) — exactly the ordering
68-
`apt` needs. This is why the release workflow injects the version with
69-
`dch --force-bad-version`: the tilde-encoded version is numerically **lower** than the
70-
pinned `<upstream>-<rev>` changelog top, and plain `dch --newversion` refuses a
71-
lower-than-current version (per `dch(1)`). `--force-bad-version` is **required**, not
72-
optional.
113+
(`<base>` is the source's `<upstream>-<rev>`, e.g. `1.24.2-2`.) Its legacy members are every
114+
version that exists as a published artifact today, so the chain is the proof that a fleet
115+
device upgrades cleanly from any shipped release into the counter scheme, and that every
116+
CeraLive rebuild still sorts **below** a hypothetical stock Debian `1.24.2-2`.
73117

74-
The exact injection command, run once per source, is:
118+
Nothing here is asserted on paper. `prove_chain_ordered` runs the whole chain through real
119+
`dpkg --compare-versions`, and it is exercised from both lanes: the host-runnable
120+
`packaging/ci/test-suffix-coherence-manifest.sh` (which also carries a non-vacuity control
121+
showing a lexical compare inverts `.2` against `.10`) and the container suite
122+
`packaging/ci/test-package-contract.sh` CHECK 6. CHECK 5 is the coherence half — sources at
123+
differing counters pass, a source disagreeing with **itself** fails closed naming that
124+
source.
125+
126+
Tilde ordering is also why the workflow injects with `dch --force-bad-version`: the
127+
tilde-encoded version is numerically **lower** than the pinned `<upstream>-<rev>` changelog
128+
top, and plain `dch --newversion` refuses a lower-than-current version (per `dch(1)`).
129+
`--force-bad-version` is **required**, not optional.
130+
131+
The exact injection command, run once per **rebuilt** source, is:
75132

76133
```sh
77-
dch --force-bad-version --newversion "<upstream>-<rev>~ceralive<X.Y.Z>" "CeraLive rebuild"
134+
dch --force-bad-version --newversion "<upstream>-<rev>~ceralive.N" "CeraLive rebuild"
78135
```
79136

80-
All four sources take the **same** `~ceralive<X.Y.Z>` suffix for a given release.
137+
### The manifest header states the scheme, not a value
138+
139+
Under per-source counters no single suffix value is truthful for a release, so the release
140+
manifest carries:
141+
142+
```
143+
suffix_scheme: per-source-counter
144+
```
145+
146+
and **no `deb_version_suffix:` header** — that field no longer exists in any manifest this
147+
repo produces. Every row keeps carrying its own version, which it always did (rows are
148+
parsed from real filenames), so a carried-forward deb at an old counter and a freshly built
149+
one at a new counter both emit correctly. `version:` is unrelated and stays: it is the
150+
release's own SemVer, not a per-deb suffix.
151+
152+
### The companion is outside this scheme entirely
153+
154+
`ceralive-modem-support` is a first-party native package with no upstream version to order
155+
against. It takes the repo's SemVer tag **verbatim** (`v1.1.0``1.1.0`) with no
156+
`~ceralive` suffix at all, and it is **always rebuilt** — it is never detected, never
157+
verdicted, and never carried forward. That is unrelated to the counter scheme, and neither
158+
rule affects the other.
81159

82160
## Tag guard (fail-closed)
83161

@@ -92,7 +170,7 @@ Anything that does not match **fails closed before any other job runs**. In part
92170
| Input | Result | Why |
93171
|-------|--------|-----|
94172
| `v1.0.0` | ✅ accepted | canonical `vX.Y.Z` |
95-
| `v1.0.0-rc.1` | ❌ rejected | a pre-release tag inverts dpkg ordering — the rc's tilde-encoded version would outrank the final release (`~ceralive1.0.0-rc.1` vs `~ceralive1.0.0`), which is wrong |
173+
| `v1.0.0-rc.1` | ❌ rejected | a pre-release tag inverts dpkg ordering — the companion takes the tag verbatim, and `dpkg` reads the `-rc.1` as a Debian revision, so the rc would sort **above** the final `1.0.0`, which is wrong |
96174
| `v1.0.0+build5` | ❌ rejected | build metadata has no meaning in a `.deb` version and is not part of the contract |
97175
| `1.0.0` | ❌ rejected | missing the `v` prefix |
98176

0 commit comments

Comments
 (0)