diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 62f366e..f671b02 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -173,3 +173,24 @@ jobs: app/dist/*.AppImage app/dist/*.deb if-no-files-found: ignore + + checksums: + needs: build + if: startsWith(github.ref, 'refs/tags/') + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Attach SHA256SUMS after all installers upload + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + run: | + gh api --paginate "repos/$GH_REPO/releases?per_page=100" --jq '.[]' > releases.jsonl + python3 scripts/release-checksums.py --tag "$GITHUB_REF_NAME" < releases.jsonl > SHA256SUMS + # Find the same draft using its exact tag, or its version title if a + # draft edit made GitHub temporarily show an untagged-* tag name. + id=$(python3 scripts/release-checksums.py --tag "$GITHUB_REF_NAME" --id < releases.jsonl) + old=$(gh api "repos/$GH_REPO/releases/$id" --jq '.assets[] | select(.name == "SHA256SUMS") | .id') + for asset in $old; do gh api -X DELETE "repos/$GH_REPO/releases/assets/$asset" >/dev/null; done + gh api -X POST "https://uploads.github.com/repos/$GH_REPO/releases/$id/assets?name=SHA256SUMS" \ + -H "Content-Type: text/plain" --input SHA256SUMS >/dev/null diff --git a/README.md b/README.md index 99e1930..ea20561 100644 --- a/README.md +++ b/README.md @@ -132,6 +132,24 @@ Choose the operating system and processor of the computer you will use. | **Linux** (x64) | [AppImage](https://github.com/saphid/frame-control/releases/latest/download/Frame-Control-linux-x86_64.AppImage) · [.deb](https://github.com/saphid/frame-control/releases/latest/download/Frame-Control-linux-amd64.deb) | `ssh` (most desktops have it) | | **Linux** (arm64) | [AppImage](https://github.com/saphid/frame-control/releases/latest/download/Frame-Control-linux-arm64.AppImage) · [.deb](https://github.com/saphid/frame-control/releases/latest/download/Frame-Control-linux-arm64.deb) | `ssh`, and `adb` for Android apps (`sudo apt install adb`) | +### Is it safe? Check your download + +Starting with **v0.4.26**, get the installer and [SHA256SUMS](https://github.com/saphid/frame-control/releases/latest/download/SHA256SUMS) from the **same release**. Earlier releases do not have this file. A matching SHA-256 means your file matches that release asset. It does not prove the app is free of malware. Use the command for your downloaded file, then compare all 64 characters with its line in `SHA256SUMS`: + +```powershell +# Windows PowerShell +Get-FileHash .\Frame-Control-Setup-x64.exe -Algorithm SHA256 +``` + +```sh +# macOS: shasum -a 256 Frame-Control-mac-arm64.dmg +# Linux: sha256sum Frame-Control-linux-x86_64.AppImage +``` + +The [source](https://github.com/saphid/frame-control) and [release build workflow](.github/workflows/release.yml) are public. The Windows installer is **not code-signed**, so SmartScreen may warn. The macOS app has only an **ad hoc signature**, not an Apple Developer ID signature; the DMG is **unsigned and not notarized**, so Gatekeeper may warn or block it. Only follow the opening steps below if you trust the download. + +日本語: v0.4.26 以降は公式リリースの SHA256SUMS とファイルの SHA-256 値を照合してください。Windows は未署名、macOS は開発者 ID による署名・公証がありません。 + **Which build?** On a Mac, check Apple menu → About This Mac: the download is for Apple Silicon (M-series); no Intel Mac build is published. On Windows, check Settings → System → About → System type: the release is x64, not a native @@ -191,7 +209,7 @@ or run it with `--appimage-extract-and-run`. **Optional: open at login.** In the installed desktop app, choose **Frame → Open at Login**. It is off by default. When on, a login launch starts in the tray or menu bar if that control is available; click its icon to open the window, or use its **Quit Frame Control** menu item. Closing the window keeps the app in the tray while this option is on. Turn the same menu option off to remove its OS login item. On Linux this creates or removes only Frame Control's own file in `~/.config/autostart/` (or `$XDG_CONFIG_HOME/autostart/`). If the desktop has no working tray, the window opens at login instead. A source checkout does not offer this setting. - **macOS:** open the `.dmg`, drag **Frame Control** to **Applications**, then - open it from Applications. For the unsigned-app warning, use the macOS steps + open it from Applications. For the Gatekeeper warning, use the macOS steps above. Allow local network access and control of Terminal when prompted. - **Windows:** run the installer, then open **Frame Control** from Start. For the portable download, extract the entire `.zip` first and run diff --git a/docs/releasing.md b/docs/releasing.md index b4d4cf3..ebe22ef 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -13,7 +13,9 @@ count. So a build reaches people only when you publish it, after testing it. ``` `.github/workflows/release.yml` builds macOS, Windows and Linux, and - attaches everything to a **draft** release for that tag. Nobody is + attaches everything to a **draft** release for that tag. After all eight + installers upload, the workflow also attaches `SHA256SUMS` built from + GitHub's SHA-256 digests of those uploaded bytes. Nobody is offered a draft. Editing a draft while installers are uploading can make GitHub temporarily show its tag as `untagged-…`. The workflow finds that draft by its exact version title, restores the tag association and retries @@ -32,7 +34,9 @@ count. So a build reaches people only when you publish it, after testing it. ``` 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 + are attached, each with the SHA-256 digest GitHub records. It also checks + that the uploaded `SHA256SUMS` digest matches a fresh list of those eight + installer digests. 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 diff --git a/scripts/publish-release.sh b/scripts/publish-release.sh index 9101c45..8198737 100755 --- a/scripts/publish-release.sh +++ b/scripts/publish-release.sh @@ -70,6 +70,38 @@ print(next((a.get("digest") or "" for a in d["assets"] if a["name"]==n), "absent done [ -z "$missing" ] || { echo "not publishing: fix the assets above" >&2; exit 1; } +# The workflow uploads this only after all eight platform builds complete. +# Regenerate it from GitHub's installer digests and compare its own GitHub +# digest, so a stale or partial checksum file cannot pass the release gate. +python3 "$(dirname "$0")/release-checksums.py" < "$tmp/release.json" > "$tmp/SHA256SUMS" +expected_sum=$(sha256sum "$tmp/SHA256SUMS" | cut -d ' ' -f 1) +checksum_digest=$(python3 -c 'import json,sys +d=json.load(open(sys.argv[1])); matches=[a.get("digest") for a in d["assets"] if a["name"]=="SHA256SUMS"] +print(matches[0] if len(matches)==1 else "absent")' "$tmp/release.json") +[ "$checksum_digest" = "sha256:$expected_sum" ] || { + echo "not publishing: SHA256SUMS is absent or does not match the eight installer digests" >&2; exit 1; +} +echo "ok SHA256SUMS" + +# Every published release carries the same concrete verification instructions. +# Keep the release's own notes, and append this section only once. The tag URL +# is stable even while a draft temporarily has an untagged-* API name. +python3 - "$tmp/release.json" "$(dirname "$0")/release-safety-note.md" "$tag" > "$tmp/release-with-note.json" <<'EOF' +import json, pathlib, sys +d = json.load(open(sys.argv[1])) +body = (d.get("body") or "").rstrip() +if "## Is it safe?" not in body: + note = pathlib.Path(sys.argv[2]).read_text(encoding="utf-8").replace("{tag}", sys.argv[3]).strip() + body = (body + "\n\n" if body else "") + note +d["body"] = body +json.dump(d, sys.stdout) +EOF +mv "$tmp/release-with-note.json" "$tmp/release.json" +python3 - "$tmp/release.json" > "$tmp/release-body.txt" <<'EOF' +import json, sys +sys.stdout.buffer.write(json.load(open(sys.argv[1]))["body"].encode("utf-8")) +EOF + # update.json: what running copies read (app/updater.js), from github.com's # latest/download link rather than the rate-limited REST API. python3 - "$tmp/release.json" "$tag" "$page" "$expected" > "$tmp/update.json" <<'EOF' @@ -87,6 +119,7 @@ print(" ".join(str(a["id"]) for a in d["assets"] if a["name"]=="update.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 include the Is it safe? section in the release body" echo "dry run: would PATCH release $id: tag_name=$tag draft=false prerelease=false make_latest=true" exit 0 fi @@ -98,6 +131,6 @@ gh api -X POST "https://uploads.github.com/repos/$repo/releases/$id/assets?name= -H "Content-Type: application/json" --input "$tmp/update.json" >/dev/null echo "ok update.json" -gh api -X PATCH "repos/$repo/releases/$id" -f tag_name="$tag" -F draft=false -F prerelease=false \ +gh api -X PATCH "repos/$repo/releases/$id" -f tag_name="$tag" -f body="$(cat "$tmp/release-body.txt")" -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/scripts/release-checksums.py b/scripts/release-checksums.py new file mode 100644 index 0000000..512e4d7 --- /dev/null +++ b/scripts/release-checksums.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 +"""Print SHA256SUMS for the eight release installers from GitHub asset metadata. + +GitHub computes each asset digest from its uploaded bytes. The workflow runs +this only after all platform builds finish; publish-release.sh checks that the +uploaded SHA256SUMS bytes still match these digests before publishing. +""" +import argparse +import json +import re +import sys + +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 find_release(lines, tag): + releases = [json.loads(line) for line in lines if line.strip()] + hits = [r for r in releases if r.get("tag_name") == tag] + if not hits: + title = re.compile(r"Frame Control " + re.escape(tag.removeprefix("v")) + r"(: .*)?") + hits = [r for r in releases if r.get("draft") + and str(r.get("tag_name") or "").startswith("untagged-") + and title.fullmatch(r.get("name") or "")] + if len(hits) != 1: + raise ValueError(f"expected one release for {tag}; found {len(hits)}") + return hits[0] + + +def checksums(release): + assets = release.get("assets") or [] + lines = [] + for name in INSTALLERS: + matches = [a for a in assets if a.get("name") == name] + if len(matches) != 1: + raise ValueError(f"expected one asset named {name}; found {len(matches)}") + digest = matches[0].get("digest") or "" + if not re.fullmatch(r"sha256:[0-9a-fA-F]{64}", digest): + raise ValueError(f"missing SHA-256 digest for {name}") + lines.append(f"{digest[7:].lower()} {name}") + return "\n".join(lines) + "\n" + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--tag", help="find exact release in JSON-lines input") + parser.add_argument("--id", action="store_true", help="print the selected release id") + args = parser.parse_args() + data = sys.stdin.read() + release = find_release(data.splitlines(), args.tag) if args.tag else json.loads(data) + if args.id: + print(int(release["id"])) + else: + # Windows text stdout changes LF to CRLF. SHA256SUMS must have the same + # bytes on every runner, including publish-release.sh's verification. + sys.stdout.buffer.write(checksums(release).encode("ascii")) + + +if __name__ == "__main__": + try: + main() + except ValueError as exc: + sys.exit(str(exc)) diff --git a/scripts/release-safety-note.md b/scripts/release-safety-note.md new file mode 100644 index 0000000..c5d34c5 --- /dev/null +++ b/scripts/release-safety-note.md @@ -0,0 +1,11 @@ +## Is it safe? + +Download only from this GitHub release. Check your installer against [SHA256SUMS](https://github.com/saphid/frame-control/releases/download/{tag}/SHA256SUMS). A matching hash proves that your file matches the file in this release. It does not prove that any program is free of malware. + +- Windows PowerShell: `Get-FileHash .\Frame-Control-Setup-x64.exe -Algorithm SHA256` +- macOS Terminal: `shasum -a 256 Frame-Control-mac-arm64.dmg` +- Linux terminal: `sha256sum Frame-Control-linux-x86_64.AppImage` + +Find the same filename in `SHA256SUMS` and compare all 64 hex characters. [Read the source](https://github.com/saphid/frame-control) and [see the build workflow](https://github.com/saphid/frame-control/blob/main/.github/workflows/release.yml). The Windows installer is **not code-signed**; SmartScreen may warn. The macOS app has only an **ad hoc signature**, not an Apple Developer ID signature; the DMG is **unsigned and not notarized**. Gatekeeper may warn or block it. Follow the opening steps in the README only if you trust this download. + +日本語: 公式リリースの SHA256SUMS とファイルの SHA-256 値を照合してください。Windows は未署名、macOS は開発者 ID による署名・公証がありません。 diff --git a/site/public/index.html b/site/public/index.html index 4901d6d..925d0fb 100644 --- a/site/public/index.html +++ b/site/public/index.html @@ -257,6 +257,13 @@
Starting with v0.4.26, download Frame Control and SHA256SUMS from the same GitHub release. Earlier releases do not have this file. A matching SHA-256 means your file matches that release asset. It does not prove the app is free of malware. Compare all 64 characters with the line for your filename.
+Windows PowerShell: Get-FileHash .\Frame-Control-Setup-x64.exe -Algorithm SHA256
macOS Terminal: shasum -a 256 Frame-Control-mac-arm64.dmg
Linux terminal: sha256sum Frame-Control-linux-x86_64.AppImage
The source and build workflow are public. The Windows installer is not code-signed, so SmartScreen may warn. The macOS app has only an ad hoc signature, not an Apple Developer ID signature; the DMG is unsigned and not notarized, so Gatekeeper may warn or block it. Only follow the opening steps below if you trust the download.
+日本語: v0.4.26 以降は公式リリースの SHA256SUMS とファイルの SHA-256 値を照合してください。Windows は未署名、macOS は開発者 ID による署名・公証がありません。
The app isn't notarized, because there's no paid Apple developer account behind it. Only for a download you trust from the releases linked above: drag it to Applications, then clear the download quarantine once in Terminal:
diff --git a/tests/test_publish_release.py b/tests/test_publish_release.py index 07baece..3b75b79 100644 --- a/tests/test_publish_release.py +++ b/tests/test_publish_release.py @@ -7,6 +7,7 @@ Run: python3 -m unittest discover -s tests """ import sandbox # noqa: F401 (first: keeps tests off real data and services) import json +import hashlib import os import subprocess import tempfile @@ -26,6 +27,9 @@ def draft(tag_name="untagged-c6ddfed7f75d67db2e99", name="Frame Control 9.8.7", if assets is None: assets = [{"id": 100 + i, "name": n, "size": 1000 + i, "digest": "sha256:" + "%064x" % i} for i, n in enumerate(INSTALLERS)] + body = "".join(f"{i:064x} {n}\n" for i, n in enumerate(INSTALLERS)).encode() + assets.append({"id": 200, "name": "SHA256SUMS", "size": len(body), + "digest": "sha256:" + hashlib.sha256(body).hexdigest()}) 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} @@ -79,6 +83,16 @@ class PublishRelease(unittest.TestCase): self.assertIsNone(upload) self.assertFalse(any(c[1:3] == ["-X", "PATCH"] for c in log)) + def test_refuses_missing_or_stale_checksums(self): + for assets in (draft()["assets"][:-1], + draft()["assets"][:-1] + [{"id": 200, "name": "SHA256SUMS", + "digest": "sha256:" + "0" * 64}]): + p, log, upload = self.run_script([draft(assets=assets)], "v9.8.7") + self.assertNotEqual(p.returncode, 0) + self.assertIn("SHA256SUMS is absent or does not match", p.stderr) + self.assertIsNone(upload) + self.assertFalse(any("PATCH" in c 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) diff --git a/tests/test_release_checksums.py b/tests/test_release_checksums.py new file mode 100644 index 0000000..74f56b8 --- /dev/null +++ b/tests/test_release_checksums.py @@ -0,0 +1,38 @@ +"""Checksum generator uses only complete, unambiguous GitHub asset metadata.""" +import sandbox # noqa: F401 +import hashlib +import importlib.util +import unittest +from pathlib import Path + +SCRIPT = Path(__file__).resolve().parent.parent / "scripts" / "release-checksums.py" +spec = importlib.util.spec_from_file_location("release_checksums", SCRIPT) +module = importlib.util.module_from_spec(spec) +spec.loader.exec_module(module) + + +class Checksums(unittest.TestCase): + def test_complete_list_matches_bytes(self): + assets = [] + for i, name in enumerate(module.INSTALLERS): + assets.append({"name": name, "digest": "sha256:" + hashlib.sha256(str(i).encode()).hexdigest()}) + actual = module.checksums({"assets": assets}) + self.assertEqual(len(actual.splitlines()), 8) + self.assertEqual(actual.splitlines()[0], hashlib.sha256(b"0").hexdigest() + " " + module.INSTALLERS[0]) + + def test_missing_duplicate_and_bad_digest_fail(self): + assets = [{"name": name, "digest": "sha256:" + "0" * 64} for name in module.INSTALLERS] + for changed in (assets[:-1], assets + [assets[0]], + [dict(assets[0], digest="sha256:bad"), *assets[1:]]): + with self.assertRaises(ValueError): + module.checksums({"assets": changed}) + + def test_tag_or_exact_untagged_draft(self): + lines = ['{"id": 1, "tag_name": "untagged-a", "draft": true, "name": "Frame Control 9.8.7"}', + '{"id": 2, "tag_name": "v9.8.8", "draft": false, "name": "Frame Control 9.8.8"}'] + self.assertEqual(module.find_release(lines, "v9.8.7")["id"], 1) + self.assertEqual(module.find_release(lines, "v9.8.8")["id"], 2) + + +if __name__ == "__main__": + unittest.main()