mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
Every Nightly was signed for the constant "horizon release nightly", and the version a launcher installed it as came from the unsigned GitHub title, so anyone able to edit the release could serve an older signed Nightly as tonight's. - nightly-build.yml signs SHA256SUMS twice with the same key(s): SHA256SUMS.sig for the channel, as before, for installed launchers; SHA256SUMS.v2.sig for nightly-<date>-<commit>, the version the title names (sign_release.sh ALSO_FOR). Both are verified before publishing. - horizon-release-sign: nightly_title_version() parses the title strictly (no fallback to published_at); nightly_digest() takes that version and checks SHA256SUMS.v2.sig for exactly it; is_nightly_build(); SIGNATURE_V2. `verify --signature <file>` checks the v2 file. - Every installer that checks a Nightly moves in the same step: the launcher, horizon-server update and packaging/server/install.sh refuse a Nightly whose title doesn't parse and install it only with a v2 signature for that version. install.ps1 checks no signature (only GitHub's SHA-256), so it has nothing to move. - An older Nightly still installs on request as a rollback with a warning; the launcher's update never moves the selection back to it, and horizon-server prints a rollback notice for --version. - Nightlies from before this change have no v2 signature: the refusal now says so and names `horizon-server rollback`, and docs/security.md "Nightly signatures" describes the changeover (martipops's review of #9129). The launcher's new messages go through trf(), in every language table in English until translated. - tests/install_sh_signature.sh: a v2 Nightly installs as its version; a Nightly with only the channel signature, an older Nightly retitled as a newer one, and an unreadable title are refused (all three installed before this change). Split from #9129, as martipops's review suggested. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
82 lines
4.2 KiB
Python
82 lines
4.2 KiB
Python
"""Execute release shell steps with optional archives present and absent."""
|
|
|
|
import hashlib
|
|
import os
|
|
from pathlib import Path
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import tempfile
|
|
import unittest
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
REQUIRED = {
|
|
"horizon-linux-x86_64.tar.gz", "horizon-windows-x64.zip", "horizon-macos-arm64.zip",
|
|
"horizon-macos-x64.zip", "horizon-server-linux-x86_64.tar.gz", "horizon-source.tar.gz",
|
|
}
|
|
OPTIONAL = ("horizon-windows-arm64.zip", "horizon-linux-musl-x86_64.tar.gz")
|
|
|
|
|
|
def run_step(containing):
|
|
workflow = (ROOT / ".github/workflows/nightly-build.yml").read_text()
|
|
for match in re.finditer(r"^ run: \|\n((?: .*\n|\n)+)", workflow, re.MULTILINE):
|
|
script = "\n".join(line[10:] for line in match[1].splitlines())
|
|
if containing in script:
|
|
return script
|
|
raise AssertionError(f"no workflow step containing {containing}")
|
|
|
|
|
|
@unittest.skipUnless(shutil.which("bash") and shutil.which("openssl") and shutil.which("sha256sum"),
|
|
"release shell checks require bash, OpenSSL and sha256sum")
|
|
class OptionalReleaseAssetsTests(unittest.TestCase):
|
|
def test_sign_and_publish_with_each_optional_archive_combination(self):
|
|
for mask in range(4):
|
|
with self.subTest(mask=mask), tempfile.TemporaryDirectory() as folder:
|
|
root = Path(folder)
|
|
dist = root / "dist"
|
|
dist.mkdir()
|
|
present = REQUIRED | {name for i, name in enumerate(OPTIONAL) if mask & (1 << i)}
|
|
for name in present:
|
|
(dist / name).write_text(name)
|
|
script_dir = root / "tools/ci"
|
|
script_dir.mkdir(parents=True)
|
|
shutil.copy(ROOT / "tools/ci/sign_release.sh", script_dir)
|
|
# The steps' own env from nightly-build.yml: SHORT is the commit the title and
|
|
# SHA256SUMS.v2.sig name (ALSO_FOR, nightly-<date>-<short>).
|
|
env = dict(os.environ, EPHEMERAL="yes", SHA="0123456789abcdef", SHORT="0123456", DATE="2026-11-04",
|
|
ALSO_FOR="nightly-2026-11-04-0123456", SOURCE_TAG="nightly-test",
|
|
RELEASE_SIGNING_KEY="", RELEASE_SIGNING_KEY_NEXT="")
|
|
signed = subprocess.run(["bash", "-e", "-c", run_step("sign_release.sh nightly")],
|
|
cwd=root, env=env, text=True, capture_output=True)
|
|
self.assertEqual(signed.returncode, 0, signed.stderr)
|
|
sums = {name: digest for digest, name in
|
|
(line.split() for line in (dist / "SHA256SUMS").read_text().splitlines())}
|
|
self.assertEqual(sums, {name: hashlib.sha256(name.encode()).hexdigest() for name in present})
|
|
self.assertTrue((dist / "SHA256SUMS.sig").stat().st_size)
|
|
self.assertTrue((dist / "SHA256SUMS.v2.sig").stat().st_size)
|
|
(dist / "smoke-test.json").write_text("{}")
|
|
(dist / "nightly-notes.md").write_text("test")
|
|
# Isolate GitHub and tag rotation; validate the exact attachment arguments
|
|
# produced by the real workflow, including paths with display labels.
|
|
stubs = '''
|
|
python3() { :; }
|
|
gh() {
|
|
for arg in "$@"; do
|
|
case "$arg" in dist/*)
|
|
file=${arg%%#*}
|
|
test -f "$file" || return 1
|
|
printf '%s\\n' "$file" >> published
|
|
esac
|
|
done
|
|
}
|
|
'''
|
|
published = subprocess.run(["bash", "-e", "-c", stubs + run_step("gh release create nightly")],
|
|
cwd=root, env=env, text=True, capture_output=True)
|
|
self.assertEqual(published.returncode, 0, published.stderr)
|
|
files = {Path(name).name for name in (root / "published").read_text().splitlines()}
|
|
self.assertEqual(files, present | {"SHA256SUMS", "SHA256SUMS.sig", "SHA256SUMS.v2.sig", "smoke-test.json",
|
|
"nightly-notes.md"})
|
|
for name in set(OPTIONAL) - present:
|
|
self.assertIn(f"::warning::{name} is missing", signed.stdout)
|
|
self.assertIn(f"::warning::{name} is missing", published.stdout)
|