mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-28 20:02:12 +01:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9658f38382 | ||
|
|
eb77fe1d2f | ||
|
|
c733fab6ae | ||
|
|
2fcf50e59a |
No files matched your search
@@ -0,0 +1,35 @@
|
||||
# Repeatable installer audit command, 28 October 2026
|
||||
|
||||
The independent installer check used for v0.4.53 was previously a local
|
||||
one-off script. The new script at scripts/audit-release-assets.py makes that
|
||||
check repeatable before future releases are published. It reads the release
|
||||
by exact tag or the draft's exact version title, requires ten installers plus
|
||||
SHA256SUMS, downloads each by asset ID, checks size and SHA-256 against GitHub
|
||||
metadata, matches the downloaded checksum file byte for byte, and inspects
|
||||
package formats and versions. On an x86_64 Linux host, its optional flag also
|
||||
launches the tagged x86_64 AppImage under isolated Xvfb.
|
||||
|
||||
The command writes a JSON report only after all checks pass and refuses to
|
||||
overwrite an existing report. It performs no headset operation and makes no
|
||||
GitHub changes. It does not prove physical Mac or Windows installer launch.
|
||||
|
||||
## Verification
|
||||
|
||||
- Eight focused offline tests passed, including incomplete metadata, wrong
|
||||
Mac bundle version, wrong AppImage architecture, a bad downloaded checksum
|
||||
file, and refusal to overwrite a report. Three existing checksum-generator
|
||||
tests passed.
|
||||
- After v0.4.53 publication, the new command audited its public release with
|
||||
the tag v0.4.53, report path /tmp/frame-v0453-reusable-audit.json, and the
|
||||
--launch-x86-appimage option. It exited successfully after independently
|
||||
downloading all ten installers. Their names, sizes, hashes and package
|
||||
checks matched the [prepublication asset audit](v0453-assets-2026-10-28.json),
|
||||
and the downloaded SHA256SUMS had the same SHA-256:
|
||||
813441ac5c121e2c9effd274576aa6db1ff01187f811463a1014f86e0172608c.
|
||||
The x86_64 AppImage's bundled server returned HTTP 200 with FrameControl/1
|
||||
and stopped after app exit. The local JSON report had SHA-256
|
||||
ad8d7feaf6c0398b216f43c00206dd01d6efc714e3b341048bece96c0e5be376.
|
||||
|
||||
This postpublication repeat verifies the new command; it does not replace
|
||||
the prepublication v0.4.53 release gates recorded in
|
||||
[release verification](v0453-release-2026-10-28.md).
|
||||
@@ -0,0 +1,76 @@
|
||||
{
|
||||
"tag": "v0.4.53",
|
||||
"releaseId": 427006429,
|
||||
"tagNameAtAudit": "v0.4.53",
|
||||
"draftAtAudit": true,
|
||||
"sha256sumsSha256": "813441ac5c121e2c9effd274576aa6db1ff01187f811463a1014f86e0172608c",
|
||||
"assets": [
|
||||
{
|
||||
"name": "Frame-Control-mac-arm64.dmg",
|
||||
"size": 177629060,
|
||||
"sha256": "8ae712108aa3fdfb5f40a2f205db65ffd7694914bab2ac0af7a85dafb1c8c31e",
|
||||
"udifTrailer": "ok"
|
||||
},
|
||||
{
|
||||
"name": "Frame-Control-mac-arm64.zip",
|
||||
"size": 177452293,
|
||||
"sha256": "f6f683e43b397d51432a8651d0b54d5d96cfd3e9d75e0fe4a9769cdd10ccf33b",
|
||||
"zipCrc": "ok",
|
||||
"members": 1673,
|
||||
"bundleVersion": "0.4.53"
|
||||
},
|
||||
{
|
||||
"name": "Frame-Control-mac-x64.dmg",
|
||||
"size": 184700330,
|
||||
"sha256": "7c25a0d6633aa5312c5e7a1abf8d1abd656e109cc78093a2c5bd29bf790d8e03",
|
||||
"udifTrailer": "ok"
|
||||
},
|
||||
{
|
||||
"name": "Frame-Control-mac-x64.zip",
|
||||
"size": 184538726,
|
||||
"sha256": "fe7c5b46ea718a5e8f5d8cc511813d420fdbdfea4858e4c4c20f92b7aaaed529",
|
||||
"zipCrc": "ok",
|
||||
"members": 1669,
|
||||
"bundleVersion": "0.4.53"
|
||||
},
|
||||
{
|
||||
"name": "Frame-Control-Setup-x64.exe",
|
||||
"size": 151885488,
|
||||
"sha256": "9bdbafc00fd170a4e31dbaafc73ce89c952c85a1cbad9198822ea2c473489597",
|
||||
"peMachine": 332
|
||||
},
|
||||
{
|
||||
"name": "Frame-Control-win-x64.zip",
|
||||
"size": 198840858,
|
||||
"sha256": "8360ea2427a84374a2106267d0440dbf5e4d9b22f9ef44e465d31f81c7ebfaff",
|
||||
"zipCrc": "ok",
|
||||
"members": 1517
|
||||
},
|
||||
{
|
||||
"name": "Frame-Control-linux-x86_64.AppImage",
|
||||
"size": 179997230,
|
||||
"sha256": "7755621360a1aafb6e1bdf2b41838d172a3f6d7f5662d2b5217f6aae08486c2b",
|
||||
"elfMachine": 62
|
||||
},
|
||||
{
|
||||
"name": "Frame-Control-linux-arm64.AppImage",
|
||||
"size": 171579370,
|
||||
"sha256": "985b0a1c3e04af5cc1a41e2e7ad9e8481aac45762b369044bf314972e894dc64",
|
||||
"elfMachine": 183
|
||||
},
|
||||
{
|
||||
"name": "Frame-Control-linux-amd64.deb",
|
||||
"size": 146412012,
|
||||
"sha256": "2adbef7c1bec3e9e40244984598a2e2c9816eb2c21fdb21d6996f9d30f2d0fec",
|
||||
"debVersion": "0.4.53",
|
||||
"architecture": "amd64"
|
||||
},
|
||||
{
|
||||
"name": "Frame-Control-linux-arm64.deb",
|
||||
"size": 133498848,
|
||||
"sha256": "0625dfdb1959f8eab52ac3f6c4bf909b7de3966c9ca959c391e43630516ff9c7",
|
||||
"debVersion": "0.4.53",
|
||||
"architecture": "arm64"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
# v0.4.53 release verification, 28 October 2026
|
||||
|
||||
The annotated `v0.4.53` tag dereferences to merged main commit
|
||||
`e4ce91d6868665e6689a4aa17a27a46bf9abd089`. Its tree matches reviewed
|
||||
[candidate PR #380](https://github.com/saphid/frame-control/pull/380) head
|
||||
`a1a376b4ee9464ac9d97426cce21e662e3a9bf1e`; the diff is empty.
|
||||
|
||||
## Exact commit gates
|
||||
|
||||
- [Main checks](https://github.com/saphid/frame-control/actions/runs/40739079630)
|
||||
passed all seven jobs on the first attempt, including e2e, iOS, Windows,
|
||||
macOS, Linux and aggregate checks.
|
||||
- [Native Decky runtime](https://github.com/saphid/frame-control/actions/runs/40739079822)
|
||||
passed both published Decky 3.3.0 AMD64 and ARM64 jobs on the first attempt.
|
||||
- Tagged installer and checksum workflow:
|
||||
[run 40740373224](https://github.com/saphid/frame-control/actions/runs/40740373224).
|
||||
All six jobs passed on the first attempt: draft, Linux, Windows, Intel Mac,
|
||||
Apple Silicon Mac and checksums. No tagged job failed, was cancelled,
|
||||
skipped or overridden.
|
||||
|
||||
Before publication the draft held exactly ten installers and `SHA256SUMS`.
|
||||
Every installer was independently downloaded by GitHub asset ID; each size
|
||||
and SHA-256 matched the asset metadata. The downloaded `SHA256SUMS` matched
|
||||
all ten entries byte for byte and had SHA-256
|
||||
`813441ac5c121e2c9effd274576aa6db1ff01187f811463a1014f86e0172608c`.
|
||||
The [sanitized asset audit](v0453-assets-2026-10-28.json) records names,
|
||||
sizes, hashes and package checks. It verified both Mac ZIP CRCs and bundle
|
||||
version `0.4.53`, both DMG UDIF trailers, Windows ZIP CRC and PE header,
|
||||
both AppImage ELF architectures, and both DEB version and architecture fields.
|
||||
|
||||
## Real Frame and package checks
|
||||
|
||||
The [candidate smoke evidence](v0453-candidate-2026-10-28.md)
|
||||
records a unique-lock source smoke: 12 passed, one optional x86_64 launch
|
||||
skipped because Steam Linux Runtime 4 was absent, zero failed. Independent
|
||||
fresh-lock cleanup found no reserved test titles or owned processes. The
|
||||
source tree changed only in documentation after the smoke, and the merged
|
||||
release tree matches the PR head. SteamVR was in standby; nobody was confirmed
|
||||
wearing the Frame, and no VR input was sent.
|
||||
The private smoke result had SHA-256
|
||||
`5a5bc8b1da1ef405235d660a2587c84204afd0816c80f8d9dfd9577dac72d547`;
|
||||
the fresh-lock cleanup audit had SHA-256
|
||||
`6ab884b6adffb14c4643e30ee2e5fe247b2ebba7a3ad5424515927cd40146e79`.
|
||||
|
||||
The tagged x86_64 AppImage downloaded independently by asset ID had SHA-256
|
||||
`7755621360a1aafb6e1bdf2b41838d172a3f6d7f5662d2b5217f6aae08486c2b`.
|
||||
It launched under isolated Xvfb with a temporary profile, telemetry and update
|
||||
checks disabled; its bundled server returned HTTP 200 with `FrameControl/1`
|
||||
and stopped after app exit. No physical Mac or Windows launch of these exact
|
||||
installers was performed.
|
||||
|
||||
## Publication
|
||||
|
||||
`scripts/publish-release.sh --dry-run v0.4.53` passed exact-main, native
|
||||
runtime and tagged release jobs, all ten installer hashes, regenerated
|
||||
`SHA256SUMS`, draft notes and update-manifest checks. Its log had SHA-256
|
||||
`ddad938f25d4321a8d24b29ff895a20431ec6d1c2c6faf8fd42f939100c97bff`.
|
||||
The publisher repeated those gates, attached `update.json`, and published
|
||||
[v0.4.53](https://github.com/saphid/frame-control/releases/tag/v0.4.53) at
|
||||
**18:14:22 UTC**. Its log had SHA-256
|
||||
`b54cd7f87c0ba858cc61086fc6293287bdb7555c6a49d3f1c9b8216b39d0dcf3`.
|
||||
GitHub's latest-release API returned `draft=false`, `prerelease=false`, the
|
||||
correct tag, ten installers, `SHA256SUMS` and `update.json`. A fresh download
|
||||
from the public `releases/latest/download/update.json` route reported version
|
||||
`0.4.53`, the correct release page, all ten names, sizes and hashes matching
|
||||
the independent downloads, and the explicit 26.5 GB transfer limit. The
|
||||
public manifest had SHA-256
|
||||
`b873a3422e60e2fec2ff4a08f0a1fe610b9df18eb94a1a72e259beefb763bb38`.
|
||||
|
||||
The exact-version Decky catalog remains **6 works / 54 partial / 45 broken /
|
||||
11 N/A**. A physical 26.5 GB transfer, wearer-visible VR, physical-controller
|
||||
use and the optional x86_64 title launch are unverified. The separate hourly
|
||||
worn check remains enabled.
|
||||
+16
-2
@@ -27,8 +27,22 @@ count. So a build reaches people only when you publish it, after testing it.
|
||||
temporary profile. This CI test does not test a physical display, FUSE mount
|
||||
or a headset.
|
||||
|
||||
2. Download the draft's installers and test them. An installed copy of the
|
||||
previous version won't offer the draft, so install it directly.
|
||||
2. Independently download and inspect the draft's installers on an x86_64
|
||||
Linux host with `gh`, `dpkg-deb` and `xvfb-run` installed:
|
||||
|
||||
```sh
|
||||
scripts/audit-release-assets.py v0.4.0 \
|
||||
--report /tmp/frame-v0.4.0-assets.json --launch-x86-appimage
|
||||
```
|
||||
|
||||
The command downloads each of the ten installers by asset ID, verifies its
|
||||
size and SHA-256 against GitHub metadata, matches the downloaded
|
||||
`SHA256SUMS` byte for byte, checks package formats and versions, and starts
|
||||
the x86_64 AppImage with a temporary profile and isolated Xvfb display. It
|
||||
writes the report only after all checks pass. This does not prove that the
|
||||
Mac or Windows installers launch on physical machines. Test those packages
|
||||
separately when the required machines are available. An installed copy of
|
||||
the previous version won't offer the draft, so install it directly.
|
||||
|
||||
3. Write the release notes on the draft. The update banner links to them.
|
||||
|
||||
|
||||
Executable
+236
@@ -0,0 +1,236 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Independently download and inspect every installer in a GitHub release.
|
||||
|
||||
Run on Linux after the tagged workflow finishes and before publishing its
|
||||
draft. The JSON report is written only after every check passes.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import platform
|
||||
import plistlib
|
||||
import re
|
||||
import runpy
|
||||
import shutil
|
||||
import struct
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
REPO = "saphid/frame-control"
|
||||
SCRIPTS = Path(__file__).resolve().parent
|
||||
CHECKSUMS = runpy.run_path(str(SCRIPTS / "release-checksums.py"))
|
||||
INSTALLERS = CHECKSUMS["INSTALLERS"]
|
||||
|
||||
|
||||
class AuditError(Exception):
|
||||
"""Release asset metadata or downloaded bytes failed a check."""
|
||||
|
||||
|
||||
def github_json(path):
|
||||
result = subprocess.run(
|
||||
["gh", "api", f"repos/{REPO}/{path}"],
|
||||
capture_output=True, text=True, check=False, timeout=60,
|
||||
)
|
||||
if result.returncode:
|
||||
raise AuditError(f"GitHub read failed for {path}: {result.stderr.strip()}")
|
||||
try:
|
||||
return json.loads(result.stdout)
|
||||
except json.JSONDecodeError as error:
|
||||
raise AuditError(f"invalid GitHub JSON for {path}: {error}") from error
|
||||
|
||||
|
||||
def find_release(tag):
|
||||
releases = []
|
||||
for page in range(1, 21):
|
||||
batch = github_json(f"releases?per_page=100&page={page}")
|
||||
if not isinstance(batch, list):
|
||||
raise AuditError("GitHub release list is not an array")
|
||||
releases.extend(batch)
|
||||
try:
|
||||
return CHECKSUMS["find_release"](
|
||||
(json.dumps(item) for item in releases), tag
|
||||
)
|
||||
except ValueError as error:
|
||||
if "found 0" not in str(error):
|
||||
raise AuditError(str(error)) from error
|
||||
if len(batch) < 100:
|
||||
break
|
||||
raise AuditError(f"no unique release found for {tag}")
|
||||
|
||||
|
||||
def validate_assets(release):
|
||||
assets = release.get("assets")
|
||||
expected = set(INSTALLERS) | {"SHA256SUMS"}
|
||||
if release.get("draft") is False:
|
||||
expected.add("update.json")
|
||||
if not isinstance(assets, list) or len(assets) != len(expected):
|
||||
raise AuditError(f"expected exactly {len(expected)} release assets")
|
||||
by_name = {asset.get("name"): asset for asset in assets if isinstance(asset, dict)}
|
||||
if set(by_name) != expected or len(by_name) != len(assets):
|
||||
raise AuditError("release has missing, duplicate, or unexpected assets")
|
||||
for name, asset in by_name.items():
|
||||
if (not isinstance(asset.get("id"), int) or asset["id"] <= 0
|
||||
or not isinstance(asset.get("size"), int) or asset["size"] <= 0
|
||||
or not re.fullmatch(r"sha256:[0-9a-fA-F]{64}", asset.get("digest") or "")):
|
||||
raise AuditError(f"{name} has no valid ID, size, or SHA-256 digest")
|
||||
try:
|
||||
expected_sums = CHECKSUMS["checksums"](release).encode("ascii")
|
||||
except ValueError as error:
|
||||
raise AuditError(str(error)) from error
|
||||
return by_name, expected_sums
|
||||
|
||||
|
||||
def sha256(path):
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as source:
|
||||
for block in iter(lambda: source.read(1024 * 1024), b""):
|
||||
digest.update(block)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def download_asset(asset, path):
|
||||
with path.open("wb") as target:
|
||||
result = subprocess.run(
|
||||
["gh", "api", "-H", "Accept: application/octet-stream",
|
||||
f"repos/{REPO}/releases/assets/{asset['id']}"],
|
||||
stdout=target, stderr=subprocess.PIPE, check=False, timeout=1800,
|
||||
)
|
||||
if result.returncode:
|
||||
raise AuditError(f"download failed for {asset['name']}: "
|
||||
f"{result.stderr.decode(errors='replace').strip()}")
|
||||
if path.stat().st_size != asset["size"]:
|
||||
raise AuditError(f"{asset['name']} size differs from GitHub metadata")
|
||||
digest = sha256(path)
|
||||
if digest != asset["digest"][7:].lower():
|
||||
raise AuditError(f"{asset['name']} SHA-256 differs from GitHub metadata")
|
||||
return digest
|
||||
|
||||
|
||||
def inspect_package(path, name, version):
|
||||
if name.endswith(".zip"):
|
||||
with zipfile.ZipFile(path) as archive:
|
||||
bad = archive.testzip()
|
||||
if bad is not None:
|
||||
raise AuditError(f"{name} has a bad ZIP member: {bad}")
|
||||
detail = {"zipCrc": "ok", "members": len(archive.infolist())}
|
||||
if "-mac-" in name:
|
||||
matches = [entry for entry in archive.namelist()
|
||||
if entry.endswith("Frame Control.app/Contents/Info.plist")]
|
||||
if len(matches) != 1:
|
||||
raise AuditError(f"{name} has {len(matches)} app Info.plist files")
|
||||
found = plistlib.loads(archive.read(matches[0])).get("CFBundleShortVersionString")
|
||||
if found != version:
|
||||
raise AuditError(f"{name} bundle version {found!r} is not {version}")
|
||||
detail["bundleVersion"] = found
|
||||
return detail
|
||||
if name.endswith(".deb"):
|
||||
result = subprocess.run(
|
||||
["dpkg-deb", "-f", str(path), "Version", "Architecture"],
|
||||
capture_output=True, text=True, check=False, timeout=60,
|
||||
)
|
||||
fields = [line.partition(":")[2].strip() for line in result.stdout.splitlines()]
|
||||
architecture = "arm64" if "arm64" in name else "amd64"
|
||||
if result.returncode or fields != [version, architecture]:
|
||||
raise AuditError(f"{name} DEB version/architecture mismatch: {fields}")
|
||||
return {"debVersion": fields[0], "architecture": fields[1]}
|
||||
if name.endswith(".AppImage"):
|
||||
with path.open("rb") as source:
|
||||
head = source.read(20)
|
||||
machine = struct.unpack("<H", head[18:20])[0] if len(head) >= 20 else None
|
||||
expected = 183 if "arm64" in name else 62
|
||||
if head[:4] != b"\x7fELF" or machine != expected:
|
||||
raise AuditError(f"{name} ELF machine {machine!r} is not {expected}")
|
||||
return {"elfMachine": machine}
|
||||
if name.endswith(".dmg"):
|
||||
if path.stat().st_size < 512:
|
||||
raise AuditError(f"{name} is too small for a UDIF trailer")
|
||||
with path.open("rb") as source:
|
||||
source.seek(-512, 2)
|
||||
trailer = source.read(512)
|
||||
if trailer[:4] != b"koly":
|
||||
raise AuditError(f"{name} has no UDIF trailer")
|
||||
return {"udifTrailer": "ok"}
|
||||
if name.endswith(".exe"):
|
||||
with path.open("rb") as source:
|
||||
head = source.read(64)
|
||||
if len(head) < 64 or head[:2] != b"MZ":
|
||||
raise AuditError(f"{name} has no MZ header")
|
||||
source.seek(struct.unpack("<I", head[60:64])[0])
|
||||
pe = source.read(6)
|
||||
machine = struct.unpack("<H", pe[4:6])[0] if len(pe) == 6 else None
|
||||
if pe[:4] != b"PE\x00\x00" or machine not in (0x14C, 0x8664):
|
||||
raise AuditError(f"{name} has an invalid PE header or machine")
|
||||
return {"peMachine": machine}
|
||||
raise AuditError(f"no package inspection for {name}")
|
||||
|
||||
|
||||
def audit(tag, report_path, launch_x86_appimage=False):
|
||||
if not re.fullmatch(r"v\d+\.\d+\.\d+", tag):
|
||||
raise AuditError("expected a version tag such as v0.4.54")
|
||||
if report_path.exists():
|
||||
raise AuditError(f"report already exists; choose a new path: {report_path}")
|
||||
if not report_path.parent.is_dir():
|
||||
raise AuditError(f"report directory does not exist: {report_path.parent}")
|
||||
if not shutil.which("gh") or not shutil.which("dpkg-deb"):
|
||||
raise AuditError("gh and dpkg-deb are required on the audit host")
|
||||
if launch_x86_appimage and not shutil.which("xvfb-run"):
|
||||
raise AuditError("xvfb-run is required for --launch-x86-appimage")
|
||||
if launch_x86_appimage and platform.machine().lower() not in ("x86_64", "amd64"):
|
||||
raise AuditError("--launch-x86-appimage requires an x86_64 audit host")
|
||||
release = find_release(tag)
|
||||
by_name, expected_sums = validate_assets(release)
|
||||
with tempfile.TemporaryDirectory(prefix=f"frame-{tag}-assets-") as directory:
|
||||
scratch = Path(directory)
|
||||
sums_path = scratch / "SHA256SUMS"
|
||||
sums_hash = download_asset(by_name["SHA256SUMS"], sums_path)
|
||||
if sums_path.read_bytes() != expected_sums:
|
||||
raise AuditError("SHA256SUMS bytes differ from the ten GitHub asset digests")
|
||||
rows = []
|
||||
for name in INSTALLERS:
|
||||
path = scratch / name
|
||||
asset = by_name[name]
|
||||
digest = download_asset(asset, path)
|
||||
detail = inspect_package(path, name, tag[1:])
|
||||
if launch_x86_appimage and name == "Frame-Control-linux-x86_64.AppImage":
|
||||
path.chmod(0o755)
|
||||
result = subprocess.run(
|
||||
[sys.executable, str(SCRIPTS / "check-appimage-launch.py"), str(path)],
|
||||
capture_output=True, text=True, check=False, timeout=120,
|
||||
)
|
||||
if result.returncode or digest not in result.stdout:
|
||||
raise AuditError(f"isolated AppImage launch failed: {result.stderr.strip()}")
|
||||
detail["appimageLaunch"] = "HTTP 200; FrameControl/1; server stopped"
|
||||
rows.append({"name": name, "size": asset["size"], "sha256": digest, **detail})
|
||||
path.unlink()
|
||||
print(f"verified {name}", flush=True)
|
||||
report = {"tag": tag, "releaseId": release["id"],
|
||||
"tagNameAtAudit": release.get("tag_name"), "draftAtAudit": release.get("draft"),
|
||||
"sha256sumsSha256": sums_hash, "assets": rows}
|
||||
with report_path.open("x", encoding="utf-8") as output:
|
||||
json.dump(report, output, indent=2)
|
||||
output.write("\n")
|
||||
print(f"verified {len(rows)} installer downloads; report {report_path}")
|
||||
return report
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||
parser.add_argument("tag", help="release tag, for example v0.4.54")
|
||||
parser.add_argument("--report", required=True, type=Path, help="new JSON report path")
|
||||
parser.add_argument("--launch-x86-appimage", action="store_true",
|
||||
help="also launch the tagged x86_64 AppImage in isolated Xvfb")
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
audit(args.tag, args.report, args.launch_x86_appimage)
|
||||
except (AuditError, OSError, subprocess.TimeoutExpired,
|
||||
zipfile.BadZipFile, plistlib.InvalidFileException) as error:
|
||||
parser.exit(1, f"asset audit failed: {error}\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,123 @@
|
||||
"""Independent release audit rejects incomplete metadata and wrong package versions."""
|
||||
import sandbox # noqa: F401 (first: keeps tests off real data and services)
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import plistlib
|
||||
import struct
|
||||
import tempfile
|
||||
import unittest
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
|
||||
SCRIPT = Path(__file__).resolve().parents[1] / "scripts" / "audit-release-assets.py"
|
||||
spec = importlib.util.spec_from_file_location("audit_release_assets", SCRIPT)
|
||||
audit = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(audit)
|
||||
|
||||
|
||||
def release_assets():
|
||||
assets = []
|
||||
for number, name in enumerate(audit.INSTALLERS, 1):
|
||||
assets.append({
|
||||
"id": number, "name": name, "size": number,
|
||||
"digest": "sha256:" + hashlib.sha256(str(number).encode()).hexdigest(),
|
||||
})
|
||||
sums = audit.CHECKSUMS["checksums"]({"assets": assets}).encode("ascii")
|
||||
assets.append({
|
||||
"id": 99, "name": "SHA256SUMS", "size": len(sums),
|
||||
"digest": "sha256:" + hashlib.sha256(sums).hexdigest(),
|
||||
})
|
||||
return assets, sums
|
||||
|
||||
|
||||
class AssetAudit(unittest.TestCase):
|
||||
def test_complete_metadata_uses_release_checksum_order(self):
|
||||
assets, sums = release_assets()
|
||||
by_name, expected = audit.validate_assets({"assets": assets})
|
||||
self.assertEqual(len(by_name), 11)
|
||||
self.assertEqual(expected, sums)
|
||||
|
||||
def test_published_release_requires_update_manifest(self):
|
||||
assets, sums = release_assets()
|
||||
manifest = {"id": 100, "name": "update.json", "size": 20,
|
||||
"digest": "sha256:" + "a" * 64}
|
||||
with self.assertRaises(audit.AuditError):
|
||||
audit.validate_assets({"draft": False, "assets": assets})
|
||||
by_name, expected = audit.validate_assets({"draft": False, "assets": [*assets, manifest]})
|
||||
self.assertEqual(len(by_name), 12)
|
||||
self.assertEqual(expected, sums)
|
||||
|
||||
def test_missing_duplicate_and_unhashed_assets_fail(self):
|
||||
assets, _ = release_assets()
|
||||
variants = (
|
||||
assets[:-1],
|
||||
[*assets, assets[0]],
|
||||
[dict(assets[0], digest="sha256:bad"), *assets[1:]],
|
||||
[dict(assets[0], size=0), *assets[1:]],
|
||||
)
|
||||
for changed in variants:
|
||||
with self.subTest(changed=changed[0]), self.assertRaises(audit.AuditError):
|
||||
audit.validate_assets({"assets": changed})
|
||||
|
||||
def test_mac_zip_bundle_version_must_match_tag(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path = Path(directory) / "mac.zip"
|
||||
with zipfile.ZipFile(path, "w") as archive:
|
||||
archive.writestr(
|
||||
"Frame Control.app/Contents/Info.plist",
|
||||
plistlib.dumps({"CFBundleShortVersionString": "9.8.7"}),
|
||||
)
|
||||
self.assertEqual(
|
||||
audit.inspect_package(path, "Frame-Control-mac-arm64.zip", "9.8.7")["bundleVersion"],
|
||||
"9.8.7",
|
||||
)
|
||||
with self.assertRaises(audit.AuditError):
|
||||
audit.inspect_package(path, "Frame-Control-mac-arm64.zip", "9.8.8")
|
||||
|
||||
def test_appimage_machine_must_match_name(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path = Path(directory) / "image"
|
||||
path.write_bytes(b"\x7fELF" + bytes(14) + struct.pack("<H", 183))
|
||||
self.assertEqual(
|
||||
audit.inspect_package(path, "Frame-Control-linux-arm64.AppImage", "9.8.7"),
|
||||
{"elfMachine": 183},
|
||||
)
|
||||
with self.assertRaises(audit.AuditError):
|
||||
audit.inspect_package(path, "Frame-Control-linux-x86_64.AppImage", "9.8.7")
|
||||
|
||||
def test_existing_report_is_never_overwritten(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path = Path(directory) / "report.json"
|
||||
path.write_text("keep")
|
||||
with self.assertRaisesRegex(audit.AuditError, "already exists"):
|
||||
audit.audit("v9.8.7", path)
|
||||
self.assertEqual(path.read_text(), "keep")
|
||||
|
||||
def test_missing_report_directory_fails_before_download(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path = Path(directory) / "missing" / "report.json"
|
||||
with self.assertRaisesRegex(audit.AuditError, "directory does not exist"):
|
||||
audit.audit("v9.8.7", path)
|
||||
|
||||
def test_bad_downloaded_checksum_file_creates_no_report(self):
|
||||
assets, _ = release_assets()
|
||||
release = {"id": 42, "tag_name": "v9.8.7", "draft": True, "assets": assets}
|
||||
|
||||
def bad_download(asset, path):
|
||||
path.write_bytes(b"wrong checksum file\n")
|
||||
return asset["digest"][7:]
|
||||
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
report = Path(directory) / "report.json"
|
||||
with mock.patch.object(audit, "find_release", return_value=release), \
|
||||
mock.patch.object(audit, "download_asset", side_effect=bad_download), \
|
||||
mock.patch.object(audit.shutil, "which", return_value="/fake/tool"):
|
||||
with self.assertRaisesRegex(audit.AuditError, "SHA256SUMS bytes differ"):
|
||||
audit.audit("v9.8.7", report)
|
||||
self.assertFalse(report.exists())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user