Skip to content

Commit b36dbca

Browse files
release: gate Hive publishing on live updater parity
Fail Hive release publishing when qsdm.tech still advertises stale Windows or Linux updater metadata, and validate the pinned signed release envelopes before declaring a release complete.
1 parent 37d85a3 commit b36dbca

4 files changed

Lines changed: 370 additions & 0 deletions

File tree

.github/workflows/qsdm-hive-publish-release.yml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,9 @@ jobs:
144144
! -name ARTIFACTS-SHA256SUMS.txt -print0 | sort -z | \
145145
xargs -0 sha256sum > release-assets/ARTIFACTS-SHA256SUMS.txt
146146
147+
- name: Test production feed checker
148+
run: python -m unittest scripts.test_check_hive_release_feed
149+
147150
- name: Publish GitHub release
148151
shell: bash
149152
env:
@@ -188,3 +191,13 @@ jobs:
188191
fi
189192
190193
gh release view "$TAG" --json tagName,isDraft,isPrerelease,url,assets
194+
- name: Require production updater feed parity
195+
shell: bash
196+
env:
197+
VERSION: ${{ steps.request.outputs.version }}
198+
SOURCE_COMMIT: ${{ steps.request.outputs.source_commit }}
199+
run: |
200+
set -euo pipefail
201+
python scripts/check_hive_release_feed.py \
202+
--expected-version "$VERSION" \
203+
--expected-commit "$SOURCE_COMMIT"

QSDM/docs/docs/BUILD_AND_RELEASE_GUIDELINES.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -355,6 +355,14 @@ Publish versioned, immutable artifacts first. Update the `latest` pointer only
355355
after remote checksum/signature verification and smoke tests pass. Attach or
356356
retain:
357357

358+
The GitHub release workflow intentionally has no production-server or release
359+
signing credentials. Its final production-feed parity gate therefore remains
360+
red until the release owner creates both local ML-DSA envelopes and runs
361+
`publish_hive_dual_platform_release.sh` against `qsdm.tech`. Rerun the failed
362+
workflow after that atomic publication. A GitHub release is not complete, and
363+
must not be announced, while this gate is red; otherwise installed Hive clients
364+
will continue to see the previous `latest.yml` version.
365+
358366
- release notes and migration/rollback instructions;
359367
- artifact manifest and SHA-256 checksums;
360368
- both QSDM-native signed release envelopes generated from the pinned release

scripts/check_hive_release_feed.py

Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
#!/usr/bin/env python3
2+
"""Verify that the public QSDM Hive updater feed matches an approved release."""
3+
4+
from __future__ import annotations
5+
6+
import argparse
7+
import base64
8+
import json
9+
import re
10+
import ssl
11+
import sys
12+
import urllib.error
13+
import urllib.request
14+
from collections.abc import Callable
15+
from dataclasses import dataclass
16+
17+
18+
DEFAULT_BASE_URL = "https://qsdm.tech/downloads"
19+
PINNED_RELEASE_KEY_ID = (
20+
"10ab9c5710761d4c9dca59d42446e9ea0e3315d15cdc3715df1dcb8c96fa07a1"
21+
)
22+
SEMVER_RE = re.compile(r"^[0-9]+\.[0-9]+\.[0-9]+$")
23+
COMMIT_RE = re.compile(r"^[0-9a-f]{40}$")
24+
25+
26+
class FeedCheckError(RuntimeError):
27+
"""Raised when the public release feed is missing or inconsistent."""
28+
29+
30+
@dataclass(frozen=True)
31+
class UpdaterManifest:
32+
version: str
33+
path: str
34+
35+
36+
def parse_updater_manifest(content: str, source: str) -> UpdaterManifest:
37+
values: dict[str, str] = {}
38+
for line in content.splitlines():
39+
match = re.match(r"^(version|path):\s*['\"]?([^'\"\s]+)", line.strip())
40+
if match and match.group(1) not in values:
41+
values[match.group(1)] = match.group(2)
42+
43+
if not values.get("version") or not values.get("path"):
44+
raise FeedCheckError(f"{source} is missing version or path")
45+
return UpdaterManifest(version=values["version"], path=values["path"])
46+
47+
48+
def validate_release_envelope(
49+
content: str,
50+
*,
51+
source: str,
52+
expected_version: str,
53+
expected_commit: str,
54+
expected_platform: str,
55+
expected_artifact: str,
56+
) -> None:
57+
try:
58+
envelope = json.loads(content)
59+
except json.JSONDecodeError as error:
60+
raise FeedCheckError(f"{source} is not valid JSON: {error}") from error
61+
62+
if envelope.get("schema") != "qsdm.signed-release.v1":
63+
raise FeedCheckError(f"{source} has an unsupported envelope schema")
64+
if envelope.get("algorithm") != "ML-DSA-87":
65+
raise FeedCheckError(f"{source} is not signed with ML-DSA-87")
66+
if envelope.get("key_id") != PINNED_RELEASE_KEY_ID:
67+
raise FeedCheckError(f"{source} is signed by an unapproved release key")
68+
69+
encoded_manifest = envelope.get("manifest_base64")
70+
if not isinstance(encoded_manifest, str) or not encoded_manifest:
71+
raise FeedCheckError(f"{source} has no signed manifest payload")
72+
try:
73+
manifest = json.loads(
74+
base64.b64decode(encoded_manifest, validate=True).decode("utf-8")
75+
)
76+
except (ValueError, UnicodeDecodeError, json.JSONDecodeError) as error:
77+
raise FeedCheckError(f"{source} has an invalid signed manifest: {error}") from error
78+
79+
expected_fields = {
80+
"schema": "qsdm.release-manifest.v1",
81+
"product": "qsdm-hive",
82+
"channel": "stable",
83+
"version": expected_version,
84+
"commit": expected_commit,
85+
"platform": expected_platform,
86+
"key_id": PINNED_RELEASE_KEY_ID,
87+
}
88+
for field, expected in expected_fields.items():
89+
if manifest.get(field) != expected:
90+
raise FeedCheckError(
91+
f"{source} signed manifest {field} is {manifest.get(field)!r}; "
92+
f"expected {expected!r}"
93+
)
94+
95+
artifacts = manifest.get("artifacts")
96+
if not isinstance(artifacts, list):
97+
raise FeedCheckError(f"{source} signed manifest has no artifact list")
98+
if not any(
99+
isinstance(artifact, dict)
100+
and artifact.get("name") == expected_artifact
101+
and artifact.get("role") == "installer"
102+
for artifact in artifacts
103+
):
104+
raise FeedCheckError(
105+
f"{source} does not authorize installer {expected_artifact}"
106+
)
107+
108+
109+
def verify_feed(
110+
*,
111+
base_url: str,
112+
expected_version: str,
113+
expected_commit: str,
114+
fetch_text: Callable[[str], str],
115+
require_url: Callable[[str], None],
116+
) -> None:
117+
base_url = base_url.rstrip("/")
118+
expected_artifacts = {
119+
"windows": f"qsdm-hive-{expected_version}-win-x64.exe",
120+
"linux": f"qsdm-hive-{expected_version}-linux-x86_64.AppImage",
121+
}
122+
manifest_names = {"windows": "latest.yml", "linux": "latest-linux.yml"}
123+
124+
for platform in ("windows", "linux"):
125+
manifest_name = manifest_names[platform]
126+
manifest_url = f"{base_url}/{manifest_name}"
127+
updater_manifest = parse_updater_manifest(
128+
fetch_text(manifest_url), manifest_url
129+
)
130+
if updater_manifest.version != expected_version:
131+
raise FeedCheckError(
132+
f"{manifest_url} advertises {updater_manifest.version}; "
133+
f"expected {expected_version}"
134+
)
135+
expected_artifact = expected_artifacts[platform]
136+
if updater_manifest.path != expected_artifact:
137+
raise FeedCheckError(
138+
f"{manifest_url} points to {updater_manifest.path}; "
139+
f"expected {expected_artifact}"
140+
)
141+
require_url(f"{base_url}/{expected_artifact}")
142+
143+
envelope_url = f"{base_url}/qsdm-hive-release-{platform}.json"
144+
validate_release_envelope(
145+
fetch_text(envelope_url),
146+
source=envelope_url,
147+
expected_version=expected_version,
148+
expected_commit=expected_commit,
149+
expected_platform=platform,
150+
expected_artifact=expected_artifact,
151+
)
152+
153+
154+
def _fetch_text(url: str) -> str:
155+
request = urllib.request.Request(
156+
url, headers={"User-Agent": "qsdm-hive-release-feed-check/1"}
157+
)
158+
try:
159+
with urllib.request.urlopen(
160+
request, timeout=20, context=_certificate_context()
161+
) as response:
162+
return response.read().decode("utf-8")
163+
except (urllib.error.URLError, TimeoutError, UnicodeDecodeError) as error:
164+
raise FeedCheckError(f"could not read {url}: {error}") from error
165+
166+
167+
def _require_url(url: str) -> None:
168+
request = urllib.request.Request(
169+
url,
170+
method="HEAD",
171+
headers={"User-Agent": "qsdm-hive-release-feed-check/1"},
172+
)
173+
try:
174+
with urllib.request.urlopen(
175+
request, timeout=20, context=_certificate_context()
176+
) as response:
177+
if response.status != 200:
178+
raise FeedCheckError(f"{url} returned HTTP {response.status}")
179+
except (urllib.error.URLError, TimeoutError) as error:
180+
raise FeedCheckError(f"could not reach {url}: {error}") from error
181+
182+
183+
def _certificate_context() -> ssl.SSLContext:
184+
# Some supported Windows operator hosts use Python builds whose bundled
185+
# OpenSSL trust path is stale even though the Windows and Git trust stores
186+
# are current. Prefer certifi when the host already provides it; CI needs no
187+
# additional dependency and continues to use its operating-system bundle.
188+
try:
189+
import certifi
190+
except ImportError:
191+
return ssl.create_default_context()
192+
return ssl.create_default_context(cafile=certifi.where())
193+
194+
195+
def main() -> int:
196+
parser = argparse.ArgumentParser(description=__doc__)
197+
parser.add_argument("--expected-version", required=True)
198+
parser.add_argument("--expected-commit", required=True)
199+
parser.add_argument("--base-url", default=DEFAULT_BASE_URL)
200+
args = parser.parse_args()
201+
202+
if not SEMVER_RE.fullmatch(args.expected_version):
203+
parser.error("--expected-version must be MAJOR.MINOR.PATCH")
204+
if not COMMIT_RE.fullmatch(args.expected_commit):
205+
parser.error("--expected-commit must be a full lowercase Git commit")
206+
207+
try:
208+
verify_feed(
209+
base_url=args.base_url,
210+
expected_version=args.expected_version,
211+
expected_commit=args.expected_commit,
212+
fetch_text=_fetch_text,
213+
require_url=_require_url,
214+
)
215+
except FeedCheckError as error:
216+
print(f"QSDM Hive production feed check failed: {error}", file=sys.stderr)
217+
print(
218+
"Publish the locally signed Windows and Linux release with "
219+
"QSDM/deploy/scripts/publish_hive_dual_platform_release.sh, "
220+
"then rerun the release workflow.",
221+
file=sys.stderr,
222+
)
223+
return 1
224+
225+
print(
226+
f"QSDM Hive production feed matches {args.expected_version} "
227+
f"at {args.expected_commit}."
228+
)
229+
return 0
230+
231+
232+
if __name__ == "__main__":
233+
raise SystemExit(main())
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import base64
2+
import json
3+
import unittest
4+
5+
from scripts.check_hive_release_feed import (
6+
FeedCheckError,
7+
PINNED_RELEASE_KEY_ID,
8+
parse_updater_manifest,
9+
verify_feed,
10+
)
11+
12+
13+
VERSION = "1.4.16"
14+
COMMIT = "f4e3f36bdf16dab7f568c5b2928be524cc51421f"
15+
BASE_URL = "https://downloads.example.test"
16+
17+
18+
def envelope(platform: str, artifact: str, *, version: str = VERSION) -> str:
19+
manifest = {
20+
"schema": "qsdm.release-manifest.v1",
21+
"product": "qsdm-hive",
22+
"channel": "stable",
23+
"platform": platform,
24+
"version": version,
25+
"commit": COMMIT,
26+
"key_id": PINNED_RELEASE_KEY_ID,
27+
"artifacts": [{"name": artifact, "role": "installer"}],
28+
}
29+
return json.dumps(
30+
{
31+
"schema": "qsdm.signed-release.v1",
32+
"algorithm": "ML-DSA-87",
33+
"key_id": PINNED_RELEASE_KEY_ID,
34+
"manifest_base64": base64.b64encode(
35+
json.dumps(manifest).encode("utf-8")
36+
).decode("ascii"),
37+
}
38+
)
39+
40+
41+
def valid_feed() -> dict[str, str]:
42+
windows = f"qsdm-hive-{VERSION}-win-x64.exe"
43+
linux = f"qsdm-hive-{VERSION}-linux-x86_64.AppImage"
44+
return {
45+
f"{BASE_URL}/latest.yml": f"version: {VERSION}\npath: {windows}\n",
46+
f"{BASE_URL}/latest-linux.yml": f"version: {VERSION}\npath: {linux}\n",
47+
f"{BASE_URL}/qsdm-hive-release-windows.json": envelope(
48+
"windows", windows
49+
),
50+
f"{BASE_URL}/qsdm-hive-release-linux.json": envelope("linux", linux),
51+
}
52+
53+
54+
class HiveReleaseFeedCheckTests(unittest.TestCase):
55+
def test_parses_electron_builder_manifest(self) -> None:
56+
manifest = parse_updater_manifest(
57+
"version: 1.4.16\nfiles:\n - url: ignored\npath: app.exe\n",
58+
"latest.yml",
59+
)
60+
self.assertEqual(manifest.version, VERSION)
61+
self.assertEqual(manifest.path, "app.exe")
62+
63+
def test_accepts_matching_dual_platform_feed(self) -> None:
64+
feed = valid_feed()
65+
checked_urls: list[str] = []
66+
67+
verify_feed(
68+
base_url=BASE_URL,
69+
expected_version=VERSION,
70+
expected_commit=COMMIT,
71+
fetch_text=feed.__getitem__,
72+
require_url=checked_urls.append,
73+
)
74+
75+
self.assertEqual(
76+
checked_urls,
77+
[
78+
f"{BASE_URL}/qsdm-hive-{VERSION}-win-x64.exe",
79+
f"{BASE_URL}/qsdm-hive-{VERSION}-linux-x86_64.AppImage",
80+
],
81+
)
82+
83+
def test_rejects_stale_updater_pointer(self) -> None:
84+
feed = valid_feed()
85+
feed[f"{BASE_URL}/latest.yml"] = (
86+
"version: 1.4.15\npath: qsdm-hive-1.4.15-win-x64.exe\n"
87+
)
88+
89+
with self.assertRaisesRegex(FeedCheckError, "advertises 1.4.15"):
90+
verify_feed(
91+
base_url=BASE_URL,
92+
expected_version=VERSION,
93+
expected_commit=COMMIT,
94+
fetch_text=feed.__getitem__,
95+
require_url=lambda _url: None,
96+
)
97+
98+
def test_rejects_envelope_for_different_release(self) -> None:
99+
feed = valid_feed()
100+
windows = f"qsdm-hive-{VERSION}-win-x64.exe"
101+
feed[f"{BASE_URL}/qsdm-hive-release-windows.json"] = envelope(
102+
"windows", windows, version="1.4.15"
103+
)
104+
105+
with self.assertRaisesRegex(FeedCheckError, "expected '1.4.16'"):
106+
verify_feed(
107+
base_url=BASE_URL,
108+
expected_version=VERSION,
109+
expected_commit=COMMIT,
110+
fetch_text=feed.__getitem__,
111+
require_url=lambda _url: None,
112+
)
113+
114+
115+
if __name__ == "__main__":
116+
unittest.main()

0 commit comments

Comments
 (0)