mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-27 19:03:22 +01:00
Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a17ebf96fe | ||
|
|
6247dfbf89 | ||
|
|
7ed59a9b25 | ||
|
|
14d677f527 | ||
|
|
fe8a860afb | ||
|
|
72f8152525 | ||
|
|
62e27a2450 | ||
|
|
4c3381f006 | ||
|
|
1cd1c07b7a | ||
|
|
55fb9226ba | ||
|
|
5418c6f094 | ||
|
|
14f9506e74 | ||
|
|
0026095096 | ||
|
|
1309a0883d | ||
|
|
d1a4270b45 | ||
|
|
1e6d742b98 | ||
|
|
818ec2dcba |
No files matched your search
Generated
+2
-2
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user