mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-27 04:08:59 +01:00
Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6cfaabbe8f | ||
|
|
0905842ded | ||
|
|
f4cc9ce0a1 | ||
|
|
38db6a2846 | ||
|
|
a17ebf96fe | ||
|
|
6247dfbf89 | ||
|
|
7ed59a9b25 | ||
|
|
14d677f527 | ||
|
|
72f8152525 | ||
|
|
62e27a2450 | ||
|
|
1cd1c07b7a | ||
|
|
55fb9226ba |
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,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.
|
||||
@@ -0,0 +1,59 @@
|
||||
# v0.4.49 tagged release verification, 26 October 2026
|
||||
|
||||
The annotated `v0.4.49` tag dereferences to merged main commit
|
||||
`38db6a284696c3bb86bf97f6b853af7e97681701`. This is the same tree as
|
||||
the reviewed [candidate PR #348](https://github.com/saphid/frame-control/pull/348).
|
||||
Its [main checks run](https://github.com/saphid/frame-control/actions/runs/40417275314)
|
||||
passed all seven jobs, including e2e, iOS, macOS, Windows and Linux.
|
||||
The [native Decky 3.3.0 run](https://github.com/saphid/frame-control/actions/runs/40417275256)
|
||||
passed on AMD64 and ARM64. The regression gate includes an expected failure
|
||||
for v0.4.48's bad legacy calling convention.
|
||||
|
||||
The exact-tag [release workflow](https://github.com/saphid/frame-control/actions/runs/40418177330)
|
||||
passed all six jobs on its first attempt: draft, Linux, Windows, Apple Silicon
|
||||
Mac, Intel Mac, and checksums. No tagged build failed, was cancelled, or was
|
||||
skipped. The draft contained exactly ten installers and `SHA256SUMS` before
|
||||
publication. Every asset had a GitHub SHA-256 digest. A fresh download of
|
||||
`SHA256SUMS` had SHA-256
|
||||
`8a04f81f3b1efea9903e9acbba2c61b67f275869c160e0c6fa1d388a4803b42a`;
|
||||
it matched a list regenerated from the ten GitHub installer digests byte for
|
||||
byte. All ten installers were independently downloaded and passed
|
||||
`sha256sum -c SHA256SUMS`.
|
||||
|
||||
## Independent package checks
|
||||
|
||||
| Package | Check |
|
||||
| --- | --- |
|
||||
| Linux x86_64 AppImage | SHA-256 `0ef192e6f13aed39469392c22b971b311d911a0bebae16cbbabbfa729b95ced0`; isolated Xvfb launch returned HTTP 200 with `FrameControl/1`, then its server stopped. The first local launch attempt hit the workspace sandbox's `Operation not permitted` restriction; the same downloaded bytes passed outside that sandbox. Tagged Linux CI also launched the AppImage. |
|
||||
| Linux arm64 DEB | SHA-256 `a8e448b8b8f10b2813546cb9d8fea6bbe424e14dc3a739df86daa69bfd4023c1`; declares version 0.4.49 and arm64. |
|
||||
| Linux amd64 DEB | SHA-256 `10bb554cc761c61c99d059ae3601f4889187d7f18bdc8f9eb3cf768822b5765d`; declares version 0.4.49 and amd64. |
|
||||
| Mac arm64 ZIP | SHA-256 `c43c1017b3d244fcf59ab8f5bd35dbad17af4562b2aca3baf65908814f2ae4c3`; ZIP CRC, bundle version 0.4.49 and main Mach-O arm64 CPU passed. |
|
||||
| Mac x64 ZIP | SHA-256 `0dd838a1322107928893ff5efce817719867a5db1af2fa09124b6bb03018a19d`; ZIP CRC, bundle version 0.4.49 and main Mach-O x86_64 CPU passed. |
|
||||
| Windows x64 Setup | SHA-256 `386d99fd15314e1caef577f7cdf21e9e9f572d1c0dbaa65c369e5a5b0c047d31`; downloaded file is a PE/Nullsoft installer. Tagged Windows CI installed it and checked its packaged resources and Python libraries. |
|
||||
| Windows x64 ZIP | SHA-256 `2c201a8d1115dd1a07ba7b9293f7e05c319bd802f574a4b2f9565268a7f5cf8b`; ZIP CRC passed. |
|
||||
|
||||
The remaining three packages (both Mac DMGs and the ARM64 AppImage) also
|
||||
matched `SHA256SUMS`; tagged CI built and checked their platform bundles.
|
||||
Physical launch of these exact Mac and Windows installers was not performed.
|
||||
|
||||
## Publication and real Frame scope
|
||||
|
||||
The [candidate evidence](v0449-candidate-2026-10-26.md) records a unique-lock
|
||||
real Frame smoke on the version-bumped source: **12 passed, one optional
|
||||
x86_64 launch skipped, zero failed**, followed by an independent locked audit
|
||||
with no test-title folders or processes. The optional runtime was absent; no
|
||||
x86_64 launch is claimed. SteamVR stayed in standby, no wearer was confirmed,
|
||||
and neither a worn test nor physical controller check was run.
|
||||
|
||||
`scripts/publish-release.sh --dry-run v0.4.49` passed the exact main, native
|
||||
Decky and tagged release job gates, ten installer hashes, regenerated
|
||||
`SHA256SUMS`, draft notes and update manifest. The real publisher repeated
|
||||
those gates, attached `update.json`, and published
|
||||
[v0.4.49](https://github.com/saphid/frame-control/releases/tag/v0.4.49) at
|
||||
**23:25:45 UTC**. GitHub's latest-release API returned `draft=false`,
|
||||
`prerelease=false`, ten installers, `SHA256SUMS` and `update.json`. A fresh
|
||||
public latest-download of `update.json` reported version 0.4.49, the correct
|
||||
page, ten hashed assets, the v0.4.48 repair note and the worn-VR limit.
|
||||
The v0.4.48 release remains a withdrawn draft; its notice points to v0.4.49.
|
||||
Existing v0.4.48 installations do not auto-downgrade, and affected installed
|
||||
Music Control plugins need managed Update or Remove and reinstall.
|
||||
@@ -74,10 +74,13 @@ none of these frontend controls has passed a worn-headset check yet.
|
||||
| API v1/v2 `addEventListener` / `removeEventListener` | Plugin-scoped real backend events while panel is open; bounded queue, about one-second polling, subscription expiry | Real ARM64 Simple Timer countdown, start/cancel and completion toast via local Chromium; scope/bounds/reconnect tests. No closed-panel notifications. |
|
||||
| `toaster.toast` | Visible toast inside the plugin panel; plain text or React nodes for title, body, icon, logo and subtext; `dismiss` and optional `onClick` | Host unit test; no Steam system notification or worn VR check |
|
||||
| `localStorage` | Synchronous, per-plugin cache backed by the Frame Control panel origin. Up to 64 keys, 64 KiB per value and 128 KiB total; `clear()` removes only this plugin's cache. Falls back to memory if panel storage is denied. | Parent/iframe round-trip and reopen in the local host test; real headset persistence remains unverified. |
|
||||
| `SteamClient.Storage.GetJSON` / `SetObject` | Uses the same bounded per-plugin cache; a missing key rejects with `Not found`. It cannot read Steam account or global storage. | Host contract tests and local Chromium settings views; headset persistence after reopen remains unverified. |
|
||||
| IndexedDB / localForage | IndexedDB is deliberately absent inside the opaque iframe so feature detection chooses the bounded localStorage driver. Plugins requiring IndexedDB directly remain unsupported. | HLTB official frontend in local Chromium: its previous `IDBFactory.open` SecurityError disappeared. No live HLTB data request was run. |
|
||||
| `routerHook.addRoute` / `removeRoute`, `Navigation.Navigate` / `NavigateBack`, `useParams` | Local routes inside this plugin panel, with named segments such as `/plugin/game/:appid/:view?`; `useParams()` reads the current segment values | Host unit test; VR navigation unverified |
|
||||
| `routerHook.addPatch`, global components | No-op; cannot patch Steam routes | Not applicable |
|
||||
| `findModuleChild`, `Navigation.CloseSideMenus` | Steam module lookup returns `null`; close returns to this plugin's main panel | Pinned HLTB for Deck 2.0.12 entry point and shallow settings view in local host test. Steam library patch and live HLTB search remain unverified. |
|
||||
| `findModule`, `findModuleChild`, `findModuleExport`, class lookup and module mapping | Searches a finite registry of Framey controls and CSS maps. Unknown Steam modules return `undefined` or an empty mapping. The optional UI-audio export cannot play sound. | Host contract tests; pinned TabMaster 2.15.1 QAM and documentation render in local Chromium. Steam library editing and worn VR remain unverified. |
|
||||
| `Navigation.CloseSideMenus` | Returns to this plugin's main panel | Pinned HLTB for Deck 2.0.12 shallow settings view in local host test. Live HLTB search remains unverified. |
|
||||
| `Focusable.onActivate`, `showModal`, `ConfirmModal` | Pointer and Enter/Space activation, disabled state, and local confirmation dialogs with cancel/close and nested-dialog focus | Host tests and official AlarMe 1.6.3 timer/preset controls with a real ARM64 backend in local Chromium. Sound, suspend and worn VR remain unverified. |
|
||||
| Steam library/store patches, CEF tab injection | Unavailable; no matching target in the isolated panel | Frame CEF inspection |
|
||||
| `fetchNoCors`, file picker, root calls | Unavailable | Not supported by Framey |
|
||||
|
||||
@@ -88,6 +91,17 @@ Optional route segments must be at the end. Framey accepts local path segments
|
||||
only; query strings, external URLs, Steam library routes and duplicate parameter
|
||||
names are rejected. Removing an active route returns to the plugin panel.
|
||||
Toasts stay inside the panel, so keep essential errors in the plugin UI too.
|
||||
Treat module lookup results as Framey-local exports. Do not depend on Steam
|
||||
webpack IDs, function-source signatures, or Steam-only CSS class names; a
|
||||
lookup that works in the Steam Deck QAM can still return `undefined` here.
|
||||
Use a local control or a visible unsupported message when it does. For a
|
||||
confirmation, call `showModal` with a `ConfirmModal` and keep any irreversible
|
||||
operation behind its OK action. `Focusable.onActivate` accepts pointer and
|
||||
Enter/Space input in Framey, but only a worn-headset test can establish how a
|
||||
physical controller reaches it. See the
|
||||
[module lookup evidence](evidence/framey-module-lookup-2026-10-26.md) and
|
||||
[dialog control evidence](evidence/framey-dialog-controls-2026-10-26.md) for
|
||||
the exact tested official versions.
|
||||
An API v1 manifest does not guarantee a working Framey panel. Test the exact
|
||||
store archive and do not assume SteamClient app details, library patches, or
|
||||
destructive actions work in VR. The
|
||||
|
||||
+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))
|
||||
|
||||
@@ -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