Skip to content

Commit 3771dbd

Browse files
fix(packaging): tilde-encode pre-release versions so RPM/DEB upgrades work (#588)
Releases rc.3 through rc.8 stripped the pre-release suffix (${VERSION%%-*}), so every RC built the same NVR openwatch-0.2.0-1 — dnf reported the next RC as 'already installed' and never upgraded. The DEB used a hyphen revision (0.2.0-rc.8), which orders RCs but sorts ABOVE 0.2.0, so GA would never supersede an RC. Encode pre-releases with a tilde instead (0.2.0~rc.8), which sorts below GA and distinguishes RCs (verified via rpm.vercmp and dpkg --compare-versions). Add Epoch 1 (RPM Epoch:, DEB 1: prefix) so an upgrade from the already-published bare-0.2.0 installs is not seen as a downgrade — proven in a rockylinux:9 container: the published rc.7 (0:0.2.0-1) upgrades cleanly to 1:0.2.0~rc.8-1. Build the binary with the full semver so 'openwatch --version' still reports 0.2.0-rc.8. Spec release-package-build v1.1.0 (C-12/AC-21) + a source-inspection regression test. Install-guide examples updated to the tilde filenames.
1 parent db1e215 commit 3771dbd

6 files changed

Lines changed: 111 additions & 12 deletions

File tree

docs/engineering/install_guide.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ PGPASSWORD='replace-with-a-strong-password' \
9999
### Step 3 — Install the packages
100100

101101
```bash
102-
sudo dnf install -y ./openwatch-0.2.0-1.x86_64.rpm ./kensa-rules-0.4.3-1.noarch.rpm
102+
sudo dnf install -y ./openwatch-0.2.0~rc.8-1.x86_64.rpm ./kensa-rules-0.4.3-1.noarch.rpm
103103
```
104104

105105
Install **both** files in one transaction. `openwatch` declares a hard
@@ -247,7 +247,7 @@ PGPASSWORD='replace-with-a-strong-password' \
247247
### Step 3 — Install the packages
248248

249249
```bash
250-
sudo apt install -y ./openwatch_0.2.0-rc.8_amd64.deb ./kensa-rules_0.4.3_all.deb
250+
sudo apt install -y ./openwatch_0.2.0~rc.8_amd64.deb ./kensa-rules_0.4.3_all.deb
251251
```
252252

253253
Install **both** files together — `openwatch` `Depends` on `kensa-rules` (the

packaging/deb/build-deb.sh

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,15 @@ if [ -z "${VERSION:-}" ]; then
2525
VERSION="0.1.0"
2626
fi
2727
fi
28-
# DEB allows hyphens; reuse upstream version as-is.
29-
DEB_VERSION="$VERSION"
28+
# Pre-release ordering: DEB (like RPM) needs '~' to sort a pre-release BELOW
29+
# the final release (0.2.0~rc.8 < 0.2.0). A plain hyphen revision (0.2.0-rc.8)
30+
# sorts ABOVE 0.2.0, so GA would never supersede an RC. Epoch 1 steps over the
31+
# rc.3..rc.8 .debs already published with the hyphen form. The control file
32+
# carries the epoch; the .deb filename omits it (Debian convention). The binary
33+
# keeps the true semver ($VERSION, e.g. 0.2.0-rc.8).
34+
DEB_EPOCH="${DEB_EPOCH:-1}"
35+
DEB_UPSTREAM="${VERSION/-/\~}" # 0.2.0-rc.8 -> 0.2.0~rc.8 (GA unchanged)
36+
DEB_VERSION="${DEB_EPOCH}:${DEB_UPSTREAM}" # control Version field (e.g. 1:0.2.0~rc.8)
3037
# Target architecture (Debian names, which match GOARCH for our targets).
3138
ARCH="${ARCH:-amd64}"
3239
case "$ARCH" in
@@ -40,8 +47,8 @@ mkdir -p "$DIST_DIR"
4047
# Step 1: build the Go binary for the target arch. CGO is disabled so the
4148
# binary is portable and cross-compiles without a C toolchain (the embedded
4249
# frontend SPA is arch-independent).
43-
echo ">> building openwatch binary (version=${DEB_VERSION}, arch=${ARCH})"
44-
GOOS=linux GOARCH="$ARCH" CGO_ENABLED=0 make build VERSION="$DEB_VERSION" >/dev/null
50+
echo ">> building openwatch binary (version=${VERSION}, arch=${ARCH})"
51+
GOOS=linux GOARCH="$ARCH" CGO_ENABLED=0 make build VERSION="$VERSION" >/dev/null
4552

4653
# Step 2: stage the package tree.
4754
STAGE="$(mktemp -d)"
@@ -90,6 +97,6 @@ install -m 0755 "$APP_DIR/packaging/deb/postrm" "$STAGE/DEBIAN/postrm"
9097

9198
# Step 4: dpkg-deb.
9299
echo ">> running dpkg-deb"
93-
OUT="$DIST_DIR/openwatch_${DEB_VERSION}_${ARCH}.deb"
100+
OUT="$DIST_DIR/openwatch_${DEB_UPSTREAM}_${ARCH}.deb"
94101
dpkg-deb --root-owner-group --build "$STAGE" "$OUT" >/dev/null
95102
echo ">> wrote $(basename "$OUT") to $DIST_DIR/"

packaging/rpm/build-rpm.sh

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,14 @@ if [ -z "${VERSION:-}" ]; then
2626
VERSION="0.1.0"
2727
fi
2828
fi
29-
# Strip any -dev / -alpha / -rc suffixes — RPM version field doesn't allow them.
30-
RPM_VERSION="${VERSION%%-*}"
29+
# RPM versions cannot contain '-', so encode a pre-release suffix with '~'
30+
# (tilde), which RPM sorts BELOW the final release and orders RCs correctly:
31+
# 0.2.0~rc.7 < 0.2.0~rc.8 < 0.2.0 (GA)
32+
# A bare GA version (no '-') is left unchanged. Do NOT strip the suffix — that
33+
# collapsed every RC to the same NVR (openwatch-0.2.0-1), so dnf saw rc.8 as
34+
# already installed. The spec carries Epoch:1 to step over those earlier
35+
# bare-0.2.0 RCs on upgrade.
36+
RPM_VERSION="${VERSION/-/\~}"
3137
RPM_RELEASE="${RPM_RELEASE:-1}"
3238
DIST_DIR="${APP_DIR}/dist"
3339

@@ -45,8 +51,10 @@ mkdir -p "$DIST_DIR"
4551
# outside rpmbuild's %build so the chroot needs no Go toolchain. CGO is
4652
# disabled for a portable binary that cross-compiles without a C toolchain
4753
# (the embedded frontend SPA is arch-independent).
48-
echo ">> building openwatch binary (version=${RPM_VERSION}, arch=${RPM_ARCH})"
49-
GOOS=linux GOARCH="$GOARCH" CGO_ENABLED=0 make build VERSION="$RPM_VERSION" >/dev/null
54+
# Build the binary with the FULL version (e.g. 0.2.0-rc.8) so `openwatch
55+
# --version` reports the real pre-release, not the tilde-encoded RPM form.
56+
echo ">> building openwatch binary (version=${VERSION}, arch=${RPM_ARCH})"
57+
GOOS=linux GOARCH="$GOARCH" CGO_ENABLED=0 make build VERSION="$VERSION" >/dev/null
5058

5159
# Step 2: stage the rpmbuild tree.
5260
RPMTOP="$(mktemp -d)"

packaging/rpm/openwatch.spec

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,14 @@
1818
%{!?ow_release: %global ow_release 1}
1919

2020
Name: openwatch
21+
# Epoch 1 is a permanent, deliberate bump. Releases rc.3 through rc.8 shipped
22+
# with the pre-release suffix stripped, so they all carried the NVR
23+
# openwatch-0.2.0-1 (Epoch 0). The build now encodes pre-releases with a tilde
24+
# (0.2.0~rc.N, which sorts below GA 0.2.0), but 0.2.0~rc.N < 0.2.0 would read as
25+
# a DOWNGRADE from those mis-versioned installs. Epoch 1 sorts strictly above
26+
# Epoch 0, so `dnf upgrade` cleanly moves a bare-0.2.0 install onto the
27+
# correctly-versioned packages. Epoch is sticky: never lower it.
28+
Epoch: 1
2129
Version: %{ow_version}
2230
Release: %{ow_release}%{?dist}
2331
Summary: OpenWatch Compliance Platform (Go binary)

packaging/tests/package_test.go

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -650,3 +650,52 @@ func sizeOr(info os.FileInfo) int64 {
650650

651651
// Verify there's no spurious error import.
652652
var _ = errors.New
653+
654+
// @ac AC-21
655+
// AC-21: pre-release versions are tilde-encoded with Epoch 1 across both
656+
// packages, and the binary keeps the true semver. Source-inspection backstop
657+
// for the rc.3..rc.8 regression where the suffix was stripped, collapsing
658+
// every RC to the same NVR (openwatch-0.2.0-1) so dnf saw the next RC as
659+
// already installed.
660+
func TestPackaging_PreReleaseVersioning(t *testing.T) {
661+
t.Run("release-package-build/AC-21", func(t *testing.T) {
662+
dir := appDir(t)
663+
read := func(rel string) string {
664+
b, err := os.ReadFile(filepath.Join(dir, rel))
665+
if err != nil {
666+
t.Fatalf("read %s: %v", rel, err)
667+
}
668+
return string(b)
669+
}
670+
rpmBuild := read("packaging/rpm/build-rpm.sh")
671+
spec := read("packaging/rpm/openwatch.spec")
672+
debBuild := read("packaging/deb/build-deb.sh")
673+
674+
// The stripping bug must be gone, replaced by a tilde conversion.
675+
if strings.Contains(rpmBuild, "${VERSION%%-*}") {
676+
t.Error("build-rpm.sh still strips the pre-release suffix (${VERSION%%-*}); use a tilde instead")
677+
}
678+
if !strings.Contains(rpmBuild, `${VERSION/-/\~}`) {
679+
t.Error("build-rpm.sh must tilde-encode the version (${VERSION/-/\\~})")
680+
}
681+
// RPM spec carries Epoch.
682+
if !regexp.MustCompile(`(?m)^Epoch:\s*1\b`).MatchString(spec) {
683+
t.Error("openwatch.spec must declare 'Epoch: 1'")
684+
}
685+
// DEB: tilde upstream + epoch prefix in the control version.
686+
if !strings.Contains(debBuild, `${VERSION/-/\~}`) {
687+
t.Error("build-deb.sh must tilde-encode the upstream version")
688+
}
689+
if !strings.Contains(debBuild, "DEB_EPOCH") || !strings.Contains(debBuild, `${DEB_EPOCH}:${DEB_UPSTREAM}`) {
690+
t.Error("build-deb.sh must prefix the control version with the epoch (1:upstream)")
691+
}
692+
// Both scripts build the binary with the FULL semver, not the
693+
// tilde/stripped package version.
694+
if !strings.Contains(rpmBuild, `make build VERSION="$VERSION"`) {
695+
t.Error("build-rpm.sh must build the binary with the full $VERSION (true semver)")
696+
}
697+
if !strings.Contains(debBuild, `make build VERSION="$VERSION"`) {
698+
t.Error("build-deb.sh must build the binary with the full $VERSION (true semver)")
699+
}
700+
})
701+
}

specs/release/package-build.spec.yaml

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
spec:
22
id: release-package-build
33
title: Native RPM and DEB packaging
4-
version: "1.0.0"
4+
version: "1.1.0"
55
status: approved
66
tier: 2
77

@@ -106,6 +106,22 @@ spec:
106106
per install). openssl MUST be a package dependency.
107107
type: security
108108
enforcement: error
109+
- id: C-12
110+
description: >
111+
A pre-release VERSION (one carrying a hyphen suffix such as
112+
0.2.0-rc.8) MUST be encoded in both packages with a tilde, never by
113+
stripping the suffix: the RPM Version and the DEB upstream version MUST
114+
be 0.2.0~rc.8. The tilde sorts a pre-release BELOW the final release
115+
(0.2.0~rc.8 < 0.2.0) so GA cleanly supersedes it, and distinguishes RCs
116+
from each other (stripping collapsed every RC to the same NVR, so dnf
117+
reported the next RC as already installed). Because releases rc.3..rc.8
118+
shipped with the suffix stripped (bare 0.2.0, Epoch 0 / no epoch), both
119+
packages MUST carry Epoch 1 (RPM Epoch:, DEB version prefix 1:) so an
120+
upgrade from those mis-versioned installs is not seen as a downgrade.
121+
The embedded binary MUST still report the true semver (0.2.0-rc.8), not
122+
the tilde form. Source-inspection enforces (AC-21).
123+
type: technical
124+
enforcement: error
109125

110126
acceptance_criteria:
111127
- id: AC-01
@@ -186,3 +202,14 @@ spec:
186202
description: The package payloads ship the empty /etc/openwatch/keys directory and the provisioning helper at /usr/lib/openwatch/, but do NOT contain jwt_private.pem or credential.key (verified via rpm -qpl and dpkg-deb -c — the keys are generated per-install, never packaged).
187203
priority: critical
188204
references_constraints: [C-11]
205+
- id: AC-21
206+
description: >-
207+
Source-inspection of the build scripts and RPM spec — build-rpm.sh
208+
encodes the version with a tilde (it must NOT strip the suffix via the
209+
bash %%- expansion), openwatch.spec declares Epoch 1, build-deb.sh
210+
prefixes the DEB control version with the epoch-1 form and tilde-encodes
211+
the upstream version, and both scripts build the binary with the full
212+
semver $VERSION (not the tilde/stripped form). Backstops the rc.3..rc.8
213+
NVR-collision regression.
214+
priority: critical
215+
references_constraints: [C-12]

0 commit comments

Comments
 (0)