diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4afce24..fabd7a7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,7 +14,31 @@ permissions: contents: write jobs: + # One job makes the draft, before the builds: three matrix jobs each running + # "view || create" could race and make duplicate drafts. The draft is tied to + # the pushed tag (--verify-tag, then tag_name set explicitly), so + # scripts/publish-release.sh and `gh release upload` find it by tag. + draft: + if: startsWith(github.ref, 'refs/tags/') + runs-on: ubuntu-latest + steps: + - name: Create the draft release + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + run: | + tag="${GITHUB_REF_NAME}" + id=$(gh api --paginate "repos/$GH_REPO/releases?per_page=100" --jq ".[] | select(.tag_name == \"$tag\") | .id" | head -n 1) + if [ -z "$id" ]; then + gh release create "$tag" --draft --verify-tag --title "Frame Control ${tag#v}" --notes "" + id=$(gh api --paginate "repos/$GH_REPO/releases?per_page=100" --jq ".[] | select(.draft and .name == \"Frame Control ${tag#v}\") | .id" | head -n 1) + fi + gh api -X PATCH "repos/$GH_REPO/releases/$id" -f tag_name="$tag" --jq '"release " + (.id|tostring) + " tag_name " + .tag_name' + build: + needs: draft + # Still runs for pull requests and manual runs, where the draft job is skipped. + if: ${{ !failure() && !cancelled() }} strategy: fail-fast: false matrix: @@ -60,9 +84,7 @@ jobs: env: GH_TOKEN: ${{ github.token }} run: | - tag="${GITHUB_REF_NAME}" - gh release view "$tag" >/dev/null 2>&1 || gh release create "$tag" --draft --title "Frame Control ${tag#v}" --notes "" - gh release upload "$tag" ${{ matrix.files }} --clobber + gh release upload "${GITHUB_REF_NAME}" ${{ matrix.files }} --clobber - uses: actions/upload-artifact@v4 with: name: frame-control-${{ matrix.os }} diff --git a/apk-catalog/reports.py b/apk-catalog/reports.py index b425d04..6429910 100644 --- a/apk-catalog/reports.py +++ b/apk-catalog/reports.py @@ -6,11 +6,25 @@ A report: package, version, result (runs | crashes | install_failed | instance_failed, from an automated test), rating (works | issues | broken, from a person), notes, via (harness | probe | user), date, steamos, lepton, runtime. Newest wins, and a person's rating beats an automated result. + +An install_failed report saying the file had no arm64-v8a build (or needed a +newer Android) is about that one APK file, often the wrong per-ABI download of +an app that has an arm64 build, so it says nothing about the app and is left out. """ +import re + +FILE_FAULT = re.compile(r'no arm64-v8a build|needs Android API') + + +def about_app(r): + """False for reports about one wrong APK file rather than the app itself.""" + return not (r.get('result') == 'install_failed' and not r.get('rating') + and FILE_FAULT.search(r.get('notes') or '')) def verdict(reports): """(verdict, summary lines) for one package's reports, or None.""" + reports = [r for r in reports or () if about_app(r)] if not reports: return None rs = sorted(reports, key=lambda r: r.get('date') or '') diff --git a/app/test/updater.test.js b/app/test/updater.test.js index 4bf9594..2ae00af 100644 --- a/app/test/updater.test.js +++ b/app/test/updater.test.js @@ -51,6 +51,27 @@ test("update.json assets always download from this repository's release", () => assert.throws(() => require("../updater").fromManifest({ version: "nope", assets: [] })); }); +test("the release page is this repository's tag page, never a draft's untagged-... link", () => { + const { fromManifest, fromApi } = require("../updater"); + const tagPage = "https://github.com/saphid/frame-control/releases/tag/v0.4.0"; + const page = (p) => fromManifest({ version: "0.4.0", assets: [], page: p }).page; + assert.strictEqual(page(tagPage), tagPage); + // 0.4.0's real update.json: the draft's address, a 404 once published. + assert.strictEqual(page("https://github.com/saphid/frame-control/releases/tag/untagged-c6ddfed7f75d67db2e99"), tagPage); + assert.strictEqual(page(undefined), tagPage); + assert.strictEqual(page("https://evil.example/releases/tag/v0.4.0"), tagPage); + assert.strictEqual(page("https://github.com/saphid/frame-control/releases/tag/v0.4.0?x=1"), tagPage); + // Paths that normalize to another repository. + assert.strictEqual(page("https://github.com/saphid/frame-control/releases/tag/..\\..\\..\\..\\other-owner\\other-repo"), + tagPage); + assert.strictEqual(page("https://github.com/saphid/frame-control/releases/tag/%2e%2e/%2e%2e/%2e%2e/%2e%2e/other-owner/other-repo"), + tagPage); + assert.strictEqual(page("https://github.com/saphid/frame-control/releases/tag/v0.3.9"), tagPage); // only its own tag + assert.strictEqual(fromApi({ tag_name: "../../x", assets: [] }).page, "https://github.com/saphid/frame-control/releases"); + assert.strictEqual(fromApi({ tag_name: "v0.4.0", assets: [], + html_url: "https://github.com/saphid/frame-control/releases/tag/untagged-x" }).page, tagPage); +}); + test("prepare refuses a release that isn't newer (no downgrades)", async () => { const { prepare } = require("../updater"); const release = { version: "0.3.1", assets: [] }; diff --git a/app/updater.js b/app/updater.js index 85caab5..e6ccbc9 100644 --- a/app/updater.js +++ b/app/updater.js @@ -106,12 +106,24 @@ async function getJson(url, headers) { return JSON.parse(body); } +// The release page the banner links to. update.json for 0.4.0 carried the draft's +// address (releases/tag/untagged-...), which is dead once the release is published, +// and a page from the manifest could also be steered elsewhere (e.g. tag/..\..\other/repo +// normalizes to another repository) before shell.openExternal. So the given page is +// ignored: the link is always this repository's tag page, built from a valid version. +function releasePage(version) { + const v = parseVersion(version); + if (!v) return RELEASES; + return `${RELEASES}/tag/v${v.nums.join(".")}${v.pre ? "-" + v.pre : ""}`; +} + // update.json and the API's release both become { version, notes, page, assets }. function fromManifest(m) { if (!parseVersion(m.version) || !Array.isArray(m.assets)) throw new Error("update.json is malformed"); - const base = `https://github.com/${REPO}/releases/download/v${String(m.version).replace(/^v/i, "")}/`; - return { version: String(m.version).replace(/^v/i, ""), notes: String(m.notes || "").slice(0, 4000), - page: m.page || RELEASES, + const version = String(m.version).replace(/^v/i, ""); + const base = `https://github.com/${REPO}/releases/download/v${version}/`; + return { version, notes: String(m.notes || "").slice(0, 4000), + page: releasePage(version), // Assets always come from this repository's release, whatever the manifest says. assets: m.assets.map((a) => ({ name: String(a.name), url: base + encodeURIComponent(String(a.name)), size: a.size, digest: a.digest || null })) }; @@ -119,8 +131,9 @@ function fromManifest(m) { function fromApi(r) { if (r.draft || r.prerelease) throw new Error("GitHub returned an unpublished release"); - return { version: String(r.tag_name || "").replace(/^v/i, ""), notes: String(r.body || "").slice(0, 4000), - page: r.html_url || RELEASES, + const version = String(r.tag_name || "").replace(/^v/i, ""); + return { version, notes: String(r.body || "").slice(0, 4000), + page: releasePage(version), assets: (r.assets || []).map((a) => ({ name: a.name, url: a.browser_download_url, size: a.size, digest: a.digest || null })) }; } diff --git a/docs/releasing.md b/docs/releasing.md index 7db5e3a..c4ce8e1 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -24,13 +24,20 @@ count. So a build reaches people only when you publish it, after testing it. 4. Publish: ```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 all eight installers are attached, each with the - SHA-256 digest GitHub records. It attaches `update.json` (the version, the - notes and each installer's digest), then publishes the release and marks it - latest. From then on, running copies see the update. They check about 8 + The script checks that the tag is on GitHub and that all eight installers + are attached, each with the SHA-256 digest GitHub records. It attaches + `update.json` (the version, the notes, the release page and each + installer's digest), then publishes the release and marks it latest. It + uses only the REST API: `gh release view` can't find a draft whose + `tag_name` still reads `untagged-…`, and GraphQL is often rate-limited. + Such a draft is found by its exact title (`Frame Control 0.4.0`, or that + followed by `: subtitle`) and tied to the tag when it's published. The release page in `update.json` is always + `releases/tag/`, because a draft's own address (`releases/tag/untagged-…`) + stops working once it's published. From then on, running copies see the update. They check about 8 seconds after starting, then every 6 hours, and anyone can use **Check for Updates…** (the app menu on macOS, the Help menu elsewhere). diff --git a/scripts/publish-release.sh b/scripts/publish-release.sh index 82b5c10..9101c45 100755 --- a/scripts/publish-release.sh +++ b/scripts/publish-release.sh @@ -3,23 +3,65 @@ # (docs/releasing.md). Checks every installer is attached with a SHA-256 # digest first, since the app's updater refuses assets without one, then # attaches update.json, the manifest the updater reads. -# Usage: scripts/publish-release.sh v0.4.0 +# Usage: scripts/publish-release.sh [--dry-run] v0.4.0 +# +# Everything goes through the REST API (gh api), not `gh release view/edit`: +# those look a draft up by its tag, and a draft can show tag_name +# "untagged-..." until it's published, so they report "release not found" +# (and GraphQL is often rate-limited). A draft's html_url is an untagged-... +# link that dies on publishing, so update.json's page is built from the tag. set -eu -tag="${1:?usage: $0 vX.Y.Z}" +dry="" +[ "${1:-}" = "--dry-run" ] && { dry=1; shift; } +tag="${1:?usage: $0 [--dry-run] vX.Y.Z}" repo=saphid/frame-control expected="Frame-Control-mac-arm64.dmg Frame-Control-mac-arm64.zip Frame-Control-Setup-x64.exe Frame-Control-win-x64.zip Frame-Control-linux-x86_64.AppImage Frame-Control-linux-arm64.AppImage Frame-Control-linux-amd64.deb Frame-Control-linux-arm64.deb" +page="https://github.com/$repo/releases/tag/$tag" -info=$(gh release view "$tag" -R "$repo" --json isDraft,isPrerelease,assets) version=$(sed -n 's/.*"version": *"\([^"]*\)".*/\1/p' "$(dirname "$0")/../app/package.json") [ "v$version" = "$tag" ] || echo "note: app/package.json here says $version (the release was built from the tag)" +tmp=$(mktemp -d) +trap 'rm -rf "$tmp"' EXIT + +# Publishing sets tag_name; if the tag didn't exist GitHub would create it on +# the default branch, which isn't what was built and tested. +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; } + +# 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 +# titled "Frame Control X.Y.Z" (release.yml's title), optionally ": subtitle". +python3 - "$tag" "$tmp/releases" > "$tmp/release.json" <<'EOF' +import json, re, sys +tag, path = sys.argv[1], sys.argv[2] +rels = [json.loads(line) for line in open(path) if line.strip()] +hits = [r for r in rels if r.get("tag_name") == tag] +if not hits: + # Exactly this version: "Frame Control 0.4.0", or that followed by ": ". + # Never "Frame Control 0.4.0-rc.1" or "0.4.00", and only drafts with no real tag. + title = re.compile(r"Frame Control " + re.escape(tag.lstrip("v")) + r"(: .*)?", re.S) + hits = [r for r in rels if r.get("draft") and str(r.get("tag_name") or "").startswith("untagged-") + and title.fullmatch(r.get("name") or "")] +if len(hits) != 1: + why = "no release" if not hits else "%d releases (ids %s)" % (len(hits), ", ".join(str(r["id"]) for r in hits)) + sys.exit("found %s for %s; expected one draft" % (why, tag)) +r = hits[0] +if not r.get("draft"): + print("note: %s is already published; refreshing update.json and marking it latest" % tag, file=sys.stderr) +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"))" + missing="" for name in $expected; do - digest=$(printf '%s' "$info" | python3 -c 'import json,sys -d=json.load(sys.stdin); n=sys.argv[1] -print(next((a.get("digest") or "" for a in d["assets"] if a["name"]==n), "absent"))' "$name") + digest=$(python3 -c 'import json,sys +d=json.load(open(sys.argv[1])); n=sys.argv[2] +print(next((a.get("digest") or "" for a in d["assets"] if a["name"]==n), "absent"))' "$tmp/release.json" "$name") case "$digest" in sha256:*) echo "ok $name" ;; absent) echo "MISSING $name"; missing=1 ;; @@ -30,16 +72,32 @@ done # update.json: what running copies read (app/updater.js), from github.com's # latest/download link rather than the rate-limited REST API. -tmp=$(mktemp -d) -trap 'rm -rf "$tmp"' EXIT -gh release view "$tag" -R "$repo" --json tagName,url,body,assets | python3 -c 'import json,sys -d=json.load(sys.stdin) -names=set(sys.argv[1].split()) -print(json.dumps({"version": d["tagName"].lstrip("v"), "page": d["url"], "notes": d["body"][:4000], +python3 - "$tmp/release.json" "$tag" "$page" "$expected" > "$tmp/update.json" <<'EOF' +import json, sys +d = json.load(open(sys.argv[1])) +tag, page, names = sys.argv[2], sys.argv[3], set(sys.argv[4].split()) +print(json.dumps({"version": tag.lstrip("v"), "page": page, "notes": (d.get("body") or "")[:4000], "assets": [{"name": a["name"], "size": a["size"], "digest": a["digest"]} - for a in d["assets"] if a["name"] in names]}, indent=1))' "$expected" > "$tmp/update.json" -gh release upload "$tag" -R "$repo" "$tmp/update.json" --clobber + for a in d["assets"] if a["name"] in names]}, indent=1)) +EOF +old=$(python3 -c 'import json,sys +d=json.load(open(sys.argv[1])) +print(" ".join(str(a["id"]) for a in d["assets"] if a["name"]=="update.json"))' "$tmp/release.json") + +if [ -n "$dry" ]; then + echo "dry run: would replace update.json (old asset ids: ${old:-none}) with:" + cat "$tmp/update.json" + echo "dry run: would PATCH release $id: tag_name=$tag draft=false prerelease=false make_latest=true" + exit 0 +fi + +for asset in $old; do + gh api -X DELETE "repos/$repo/releases/assets/$asset" >/dev/null +done +gh api -X POST "https://uploads.github.com/repos/$repo/releases/$id/assets?name=update.json" \ + -H "Content-Type: application/json" --input "$tmp/update.json" >/dev/null echo "ok update.json" -gh release edit "$tag" -R "$repo" --draft=false --prerelease=false --latest -echo "published $tag; running copies will offer it at their next check" +gh api -X PATCH "repos/$repo/releases/$id" -f tag_name="$tag" -F draft=false -F prerelease=false \ + -f make_latest=true --jq '"published " + .tag_name + " at " + .html_url' +echo "running copies will offer $tag at their next check" diff --git a/tests/fakegh/gh b/tests/fakegh/gh new file mode 100755 index 0000000..cbac489 --- /dev/null +++ b/tests/fakegh/gh @@ -0,0 +1,55 @@ +#!/usr/bin/env python3 +"""A stand-in for the GitHub CLI's `gh api`, for tests/test_publish_release.py. Serves +the releases in $FAKEGH_RELEASES (a JSON list, drafts included) and the tags in +$FAKEGH_TAGS (space-separated); an upload's body is written to $FAKEGH_UPLOAD. +Every call is appended to $FAKEGH_LOG as a JSON line. Anything else fails, so the +script under test can't fall back to `gh release ...` (GraphQL) unnoticed.""" +import json +import os +import sys + +args = sys.argv[1:] +with open(os.environ["FAKEGH_LOG"], "a") as f: + f.write(json.dumps(args) + "\n") +if not args or args[0] != "api": + sys.exit("fakegh: only `gh api` is supported: %r" % args) + +method, endpoint, fields, inp, i = "GET", None, {}, None, 1 +while i < len(args): + a = args[i] + if a == "-X": + method = args[i + 1]; i += 2 + elif a in ("-f", "-F"): + k, _, v = args[i + 1].partition("="); fields[k] = v; i += 2 + elif a == "--input": + inp = args[i + 1]; i += 2 + elif a in ("-H", "--jq"): + i += 2 + elif a.startswith("-"): + i += 1 + elif endpoint is None: + endpoint = a; i += 1 + else: + sys.exit("fakegh: unexpected argument %r" % a) + +releases = json.load(open(os.environ["FAKEGH_RELEASES"])) +repo = "repos/saphid/frame-control/" +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})) +elif method == "GET" and endpoint.startswith(repo + "releases?"): + for r in releases: # what --jq '.[]' prints + print(json.dumps(r)) +elif method == "DELETE" and endpoint.startswith(repo + "releases/assets/"): + pass +elif method == "POST" and endpoint.startswith("https://uploads.github.com/" + repo + "releases/"): + with open(inp) as src, open(os.environ["FAKEGH_UPLOAD"], "w") as dst: + dst.write(src.read()) + print("{}") +elif method == "PATCH" and endpoint.startswith(repo + "releases/"): + print("published %s at https://github.com/saphid/frame-control/releases/tag/%s" + % (fields.get("tag_name"), fields.get("tag_name"))) +else: + sys.exit("fakegh: unhandled %s %s" % (method, endpoint)) diff --git a/tests/test_fdroid_sources.py b/tests/test_fdroid_sources.py index 4285721..f264482 100644 --- a/tests/test_fdroid_sources.py +++ b/tests/test_fdroid_sources.py @@ -256,6 +256,36 @@ class Repositories(unittest.TestCase): self.assertEqual(result['icon'], URL + 'icons/legacy.1.png') self.assertEqual(result['images']['screenshots'], [URL + 'org.example.app/fr/sevenInchScreenshots/tablet.png']) + def test_per_abi_builds_offer_and_download_the_arm64_one(self): + import hashlib + + def build(code, name, abis): + self.files[name] = name.encode() + return {'manifest': {'versionName': '391', 'versionCode': code, 'usesSdk': {'minSdkVersion': 28}, + 'nativecode': abis}, + 'file': {'name': '/' + name, 'sha256': hashlib.sha256(name.encode()).hexdigest(), 'size': code}, + 'added': 0} + # One APK per ABI under different codes (the x86_64 one highest, as F-Droid often does), + # plus a universal and an arm64-only build sharing a code. + builds = [build(3911, 'app-armeabi-v7a.apk', ['armeabi-v7a']), build(3914, 'app-x86_64.apk', ['x86_64']), + build(3913, 'app-x86.apk', ['x86']), + build(3912, 'app-universal.apk', ['arm64-v8a', 'armeabi-v7a', 'x86_64']), + build(3912, 'app-arm64-v8a.apk', ['arm64-v8a'])] + raw = self.root / 'splits.json' + raw.write_text(json.dumps({'packages': {'com.futo.platformplayer': { + 'metadata': {'name': {'en-US': 'Grayjay'}}, + 'versions': {str(i): b for i, b in enumerate(builds)}}}})) + source = {'id': 'test', 'url': URL} + app = fdroid._reduce(raw, source)['com.futo.platformplayer'] + self.assertEqual([v['name'] for v in app['versions']], ['/app-arm64-v8a.apk', '/app-universal.apk']) + self.assertEqual((app['version_code'], app['abis']), (3912, ['arm64-v8a'])) + with patch.object(fdroid, 'details', return_value=app): + for code in (None, 3912): + fdroid.download(source, 'com.futo.platformplayer', version_code=code) + self.assertTrue(self.fetch_mock.call_args[0][0].endswith('/app-arm64-v8a.apk')) + with self.assertRaises(SourceError): # the x86_64 build is never offered + fdroid.download(source, 'com.futo.platformplayer', version_code=3914) + def test_v2_legacy_screenshot_keys_and_limit(self): meta = {'phoneScreenshots': {'fr': [{'name': '/phone/' + str(i) + '.png'} for i in range(8)]}, 'sevenInchScreenshots': {'en-US': [{'name': '/tablet.png'}]}} diff --git a/tests/test_frame_apk_versions.py b/tests/test_frame_apk_versions.py index ea70373..53da535 100644 --- a/tests/test_frame_apk_versions.py +++ b/tests/test_frame_apk_versions.py @@ -184,6 +184,35 @@ class VersionsTest(unittest.TestCase): info['abis'] = ['armeabi-v7a'] self.assertIn('no arm64-v8a build', versions.describe(info)) + def test_wrong_abi_error_says_which_file_to_get(self): + import frame_telemetry + for abis in (['armeabi-v7a'], ['x86_64']): # the two per-ABI Grayjay files users tried + info = {'label': 'Grayjay', 'min_sdk': 28, 'abis': abis} + with self.assertRaises(frame_android.FrameError) as error: + frame_android.check_installable(info) + message = str(error.exception) + self.assertIn('no arm64-v8a build (%s)' % abis[0], message) + self.assertIn('download the APK marked arm64-v8a', message) + self.assertEqual(frame_telemetry.categorize(message)[0], 'apk_wrong_abi') + frame_android.check_installable({'label': 'Universal', 'min_sdk': 28, + 'abis': ['arm64-v8a', 'armeabi-v7a', 'x86', 'x86_64']}) + + def test_wrong_file_reports_do_not_rate_the_app(self): + reports = frame_catalog.reports + wrong_file = {'package': 'org.example.app', 'version': '391', 'result': 'install_failed', + 'notes': 'Example has no arm64-v8a build (x86_64); Lepton is 64-bit ARM only', + 'date': '2026-10-01T10:00:00'} + self.assertIsNone(reports.verdict([wrong_file])) + app = frame_catalog.catalog_build.finalize({'pr': 'likely', 'pw': ['No known blockers']}, [wrong_file]) + self.assertEqual((app['r'], app['t']), ('likely', False)) # the prediction stands + # A real installer failure, a crash or a person's rating still counts. + installer = dict(wrong_file, notes='INSTALL_FAILED_INVALID_APK') + self.assertEqual(reports.verdict([installer])[0], 'no') + crash = dict(wrong_file, result='crashes', notes=None, date='2026-10-02') + self.assertEqual(reports.verdict([wrong_file, crash])[0], 'no') + rated = dict(wrong_file, rating='works', date='2026-10-03') + self.assertEqual(reports.verdict([wrong_file, rated])[0], 'works') + def test_install_resolves_index_hash(self): versions.alternatives('org.example.app') with patch.object(versions, 'alternatives', side_effect=AssertionError('recomputed')), \ diff --git a/tests/test_publish_release.py b/tests/test_publish_release.py new file mode 100644 index 0000000..07baece --- /dev/null +++ b/tests/test_publish_release.py @@ -0,0 +1,110 @@ +"""scripts/publish-release.sh against a stand-in gh (tests/fakegh/gh): finds the draft +through the REST API even when its tag_name still says untagged-..., refuses +missing installers or digests, writes update.json's page from the tag, and +publishes with one PATCH. Nothing here talks to GitHub. + +Run: python3 -m unittest discover -s tests +""" +import sandbox # noqa: F401 (first: keeps tests off real data and services) +import json +import os +import subprocess +import tempfile +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +SCRIPT = ROOT / "scripts" / "publish-release.sh" +FAKEGH = ROOT / "tests" / "fakegh" +INSTALLERS = ["Frame-Control-mac-arm64.dmg", "Frame-Control-mac-arm64.zip", "Frame-Control-Setup-x64.exe", + "Frame-Control-win-x64.zip", "Frame-Control-linux-x86_64.AppImage", + "Frame-Control-linux-arm64.AppImage", "Frame-Control-linux-amd64.deb", + "Frame-Control-linux-arm64.deb"] + + +def draft(tag_name="untagged-c6ddfed7f75d67db2e99", name="Frame Control 9.8.7", rid=42, assets=None): + if assets is None: + assets = [{"id": 100 + i, "name": n, "size": 1000 + i, "digest": "sha256:" + "%064x" % i} + for i, n in enumerate(INSTALLERS)] + return {"id": rid, "tag_name": tag_name, "name": name, "draft": True, "prerelease": False, + "body": "notes", "html_url": "https://github.com/saphid/frame-control/releases/tag/" + tag_name, + "assets": assets} + + +class PublishRelease(unittest.TestCase): + def run_script(self, releases, *args, tags="v9.8.7"): + d = Path(tempfile.mkdtemp()) + (d / "releases.json").write_text(json.dumps(releases)) + 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")) + 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 + return p, log, upload + + def test_publishes_an_untagged_draft_by_title_with_the_tag_page(self): + old = {"id": 7, "name": "update.json", "size": 1, "digest": None} + rel = draft(assets=draft()["assets"] + [old]) + p, log, upload = self.run_script([rel], "v9.8.7") + self.assertEqual(p.returncode, 0, p.stderr + p.stdout) + self.assertEqual(upload["page"], "https://github.com/saphid/frame-control/releases/tag/v9.8.7") + self.assertEqual(upload["version"], "9.8.7") + self.assertEqual(sorted(a["name"] for a in upload["assets"]), sorted(INSTALLERS)) + self.assertIn(["api", "-X", "DELETE", "repos/saphid/frame-control/releases/assets/7"], log) + patch = next(c for c in log if "PATCH" in c) + self.assertEqual(patch[3], "repos/saphid/frame-control/releases/42") + for f in ("tag_name=v9.8.7", "draft=false", "prerelease=false", "make_latest=true"): + self.assertIn(f, patch) + self.assertTrue(all(c[0] == "api" for c in log)) # never `gh release ...` (GraphQL) + + def test_an_untagged_draft_may_carry_a_subtitle(self): + p, log, upload = self.run_script([draft(name="Frame Control 9.8.7: faster")], "v9.8.7") + self.assertEqual(p.returncode, 0, p.stderr + p.stdout) + self.assertEqual(upload["version"], "9.8.7") + + def test_prefers_the_release_whose_tag_name_matches(self): + p, log, upload = self.run_script([draft(name="Frame Control 9.8.7 old", rid=1), + draft(tag_name="v9.8.7", name="Renamed", rid=2)], "v9.8.7") + self.assertEqual(p.returncode, 0, p.stderr + p.stdout) + self.assertEqual(next(c for c in log if "PATCH" in c)[3], "repos/saphid/frame-control/releases/2") + + def test_refuses_missing_or_unhashed_installers(self): + assets = draft()["assets"][1:] + assets[0] = dict(assets[0], digest=None) + p, log, upload = self.run_script([draft(assets=assets)], "v9.8.7") + self.assertNotEqual(p.returncode, 0) + self.assertIn("MISSING Frame-Control-mac-arm64.dmg", p.stdout) + self.assertIn("NO HASH Frame-Control-mac-arm64.zip", p.stdout) + self.assertIsNone(upload) + self.assertFalse(any(c[1:3] == ["-X", "PATCH"] for c in log)) + + def test_refuses_ambiguous_or_absent_drafts_and_missing_tags(self): + p, _, _ = self.run_script([draft(rid=1), draft(rid=2)], "v9.8.7") + self.assertNotEqual(p.returncode, 0) + self.assertIn("2 releases", p.stderr) + p, _, _ = self.run_script([draft(name="Frame Control 9.8.70")], "v9.8.7") + self.assertNotEqual(p.returncode, 0) + self.assertIn("no release", p.stderr) + # A pre-release's draft, or one bound to an unrelated tag, is never taken for v9.8.7. + for rel in (draft(name="Frame Control 9.8.7-rc.1"), draft(name="Frame Control 9.8.7.1"), + draft(tag_name="kdeconnect-frame-1"), draft(tag_name="")): + p, log, upload = self.run_script([rel], "v9.8.7") + self.assertNotEqual(p.returncode, 0, rel) + self.assertIn("no release", p.stderr) + self.assertIsNone(upload) + self.assertFalse(any("PATCH" in c for c in log)) + p, log, _ = self.run_script([draft()], "v9.8.7", tags="") + self.assertNotEqual(p.returncode, 0) + self.assertIn("push it first", p.stderr) + + def test_dry_run_changes_nothing(self): + p, log, upload = self.run_script([draft()], "--dry-run", "v9.8.7") + self.assertEqual(p.returncode, 0, p.stderr + p.stdout) + self.assertIn('"page": "https://github.com/saphid/frame-control/releases/tag/v9.8.7"', p.stdout) + self.assertIsNone(upload) + self.assertTrue(all("-X" not in c for c in log)) + + +if __name__ == "__main__": + unittest.main() diff --git a/ui/frame_android.py b/ui/frame_android.py index 070b53d..72e710b 100644 --- a/ui/frame_android.py +++ b/ui/frame_android.py @@ -125,7 +125,11 @@ def check_installable(info): if info['min_sdk'] and info['min_sdk'] > 30: raise FrameError(f"{info['label']} needs Android API {info['min_sdk']}; Lepton is Android 11 (API 30)") if info['abis'] and 'arm64-v8a' not in info['abis']: - raise FrameError(f"{info['label']} has no arm64-v8a build ({', '.join(info['abis'])}); Lepton is 64-bit ARM only") + # Sites that offer one APK per ABI (Grayjay: arm64-v8a, armeabi-v7a, x86, x86_64, + # universal) leave the choice to the user; say which file to fetch instead. + raise FrameError(f"{info['label']} has no arm64-v8a build ({', '.join(info['abis'])}); Lepton is 64-bit ARM only. " + "This file is for other devices: download the APK marked arm64-v8a " + "(or arm64, or universal) and install that instead") _install_lock = threading.Lock() # installs are rare; one at a time avoids every race