Compare commits

...
Author SHA1 Message Date
Alex Southwell 38db6a2846 Merge pull request #348 from saphid/release/v0.4.49
Prepare Frame Control v0.4.49 repair candidate
2026-10-26 23:05:19 +00:00
SAPHID a17ebf96fe Record locked v0.4.49 candidate smoke 2026-10-26 22:51:00 +00:00
SAPHID 6247dfbf89 Prepare Frame Control v0.4.49 candidate 2026-10-26 22:48:02 +00:00
Alex Southwell 7ed59a9b25 Merge pull request #344 from saphid/fix/release-publisher-gates
Require exact CI and installer gates before publishing
2026-10-26 22:46:48 +00:00
SAPHID 14d677f527 Merge remote-tracking branch 'origin/main' into fix/release-publisher-gates 2026-10-26 22:36:58 +00:00
Alex Southwell fe8a860afb Merge pull request #343 from saphid/test/framey-decky-runtime
Test managed shims in actual Decky 3.3.0 runtime
2026-10-26 22:36:34 +00:00
SAPHID 72f8152525 Resolve gh consistently through publisher shell 2026-10-26 22:35:21 +00:00
SAPHID 62e27a2450 Require native Decky regression gate for publication 2026-10-26 22:28:50 +00:00
Alex Southwell 4c3381f006 Merge pull request #339 from saphid/docs/v0448-release-evidence
Record v0.4.48 release verification
2026-10-26 22:26:58 +00:00
SAPHID 1cd1c07b7a Keep withdrawn releases out of the update channel 2026-10-26 22:25:05 +00:00
SAPHID 55fb9226ba Fail closed on exact release CI and installer gates 2026-10-26 22:21:53 +00:00
SAPHID 5418c6f094 Clarify cancelled v0.4.48 preview versus tagged release 2026-10-26 22:18:01 +00:00
Alex Southwell 14f9506e74 Merge pull request #340 from saphid/fix/artwork-resolver-deadline-test
Keep artwork DNS deadline test stable on loaded CI
2026-10-26 22:16:56 +00:00
SAPHID 0026095096 Record merged repair and installed-plugin recovery 2026-10-26 22:10:06 +00:00
SAPHID 1309a0883d Compare resolver wait with computed deadline interval 2026-10-26 22:06:30 +00:00
SAPHID d1a4270b45 Record v0.4.48 withdrawal after Decky regression 2026-10-26 22:02:20 +00:00
SAPHID 1e6d742b98 Check artwork DNS deadline without runner timing flake 2026-10-26 21:58:44 +00:00
SAPHID 818ec2dcba Record v0.4.48 release verification 2026-10-26 21:54:28 +00:00
10 changed files with 354 additions and 13 deletions

No files matched your search

+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "frame-control",
"version": "0.4.48",
"version": "0.4.49",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "frame-control",
"version": "0.4.48",
"version": "0.4.49",
"license": "MIT",
"devDependencies": {
"electron": "^44.4.5",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "frame-control",
"productName": "Frame Control",
"version": "0.4.48",
"version": "0.4.49",
"description": "Desktop app for managing a Valve Steam Frame over SSH",
"private": true,
"main": "main.js",
+85
View File
@@ -0,0 +1,85 @@
# v0.4.48 release verification and withdrawal, 26 October 2026
The annotated [`v0.4.48` tag](https://github.com/saphid/frame-control/releases/tag/v0.4.48)
dereferences to main commit `5438e1d9d05ca472f200dcde50799c8ae882fafb`,
which contains the version bump and [candidate evidence](v0448-candidate-2026-10-26.md).
The exact-commit [main checks run](https://github.com/saphid/frame-control/actions/runs/40356645911)
finished successfully: all seven jobs passed, including e2e, iOS, Windows,
macOS and Linux tests. Its first two attempts had an iOS job stuck in the
runner queue and were cancelled; attempt 3 ran that job and ended with all
seven required checks successful. The later documentation-only main commit
`1ddb6278b8ae0a98ecc79a181bba4d3cc67a52b6` also passed all seven
[main checks](https://github.com/saphid/frame-control/actions/runs/40406851705).
The exact-tag [release workflow](https://github.com/saphid/frame-control/actions/runs/40406781383)
finished successfully. Its draft, Linux, Windows, Apple Silicon Mac, Intel
Mac and checksum jobs all passed; no tagged installer build failed or was
cancelled. Tagged CI launched the Linux x86_64 AppImage on Xvfb, checked both
Mac bundles against their runner architecture, installed the Windows Setup
and compared its installed resources with the unpacked build, and tested its
packaged Python and native PC libraries.
An earlier [release-PR preview run](https://github.com/saphid/frame-control/actions/runs/40352797572/attempts/1)
was not a passing gate: its Apple Silicon Mac build was cancelled and its
checksum job was skipped. [Attempt 2](https://github.com/saphid/frame-control/actions/runs/40352797572/attempts/2)
completed all four preview installer builds successfully. Publication used the
separate exact-tag workflow above, whose six jobs all succeeded.
The draft had exactly ten installers and `SHA256SUMS`, each with a GitHub
SHA-256 digest. An independently downloaded `SHA256SUMS` matched a fresh
ten-line list generated from GitHub's asset metadata byte for byte. Both
its downloaded SHA-256 and GitHub's own asset digest were
`8cf7d8808f0b0b9da08e2590461e83ef557412a0ce2cc780c8e1d02e34ce1a60`.
Independent downloads on lxso1 matched `SHA256SUMS` for these packages:
| Package | SHA-256 and independent check |
| --- | --- |
| Linux x86_64 AppImage | `7c0cbef7c0330c48ecb69f6fcee027a8b45fe84b0458985db31d16b09fc45cbe`; isolated Xvfb launch served HTTP 200 with `FrameControl/1`, then its server stopped. |
| Linux arm64 DEB | `d3c2cdc4310fc3fed11460ad970a3ff6da8b80a6ec566acf1bcaa5a9570efc3e`; package declares version 0.4.48 and arm64 architecture. |
| Mac arm64 ZIP | `3c5b0ad0877e4c1e1adbac17a9eb4f1114294a25576330050ac9ce338e0b1c00`; ZIP CRC, bundle version 0.4.48 and main Mach-O arm64 CPU passed. |
| Mac x64 ZIP | `3bb90246794f0082fd4f34d86113e292ae543671ff8bea4096d888a9273a529c`; ZIP CRC, bundle version 0.4.48 and main Mach-O x86_64 CPU passed. |
| Windows x64 Setup | `def654c238575778120c6f2a4df1af319b0cd169f64c2fceebe26ddc5e3c68ff`; downloaded file is a PE/Nullsoft installer; tagged Windows CI installed and tested its contents. |
After attaching the final release notes,
`scripts/publish-release.sh --dry-run v0.4.48` passed the exact tag, ten
installer digests, checksum asset and update-manifest checks.
`scripts/publish-release.sh v0.4.48` published the release at
**21:53:29 UTC**. GitHub's latest-release API read back tag `v0.4.48`,
`draft=false`, `prerelease=false`, ten installers, `SHA256SUMS` and
`update.json`. Immediately after publication, a fresh public latest-download
of `update.json` reported version 0.4.48, the release page, ten hashed
installers and the worn VR limit in its notes. This is historical evidence,
not the current update-channel state.
## Withdrawn after a real Decky regression was confirmed
The all-green release gate did not include Music Control's actual Decky 3.3.0
legacy class calling convention. [PR #338](https://github.com/saphid/frame-control/pull/338)
reproduced a regression in the #335 digest-scoped Music Control 1.1.7 install
shim: it removes explicit `self` from calls that the real legacy runtime needs,
so player discovery fails with `_get_dbus_env() missing 1 required positional
argument: 'self'`. The earlier fixture had instantiated `Plugin()` and missed
this behavior. A passing CI run therefore did not prove this plugin path worked.
At **22:01:20 UTC**, v0.4.48 was returned to draft and the previously green
v0.4.47 was marked latest. The v0.4.48 tag and build artifacts were preserved;
no tag or installer was deleted. The preserved draft's notes now begin with a
withdrawal notice. GitHub's latest-release API and a fresh public
`releases/latest/download/update.json` now both point to **v0.4.47** with ten
hashed installers. Existing v0.4.48 installations do not auto-downgrade.
The repair in #338 merged at `04cde721c862c80a18dad17c2aab0c86ca8d3082`.
It requires green post-merge main checks, a new release candidate and all
release gates before any replacement version is offered. An app update alone
does not rewrite the bad installed plugin shim: affected users need an explicit
managed plugin update or remove/reinstall after the repair ships.
The [candidate smoke](v0448-candidate-2026-10-26.md) on the real Frame passed
**12 / one optional x86_64 launch skipped / zero failed**. The optional launch
needed Steam Linux Runtime 4, which was absent; no x86_64 headset launch was
claimed. Owned test titles and processes were removed, the lock was released,
and a fresh locked check found standby and no test-title leftovers. A later
read-only locked check found SteamVR activity 3 (standby), proximity raw
3.0625 and battery 100%, then released its own lock. No wearer was confirmed,
so neither the worn-check script nor physical controller input was run.
Official-plugin wearer visibility, controller use, VR keyboard, optical comfort
and physical launch of these exact Mac/Windows installers remain unverified.
@@ -0,0 +1,37 @@
# v0.4.49 candidate gate, 26 October 2026
The version bump at `6247dfb` follows the merged Music Control repair
([#338](https://github.com/saphid/frame-control/pull/338)), the real Decky
runtime gate ([#343](https://github.com/saphid/frame-control/pull/343)), and
the fail-closed publisher ([#344](https://github.com/saphid/frame-control/pull/344)).
The published v0.4.48 draft remains withdrawn. This candidate is not a
published update.
## Locked real Frame smoke
At 22:48 UTC, `scripts/frame-smoke.sh` ran from exact version-bumped source
commit `6247dfb` on SteamOS 0.5.6, build `20261022.6153909`. It acquired a
unique `/tmp/frame-test.lock` owner before reading status. SteamVR activity was
**3 (standby)**, proximity raw **3.0625**, and battery **99%**. The result was
**12 passed / one optional x86_64 launch skipped / zero failed**. The x86_64
title could not launch because Steam Linux Runtime 4 was not installed; no
x86_64 launch is claimed. ARM64 and Windows/Proton test titles installed,
launched and were removed. The script's owned cleanup passed and exited 0.
The private result is
`tests/smoke/results/2026-10-26T224816Z-20261022.6153909.json`.
A separate read-only audit acquired a fresh unique lock after the smoke. It
found activity **3**, proximity raw **3.0**, battery **99%**, no `fc_smoke_*`
or `fc-smoke-*` title folders and no matching test processes. The audit
released only its own lock. Neither run woke the headset or ran the worn
interaction script. No wearer was confirmed; official-plugin wearer
visibility, physical controller input, VR keyboard and optical comfort remain
unverified.
## Remaining publication gates
The final candidate head needs green PR CI, native amd64 and ARM64 Decky
runtime checks, all four installer previews, post-merge exact-commit main CI,
tagged builds of all ten installers, checksums, independent package
verification, publisher dry run and public update-channel readback. A gate
that fails, is cancelled or remains pending holds publication.
+15 -1
View File
@@ -34,13 +34,27 @@ count. So a build reaches people only when you publish it, after testing it.
4. Publish:
Confirm the exact tag commit's main CI and e2e, the locked real Frame smoke,
release verification, independent package checks, and all four tagged
installer builds have passed. Hold the release if any gate is failed,
cancelled, skipped, still running, or unverified. The publisher also reads
the exact commit's GitHub CI, native Decky runtime gate, and tagged build
job records and refuses to
publish unless every required job succeeded; it cannot prove physical
headset or installer behavior on its own.
```sh
scripts/publish-release.sh --dry-run v0.4.0 # checks and shows update.json, changes nothing
scripts/publish-release.sh v0.4.0
```
The script checks that the tag is on GitHub and that all ten installers
The script checks that the tag is on GitHub, that exact-commit main CI
(including e2e), native amd64/ARM64 Decky runtime checks, and the tagged
release workflow are green with no failed,
cancelled, skipped or running jobs, and that all ten installers
are attached, each with the SHA-256 digest GitHub records. It also checks
that the draft does not begin with a `WITHDRAWN` notice, so the preserved
v0.4.48 draft cannot accidentally be offered again. It checks
that the uploaded `SHA256SUMS` digest matches a fresh list of those ten
installer digests. It attaches
`update.json` (the version, the notes, the release page and each
+11
View File
@@ -31,6 +31,11 @@ trap 'rm -rf "$tmp"' EXIT
gh api "repos/$repo/git/ref/tags/$tag" >/dev/null 2>&1 \
|| { echo "tag $tag isn't on GitHub; push it first (git push origin $tag)" >&2; exit 1; }
# A complete asset list is insufficient: a cancelled installer build can leave
# old uploaded bytes behind. Check the exact tagged commit's main/e2e jobs and
# the latest exact-tag release attempt before any release mutation.
python3 "$(dirname "$0")/verify-release-gates.py" "$tag"
# Every release, drafts included, one JSON object per line.
gh api --paginate "repos/$repo/releases?per_page=100" --jq '.[]' > "$tmp/releases"
# The release whose tag_name is the tag; failing that, the one untagged-... draft
@@ -56,6 +61,12 @@ json.dump(r, sys.stdout)
EOF
id=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["id"])' "$tmp/release.json")
echo "release $id ($(python3 -c 'import json,sys; r=json.load(open(sys.argv[1])); print(("draft" if r["draft"] else "published") + ", tag_name " + str(r["tag_name"]))' "$tmp/release.json"))"
python3 - "$tmp/release.json" <<'EOF'
import json, sys
release = json.load(open(sys.argv[1]))
if (release.get("body") or "").lstrip().startswith("> **WITHDRAWN"):
sys.exit("not publishing: this release is marked WITHDRAWN")
EOF
missing=""
for name in $expected; do
+109
View File
@@ -0,0 +1,109 @@
#!/usr/bin/env python3
"""Refuse publication unless the exact tag commit and tagged installers passed CI.
This checks GitHub's current run and job records, not a prior PR's check marks.
The real Frame smoke and independent package checks remain release-procedure
gates documented in docs/releasing.md.
"""
import json
import subprocess
import sys
from urllib.parse import quote
REPO = "saphid/frame-control"
MAIN_JOBS = {
"checks", "e2e", "ios", "wireless-windows",
"server-tests (ubuntu-latest, 3.13)",
"server-tests (macos-latest, 3.12)",
"server-tests (windows-latest, 3.12)",
}
RELEASE_JOBS = {
"draft", "checksums",
"build (windows-latest, dist:win, app/dist/*.exe app/dist/*.zip)",
"build (ubuntu-latest, dist:linux, app/dist/*.AppImage app/dist/*.deb)",
"build (macos-15-intel, dist:mac:x64, app/dist/*.dmg app/dist/*.zip)",
"build (macos-latest, dist, app/dist/*.dmg app/dist/*.zip)",
}
DECKY_JOBS = {"decky-runtime (ubuntu-latest)", "decky-runtime (ubuntu-24.04-arm)"}
class GateError(Exception):
pass
def api(path):
# publish-release.sh already requires sh. Resolve gh through that same
# shell: on Git Bash, Python's Windows CreateProcess skips an extensionless
# test gh shim in PATH and accidentally launches the real gh.exe instead.
command = ["sh", "-c", 'gh api "$1"', "sh", f"repos/{REPO}/{path}"]
result = subprocess.run(command, text=True, capture_output=True, check=False)
if result.returncode:
raise GateError(f"GitHub gate read failed for {path}: {result.stderr.strip()}")
try:
return json.loads(result.stdout)
except json.JSONDecodeError as error:
raise GateError(f"invalid GitHub gate response for {path}: {error}") from error
def tag_commit(tag):
target = api(f"git/ref/tags/{quote(tag, safe='')}").get("object") or {}
for _ in range(5):
kind, sha = target.get("type"), target.get("sha")
if not isinstance(sha, str) or len(sha) != 40:
raise GateError(f"{tag} has no valid tag target")
if kind == "commit":
return sha
if kind != "tag":
raise GateError(f"{tag} points to unsupported Git object {kind!r}")
target = api(f"git/tags/{sha}").get("object") or {}
raise GateError(f"{tag} has too many nested tag objects")
def exact_run(workflow, branch, sha, event):
query = f"actions/workflows/{workflow}/runs?branch={quote(branch, safe='')}&head_sha={sha}&per_page=100"
runs = api(query).get("workflow_runs") or []
matching = [run for run in runs if run.get("head_sha") == sha
and run.get("head_branch") == branch and run.get("event") == event]
if not matching:
raise GateError(f"no exact {workflow} {event} run for {branch} at {sha}")
run = max(matching, key=lambda item: item["id"])
if run.get("status") != "completed" or run.get("conclusion") != "success":
raise GateError(f"{workflow} run {run['id']} is {run.get('status')}/{run.get('conclusion')}")
return run
def successful_jobs(run, required):
attempt = run.get("run_attempt")
if not isinstance(attempt, int) or attempt < 1:
raise GateError(f"run {run['id']} has no valid attempt number")
data = api(f"actions/runs/{run['id']}/attempts/{attempt}/jobs?per_page=100")
jobs = data.get("jobs") or []
if data.get("total_count") != len(jobs):
raise GateError(f"run {run['id']} has more jobs than the gate inspected")
names = [job.get("name") for job in jobs]
if len(names) != len(set(names)) or not required.issubset(names):
missing = sorted(required.difference(names))
raise GateError(f"run {run['id']} has missing or duplicate jobs: {missing}")
bad = [f"{job.get('name')}: {job.get('status')}/{job.get('conclusion')}"
for job in jobs if job.get("status") != "completed" or job.get("conclusion") != "success"]
if bad:
raise GateError(f"run {run['id']} has unfinished, failed, cancelled, or skipped jobs: {bad}")
print(f"gate passed: {run.get('html_url')} ({len(jobs)} jobs)")
def main(tag):
if not tag.startswith("v") or not tag[1:]:
raise GateError("expected a version tag beginning with v")
sha = tag_commit(tag)
successful_jobs(exact_run("checks.yml", "main", sha, "push"), MAIN_JOBS)
successful_jobs(exact_run("decky-runtime.yml", "main", sha, "push"), DECKY_JOBS)
successful_jobs(exact_run("release.yml", tag, sha, "push"), RELEASE_JOBS)
print(f"exact release commit: {sha}")
if __name__ == "__main__":
try:
main(sys.argv[1])
except (IndexError, GateError) as error:
sys.exit(f"release gate failed: {error}")
+37 -1
View File
@@ -13,6 +13,7 @@ import os
import shutil
import subprocess
import sys
from urllib.parse import parse_qs
args = sys.argv[1:]
with open(os.environ["FAKEGH_LOG"], "a") as f:
@@ -49,6 +50,18 @@ while i < len(args):
releases = json.load(open(os.environ["FAKEGH_RELEASES"]))
repo = "repos/saphid/frame-control/"
gate = json.load(open(os.environ["FAKEGH_GATES"])) if os.environ.get("FAKEGH_GATES") else {}
commit = gate.get("tag_commit", "a" * 40)
tag_object = "b" * 40
main_jobs = ("checks", "e2e", "ios", "wireless-windows",
"server-tests (ubuntu-latest, 3.13)", "server-tests (macos-latest, 3.12)",
"server-tests (windows-latest, 3.12)")
release_jobs = ("draft", "checksums",
"build (windows-latest, dist:win, app/dist/*.exe app/dist/*.zip)",
"build (ubuntu-latest, dist:linux, app/dist/*.AppImage app/dist/*.deb)",
"build (macos-15-intel, dist:mac:x64, app/dist/*.dmg app/dist/*.zip)",
"build (macos-latest, dist, app/dist/*.dmg app/dist/*.zip)")
decky_jobs = ("decky-runtime (ubuntu-latest)", "decky-runtime (ubuntu-24.04-arm)")
def answer(response, fallback):
@@ -67,7 +80,30 @@ if method == "GET" and endpoint.startswith(repo + "git/ref/tags/"):
tag = endpoint.rsplit("/", 1)[1]
if tag not in os.environ.get("FAKEGH_TAGS", "").split():
sys.exit("gh: Not Found (HTTP 404)")
print(json.dumps({"ref": "refs/tags/" + tag}))
print(json.dumps({"ref": "refs/tags/" + tag,
"object": {"type": "tag", "sha": tag_object}}))
elif method == "GET" and endpoint == repo + "git/tags/" + tag_object:
print(json.dumps({"object": {"type": "commit", "sha": commit}}))
elif method == "GET" and endpoint.startswith(repo + "actions/workflows/"):
path, _, query = endpoint.partition("?")
workflow = path.split("/")[5]
branch = parse_qs(query).get("branch", [""])[0]
is_main = workflow != "release.yml"
run_id = {"checks.yml": 501, "release.yml": 502, "decky-runtime.yml": 503}[workflow]
run = {"id": run_id, "head_sha": gate.get("main_sha" if is_main else "release_sha", commit),
"head_branch": branch, "event": "push", "run_attempt": 1,
"status": gate.get("decky_status" if run_id == 503 else ("main_status" if is_main else "release_status"), "completed"),
"conclusion": gate.get("decky_conclusion" if run_id == 503 else ("main_conclusion" if is_main else "release_conclusion"), "success"),
"html_url": "https://github.com/saphid/frame-control/actions/runs/%s" % run_id}
print(json.dumps({"workflow_runs": [run]}))
elif method == "GET" and endpoint.startswith(repo + "actions/runs/"):
run_id = endpoint[len(repo + "actions/runs/"):].split("/")[0]
names = {"501": main_jobs, "502": release_jobs, "503": decky_jobs}[run_id]
names = [name for name in names if name != gate.get("omit_job")]
jobs = [{"name": name, "status": "completed",
"conclusion": gate.get("bad_job_conclusion", "success") if name == gate.get("bad_job") else "success"}
for name in names]
print(json.dumps({"total_count": len(jobs), "jobs": jobs}))
elif method == "GET" and endpoint.startswith(repo + "releases?"):
# The fallback is what --jq '.[]' prints.
answer(releases, "\n".join(json.dumps(r) for r in releases))
+23 -6
View File
@@ -276,12 +276,29 @@ class ArtworkTests(unittest.TestCase):
import threading
from apk_sources import _images
gate = threading.Event()
with patch.object(_images.socket, 'getaddrinfo', side_effect=lambda *a, **k: gate.wait(5) and []):
start = time.monotonic()
with self.assertRaisesRegex(_images.SourceError, 'too long'):
_images.get('https://example.org/a.png', deadline=start + 0.1)
self.assertLess(time.monotonic() - start, 0.4)
gate.set()
real_join = threading.Thread.join
waits = []
def bounded_join(worker, timeout=None):
waits.append(timeout)
return real_join(worker, timeout)
try:
with patch.object(_images.socket, 'getaddrinfo', side_effect=lambda *a, **k: gate.wait(5) and []), \
patch.object(_images.threading.Thread, 'join', bounded_join):
start = time.monotonic()
deadline = start + 0.1
with self.assertRaisesRegex(_images.SourceError, 'too long'):
_images.get('https://example.org/a.png', deadline=deadline)
# Check the requested wait, not elapsed runner time: a loaded CI
# host can deschedule this test after the bounded join returns.
self.assertEqual(len(waits), 1)
self.assertIsNotNone(waits[0])
self.assertGreaterEqual(waits[0], 0)
self.assertLessEqual(waits[0], deadline - start)
finally:
gate.set()
wait_for_idle_resolvers()
with self.assertRaisesRegex(_images.SourceError, 'too long'):
_images.get('https://example.org/a.png', deadline=time.monotonic() - 1)
+34 -2
View File
@@ -37,12 +37,14 @@ def draft(tag_name="untagged-c6ddfed7f75d67db2e99", name="Frame Control 9.8.7",
class PublishRelease(unittest.TestCase):
def run_script(self, releases, *args, tags="v9.8.7"):
def run_script(self, releases, *args, tags="v9.8.7", gates=None):
d = Path(tempfile.mkdtemp())
(d / "releases.json").write_text(json.dumps(releases))
(d / "gates.json").write_text(json.dumps(gates or {}))
env = dict(os.environ, PATH="%s:%s" % (FAKEGH, os.environ["PATH"]),
FAKEGH_RELEASES=str(d / "releases.json"), FAKEGH_TAGS=tags,
FAKEGH_LOG=str(d / "log"), FAKEGH_UPLOAD=str(d / "upload.json"))
FAKEGH_LOG=str(d / "log"), FAKEGH_UPLOAD=str(d / "upload.json"),
FAKEGH_GATES=str(d / "gates.json"))
p = subprocess.run(["sh", str(SCRIPT), *args], env=env, capture_output=True, text=True, timeout=30)
log = [json.loads(line) for line in (d / "log").read_text().splitlines()] if (d / "log").exists() else []
upload = json.loads((d / "upload.json").read_text()) if (d / "upload.json").exists() else None
@@ -120,6 +122,36 @@ class PublishRelease(unittest.TestCase):
self.assertIsNone(upload)
self.assertTrue(all("-X" not in c for c in log))
def test_refuses_failed_e2e_or_cancelled_installer_before_mutation(self):
for gates in ({"bad_job": "e2e", "bad_job_conclusion": "failure"},
{"bad_job": "decky-runtime (ubuntu-24.04-arm)",
"bad_job_conclusion": "failure"},
{"bad_job": "build (macos-latest, dist, app/dist/*.dmg app/dist/*.zip)",
"bad_job_conclusion": "cancelled"}):
p, log, upload = self.run_script([draft()], "v9.8.7", gates=gates)
self.assertNotEqual(p.returncode, 0)
self.assertIn("release gate failed", p.stderr)
self.assertIsNone(upload)
self.assertFalse(any("-X" in call for call in log))
def test_refuses_missing_or_nonexact_gate_results(self):
for gates in ({"omit_job": "e2e"}, {"main_sha": "c" * 40},
{"decky_conclusion": "failure"}, {"release_conclusion": "failure"},
{"release_status": "in_progress"}):
p, log, upload = self.run_script([draft()], "v9.8.7", gates=gates)
self.assertNotEqual(p.returncode, 0)
self.assertIn("release gate failed", p.stderr)
self.assertIsNone(upload)
self.assertFalse(any("-X" in call for call in log))
def test_refuses_a_withdrawn_release_even_when_ci_is_green(self):
rel = dict(draft(), body="> **WITHDRAWN 26 October 2026.** Do not install.\n\n# Notes")
p, log, upload = self.run_script([rel], "v9.8.7")
self.assertNotEqual(p.returncode, 0)
self.assertIn("marked WITHDRAWN", p.stderr)
self.assertIsNone(upload)
self.assertFalse(any("-X" in call for call in log))
if __name__ == "__main__":
unittest.main()