diff --git a/.github/scripts/android-next-version.sh b/.github/scripts/android-next-version.sh new file mode 100755 index 000000000..f0296e631 --- /dev/null +++ b/.github/scripts/android-next-version.sh @@ -0,0 +1,93 @@ +#!/usr/bin/env bash +# Compute the next Android semver from a previous version and a bump kind. +# +# android-next-version.sh +# +# may carry an `android-v` / `v` prefix and may be the +# legacy two-part `x.y` form used by tags up to android-v0.149 — it is +# normalised to `x.y.0` before bumping, so the version sequence stays +# continuous across the switch to full semver. Prints `x.y.z` on stdout. +# +# Used by .github/workflows/android.yml; kept in its own file so the version +# maths can be exercised locally (see the self-test at the bottom: +# `android-next-version.sh --self-test`). +set -euo pipefail + +normalise() { + local v="${1#android-v}" + v="${v#v}" + if [[ "$v" =~ ^[0-9]+$ ]]; then + v="$v.0.0" + elif [[ "$v" =~ ^[0-9]+\.[0-9]+$ ]]; then + v="$v.0" + fi + if ! [[ "$v" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "android-next-version: '$1' is not a version (x, x.y or x.y.z)" >&2 + return 1 + fi + echo "$v" +} + +bump() { + local prev bump ma mi pa + # Explicit `|| return` — `set -e` is suspended inside an `if`/`&&` caller, + # so a failed normalise would otherwise fall through with an empty $prev. + prev=$(normalise "$1") || return 1 + bump="$2" + IFS=. read -r ma mi pa <<< "$prev" + case "$bump" in + major) echo "$((ma + 1)).0.0" ;; + minor) echo "${ma}.$((mi + 1)).0" ;; + patch) echo "${ma}.${mi}.$((pa + 1))" ;; + *) + echo "android-next-version: unexpected bump '$bump' (want major|minor|patch)" >&2 + return 1 ;; + esac +} + +self_test() { + local fails=0 + check() { + local got + if got=$(bump "$1" "$2" 2>/dev/null) && [[ "$got" == "$3" ]]; then + echo "ok $1 + $2 -> $got" + else + echo "FAIL $1 + $2 -> '${got:-}' (want $3)"; fails=$((fails + 1)) + fi + } + check_fails() { + if bump "$1" "$2" >/dev/null 2>&1; then + echo "FAIL $1 + $2 should have been rejected"; fails=$((fails + 1)) + else + echo "ok $1 + $2 rejected" + fi + } + # legacy two-part production tags normalise to x.y.0 first + check android-v0.149 patch 0.149.1 + check android-v0.149 minor 0.150.0 + check android-v0.149 major 1.0.0 + check 0.149 patch 0.149.1 + # full semver + check v1.2.3 patch 1.2.4 + check 1.2.3 minor 1.3.0 + check 1.2.3 major 2.0.0 + check 1.2.9 patch 1.2.10 + # one-part + check 2 patch 2.0.1 + # garbage in + check_fails 1.2.3.4 patch + check_fails abc patch + check_fails 1.2.3 huge + check_fails "" patch + if (( fails )); then echo "$fails self-test failure(s)"; return 1; fi + echo "all self-tests passed" +} + +if [[ "${1:-}" == "--self-test" ]]; then + self_test +elif [[ $# -eq 2 ]]; then + bump "$1" "$2" +else + echo "usage: $0 | --self-test" >&2 + exit 2 +fi diff --git a/.github/scripts/publish_listings.py b/.github/scripts/publish_listings.py new file mode 100644 index 000000000..4cf84325c --- /dev/null +++ b/.github/scripts/publish_listings.py @@ -0,0 +1,616 @@ +#!/usr/bin/env python3 +"""Publish the localized Play Store listings in fastlane/metadata/android to Google Play. + +The repo is the source of truth for listing *text* (title, short description, +full description) in every language the app ships. Graphics (icon, feature +graphic, screenshots) are NOT touched — Play falls back to the default +language's graphics for any locale that has none of its own, and screenshots +are still uploaded by hand (see docs/store-listings.md). + +Release notes are deliberately out of scope: android.yml already writes +distribution/whatsnew/whatsnew-en-US and hands it to the upload action. + +Modes: + --dry-run Print a unified diff of what would change against the + live listings; send nothing. Reads only, so it does NOT + prove the account may edit listings — see below. + --pull Overwrite the local tree with what is live on Play + (bootstrap / resync after someone edits in the Console). + --check-permissions Write one listing (PATCH of one Play already has, or + PUT when Play has none yet) inside an edit that is then + abandoned, to prove the service account holds the + "Manage store presence" permission. Nothing is committed. + --annotate-verdict N Print the GitHub Actions annotation for probe exit code + N (see probe_annotation) and exit 0. The workflow relays + the probe's verdict through this so the mapping is + unit-tested here instead of living in bash. Cannot be + combined with a mode: it formats a verdict, it never + produces one. + (default) Push every locale whose text differs from live, then + commit the edit so it goes to Play for review. + +Env: + PLAY_SERVICE_ACCOUNT_JSON service account JSON (contents, not a path) + PACKAGE_NAME defaults to radio.ks3ckc.ft8af +""" +import argparse +import difflib +import json +import os +import sys +from pathlib import Path + +API = "https://androidpublisher.googleapis.com/androidpublisher/v3" + +DEFAULT_PACKAGE = "radio.ks3ckc.ft8af" +DEFAULT_ROOT = Path(__file__).resolve().parents[2] / "fastlane" / "metadata" / "android" + +# Play Console limits. Exceeding any of these is rejected by the API, so we +# check locally first and name the offending file rather than surfacing a 400. +LIMITS = {"title": 30, "shortDescription": 80, "fullDescription": 4000} + +# Exit codes. The probe's result is a verdict, not pass/fail, so it needs codes +# that nothing else can produce: annotating an unrelated failure as "you are +# missing the grant" sends someone to fix a permission that was never wrong. +# +# 2 is deliberately skipped — argparse exits 2 on a usage error, so a mistyped +# flag would otherwise be indistinguishable from a probe verdict. +EXIT_OK = 0 +EXIT_ERROR = 1 # generic failure: unpublishable metadata, bad credentials, API error +EXIT_DENIED = 3 # --check-permissions: Play refused the listing write with 403 +EXIT_INCONCLUSIVE = 4 # --check-permissions: the probe never reached a verdict + +# Where the missing grant is set, quoted in every message that names it. +GRANT_ADVICE = ( + "Play Console -> Users and permissions -> App permissions -> Store presence " + '-> "Manage store presence"' +) + + +def probe_annotation(rc): + """(level, message) the workflow annotates a --check-permissions run with. + + Lives here rather than in the workflow's bash so the mapping is tested + against the EXIT_* constants it depends on: swapping the 3/4 arms, or + reporting a generic 1 as a missing grant, would send an operator to change + a Console permission that was never wrong, and a bash `case` cannot be + unit-tested. Three outcomes: 0 and 3 are verdicts (the account may / may + not edit listings), 4 means the probe ran but reached no verdict, and + anything else (1, argparse's 2, ...) means the probe never ran — which is + deliberately NOT annotated as anything about the grant. + """ + if rc == EXIT_OK: + return "notice", "The service account can edit listings." + if rc == EXIT_DENIED: + return "error", "The service account cannot edit listings. Grant it %s." % GRANT_ADVICE + if rc == EXIT_INCONCLUSIVE: + return ( + "warning", + "The probe reached no verdict — the API call failed for a reason that says " + "nothing about the grant (401: the token was not accepted, a credentials " + "problem; or a timeout, rate limit, Play 5xx). Fix the cause and run it again.", + ) + return ( + "error", + "The probe did not run (exit %d) — see the log above. This is not a verdict " + "about the grant." % rc, + ) + + +def format_annotation(level, message): + """A GitHub Actions workflow command: `::level::message` on its own line.""" + return "::%s::%s" % (level, message) + + +# Listing field <-> fastlane filename. +FIELD_FILES = { + "title": "title.txt", + "shortDescription": "short_description.txt", + "fullDescription": "full_description.txt", +} + + +class MetadataError(Exception): + """A locale directory is missing a file or a field is over the Play limit.""" + + +class CredentialsError(Exception): + """PLAY_SERVICE_ACCOUNT_JSON is missing or is not the service account JSON.""" + + +def read_locale(locale_dir): + """Read one locale directory into a listing dict. Raises MetadataError if incomplete. + + Trailing newlines are stripped: the files end with one for POSIX tidiness, + but Play stores the text verbatim and a trailing blank line shows up in the + rendered listing. + """ + listing = {} + for field, fname in FIELD_FILES.items(): + path = locale_dir / fname + if not path.is_file(): + raise MetadataError("%s: missing %s" % (locale_dir.name, fname)) + text = path.read_text(encoding="utf-8").rstrip("\n") + if not text.strip(): + raise MetadataError("%s: %s is empty" % (locale_dir.name, fname)) + listing[field] = text + return listing + + +def validate(locale, listing): + """Return a list of human-readable limit violations for one listing.""" + errors = [] + for field, limit in LIMITS.items(): + n = len(listing[field]) + if n > limit: + errors.append( + "%s: %s is %d characters, limit is %d (over by %d)" + % (locale, FIELD_FILES[field], n, limit, n - limit) + ) + return errors + + +def locale_dirs(root): + """Return the locale directories under root, sorted. Dot-directories are skipped.""" + root = Path(root) + if not root.is_dir(): + return [] + return sorted(d for d in root.iterdir() if d.is_dir() and not d.name.startswith(".")) + + +def load_metadata(root): + """Load every locale directory under root. Raises MetadataError on any problem.""" + root = Path(root) + if not root.is_dir(): + raise MetadataError("metadata root does not exist: %s" % root) + locales = locale_dirs(root) + if not locales: + raise MetadataError("no locale directories under %s" % root) + out = {} + errors = [] + for d in locales: + listing = read_locale(d) + errors.extend(validate(d.name, listing)) + out[d.name] = listing + if errors: + raise MetadataError("\n".join(errors)) + return out + + +def diff_listing(local, remote): + """Return {field: (old, new)} for fields that differ. remote may be None (new locale).""" + changed = {} + for field in FIELD_FILES: + old = (remote or {}).get(field) or "" + new = local[field] + if old != new: + changed[field] = (old, new) + return changed + + +def summarize(field, old, new): + """One-line, terminal-safe description of a field change.""" + if not old: + return "%s: (unset) -> %d chars" % (field, len(new)) + return "%s: %d chars -> %d chars" % (field, len(old), len(new)) + + +def diff_text(locale, field, old, new, context=1): + """Unified diff of one field, Play's copy against the repo's. + + Character counts alone hide a same-length edit — a fixed typo or a swapped + word reads as "3303 chars -> 3303 chars" — so a dry run, whose whole job is + to show what a real run would send, prints this instead. + """ + lines = difflib.unified_diff( + old.splitlines(), + new.splitlines(), + fromfile="play:%s/%s" % (locale, FIELD_FILES[field]), + tofile="repo:%s/%s" % (locale, FIELD_FILES[field]), + lineterm="", + n=context, + ) + return "\n".join(lines) + + +# --- Play API --------------------------------------------------------------- + + +def service_account_info(env=None): + """Parse PLAY_SERVICE_ACCOUNT_JSON into a dict, or explain what is wrong with it. + + Kept separate from play_session() so the failure is a one-line message rather + than a KeyError traceback, and so it can be tested without google-auth. + """ + env = os.environ if env is None else env + raw = env.get("PLAY_SERVICE_ACCOUNT_JSON", "").strip() + if not raw: + raise CredentialsError( + "PLAY_SERVICE_ACCOUNT_JSON is not set. Export the service account JSON " + "itself (not a path to it):\n" + ' export PLAY_SERVICE_ACCOUNT_JSON="$(cat service-account.json)"' + ) + try: + info = json.loads(raw) + except ValueError as e: + raise CredentialsError( + "PLAY_SERVICE_ACCOUNT_JSON is not valid JSON (%s). It must hold the " + "file's contents, not its path." % e + ) + if not isinstance(info, dict) or "client_email" not in info: + raise CredentialsError( + "PLAY_SERVICE_ACCOUNT_JSON parsed but has no client_email — that is not " + "a Google service account key." + ) + return info + + +def build_credentials(info, factory): + """Build Play credentials from `info`, turning a bad key into a CredentialsError. + + service_account_info() only proves the JSON parses and names a service + account. google-auth is what discovers the rest — a missing private_key or + token_uri, a corrupted PEM — and it signals that with ValueError + (MalformedError subclasses it). main() handles CredentialsError, so without + this conversion a bad key file escapes as a traceback. + + `factory` is passed in so this is testable without google-auth installed. + """ + try: + return factory(info, scopes=["https://www.googleapis.com/auth/androidpublisher"]) + except ValueError as e: + raise CredentialsError( + "PLAY_SERVICE_ACCOUNT_JSON parsed but is not a usable service account " + "key (%s). Re-download the key from the Google Cloud console." % e + ) + + +def play_session(): + import requests + from google.auth.transport.requests import Request as GAuthRequest + from google.oauth2 import service_account + + creds = build_credentials( + service_account_info(), service_account.Credentials.from_service_account_info + ) + creds.refresh(GAuthRequest()) + s = requests.Session() + s.headers["Authorization"] = "Bearer %s" % creds.token + return s + + +def fetch_listings(s, package, edit_id): + """Return {language: listing} for what is currently live.""" + r = s.get("%s/applications/%s/edits/%s/listings" % (API, package, edit_id), timeout=30) + r.raise_for_status() + return {li["language"]: li for li in r.json().get("listings", [])} + + +def abandon_edit(s, package, edit_id): + """Delete an uncommitted edit. Returns True if Play accepted the delete. + + Deliberately does not raise, for either an error status or a transport + failure: this runs in a finally block, so anything escaping here would + replace whatever the caller was already failing with (a commit error, say) + with a cleanup error. A failed delete is not fatal either — Play expires + abandoned edits on its own — but it is worth saying out loud, because until + it expires it is the app's one open edit. + """ + trouble = None + try: + r = s.delete("%s/applications/%s/edits/%s" % (API, package, edit_id), timeout=30) + except Exception as e: + # Broad on purpose. A timeout or dropped connection here is exactly the + # case where the original failure matters most, and requests is imported + # lazily so its exception types are not in scope to name. + trouble = "%s: %s" % (type(e).__name__, e) + else: + if r.ok: + return True + trouble = "HTTP %s" % r.status_code + + print( + "Warning: could not abandon edit %s (%s). It will expire on its own, but " + "until then a release publish may fail with \"This edit has expired\"." + % (edit_id, trouble), + file=sys.stderr, + ) + return False + + +def patch_listing(s, package, edit_id, locale, listing): + """PATCH an EXISTING listing, so a promo video already on it is left alone. + + Only valid for a language Play already has: PATCH is an update, and the API + answers 404 for a language with no listing yet. Use put_listing to create. + """ + r = s.patch( + "%s/applications/%s/edits/%s/listings/%s" % (API, package, edit_id, locale), + json=listing, + timeout=30, + ) + r.raise_for_status() + return r.json() + + +def put_listing(s, package, edit_id, locale, listing): + """PUT one listing, creating it if the language has none yet. + + The body carries `language` because PUT replaces the whole resource. Nothing + is lost by replacing here: this is only used for languages Play has never + had a listing for, so there is no video or other field to preserve. + """ + body = dict(listing, language=locale) + r = s.put( + "%s/applications/%s/edits/%s/listings/%s" % (API, package, edit_id, locale), + json=body, + timeout=30, + ) + r.raise_for_status() + return r.json() + + +def upsert_listing(s, package, edit_id, locale, listing, exists): + """Create or update one listing, whichever the language needs.""" + if exists: + return patch_listing(s, package, edit_id, locale, listing) + return put_listing(s, package, edit_id, locale, listing) + + +# --- Modes ------------------------------------------------------------------ + + +def run_pull(s, package, edit_id, root): + remote = fetch_listings(s, package, edit_id) + if not remote: + print("No listings live on Play — nothing to pull.") + return 0 + for locale, li in sorted(remote.items()): + d = Path(root) / locale + d.mkdir(parents=True, exist_ok=True) + for field, fname in FIELD_FILES.items(): + with open(d / fname, "w", encoding="utf-8", newline="\n") as fh: + fh.write((li.get(field) or "").rstrip("\n") + "\n") + print("pulled %s" % locale) + + # Locales in the repo that Play has never seen are the normal state for a + # language whose listing has not been published yet — deleting them here + # would throw away exactly the work --pull is meant to protect. Name them so + # the operator can tell "not published yet" from "retired upstream"; a + # genuinely retired language is removed by hand. + local_only = [d.name for d in locale_dirs(root) if d.name not in remote] + if local_only: + print( + "\nNote: %d locale(s) exist here but not on Play, and were left alone: %s\n" + " They are unpublished until the next push. Delete a directory by hand " + "only if that language is being retired." % (len(local_only), ", ".join(local_only)) + ) + return 0 + + +def run_check(s, package, edit_id, local): + """Prove the service account may actually edit listings, without publishing. + + A dry run cannot answer this: it only reads. An account with release + permission but not "Manage store presence" passes a dry + run and then fails on the first real publish. So do the one thing that + exercises the grant — writing a single listing (listings.patch on a language + Play already has, listings.put when it has none yet) — inside an edit the + caller abandons instead of committing. Nothing reaches the store. + + The probe writes back the text Play already has wherever possible, so even a + committed edit (which cannot happen here) would be a no-op. + + Returns EXIT_OK if the account may edit listings, EXIT_DENIED if Play + refused with 403 (an authenticated account that lacks the grant), and + EXIT_INCONCLUSIVE if anything else went wrong. A 401 is inconclusive too: + it means the access token was not accepted at all, which is a credentials + problem and says nothing about the grant. Likewise a timeout or a 5xx proves + nothing either way and must not be reported as a missing grant. + """ + try: + remote = fetch_listings(s, package, edit_id) + except Exception as e: + print( + "INCONCLUSIVE: could not read the current listings (%s).\n\nThe probe " + "never got as far as testing the grant. Try again." % e, + file=sys.stderr, + ) + return EXIT_INCONCLUSIVE + if remote: + locale = "en-US" if "en-US" in remote else sorted(remote)[0] + probe = {f: remote[locale].get(f) or "" for f in FIELD_FILES} + note = "writing back its current text" + else: + locale = "en-US" if "en-US" in local else sorted(local)[0] + probe = local[locale] + note = "no listings live yet, so using the repo's text" + + verb = "listings.patch" if locale in remote else "listings.put" + print("Probing %s on %s (%s)..." % (verb, locale, note)) + try: + upsert_listing(s, package, edit_id, locale, probe, locale in remote) + except Exception as e: + status = getattr(getattr(e, "response", None), "status_code", None) + if status == 403: + print( + "DENIED (HTTP 403): %s\n\nThe service account cannot edit listings. " + 'Grant it Play Console -> Users and permissions -> App permissions ' + '-> Store presence -> "Manage store presence".' % e, + file=sys.stderr, + ) + return EXIT_DENIED + if status == 401: + # Only a 403 is a verdict about the grant: it means Play knew who was + # asking and said no. A 401 means the token itself was not accepted + # (revoked key, wrong project, clock skew) — the probe never got as + # far as the permission check, so sending someone to Console to fix + # a grant would be the same false diagnosis this split exists to + # prevent. + print( + "INCONCLUSIVE (HTTP 401): %s\n\nPlay did not accept the access " + "token, so the probe never reached the permission check. This is " + "a credentials problem, not evidence about the grant: check the " + "service-account key and run it again." % e, + file=sys.stderr, + ) + return EXIT_INCONCLUSIVE + # A timeout, a rate limit, or a Play 5xx says nothing about the grant. + # Calling those "permission denied" would send someone editing Console + # permissions that were fine all along. + print( + "INCONCLUSIVE%s: %s\n\nThe probe did not reach a verdict — this is an " + "API failure, not evidence about the grant. Try again." + % ("" if status is None else " (HTTP %s)" % status, e), + file=sys.stderr, + ) + return EXIT_INCONCLUSIVE + print("OK — the service account can edit listings. The edit is discarded, not committed.") + return EXIT_OK + + +def run_push(s, package, edit_id, local, dry_run): + remote = fetch_listings(s, package, edit_id) + pending = {} + for locale in sorted(local): + changed = diff_listing(local[locale], remote.get(locale)) + if not changed: + print("%-8s unchanged" % locale) + continue + pending[locale] = local[locale] + for field, (old, new) in sorted(changed.items()): + print("%-8s %s" % (locale, summarize(field, old, new))) + if dry_run: + body = diff_text(locale, field, old, new) + if body: + print("\n".join(" " + ln for ln in body.splitlines())) + + extra = sorted(set(remote) - set(local)) + if extra: + print( + "\nNote: %d locale(s) live on Play have no directory in the repo and are " + "left untouched: %s" % (len(extra), ", ".join(extra)) + ) + + if not pending: + print("\nAll %d locale(s) already match Play. Nothing to do." % len(local)) + return 0 + if dry_run: + print("\nDRY RUN — %d locale(s) would be updated. Nothing was sent." % len(pending)) + return 0 + + for locale, listing in sorted(pending.items()): + existed = locale in remote + upsert_listing(s, package, edit_id, locale, listing, existed) + print("pushed %s%s" % (locale, "" if existed else " (created)")) + return len(pending) + + +def build_arg_parser(): + """Build the CLI parser. + + Separate from main() so the tests can introspect the real option strings and + check them against the modes play-listings.yml offers — renaming a flag here + without updating the workflow would otherwise only surface as a failed + manual run. + """ + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--dry-run", action="store_true", help="show the diff, change nothing") + ap.add_argument("--pull", action="store_true", help="overwrite the local tree from Play") + ap.add_argument( + "--check-permissions", + action="store_true", + help="verify the service account may edit listings, without publishing", + ) + ap.add_argument("--root", default=str(DEFAULT_ROOT), help="metadata root directory") + ap.add_argument("--package", default=os.environ.get("PACKAGE_NAME", DEFAULT_PACKAGE)) + ap.add_argument( + "--annotate-verdict", + type=int, + metavar="RC", + help="print the GitHub Actions annotation for probe exit code RC and exit 0", + ) + return ap + + +def main(argv=None): + ap = build_arg_parser() + args = ap.parse_args(argv) + + chosen = [ + name + for name, on in ( + ("--dry-run", args.dry_run), + ("--pull", args.pull), + ("--check-permissions", args.check_permissions), + # Listed with the modes so `--check-permissions --annotate-verdict 3` + # is rejected: the helper only formats a verdict, and letting it + # ride along with a mode would print a caller-supplied verdict for + # a probe that never ran. + ("--annotate-verdict", args.annotate_verdict is not None), + ) + if on + ] + if len(chosen) > 1: + ap.error("%s are mutually exclusive" % " and ".join(chosen)) + + if args.annotate_verdict is not None: + # Pure formatting for the workflow: no metadata, no credentials, no + # Play. The workflow exits with the probe's own code afterwards. + print(format_annotation(*probe_annotation(args.annotate_verdict))) + return EXIT_OK + + local = None + if not args.pull: + try: + local = load_metadata(args.root) + except MetadataError as e: + print("Metadata is not publishable:\n%s" % e, file=sys.stderr) + return EXIT_ERROR + print("Loaded %d locale(s) from %s\n" % (len(local), args.root)) + + try: + s = play_session() + except CredentialsError as e: + print("%s" % e, file=sys.stderr) + return EXIT_ERROR + + try: + edit = s.post("%s/applications/%s/edits" % (API, args.package), timeout=30) + edit.raise_for_status() + edit_id = edit.json()["id"] + except Exception as e: + # In probe mode this must not surface as a traceback: Python would exit + # 1, which the workflow would read as a denied verdict. + if not args.check_permissions: + raise + print( + "INCONCLUSIVE: could not open an edit (%s).\n\nThe probe never got as " + "far as testing the grant. Try again." % e, + file=sys.stderr, + ) + return EXIT_INCONCLUSIVE + + try: + if args.pull: + return run_pull(s, args.package, edit_id, args.root) + if args.check_permissions: + return run_check(s, args.package, edit_id, local) + pushed = run_push(s, args.package, edit_id, local, args.dry_run) + if pushed and not args.dry_run: + c = s.post( + "%s/applications/%s/edits/%s:commit" % (API, args.package, edit_id), timeout=60 + ) + c.raise_for_status() + print("\nCommitted edit %s — %d locale(s) sent to Play." % (edit_id, pushed)) + edit_id = None # committed; do not delete + return EXIT_OK + finally: + # A --dry-run / --pull / no-op edit is abandoned so it does not linger as + # the app's one open edit and block the release publish in android.yml. + if edit_id is not None: + abandon_edit(s, args.package, edit_id) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/scripts/release_notes.py b/.github/scripts/release_notes.py new file mode 100644 index 000000000..c7a656cf4 --- /dev/null +++ b/.github/scripts/release_notes.py @@ -0,0 +1,264 @@ +#!/usr/bin/env python3 +"""Release-notes helpers for android.yml: `restore` and `ensure-notes`. + +`ensure-notes` runs in "Write release notes" for the lanes that produce notes +(staging and main). The producers can yield a blank file — Claude's structured +output permits an empty `notes` string, and `jq -r` writes that as a bare +newline — and a release whose body carries blank markers would later be +refused by the manual production ship (`restore`, below). So before the notes +go into the markers, a blank file is replaced with the PR titles (whole lines, +up to Play's 500-character limit) or, failing that, "Bug fixes and +improvements." — the same fallback the AI step used when the API was +unreachable, now applied in one place for every producer. + +`restore` restores the release notes of the GitHub Release an android-v* tag +already has. Used by the `tag` lane. The manual production ship re-runs the +workflow on an android-v tag whose GitHub Release the main-merge run +already created, carrying the promoted staging notes in its body between the +hidden markers written by "Write release notes": + + + ...notes... + + +The workflow step runs `gh release view` and hands this script the result; the +decision — existing release / missing release / lookup failure, manual dispatch +versus tag push, and the marker parsing — lives here so test_release_notes.py +can cover every branch instead of a bash step nobody can run in CI. + +Outcomes (`found=...` is appended to --github-output): + found=true the release exists: --body-out gets its body verbatim (for the + release step to hand back to softprops unchanged) and --notes-out + gets the text between the markers (for Play's what's-new). + found=false a plain tag push found no release: nothing is written, the + workflow falls back to the version string as before. + exit 1 the run must stop, with a ::error:: line saying why. Always on a + workflow_dispatch that cannot read its release or finds no notes + in it (the manual ship is documented to send the promoted notes, + and carrying on would overwrite the release body with a + placeholder); on a tag push for any lookup failure other than a + genuine "release not found". +""" +import argparse +import os +import re +import sys + +START = "" +END = "" + +# Play's what's-new limit, in BYTES: the workflow truncates notes.txt with +# `head -c 500` before upload, and the API limit is on the encoded text. A +# character budget would let multibyte titles (Cyrillic, CJK, emoji) pass here +# and be cut mid-codepoint downstream, sending Play invalid UTF-8. +PLAY_NOTES_LIMIT = 500 +DEFAULT_NOTES = "Bug fixes and improvements." +# What the AI step's "Collect changes" writes to its prs output when the range +# had no PR merges (android.yml: `${PRS:-(no pull-request merges in range)}`). +# A placeholder for the log, not a release note; must never reach Play. +NO_PRS_SENTINEL = "(no pull-request merges in range)" + + +def utf8_len(text): + return len(text.encode("utf-8")) + + +def fallback_notes(pr_list): + """Release notes from the PR list the AI step collects ("- #123 scope: title"). + + Whole lines only, stopping before the total would exceed PLAY_NOTES_LIMIT + bytes of UTF-8 (each line counts its newline), so the downstream `head -c` + never lands inside a multibyte character. Empty input yields DEFAULT_NOTES. + """ + out = [] + n = 0 + for line in (pr_list or "").splitlines(): + line = re.sub(r"^- #[0-9]+ [^:]*: ?", "", line) + line = re.sub(r"^- #[0-9]+ ", "", line) + if not line.strip() or line.strip() == NO_PRS_SENTINEL: + continue + if n + utf8_len(line) + 1 > PLAY_NOTES_LIMIT: + break + out.append(line) + n += utf8_len(line) + 1 + return "\n".join(out) + "\n" if out else DEFAULT_NOTES + "\n" + + +def cap_notes(notes): + """Fit any producer's notes into PLAY_NOTES_LIMIT bytes without splitting a + character: cut at the last line break inside the budget, else at the last + whole codepoint. Claude is asked for <= 400 characters, which multibyte text + can push past 500 bytes; the workflow's `head -c 500` would then cut + mid-codepoint, so the cap is applied here first and that head is a no-op. + """ + data = notes.encode("utf-8") + if len(data) <= PLAY_NOTES_LIMIT: + return notes + cut = data.rfind(b"\n", 0, PLAY_NOTES_LIMIT) + if cut > 0: + return data[:cut].decode("utf-8") + "\n" + return data[:PLAY_NOTES_LIMIT].decode("utf-8", errors="ignore") + + +def ensure_notes(notes, pr_list): + """(notes, used_fallback): nonblank notes come back unchanged apart from the + byte cap; blank ones become the fallback.""" + if notes is not None and notes.strip(): + return cap_notes(notes), False + return fallback_notes(pr_list), True + + +class MalformedMarkers(Exception): + """The body does not carry exactly one ordered start/end marker pair.""" + + +def extract_notes(body): + """Text between the markers, line-wise, as the awk extraction produced it. + + Strict about structure: exactly one start marker, exactly one end marker, + start before end. Anything else raises MalformedMarkers — with the end + marker first, a lenient scan would capture everything after the start + marker and ship trailing release text to Play as the what's-new. + """ + starts = body.count(START) + ends = body.count(END) + if starts == 0 and ends == 0: + raise MalformedMarkers("no ft8af-notes markers") + if starts != 1 or ends != 1: + raise MalformedMarkers( + "expected one start and one end marker, found %d and %d" % (starts, ends) + ) + s = body.index(START) + e = body.index(END) + if e < s: + raise MalformedMarkers("end marker comes before the start marker") + inner = body[s + len(START):e] + # The markers sit on their own lines; drop the line break that follows the + # start marker so the notes begin at their first line, like awk's did. + if inner.startswith("\n"): + inner = inner[1:] + return inner + + +class Outcome: + def __init__(self, found=None, error=None, notes="", body="", message=""): + self.found = found # True / False, or None when error is set + self.error = error # the ::error:: text, or None + self.notes = notes + self.body = body + self.message = message # informational stdout line + + +def decide(event, tag, gh_status, gh_stderr, body): + """The whole decision, pure so it can be tested without gh or a runner.""" + dispatch = event == "workflow_dispatch" + gh_stderr = (gh_stderr or "").strip() + if gh_status != 0: + if dispatch: + return Outcome(error=( + "Could not read the GitHub Release for %s (%s). A manual production " + "ship needs the release the main merge created — check the tag and " + "rerun." % (tag, gh_stderr))) + if "not found" not in gh_stderr.lower(): + # A tag push with a release we merely failed to read is the same + # overwrite risk; only a genuine "no release" may fall through. + return Outcome(error=( + "Could not read the GitHub Release for %s (%s); refusing to continue " + "and risk overwriting it." % (tag, gh_stderr))) + return Outcome(found=False, message=( + "No existing release for %s (plain tag push) — Play's what's-new falls " + "back to the version string." % tag)) + + body = (body or "").replace("\r", "") + try: + notes = extract_notes(body) + except MalformedMarkers as m: + if dispatch: + return Outcome(error=( + "The GitHub Release for %s has no usable release notes (%s), so there " + "is nothing to send to Play as the what's-new. Put the notes in the " + "release body between %s and %s and rerun." % (tag, m, START, END))) + # A tag push keeps the body but ships without notes, as it always did. + notes = "" + if dispatch and not notes.strip(): + return Outcome(error=( + "The GitHub Release for %s has no release notes between the ft8af-notes " + "markers, so there is nothing to send to Play as the what's-new. Add " + "them to the release body and rerun." % tag)) + return Outcome(found=True, notes=notes, body=body, message=( + "Release %s already exists; keeping its body." % tag)) + + +def build_arg_parser(): + ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0]) + sub = ap.add_subparsers(dest="command", required=True) + + r = sub.add_parser("restore", help="restore the notes of the release a tag already has") + r.add_argument("--event", required=True, help="$GITHUB_EVENT_NAME") + r.add_argument("--tag", required=True) + r.add_argument("--gh-status", type=int, required=True, help="exit code of gh release view") + r.add_argument("--gh-stderr", required=True, help="file holding gh's stderr") + r.add_argument("--body", required=True, help="file holding gh's stdout (the body)") + r.add_argument("--notes-out", required=True) + r.add_argument("--body-out", required=True) + r.add_argument("--github-output", help="$GITHUB_OUTPUT; found=true|false is appended") + + e = sub.add_parser("ensure-notes", help="replace a blank notes file with the fallback text") + e.add_argument("--notes", required=True, help="notes file; created or rewritten when blank") + e.add_argument("--pr-list-env", default="PR_LIST", + help="env var holding the AI step's PR list (default PR_LIST)") + return ap + + +def run_ensure_notes(args): + notes = None + if os.path.exists(args.notes): + with open(args.notes, encoding="utf-8", errors="replace") as f: + notes = f.read() + ensured, used_fallback = ensure_notes(notes, os.environ.get(args.pr_list_env, "")) + if used_fallback or ensured != notes: + with open(args.notes, "w", encoding="utf-8", newline="\n") as f: + f.write(ensured) + if used_fallback: + print("::warning title=Release notes fell back::The notes producer left notes.txt " + "blank — using the fallback text (the PR titles, or the default line when " + "there were none) so the release stays shippable.") + print("Notes:") + print(ensured) + elif ensured != notes: + print("::warning title=Release notes trimmed::notes.txt exceeded Play's %d-byte " + "what's-new limit and was cut at a line boundary." % PLAY_NOTES_LIMIT) + return 0 + + +def main(argv=None): + args = build_arg_parser().parse_args(argv) + if args.command == "ensure-notes": + return run_ensure_notes(args) + + with open(args.gh_stderr, encoding="utf-8", errors="replace") as f: + gh_stderr = f.read() + with open(args.body, encoding="utf-8", errors="replace") as f: + body = f.read() + + out = decide(args.event, args.tag, args.gh_status, gh_stderr, body) + if out.error: + print("::error::" + out.error) + return 1 + if out.found: + with open(args.body_out, "w", encoding="utf-8", newline="\n") as f: + f.write(out.body if out.body.endswith("\n") else out.body + "\n") + with open(args.notes_out, "w", encoding="utf-8", newline="\n") as f: + f.write(cap_notes(out.notes)) + if args.github_output: + with open(args.github_output, "a", encoding="utf-8", newline="\n") as f: + f.write("found=%s\n" % ("true" if out.found else "false")) + print(out.message) + if out.found: + print("Notes:") + print(out.notes) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/scripts/test_publish_listings.py b/.github/scripts/test_publish_listings.py new file mode 100644 index 000000000..a36db8da8 --- /dev/null +++ b/.github/scripts/test_publish_listings.py @@ -0,0 +1,1104 @@ +#!/usr/bin/env python3 +"""Unit tests for publish_listings.py. + +Stdlib only, no network: the Play API calls live behind play_session(), which +imports requests/google-auth lazily so this module imports cleanly without them. + +Run from the repo root: python -m unittest discover -s .github/scripts -p 'test_*.py' +""" +import contextlib +import io +import re +import shutil +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +import publish_listings as pl + +REPO_ROOT = Path(__file__).resolve().parents[2] +REPO_METADATA = REPO_ROOT / "fastlane" / "metadata" / "android" +APP_RES = REPO_ROOT / "ft8af" / "app" / "src" / "main" / "res" + +# Android resource qualifier -> Play Console locale code. Play codes are not the +# resource qualifiers (values-in is Indonesian, Play calls it id), so the mapping +# is spelled out rather than derived. Adding a language to the app means adding a +# row here and a listing directory; test_every_app_language_has_a_listing fails +# until both exist. +RES_TO_PLAY = { + "values": "en-US", + "values-ar": "ar", + "values-cs": "cs-CZ", + "values-es": "es-ES", + "values-fr": "fr-FR", + "values-in": "id", + "values-it": "it-IT", + "values-ja": "ja-JP", + "values-ko": "ko-KR", + "values-nl": "nl-NL", + "values-pl": "pl-PL", + "values-pt-rBR": "pt-BR", + "values-ru": "ru-RU", + "values-tr": "tr-TR", + "values-uk": "uk", + "values-zh-rCN": "zh-CN", + "values-zh-rTW": "zh-TW", +} + +# Play-only listings with no app-resource counterpart. +PLAY_ONLY = {"es-419"} + +EXPECTED_LOCALES = set(RES_TO_PLAY.values()) | PLAY_ONLY + + +class FakeHTTPError(Exception): + """Mirrors requests.HTTPError, which carries the response that raised it.""" + + def __init__(self, message, response=None): + super().__init__(message) + self.response = response + + +class FakeResponse: + def __init__(self, payload=None, status=200): + self._payload = {} if payload is None else payload + self.status_code = status + + @property + def ok(self): + return self.status_code < 400 + + def raise_for_status(self): + if not self.ok: + raise FakeHTTPError("HTTP %d" % self.status_code, response=self) + + def json(self): + return self._payload + + +class FakeSession: + """Stands in for the requests.Session play_session() builds, recording calls.""" + + def __init__( + self, + listings=None, + edit_id="edit-1", + commit_status=200, + delete_status=200, + delete_exc=None, + patch_status=200, + patch_exc=None, + ): + self.listings = dict(listings or {}) + self.edit_id = edit_id + self.commit_status = commit_status + self.delete_status = delete_status + self.delete_exc = delete_exc + self.patch_status = patch_status + self.patch_exc = patch_exc + self.patched = {} + self.puts = {} + self.commits = [] + self.deletes = [] + + def get(self, url, timeout=None): + return FakeResponse( + {"listings": [dict(li, language=loc) for loc, li in sorted(self.listings.items())]} + ) + + def post(self, url, timeout=None): + if url.endswith(":commit"): + self.commits.append(url) + return FakeResponse({"id": self.edit_id}, self.commit_status) + return FakeResponse({"id": self.edit_id}) + + def patch(self, url, json=None, timeout=None): + locale = url.rsplit("/", 1)[-1] + self.patched[locale] = json + if self.patch_exc is not None: + raise self.patch_exc + r = FakeResponse(dict(json or {}, language=locale), self.patch_status) + r.raise_for_status() + return r + + def put(self, url, json=None, timeout=None): + locale = url.rsplit("/", 1)[-1] + self.puts[locale] = json + if self.patch_exc is not None: + raise self.patch_exc + r = FakeResponse(dict(json or {}), self.patch_status) + r.raise_for_status() + return r + + def delete(self, url, timeout=None): + self.deletes.append(url) + if self.delete_exc is not None: + raise self.delete_exc + return FakeResponse({}, self.delete_status) + + +def listing(title="FT8AF", short="short desc", full="full desc"): + return {"title": title, "shortDescription": short, "fullDescription": full} + + +@contextlib.contextmanager +def captured(): + """Run with stdout and stderr captured; yields (out, err) StringIO buffers.""" + out, err = io.StringIO(), io.StringIO() + with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err): + yield out, err + + +def write_locale(root, locale, title="FT8AF", short="short desc", full="full desc"): + d = Path(root) / locale + d.mkdir(parents=True, exist_ok=True) + for fname, text in ( + ("title.txt", title), + ("short_description.txt", short), + ("full_description.txt", full), + ): + with open(d / fname, "w", encoding="utf-8", newline="\n") as fh: + fh.write(text + "\n") + return d + + +class TempTreeTest(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, self.tmp, True) + + +class ReadLocaleTest(TempTreeTest): + def test_reads_three_fields_and_strips_trailing_newline(self): + d = write_locale(self.tmp, "en-US", full="line one\nline two") + listing = pl.read_locale(d) + self.assertEqual( + listing, + {"title": "FT8AF", "shortDescription": "short desc", "fullDescription": "line one\nline two"}, + ) + + def test_internal_blank_lines_are_preserved(self): + # Paragraph breaks are meaningful in the full description; only the + # file's own trailing newline is dropped. + d = write_locale(self.tmp, "en-US", full="para one\n\npara two") + self.assertEqual(pl.read_locale(d)["fullDescription"], "para one\n\npara two") + + def test_missing_file_is_an_error(self): + d = write_locale(self.tmp, "fr-FR") + (d / "short_description.txt").unlink() + with self.assertRaises(pl.MetadataError) as cm: + pl.read_locale(d) + self.assertIn("short_description.txt", str(cm.exception)) + + def test_whitespace_only_file_is_an_error(self): + d = write_locale(self.tmp, "fr-FR", short=" ") + with self.assertRaises(pl.MetadataError) as cm: + pl.read_locale(d) + self.assertIn("empty", str(cm.exception)) + + +class ValidateTest(unittest.TestCase): + def test_within_limits_has_no_errors(self): + listing = {"title": "FT8AF", "shortDescription": "x" * 80, "fullDescription": "y" * 4000} + self.assertEqual(pl.validate("en-US", listing), []) + + def test_reports_each_over_limit_field_with_the_overage(self): + listing = { + "title": "T" * 31, + "shortDescription": "S" * 85, + "fullDescription": "F" * 10, + } + errors = pl.validate("de-DE", listing) + self.assertEqual(len(errors), 2) + joined = "\n".join(errors) + self.assertIn("de-DE", joined) + self.assertIn("title.txt is 31 characters, limit is 30 (over by 1)", joined) + self.assertIn("short_description.txt is 85 characters, limit is 80 (over by 5)", joined) + + def test_limits_count_characters_not_bytes(self): + # CJK and accented text is well under 80 characters but far over 80 + # bytes; Play counts characters, so this must pass. + listing = { + "title": "FT8AF", + "shortDescription": "用手机玩 FT8:解码、发射、自动记录日志,不用电脑。", + "fullDescription": "描述", + } + self.assertEqual(pl.validate("zh-CN", listing), []) + + +class LoadMetadataTest(TempTreeTest): + def test_loads_every_locale_directory(self): + write_locale(self.tmp, "en-US") + write_locale(self.tmp, "ja-JP") + loaded = pl.load_metadata(self.tmp) + self.assertEqual(sorted(loaded), ["en-US", "ja-JP"]) + + def test_dot_directories_are_skipped(self): + write_locale(self.tmp, "en-US") + (Path(self.tmp) / ".git").mkdir() + self.assertEqual(sorted(pl.load_metadata(self.tmp)), ["en-US"]) + + def test_missing_root_is_an_error(self): + with self.assertRaises(pl.MetadataError): + pl.load_metadata(Path(self.tmp) / "nope") + + def test_empty_root_is_an_error(self): + with self.assertRaises(pl.MetadataError): + pl.load_metadata(self.tmp) + + def test_over_limit_locale_fails_the_whole_load(self): + write_locale(self.tmp, "en-US") + write_locale(self.tmp, "ru-RU", short="R" * 100) + with self.assertRaises(pl.MetadataError) as cm: + pl.load_metadata(self.tmp) + self.assertIn("ru-RU", str(cm.exception)) + + def test_all_over_limit_locales_are_reported_together(self): + # One run should name every bad file, not just the first. + write_locale(self.tmp, "ru-RU", short="R" * 100) + write_locale(self.tmp, "uk", title="U" * 40) + with self.assertRaises(pl.MetadataError) as cm: + pl.load_metadata(self.tmp) + self.assertIn("ru-RU", str(cm.exception)) + self.assertIn("uk", str(cm.exception)) + + +class DiffListingTest(unittest.TestCase): + LOCAL = {"title": "FT8AF", "shortDescription": "new short", "fullDescription": "new full"} + + def test_identical_listing_has_no_diff(self): + self.assertEqual(pl.diff_listing(self.LOCAL, dict(self.LOCAL)), {}) + + def test_extra_remote_fields_are_ignored(self): + # The API returns `video` and `language` too; neither is ours to manage. + remote = dict(self.LOCAL, video="https://youtu.be/x", language="en-US") + self.assertEqual(pl.diff_listing(self.LOCAL, remote), {}) + + def test_changed_field_is_reported_with_old_and_new(self): + remote = dict(self.LOCAL, shortDescription="old short") + self.assertEqual( + pl.diff_listing(self.LOCAL, remote), {"shortDescription": ("old short", "new short")} + ) + + def test_missing_remote_locale_reports_every_field(self): + self.assertEqual(sorted(pl.diff_listing(self.LOCAL, None)), sorted(pl.FIELD_FILES)) + + def test_remote_null_field_counts_as_unset(self): + # The API returns JSON null for a field that was never filled in. + remote = dict(self.LOCAL, fullDescription=None) + self.assertEqual(pl.diff_listing(self.LOCAL, remote), {"fullDescription": ("", "new full")}) + + +class SummarizeTest(unittest.TestCase): + def test_unset_old_value_is_called_out(self): + self.assertEqual(pl.summarize("title", "", "FT8AF"), "title: (unset) -> 5 chars") + + def test_changed_value_shows_both_lengths(self): + self.assertEqual(pl.summarize("title", "old", "FT8AF"), "title: 3 chars -> 5 chars") + + +class ServiceAccountInfoTest(unittest.TestCase): + GOOD = '{"type": "service_account", "client_email": "ci@ft8af.iam.gserviceaccount.com"}' + + def test_valid_json_is_parsed(self): + info = pl.service_account_info({"PLAY_SERVICE_ACCOUNT_JSON": self.GOOD}) + self.assertEqual(info["client_email"], "ci@ft8af.iam.gserviceaccount.com") + + def test_missing_variable_explains_how_to_set_it(self): + with self.assertRaises(pl.CredentialsError) as cm: + pl.service_account_info({}) + self.assertIn("is not set", str(cm.exception)) + + def test_whitespace_only_counts_as_missing(self): + with self.assertRaises(pl.CredentialsError): + pl.service_account_info({"PLAY_SERVICE_ACCOUNT_JSON": " \n"}) + + def test_a_path_instead_of_contents_is_caught(self): + # The classic mistake: exporting the filename rather than `$(cat file)`. + with self.assertRaises(pl.CredentialsError) as cm: + pl.service_account_info({"PLAY_SERVICE_ACCOUNT_JSON": "/home/me/sa.json"}) + self.assertIn("not valid JSON", str(cm.exception)) + + def test_json_without_client_email_is_rejected(self): + with self.assertRaises(pl.CredentialsError) as cm: + pl.service_account_info({"PLAY_SERVICE_ACCOUNT_JSON": '{"type": "authorized_user"}'}) + self.assertIn("client_email", str(cm.exception)) + + def test_valid_json_of_the_wrong_shape_is_rejected(self): + with self.assertRaises(pl.CredentialsError): + pl.service_account_info({"PLAY_SERVICE_ACCOUNT_JSON": '["not", "an", "object"]'}) + + +class BuildCredentialsTest(unittest.TestCase): + INFO = {"type": "service_account", "client_email": "ci@ft8af.iam.gserviceaccount.com"} + + def test_returns_what_the_factory_builds(self): + sentinel = object() + got = pl.build_credentials(self.INFO, lambda info, scopes: sentinel) + self.assertIs(got, sentinel) + + def test_androidpublisher_scope_is_requested(self): + seen = {} + + def factory(info, scopes): + seen["scopes"] = scopes + return object() + + pl.build_credentials(self.INFO, factory) + self.assertEqual(seen["scopes"], ["https://www.googleapis.com/auth/androidpublisher"]) + + def test_unusable_key_becomes_a_credentials_error(self): + # google-auth raises ValueError (MalformedError subclasses it) for a key + # that parses as JSON but has no private_key / token_uri. Without the + # conversion this escapes main()'s handler as a traceback. + def factory(info, scopes): + raise ValueError("No key could be detected.") + + with self.assertRaises(pl.CredentialsError) as cm: + pl.build_credentials(self.INFO, factory) + self.assertIn("not a usable service account key", str(cm.exception)) + self.assertIn("No key could be detected.", str(cm.exception)) + + +class MainArgsTest(unittest.TestCase): + def test_pull_and_dry_run_are_mutually_exclusive(self): + # argparse .error() exits 2; this must fail before any Play call. + with self.assertRaises(SystemExit) as cm: + pl.main(["--pull", "--dry-run"]) + self.assertEqual(cm.exception.code, 2) + + def test_check_permissions_conflicts_with_the_other_modes(self): + for other in ("--pull", "--dry-run"): + with self.assertRaises(SystemExit) as cm: + pl.main(["--check-permissions", other]) + self.assertEqual(cm.exception.code, 2) + + def test_annotate_verdict_cannot_ride_along_with_a_mode(self): + # `--check-permissions --annotate-verdict 3` must not skip the probe and + # print a caller-supplied denial with exit 0; the helper formats a + # verdict, it never produces one. + for mode in ("--check-permissions", "--dry-run", "--pull"): + with mock.patch.object(pl, "play_session", side_effect=AssertionError("no Play")): + with captured() as (out, _): + with self.assertRaises(SystemExit) as cm: + pl.main([mode, "--annotate-verdict", "3"]) + self.assertEqual(cm.exception.code, 2, mode) + self.assertNotIn("::error::", out.getvalue()) + + def test_an_unusable_key_is_reported_in_one_line_not_a_traceback(self): + err = pl.CredentialsError( + "PLAY_SERVICE_ACCOUNT_JSON parsed but is not a usable service account key " + "(No key could be detected.). Re-download the key from the Google Cloud console." + ) + tmp = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, tmp, True) + write_locale(tmp, "en-US") + with mock.patch.object(pl, "play_session", side_effect=err): + with captured() as (_, stderr): + code = pl.main(["--root", tmp]) + self.assertEqual(code, 1) + self.assertIn("not a usable service account key", stderr.getvalue()) + self.assertNotIn("Traceback", stderr.getvalue()) + + def test_unloadable_metadata_returns_1_without_touching_play(self): + # An empty root fails in load_metadata(), which runs before play_session(); + # if the ordering ever regresses this test fails on the missing credentials. + tmp = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, tmp, True) + self.assertEqual(pl.main(["--root", tmp, "--dry-run"]), 1) + + +class UpsertListingTest(unittest.TestCase): + """Creating a listing needs PUT; PATCH 404s on a language Play has never had. + + This is the bug that broke the first real publish: 17 of 18 locales did not + exist on Play yet, and PATCH answered 404 Not Found on the first one. + """ + + def test_existing_locale_is_patched(self): + s = FakeSession() + pl.upsert_listing(s, "pkg", "e1", "en-US", listing(), exists=True) + self.assertIn("en-US", s.patched) + self.assertEqual(s.puts, {}) + + def test_new_locale_is_put(self): + s = FakeSession() + pl.upsert_listing(s, "pkg", "e1", "ar", listing(), exists=False) + self.assertIn("ar", s.puts) + self.assertEqual(s.patched, {}) + + def test_put_body_carries_the_language(self): + # PUT replaces the whole resource, and the API wants the language in it. + s = FakeSession() + pl.upsert_listing(s, "pkg", "e1", "ja-JP", listing(), exists=False) + self.assertEqual(s.puts["ja-JP"]["language"], "ja-JP") + + def test_patch_body_does_not_add_a_language_field(self): + # PATCH is a partial update of an existing resource; sending extra + # fields risks clobbering what we deliberately preserve. + s = FakeSession() + pl.upsert_listing(s, "pkg", "e1", "en-US", listing(), exists=True) + self.assertNotIn("language", s.patched["en-US"]) + + +class RunPushTest(unittest.TestCase): + LOCAL = {"en-US": listing(), "fr-FR": listing(short="court")} + + def test_first_publish_creates_every_missing_locale(self): + # The real store state that broke: only en-US exists. + local = {"en-US": listing(short="new"), "ar": listing(), "ja-JP": listing()} + s = FakeSession(listings={"en-US": listing(short="old")}) + with captured() as (out, _): + pushed = pl.run_push(s, "pkg", "e1", local, dry_run=False) + self.assertEqual(pushed, 3) + self.assertEqual(sorted(s.puts), ["ar", "ja-JP"]) + self.assertEqual(sorted(s.patched), ["en-US"]) + self.assertIn("(created)", out.getvalue()) + + def test_identical_text_sends_nothing(self): + s = FakeSession(listings=dict(self.LOCAL)) + with captured() as (out, _): + pushed = pl.run_push(s, "pkg", "e1", self.LOCAL, dry_run=False) + self.assertEqual(pushed, 0) + self.assertEqual(s.patched, {}) + self.assertIn("unchanged", out.getvalue()) + + def test_only_changed_locales_are_patched(self): + remote = {"en-US": listing(), "fr-FR": listing(short="ancien")} + s = FakeSession(listings=remote) + with captured(): + pushed = pl.run_push(s, "pkg", "e1", self.LOCAL, dry_run=False) + self.assertEqual(pushed, 1) + self.assertEqual(list(s.patched), ["fr-FR"]) + self.assertEqual(s.patched["fr-FR"]["shortDescription"], "court") + + def test_locale_absent_from_play_is_created(self): + # Created with PUT, not PATCH: PATCH 404s on a language Play has never + # had a listing for. + s = FakeSession(listings={"en-US": listing()}) + with captured(): + pushed = pl.run_push(s, "pkg", "e1", self.LOCAL, dry_run=False) + self.assertEqual(pushed, 1) + self.assertEqual(list(s.puts), ["fr-FR"]) + self.assertEqual(s.patched, {}) + + def test_dry_run_reports_but_sends_nothing(self): + s = FakeSession(listings={"en-US": listing()}) + with captured() as (out, _): + pushed = pl.run_push(s, "pkg", "e1", self.LOCAL, dry_run=True) + self.assertEqual(pushed, 0) + self.assertEqual(s.patched, {}) + # fr-FR is missing on Play, so a real run would PUT it; a dry run must + # not create it either. + self.assertEqual(s.puts, {}) + self.assertIn("DRY RUN", out.getvalue()) + self.assertIn("fr-FR", out.getvalue()) + + def test_dry_run_prints_a_diff_of_the_changed_text(self): + remote = {"en-US": listing(), "fr-FR": listing(short="ancien")} + s = FakeSession(listings=remote) + with captured() as (out, _): + pl.run_push(s, "pkg", "e1", self.LOCAL, dry_run=True) + body = out.getvalue() + self.assertIn("-ancien", body) + self.assertIn("+court", body) + + def test_real_push_does_not_print_diffs(self): + # The diff is a review aid for dry runs; a real push just reports what + # it sent, so CI logs stay readable. + remote = {"en-US": listing(), "fr-FR": listing(short="ancien")} + s = FakeSession(listings=remote) + with captured() as (out, _): + pl.run_push(s, "pkg", "e1", self.LOCAL, dry_run=False) + self.assertNotIn("-ancien", out.getvalue()) + + def test_locales_only_on_play_are_reported_and_left_alone(self): + s = FakeSession(listings=dict(self.LOCAL, **{"de-DE": listing()})) + with captured() as (out, _): + pushed = pl.run_push(s, "pkg", "e1", self.LOCAL, dry_run=False) + self.assertEqual(pushed, 0) + self.assertNotIn("de-DE", s.patched) + self.assertIn("de-DE", out.getvalue()) + + +class DiffTextTest(unittest.TestCase): + def test_same_length_change_is_visible(self): + # The failure that motivated this: counts alone render a swapped word as + # "9 chars -> 9 chars", so a dry run showed nothing at all. + old, new = "hamvention", "Hamvention" + self.assertEqual(pl.summarize("fullDescription", old, new), "fullDescription: 10 chars -> 10 chars") + body = pl.diff_text("en-US", "fullDescription", old, new) + self.assertIn("-hamvention", body) + self.assertIn("+Hamvention", body) + + def test_headers_name_both_sides(self): + body = pl.diff_text("fr-FR", "title", "old", "new") + self.assertIn("play:fr-FR/title.txt", body) + self.assertIn("repo:fr-FR/title.txt", body) + + def test_new_locale_shows_every_line_as_added(self): + body = pl.diff_text("ja-JP", "fullDescription", "", "one\ntwo") + self.assertIn("+one", body) + self.assertIn("+two", body) + self.assertNotIn("-one", body) + + def test_identical_text_produces_no_diff(self): + self.assertEqual(pl.diff_text("en-US", "title", "FT8AF", "FT8AF"), "") + + +class RunCheckTest(unittest.TestCase): + LOCAL = {"en-US": listing()} + + def test_patches_one_listing_and_never_commits(self): + s = FakeSession(listings={"en-US": listing(short="live")}) + with captured() as (out, _): + self.assertEqual(pl.run_check(s, "pkg", "e1", self.LOCAL), pl.EXIT_OK) + self.assertEqual(list(s.patched), ["en-US"]) + self.assertEqual(s.puts, {}) + self.assertEqual(s.commits, [], "the probe must never commit") + self.assertIn("OK", out.getvalue()) + # The log names the call it actually made, so an operator reading it + # is not told "patch" when the store was empty and it had to put. + self.assertIn("listings.patch", out.getvalue()) + + def test_probe_writes_back_plays_own_text(self): + # So that even a committed edit — which cannot happen here — is a no-op. + live = listing(short="live text", full="live full") + s = FakeSession(listings={"en-US": live}) + with captured(): + pl.run_check(s, "pkg", "e1", self.LOCAL) + self.assertEqual(s.patched["en-US"]["shortDescription"], "live text") + + def test_falls_back_to_repo_text_when_nothing_is_published(self): + s = FakeSession(listings={}) + with captured() as (out, _): + self.assertEqual(pl.run_check(s, "pkg", "e1", self.LOCAL), pl.EXIT_OK) + # Nothing is live, so the probe has to create rather than patch. + self.assertEqual( + {k: v for k, v in s.puts["en-US"].items() if k != "language"}, + self.LOCAL["en-US"], + ) + self.assertIn("no listings live yet", out.getvalue()) + self.assertEqual(s.patched, {}) + self.assertIn("listings.put", out.getvalue()) + self.assertNotIn("listings.patch", out.getvalue()) + + def test_denied_patch_reports_the_missing_grant(self): + s = FakeSession(listings={"en-US": listing()}, patch_status=403) + with captured() as (_, err): + self.assertEqual(pl.run_check(s, "pkg", "e1", self.LOCAL), pl.EXIT_DENIED) + self.assertIn("DENIED", err.getvalue()) + self.assertIn("cannot edit listings", err.getvalue()) + self.assertIn("Manage store presence", err.getvalue()) + + def test_unauthenticated_is_inconclusive_not_a_denial(self): + # 401 means the token was not accepted, so the probe never reached the + # permission check. Calling that "denied" would send someone to fix a + # grant that was never tested — the false diagnosis the exit-code split + # exists to prevent. + s = FakeSession(listings={"en-US": listing()}, patch_status=401) + with captured() as (_, err): + self.assertEqual(pl.run_check(s, "pkg", "e1", self.LOCAL), pl.EXIT_INCONCLUSIVE) + self.assertIn("INCONCLUSIVE", err.getvalue()) + self.assertIn("401", err.getvalue()) + self.assertIn("credentials", err.getvalue()) + self.assertNotIn("DENIED", err.getvalue()) + self.assertNotIn("Manage store presence", err.getvalue()) + + def test_denied_put_on_an_empty_store_is_still_the_missing_grant(self): + # The verdict must not depend on which verb the probe had to use. + s = FakeSession(listings={}, patch_status=403) + with captured() as (_, err): + self.assertEqual(pl.run_check(s, "pkg", "e1", self.LOCAL), pl.EXIT_DENIED) + self.assertIn("Manage store presence", err.getvalue()) + + def test_server_error_is_inconclusive_not_a_grant_diagnosis(self): + # Calling a 500 "permission denied" would send someone editing Console + # permissions that were fine all along. + s = FakeSession(listings={"en-US": listing()}, patch_status=500) + with captured() as (_, err): + self.assertEqual(pl.run_check(s, "pkg", "e1", self.LOCAL), pl.EXIT_INCONCLUSIVE) + self.assertIn("INCONCLUSIVE", err.getvalue()) + self.assertNotIn("Manage store presence", err.getvalue()) + + def test_rate_limit_is_inconclusive(self): + s = FakeSession(listings={"en-US": listing()}, patch_status=429) + with captured() as (_, err): + self.assertEqual(pl.run_check(s, "pkg", "e1", self.LOCAL), pl.EXIT_INCONCLUSIVE) + self.assertIn("INCONCLUSIVE", err.getvalue()) + + def test_transport_error_with_no_response_is_inconclusive(self): + s = FakeSession(listings={"en-US": listing()}, patch_exc=OSError("timed out")) + with captured() as (_, err): + self.assertEqual(pl.run_check(s, "pkg", "e1", self.LOCAL), pl.EXIT_INCONCLUSIVE) + self.assertIn("INCONCLUSIVE", err.getvalue()) + self.assertIn("timed out", err.getvalue()) + self.assertNotIn("Manage store presence", err.getvalue()) + + +class ProbeAnnotationTest(unittest.TestCase): + """The exit-code -> annotation mapping the workflow shows the operator. + + This is the probe's main operator-facing behaviour, so it is pinned per + code: the level, the message, and — just as important — what a message + must NOT say (a non-verdict must never name the grant). + """ + + def test_ok_is_a_notice(self): + level, msg = pl.probe_annotation(pl.EXIT_OK) + self.assertEqual(level, "notice") + self.assertIn("can edit listings", msg) + + def test_denied_is_an_error_naming_the_grant(self): + level, msg = pl.probe_annotation(pl.EXIT_DENIED) + self.assertEqual(level, "error") + self.assertIn("cannot edit listings", msg) + self.assertIn("Manage store presence", msg) + + def test_inconclusive_is_a_warning_that_does_not_name_the_grant(self): + level, msg = pl.probe_annotation(pl.EXIT_INCONCLUSIVE) + self.assertEqual(level, "warning") + self.assertIn("no verdict", msg) + self.assertIn("401", msg) + self.assertNotIn("Manage store presence", msg) + + def test_generic_failure_is_an_error_but_not_a_verdict(self): + # A 1 (bad key, unreadable metadata) and argparse's 2 both mean the + # probe never ran. Reporting either as a missing grant is the false + # diagnosis the exit-code split exists to prevent. + for rc in (pl.EXIT_ERROR, 2, 42): + level, msg = pl.probe_annotation(rc) + self.assertEqual(level, "error", rc) + self.assertIn("did not run", msg) + self.assertIn("exit %d" % rc, msg) + self.assertIn("not a verdict", msg) + self.assertNotIn("Manage store presence", msg) + + def test_the_four_outcomes_are_told_apart(self): + seen = {pl.probe_annotation(rc) for rc in (0, 1, 3, 4)} + self.assertEqual(len(seen), 4) + + def test_format_is_a_workflow_command(self): + self.assertEqual(pl.format_annotation("warning", "hi"), "::warning::hi") + + def test_cli_prints_the_annotation_and_touches_nothing_else(self): + # The workflow relays the probe's code through this; it must not need + # metadata or credentials, and must exit 0 so the workflow can go on to + # exit with the probe's own code. + with mock.patch.object(pl, "play_session", side_effect=AssertionError("no Play")): + with mock.patch.object(pl, "load_metadata", side_effect=AssertionError("no tree")): + with captured() as (out, _): + self.assertEqual(pl.main(["--annotate-verdict", "3"]), pl.EXIT_OK) + self.assertEqual(out.getvalue().strip(), pl.format_annotation(*pl.probe_annotation(3))) + self.assertTrue(out.getvalue().startswith("::error::")) + + +class ExitCodeTest(unittest.TestCase): + """The probe's verdict codes must not collide with anything else.""" + + def test_verdict_codes_are_distinct_from_generic_failure(self): + codes = [pl.EXIT_OK, pl.EXIT_ERROR, pl.EXIT_DENIED, pl.EXIT_INCONCLUSIVE] + self.assertEqual(len(set(codes)), len(codes)) + + def test_no_verdict_code_collides_with_argparse_usage_error(self): + # argparse exits 2 on a bad flag. If a verdict used 2, a typo in the + # workflow would read as a real answer about the grant. + self.assertNotIn(2, (pl.EXIT_DENIED, pl.EXIT_INCONCLUSIVE)) + + def test_generic_failures_do_not_use_a_verdict_code(self): + # A missing key and unpublishable metadata both exit EXIT_ERROR, so the + # workflow can tell "the probe did not run" from "Play said no". + verdicts = (pl.EXIT_DENIED, pl.EXIT_INCONCLUSIVE) + self.assertNotIn(pl.EXIT_ERROR, verdicts) + + tmp = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, tmp, True) + with captured(): + self.assertEqual(pl.main(["--root", tmp, "--check-permissions"]), pl.EXIT_ERROR) + + write_locale(tmp, "en-US") + with mock.patch.object(pl, "play_session", side_effect=pl.CredentialsError("no key")): + with captured(): + self.assertEqual( + pl.main(["--root", tmp, "--check-permissions"]), pl.EXIT_ERROR + ) + + +class RunPullTest(TempTreeTest): + def test_writes_every_remote_locale_to_disk(self): + s = FakeSession(listings={"en-US": listing(full="line one\nline two")}) + with captured(): + self.assertEqual(pl.run_pull(s, "pkg", "e1", self.tmp), 0) + d = Path(self.tmp) / "en-US" + self.assertEqual((d / "title.txt").read_text(encoding="utf-8"), "FT8AF\n") + self.assertEqual( + (d / "full_description.txt").read_text(encoding="utf-8"), "line one\nline two\n" + ) + + def test_pulled_tree_round_trips_through_load_metadata(self): + s = FakeSession(listings={"en-US": listing(), "ja-JP": listing(short="短い説明")}) + with captured(): + pl.run_pull(s, "pkg", "e1", self.tmp) + loaded = pl.load_metadata(self.tmp) + self.assertEqual(loaded["ja-JP"]["shortDescription"], "短い説明") + + def test_null_field_from_play_is_written_as_empty(self): + # An unfilled listing field comes back as JSON null; str + rstrip would + # otherwise blow up on None. + s = FakeSession(listings={"en-US": dict(listing(), fullDescription=None)}) + with captured(): + pl.run_pull(s, "pkg", "e1", self.tmp) + self.assertEqual( + (Path(self.tmp) / "en-US" / "full_description.txt").read_text(encoding="utf-8"), "\n" + ) + + def test_no_remote_listings_writes_nothing(self): + s = FakeSession(listings={}) + with captured() as (out, _): + self.assertEqual(pl.run_pull(s, "pkg", "e1", self.tmp), 0) + self.assertEqual(list(Path(self.tmp).iterdir()), []) + self.assertIn("nothing to pull", out.getvalue()) + + def test_unpublished_local_locale_is_kept_and_reported(self): + # The state this repo is in before the first publish: every non-English + # locale exists locally and on nothing else. Deleting them would destroy + # the very work --pull exists to protect. + write_locale(self.tmp, "ja-JP", short="unpublished") + s = FakeSession(listings={"en-US": listing()}) + with captured() as (out, _): + pl.run_pull(s, "pkg", "e1", self.tmp) + self.assertTrue((Path(self.tmp) / "ja-JP" / "title.txt").is_file()) + self.assertEqual( + pl.read_locale(Path(self.tmp) / "ja-JP")["shortDescription"], "unpublished" + ) + self.assertIn("ja-JP", out.getvalue()) + self.assertIn("left alone", out.getvalue()) + + +class AbandonEditTest(unittest.TestCase): + def test_successful_delete_is_quiet(self): + s = FakeSession() + with captured() as (out, err): + self.assertTrue(pl.abandon_edit(s, "pkg", "e1")) + self.assertEqual(len(s.deletes), 1) + self.assertEqual(err.getvalue(), "") + + def test_failed_delete_warns_without_raising(self): + s = FakeSession(delete_status=403) + with captured() as (_, err): + self.assertFalse(pl.abandon_edit(s, "pkg", "e1")) + self.assertIn("could not abandon edit e1", err.getvalue()) + self.assertIn("403", err.getvalue()) + + def test_transport_error_is_swallowed_and_named(self): + # A timeout or dropped connection must not escape either — this runs in a + # finally block, so it would mask whatever the caller was failing with. + s = FakeSession(delete_exc=OSError("connection reset")) + with captured() as (_, err): + self.assertFalse(pl.abandon_edit(s, "pkg", "e1")) + self.assertIn("could not abandon edit e1", err.getvalue()) + self.assertIn("OSError", err.getvalue()) + self.assertIn("connection reset", err.getvalue()) + + def test_keyboard_interrupt_is_not_swallowed(self): + # `except Exception` is deliberate: Ctrl-C during cleanup should still + # stop the run rather than be reported as a failed delete. + s = FakeSession(delete_exc=KeyboardInterrupt()) + with captured(): + with self.assertRaises(KeyboardInterrupt): + pl.abandon_edit(s, "pkg", "e1") + + +class MainLifecycleTest(TempTreeTest): + """End-to-end through main() with the Play session faked out.""" + + def setUp(self): + super().setUp() + write_locale(self.tmp, "en-US", short="new short") + + def run_main(self, session, argv): + with mock.patch.object(pl, "play_session", return_value=session): + with captured() as (out, err): + code = pl.main(["--root", self.tmp] + argv) + return code, out.getvalue(), err.getvalue() + + def test_changed_listing_is_patched_then_committed_and_the_edit_is_kept(self): + s = FakeSession(listings={"en-US": listing(short="old short")}) + code, out, _ = self.run_main(s, []) + self.assertEqual(code, 0) + self.assertEqual(list(s.patched), ["en-US"]) + self.assertEqual(len(s.commits), 1) + self.assertEqual(s.deletes, [], "a committed edit must not be deleted") + self.assertIn("Committed edit", out) + + def test_dry_run_commits_nothing_and_abandons_the_edit(self): + s = FakeSession(listings={"en-US": listing(short="old short")}) + code, out, _ = self.run_main(s, ["--dry-run"]) + self.assertEqual(code, 0) + self.assertEqual(s.patched, {}) + self.assertEqual(s.puts, {}) + self.assertEqual(s.commits, []) + self.assertEqual(len(s.deletes), 1, "a dry-run edit must not be left open") + + def test_no_op_run_abandons_the_edit(self): + s = FakeSession(listings={"en-US": listing(short="new short")}) + code, out, _ = self.run_main(s, []) + self.assertEqual(code, 0) + self.assertEqual(s.commits, []) + self.assertEqual(len(s.deletes), 1) + self.assertIn("Nothing to do", out) + + def test_commit_failure_still_abandons_the_edit(self): + s = FakeSession(listings={"en-US": listing(short="old short")}, commit_status=500) + with mock.patch.object(pl, "play_session", return_value=s): + with captured(): + with self.assertRaises(FakeHTTPError): + pl.main(["--root", self.tmp]) + self.assertEqual(len(s.deletes), 1, "a failed commit must not leak the edit") + + def test_commit_failure_is_not_masked_by_a_failing_cleanup(self): + # abandon_edit runs in a finally block; if it raised, the 500 from the + # commit would be replaced by a cleanup error and the real cause lost. + s = FakeSession( + listings={"en-US": listing(short="old short")}, commit_status=500, delete_status=403 + ) + with mock.patch.object(pl, "play_session", return_value=s): + with captured() as (_, err): + with self.assertRaises(FakeHTTPError) as cm: + pl.main(["--root", self.tmp]) + self.assertIn("500", str(cm.exception)) + self.assertIn("could not abandon edit", err.getvalue()) + + def test_commit_failure_survives_a_cleanup_that_raises(self): + # The strongest form of the masking guard: the DELETE itself blows up + # mid-flight. The 500 from the commit must still be what propagates. + s = FakeSession( + listings={"en-US": listing(short="old short")}, + commit_status=500, + delete_exc=OSError("connection reset"), + ) + with mock.patch.object(pl, "play_session", return_value=s): + with captured() as (_, err): + with self.assertRaises(FakeHTTPError) as cm: + pl.main(["--root", self.tmp]) + self.assertIn("500", str(cm.exception)) + self.assertIn("connection reset", err.getvalue()) + + def test_check_permissions_abandons_the_edit_and_never_commits(self): + s = FakeSession(listings={"en-US": listing(short="live")}) + code, out, _ = self.run_main(s, ["--check-permissions"]) + self.assertEqual(code, 0) + self.assertEqual(list(s.patched), ["en-US"]) + self.assertEqual(s.commits, [], "the probe must never commit") + self.assertEqual(len(s.deletes), 1, "the probe edit must be abandoned") + + def test_unreadable_listings_are_inconclusive_not_denied(self): + # fetch_listings failing means the probe never tested the grant. + s = FakeSession(listings={"en-US": listing()}) + s.get = lambda url, timeout=None: (_ for _ in ()).throw(OSError("timed out")) + code, _, err = self.run_main(s, ["--check-permissions"]) + self.assertEqual(code, pl.EXIT_INCONCLUSIVE) + self.assertIn("never got as far", err) + self.assertNotIn("Manage store presence", err) + self.assertEqual(len(s.deletes), 1, "the probe edit must still be abandoned") + + def test_unopenable_edit_is_inconclusive_not_denied(self): + # A 5xx creating the edit used to escape as a traceback, which Python + # exits 1 for — indistinguishable from a denial at the workflow layer. + s = FakeSession() + s.post = lambda url, timeout=None: (_ for _ in ()).throw(OSError("play is down")) + code, _, err = self.run_main(s, ["--check-permissions"]) + self.assertEqual(code, pl.EXIT_INCONCLUSIVE) + self.assertIn("could not open an edit", err) + self.assertNotIn("Manage store presence", err) + + def test_a_failed_edit_open_still_raises_outside_probe_mode(self): + # Only the probe swallows this; a real publish must still fail loudly. + s = FakeSession(listings={"en-US": listing(short="old")}) + s.post = lambda url, timeout=None: (_ for _ in ()).throw(OSError("play is down")) + with mock.patch.object(pl, "play_session", return_value=s): + with captured(): + with self.assertRaises(OSError): + pl.main(["--root", self.tmp]) + + def test_check_permissions_returns_denied_when_the_grant_is_missing(self): + s = FakeSession(listings={"en-US": listing()}, patch_status=403) + code, _, err = self.run_main(s, ["--check-permissions"]) + self.assertEqual(code, pl.EXIT_DENIED) + self.assertIn("Manage store presence", err) + self.assertEqual(len(s.deletes), 1) + + def test_pull_writes_the_tree_and_abandons_the_edit(self): + s = FakeSession(listings={"fr-FR": listing(short="pulled")}) + code, _, _ = self.run_main(s, ["--pull"]) + self.assertEqual(code, 0) + self.assertEqual( + pl.read_locale(Path(self.tmp) / "fr-FR")["shortDescription"], "pulled" + ) + self.assertEqual(len(s.deletes), 1) + + def test_bad_credentials_never_open_an_edit(self): + s = FakeSession() + with mock.patch.object(pl, "play_session", side_effect=pl.CredentialsError("nope")): + with captured() as (_, err): + code = pl.main(["--root", self.tmp, "--dry-run"]) + self.assertEqual(code, 1) + self.assertIn("nope", err.getvalue()) + self.assertEqual(s.deletes, []) + + +class WorkflowModesTest(unittest.TestCase): + """The workflow's dispatch modes must match the CLI they invoke. + + Parsed by hand rather than with PyYAML: the validate job installs nothing, + and keeping this suite stdlib-only is what lets it stay that way. + """ + + WORKFLOW = REPO_ROOT / ".github" / "workflows" / "play-listings.yml" + + # mode -> the flag the workflow passes for it. "publish" passes none. + MODE_FLAGS = {"dry-run": "--dry-run", "check-permissions": "--check-permissions", "publish": None} + + def workflow_text(self): + return self.WORKFLOW.read_text(encoding="utf-8") + + def declared_modes(self): + """The `options:` list under the mode input, in file order.""" + text = self.workflow_text() + start = text.index(" mode:") + block = text[start : text.index("permissions:", start)] + opts = block[block.index("options:") :] + return [ + ln.strip()[2:].strip() + for ln in opts.splitlines() + if ln.strip().startswith("- ") + ] + + def test_workflow_offers_exactly_the_modes_we_support(self): + self.assertEqual(sorted(self.declared_modes()), sorted(self.MODE_FLAGS)) + + def case_arm_flags(self): + """{mode: flag-or-None} read out of the run step's case arms. + + The flag has to come from the workflow itself, not from MODE_FLAGS: a + typo like `args+=(--check-permissons)` is exactly the drift this guard + exists to catch, and comparing the table against itself would miss it. + """ + text = self.workflow_text() + run = text[text.index('case "$MODE" in') :] + run = run[: run.index("esac")] + arms = {} + for arm in re.finditer( + r"^\s{10,}([a-z][a-z-]*)\)\s*\n(.*?)^\s+;;", run, re.S | re.M + ): + mode, body = arm.group(1), arm.group(2) + flags = re.findall(r"args\+=\(([^)]*)\)", body) + self.assertLessEqual(len(flags), 1, "mode %r appends args more than once" % mode) + arms[mode] = flags[0].strip() if flags else None + return arms + + def test_every_mode_has_a_case_arm(self): + self.assertEqual(sorted(self.case_arm_flags()), sorted(self.declared_modes())) + + def test_case_arms_pass_the_flags_we_expect(self): + self.assertEqual(self.case_arm_flags(), self.MODE_FLAGS) + + def test_every_flag_the_workflow_passes_is_a_real_cli_flag(self): + # Catches both directions of drift: renaming a flag in + # build_arg_parser(), and mistyping one in the workflow. Either used to + # surface only as a failed manual run against Play. + known = set() + for action in pl.build_arg_parser()._actions: + known.update(action.option_strings) + for mode, flag in self.case_arm_flags().items(): + if flag is not None: + self.assertIn(flag, known, "mode %r passes unknown flag %s" % (mode, flag)) + + def test_publish_arm_passes_no_flag(self): + # Publishing is the script's default; passing a flag for it would mean + # the workflow and the CLI disagree about what "no mode" means. + self.assertIsNone(self.case_arm_flags()["publish"]) + + def test_default_mode_is_the_read_only_one(self): + text = self.workflow_text() + block = text[text.index(" mode:") : text.index("options:")] + self.assertIn("default: dry-run", block) + + def test_parser_accepts_each_mode_flag(self): + for flag in filter(None, self.MODE_FLAGS.values()): + args = pl.build_arg_parser().parse_args([flag]) + self.assertTrue(getattr(args, flag[2:].replace("-", "_"))) + + def run_step(self): + """The `run:` block of the Run step, from the MODE case to its exit.""" + text = self.workflow_text() + start = text.index('case "$MODE" in') + return text[start : text.index("exit $rc", start) + len("exit $rc")] + + def test_verdict_annotation_is_delegated_to_the_script(self): + # The exit-code -> annotation mapping is ProbeAnnotationTest's job; the + # workflow must relay the probe's code through --annotate-verdict and + # not keep a bash copy that could drift from the EXIT_* constants. + step = self.run_step() + self.assertIn('--annotate-verdict "$rc"', step) + self.assertNotIn('case "$rc"', step) + self.assertIn( + "--annotate-verdict", + {o for a in pl.build_arg_parser()._actions for o in a.option_strings}, + ) + + def test_step_exits_with_the_probes_own_code(self): + # The annotation helper exits 0 by design; the step must still end with + # the probe's code so a denied verdict fails the run. + step = self.run_step() + self.assertIn("rc=$?", step) + self.assertTrue(step.rstrip().endswith("exit $rc")) + + +class RepoMetadataTest(unittest.TestCase): + """The listings actually checked in must always be publishable.""" + + def test_repo_tree_loads_and_is_within_limits(self): + # Exact set, not a count: a locale quietly disappearing would otherwise + # still pass while its store page silently fell back to English. + loaded = pl.load_metadata(REPO_METADATA) + self.assertEqual(set(loaded), EXPECTED_LOCALES) + + def test_every_app_language_has_a_listing(self): + # The failure this guards against: someone adds values-xx to the app and + # ships a translated UI, but store visitors in that language still get an + # English listing because nobody added the metadata directory. + shipped = sorted( + d.name + for d in APP_RES.iterdir() + if d.is_dir() and (d / "strings_compose.xml").is_file() + ) + self.assertTrue(shipped, "found no translated resource directories — wrong path?") + + unmapped = [q for q in shipped if q not in RES_TO_PLAY] + self.assertEqual( + unmapped, [], "app language(s) with no Play locale mapping: %s" % unmapped + ) + + listings = set(pl.load_metadata(REPO_METADATA)) + missing = sorted(RES_TO_PLAY[q] for q in shipped if RES_TO_PLAY[q] not in listings) + self.assertEqual(missing, [], "app language(s) with no store listing: %s" % missing) + + def test_every_locale_has_the_same_title(self): + # FT8AF is a brand name; a locale drifting to a different title would be + # a rename in the store, not a translation. + titles = {loc: li["title"] for loc, li in pl.load_metadata(REPO_METADATA).items()} + self.assertEqual(set(titles.values()), {"FT8AF"}, titles) + + def test_no_locale_carries_a_byte_order_mark(self): + # A BOM survives into the listing text and renders as a stray glyph. + for path in sorted(REPO_METADATA.rglob("*.txt")): + with open(path, "rb") as fh: + self.assertFalse( + fh.read(3).startswith(b"\xef\xbb\xbf"), "%s starts with a UTF-8 BOM" % path + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/scripts/test_release_notes.py b/.github/scripts/test_release_notes.py new file mode 100644 index 000000000..c78dd20bb --- /dev/null +++ b/.github/scripts/test_release_notes.py @@ -0,0 +1,325 @@ +"""Tests for release_notes.py — the tag lane's release-notes restoration. + +Stdlib only, like test_publish_listings.py, so android.yml's test job can run +it with no extra install. Every branch the workflow step used to carry in bash +is pinned here: existing / missing release, lookup failure, dispatch versus +tag push, and the marker structure. +""" +import contextlib +import io +import os +import shutil +import tempfile +import unittest + +import release_notes as rn + +BODY = ( + "## Release notes\r\n\r\n" + "\r\n" + "New: hamlib CAT support.\r\nFixes for Icom SWR meters.\r\n\r\n" + "\r\n\r\n" + "\r\n" + "_Version `0.151.0` — promoted from android-dev.1155._\r\n" +) +NOTES = "New: hamlib CAT support.\nFixes for Icom SWR meters.\n\n" + + +class DecideTest(unittest.TestCase): + def test_existing_release_on_dispatch_restores_notes_and_body(self): + out = rn.decide("workflow_dispatch", "android-v0.151.0", 0, "", BODY) + self.assertTrue(out.found) + self.assertIsNone(out.error) + self.assertEqual(out.notes, NOTES) + self.assertNotIn("\r", out.body) + self.assertIn("", out.body) + + def test_existing_release_on_push_restores_too(self): + out = rn.decide("push", "android-v0.151.0", 0, "", BODY) + self.assertTrue(out.found) + self.assertEqual(out.notes, NOTES) + + def test_missing_release_on_dispatch_is_an_error(self): + out = rn.decide("workflow_dispatch", "android-v9.9.9", 1, "release not found", "") + self.assertIsNotNone(out.error) + self.assertIn("android-v9.9.9", out.error) + self.assertIn("release not found", out.error) + + def test_missing_release_on_push_falls_back(self): + out = rn.decide("push", "android-v9.9.9", 1, "release not found\n", "") + self.assertFalse(out.found) + self.assertIsNone(out.error) + self.assertIn("plain tag push", out.message) + + def test_lookup_failure_on_push_is_an_error_not_a_fallback(self): + # Auth, rate limit, transient API error: the release may well exist and + # carrying on would overwrite it. + for err in ("HTTP 401: Bad credentials", "HTTP 403: API rate limit exceeded", ""): + out = rn.decide("push", "android-v0.151.0", 1, err, "") + self.assertIsNotNone(out.error, err) + self.assertIn("refusing to continue", out.error) + + def test_lookup_failure_on_dispatch_is_an_error(self): + out = rn.decide("workflow_dispatch", "android-v0.151.0", 1, "HTTP 401: Bad credentials", "") + self.assertIsNotNone(out.error) + self.assertIn("Bad credentials", out.error) + + def test_no_markers_on_dispatch_is_an_error(self): + # A release cut by a plain tag push, or one whose body was edited. + out = rn.decide("workflow_dispatch", "android-v0.149", 0, "", "## Release\nno markers here\n") + self.assertIsNotNone(out.error) + self.assertIn("no usable release notes", out.error) + + def test_no_markers_on_push_keeps_body_and_ships_without_notes(self): + out = rn.decide("push", "android-v0.149", 0, "", "## Release\nno markers here\n") + self.assertTrue(out.found) + self.assertEqual(out.notes, "") + self.assertIn("no markers here", out.body) + + def test_blank_notes_between_markers_on_dispatch_is_an_error(self): + body = "\n\n \n\n" + out = rn.decide("workflow_dispatch", "android-v0.151.0", 0, "", body) + self.assertIsNotNone(out.error) + self.assertIn("no release notes between", out.error) + + def test_end_marker_before_start_is_malformed(self): + # A lenient scan would capture everything after the start marker and + # ship the trailing release text to Play. + body = "\n\ntrailing text\n" + out = rn.decide("workflow_dispatch", "android-v0.151.0", 0, "", body) + self.assertIsNotNone(out.error) + self.assertIn("end marker comes before", out.error) + out = rn.decide("push", "android-v0.151.0", 0, "", body) + self.assertTrue(out.found) + self.assertEqual(out.notes, "") + + def test_duplicate_markers_are_malformed(self): + body = ("\nA\n\n" + "\nB\n\n") + out = rn.decide("workflow_dispatch", "android-v0.151.0", 0, "", body) + self.assertIsNotNone(out.error) + self.assertIn("found 2 and 2", out.error) + + def test_only_one_of_the_markers_is_malformed(self): + out = rn.decide("workflow_dispatch", "android-v0.151.0", 0, "", + "\nA\n") + self.assertIsNotNone(out.error) + + +class ExtractNotesTest(unittest.TestCase): + def test_matches_the_old_awk_extraction(self): + self.assertEqual(rn.extract_notes(BODY.replace("\r", "")), NOTES) + + def test_no_markers_raises(self): + with self.assertRaises(rn.MalformedMarkers): + rn.extract_notes("plain body") + + +class MainTest(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, self.tmp, True) + + def path(self, name, content=None): + p = os.path.join(self.tmp, name) + if content is not None: + with open(p, "w", encoding="utf-8", newline="") as f: + f.write(content) + return p + + def run_main(self, event, status, stderr, body): + argv = [ + "restore", + "--event", event, "--tag", "android-v0.151.0", + "--gh-status", str(status), + "--gh-stderr", self.path("err.txt", stderr), + "--body", self.path("body.raw", body), + "--notes-out", self.path("notes.txt"), + "--body-out", self.path("existing-release-body.md"), + "--github-output", self.path("out.txt", ""), + ] + stdout = io.StringIO() + with contextlib.redirect_stdout(stdout): + code = rn.main(argv) + return code, stdout.getvalue() + + def read(self, name): + with open(os.path.join(self.tmp, name), encoding="utf-8", newline="") as f: + return f.read() + + def test_restored_notes_are_byte_capped_for_play(self): + body = "\n" + "\u0416" * 300 + "\n\n" + code, out = self.run_main("workflow_dispatch", 0, "", body) + self.assertEqual(code, 0) + self.assertLessEqual(len(self.read("notes.txt").encode("utf-8")), rn.PLAY_NOTES_LIMIT) + # The body itself is untouched: only Play's copy is capped. + self.assertIn("\u0416" * 300, self.read("existing-release-body.md")) + + def test_found_writes_notes_body_and_output(self): + code, out = self.run_main("workflow_dispatch", 0, "", BODY) + self.assertEqual(code, 0) + self.assertEqual(self.read("notes.txt"), NOTES) + self.assertNotIn("\r", self.read("existing-release-body.md")) + self.assertEqual(self.read("out.txt"), "found=true\n") + self.assertIn("hamlib CAT support", out) + + def test_not_found_on_push_writes_no_files(self): + code, out = self.run_main("push", 1, "release not found", "") + self.assertEqual(code, 0) + self.assertFalse(os.path.exists(os.path.join(self.tmp, "notes.txt"))) + self.assertFalse(os.path.exists(os.path.join(self.tmp, "existing-release-body.md"))) + self.assertEqual(self.read("out.txt"), "found=false\n") + + def test_error_exits_1_with_an_error_annotation_and_no_files(self): + code, out = self.run_main("workflow_dispatch", 1, "release not found", "") + self.assertEqual(code, 1) + self.assertTrue(out.startswith("::error::")) + self.assertFalse(os.path.exists(os.path.join(self.tmp, "notes.txt"))) + self.assertEqual(self.read("out.txt"), "") + + +class EnsureNotesTest(unittest.TestCase): + PR_LIST = ( + "- #801 rigs: Follow IC-705 dial changes\n" + "- #802 Fix Bluetooth CAT write race\n" + "- #803 ci: stop publishing to Play production on main merges\n" + ) + + def test_nonblank_notes_are_left_alone(self): + notes, fell_back = rn.ensure_notes("Real notes.\n", self.PR_LIST) + self.assertEqual(notes, "Real notes.\n") + self.assertFalse(fell_back) + + def test_blank_notes_become_the_pr_titles(self): + # Claude's schema allows an empty string and `jq -r` writes it as a + # bare newline: exactly what used to produce blank markers. + for blank in ("", "\n", " \n\n", None): + notes, fell_back = rn.ensure_notes(blank, self.PR_LIST) + self.assertTrue(fell_back, repr(blank)) + self.assertEqual( + notes, + "Follow IC-705 dial changes\n" + "Fix Bluetooth CAT write race\n" + "stop publishing to Play production on main merges\n", + ) + + def test_blank_notes_and_no_prs_use_the_default_text(self): + # The workflow never hands over an empty list: with no PR merges in the + # range it writes the "(no pull-request merges in range)" placeholder, + # which is a log line, not a release note. + for no_prs in ("", rn.NO_PRS_SENTINEL, rn.NO_PRS_SENTINEL + "\n"): + notes, fell_back = rn.ensure_notes("\n", no_prs) + self.assertTrue(fell_back, repr(no_prs)) + self.assertEqual(notes, rn.DEFAULT_NOTES + "\n", repr(no_prs)) + self.assertNotIn("pull-request", notes) + + def test_sentinel_matches_what_the_workflow_writes(self): + # Drift guard: the placeholder is spelled in android.yml; if it changes + # there, this must change too or Play gets the placeholder as notes. + wf = os.path.join(os.path.dirname(__file__), "..", "workflows", "android.yml") + with open(wf, encoding="utf-8") as f: + self.assertIn(":-" + rn.NO_PRS_SENTINEL + "}", f.read()) + + def test_fallback_keeps_whole_lines_under_plays_limit(self): + long_list = "".join("- #%d %s\n" % (i, "x" * 120) for i in range(10)) + notes = rn.fallback_notes(long_list) + self.assertLessEqual(len(notes), rn.PLAY_NOTES_LIMIT) + self.assertEqual(notes.count("\n"), 4) # 4 x 121 = 484 fits, 5 would not + self.assertTrue(all(len(l) == 120 for l in notes.splitlines())) + + def test_fallback_budgets_bytes_not_characters(self): + # 120 emoji is 120 characters but 480 bytes: one line fits, a second + # would not. A character budget would have taken four such lines and + # left `head -c 500` to cut the second one mid-codepoint. + emoji_line = "\U0001F4E1" * 120 + long_list = "".join("- #%d %s\n" % (i, emoji_line) for i in range(4)) + notes = rn.fallback_notes(long_list) + self.assertEqual(notes, emoji_line + "\n") + self.assertLessEqual(len(notes.encode("utf-8")), rn.PLAY_NOTES_LIMIT) + # The byte-truncated file must still be valid UTF-8 (a no-op here). + notes.encode("utf-8")[: rn.PLAY_NOTES_LIMIT].decode("utf-8") + + def test_cap_notes_cuts_multibyte_text_at_a_line_then_a_codepoint(self): + # Claude's notes are asked to stay under 400 characters, which Cyrillic + # (2 bytes each) pushes past 500 bytes. + line = "\u0416" * 100 # 200 bytes + newline + three_lines = (line + "\n") * 3 # 603 bytes + capped = rn.cap_notes(three_lines) + self.assertEqual(capped, (line + "\n") * 2) # 402 bytes, cut at a line + one_long_line = "\u0416" * 300 # 600 bytes, no line break to cut at + capped = rn.cap_notes(one_long_line) + self.assertLessEqual(len(capped.encode("utf-8")), rn.PLAY_NOTES_LIMIT) + self.assertEqual(capped, "\u0416" * 250) # whole codepoints only + self.assertEqual(rn.cap_notes("short\n"), "short\n") + + def test_ensure_notes_caps_real_notes_too(self): + notes, fell_back = rn.ensure_notes("\u0416" * 300 + "\n", "") + self.assertFalse(fell_back) + self.assertLessEqual(len(notes.encode("utf-8")), rn.PLAY_NOTES_LIMIT) + + def test_round_trip_producer_to_manual_ship(self): + # The producer/consumer case: notes that went through ensure_notes + # land between the markers "Write release notes" emits, and the manual + # ship's restore accepts that body — while a blank producer output that + # skipped ensure_notes would have been refused. + def body_for(notes): + return ("## Release notes\n\n%s\n%s\n%s\n\n\n" + % (rn.START, notes, rn.END)) + blank, _ = "\n", None + refused = rn.decide("workflow_dispatch", "android-v0.151.0", 0, "", body_for(blank)) + self.assertIsNotNone(refused.error) + ensured, _ = rn.ensure_notes(blank, self.PR_LIST) + accepted = rn.decide("workflow_dispatch", "android-v0.151.0", 0, "", body_for(ensured)) + self.assertTrue(accepted.found) + self.assertIn("Follow IC-705 dial changes", accepted.notes) + + +class EnsureNotesMainTest(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, self.tmp, True) + self.notes = os.path.join(self.tmp, "notes.txt") + + def run_ensure(self, content, pr_list): + if content is not None: + with open(self.notes, "w", encoding="utf-8", newline="") as f: + f.write(content) + os.environ["PR_LIST_TEST"] = pr_list + try: + stdout = io.StringIO() + with contextlib.redirect_stdout(stdout): + code = rn.main(["ensure-notes", "--notes", self.notes, "--pr-list-env", "PR_LIST_TEST"]) + finally: + del os.environ["PR_LIST_TEST"] + with open(self.notes, encoding="utf-8", newline="") as f: + return code, stdout.getvalue(), f.read() + + def test_blank_file_is_rewritten_with_a_warning(self): + code, out, notes = self.run_ensure("\n", "- #1 A title\n") + self.assertEqual(code, 0) + self.assertEqual(notes, "A title\n") + self.assertIn("::warning", out) + + def test_missing_file_is_created(self): + code, out, notes = self.run_ensure(None, rn.NO_PRS_SENTINEL) + self.assertEqual(code, 0) + self.assertEqual(notes, rn.DEFAULT_NOTES + "\n") + # The warning must not claim PR titles were used when there were none. + self.assertIn("::warning", out) + self.assertNotIn("using the PR titles instead", out) + + def test_real_notes_are_untouched_and_silent(self): + code, out, notes = self.run_ensure("Real notes.\n", "- #1 A title\n") + self.assertEqual(code, 0) + self.assertEqual(notes, "Real notes.\n") + self.assertEqual(out, "") + + def test_oversized_real_notes_are_trimmed_with_a_warning(self): + code, out, notes = self.run_ensure(("\u0416" * 100 + "\n") * 3, "") + self.assertEqual(code, 0) + self.assertLessEqual(len(notes.encode("utf-8")), rn.PLAY_NOTES_LIMIT) + self.assertIn("::warning title=Release notes trimmed", out) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 30ee81e7d..c9801db72 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -3,25 +3,54 @@ name: Android CI & Release # Android pipeline for the ft8af/ app: unit tests + coverage, instrumented # emulator tests, static analysis (lint/detekt/ktlint), and the signed # APK/AAB build + GitHub Release + Play publish (internal track on staging, -# production track on main / android-v* tags — see the Release lifecycle below). +# production track on android-v* tags only — see the Release lifecycle below). # # Release lifecycle (see docs/release-pipeline.md): # PR to dev/staging/main -> build-only (unsigned), tests # push to dev -> build-only sanity, NO release (dev builds never # reach the Releases page) -# push to staging -> android-dev. PRERELEASE + Play internal -# push to main -> android-v release + Play production +# push to staging -> android-dev. PRERELEASE + Play internal, +# versionName -dev. where x.y.z is the +# semver Claude picks from the changes since the +# last production tag (see "Versioning" below) +# push to main -> android-v release, NO Play publish, reusing +# the semver + notes of the staging build it promotes # android-v* tag push -> release + Play production +# manual run on an -> release + Play production (the manual ship step; +# android-v* tag see "Shipping to production" below) # Feature work lands on dev; the dev build is cut only when dev -> staging -# merges, and production only when staging -> main merges. +# merges. A staging -> main merge cuts the versioned GitHub Release but ships +# no AAB to Play — shipping to Play production is a deliberate, separate act. +# +# Shipping to production: the main-merge run creates the android-v tag +# itself (softprops/action-gh-release creates the ref via the Releases API), so +# there is no tag left to push by hand — `git push origin android-v` +# would be a no-op and would fire no push event. Ship it by running this +# workflow manually (Actions -> "Android CI & Release" -> "Run workflow") with +# the android-v tag selected as the ref: that run takes the same `tag` +# lane a tag push would and uploads to the Play production track. A manual run +# on a BRANCH is build-only — the lane below requires a tag ref. +# +# Versioning + release notes (ported from Sorrel's play-beta.yml): on a staging +# push the build job collects the PRs, commits and diff size since the latest +# android-v* tag and asks Claude (structured output) for the semver bump and +# the Play release notes. The notes go into the GitHub Release body and the Play +# "what's new"; the chosen version is stashed in the prerelease body as a hidden +# marker so the later main push ships the SAME version production-side instead +# of re-rolling the decision. A staging/main push whose diff since the last +# production tag touches nothing under ft8af/ (an iOS- or desktop-only +# promotion) builds but does not cut a release, so no empty versions are made. +# Requires the ANTHROPIC_API_KEY repository secret; without it (or on an API +# failure) the run warns, falls back to a patch bump, and uses the PR titles as +# notes rather than blocking the release. # # PATH-FILTERED: on pull requests and dev pushes this whole pipeline only runs # when Android-relevant files change (ft8af/** — which includes the shared # native C core at ft8af/app/src/main/cpp/ — or this workflow file). Pushes to -# staging/main and android-v* tags always build (a full release snapshot, -# regardless of what changed). The always-run `android-gate` job is the single -# required status check so branch protection never gets stuck "pending" when the -# build jobs are path-skipped. +# staging/main, android-v* tags, and manual runs on such a tag always build (a +# full release snapshot, regardless of what changed). The always-run +# `android-gate` job is the single required status check so branch protection +# never gets stuck "pending" when the build jobs are path-skipped. on: push: @@ -30,24 +59,41 @@ on: - 'android-v*' pull_request: branches: [main, staging, dev] + # The manual production ship step. Run this workflow with an android-v + # tag selected as the ref to build that tag and upload it to the Play + # production track. Needed because the main-merge run creates the tag itself + # through the Releases API, leaving nothing to push by hand. No inputs: the + # ref picker already names the release, and taking the version from anywhere + # else would let a run publish an AAB built from a different commit. + workflow_dispatch: permissions: contents: write # Google Play allows only one active "edit" per app, so two release builds # publishing at once make the second fail with "This edit has expired, please -# create a new Edit." Serialize the release-publishing runs (pushes to -# staging/main and android-v* tags) into a single queue so they never overlap on -# the Play API. cancel-in-progress is false so an in-flight publish finishes -# rather than being killed mid-upload. Other runs (PRs, dev pushes) get a unique +# create a new Edit." Serialize the release runs (pushes to staging/main, +# android-v* tag pushes, and the manual ship run) into a single queue so they +# never overlap on the Play API. A push to main no longer touches Play itself, +# but it stays in the queue for a second reason: ordering. Its "candidate" +# step promotes the android-dev.* tag the staging run creates, and if the +# staging -> main merge lands while that staging run is still building, an +# unserialized main run would fetch tags before the prerelease exists and +# re-roll a fresh version and notes instead of promoting what internal +# testers ran. The cost is that a main build holds the play-publish key +# (shared with play-listings.yml) for its duration; that is the narrow safe +# trade. cancel-in-progress is false so an in-flight publish finishes rather +# than being killed mid-upload. Other runs (PRs, dev pushes) get a unique # group keyed to run id, so they never queue and CI stays fast. concurrency: group: >- ${{ - (github.event_name == 'push' + ((github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/staging' || startsWith(github.ref, 'refs/tags/android-v'))) + || (github.event_name == 'workflow_dispatch' + && startsWith(github.ref, 'refs/tags/android-v'))) && 'play-publish' || format('build-{0}', github.run_id) }} @@ -71,6 +117,8 @@ jobs: android: - 'ft8af/**' - '.github/workflows/android.yml' + - '.github/scripts/release_notes.py' + - '.github/scripts/test_release_notes.py' - name: Decide whether to run id: decide @@ -78,11 +126,13 @@ jobs: CHANGED: ${{ steps.filter.outputs.android }} run: | set -euo pipefail - # staging/main pushes and android-v* tags always build the full - # release snapshot; otherwise (dev push, PRs) build only when - # Android-relevant files changed. + # staging/main pushes, android-v* tags, and the manual ship run on such + # a tag always build the full release snapshot; otherwise (dev push, + # PRs) build only when Android-relevant files changed. if [[ "$GITHUB_EVENT_NAME" == "push" && ( "$GITHUB_REF" == "refs/heads/main" || "$GITHUB_REF" == "refs/heads/staging" || "$GITHUB_REF" == refs/tags/android-v* ) ]]; then echo "run=true" >> "$GITHUB_OUTPUT" + elif [[ "$GITHUB_EVENT_NAME" == "workflow_dispatch" && "$GITHUB_REF" == refs/tags/android-v* ]]; then + echo "run=true" >> "$GITHUB_OUTPUT" elif [[ "$CHANGED" == "true" ]]; then echo "run=true" >> "$GITHUB_OUTPUT" else @@ -139,6 +189,11 @@ jobs: echo "::error::Failed to install NDK $NDK_VERSION after 3 attempts" exit 1 + # The tag lane's release-notes restoration is a Python script so its + # branches can be tested here instead of only on a real ship run. + - name: Release-script unit tests + run: python3 -m unittest discover -s .github/scripts -p 'test_release_notes.py' -v + - name: Run unit tests working-directory: ft8af run: ./gradlew testDebugUnitTest --stacktrace @@ -364,77 +419,429 @@ jobs: - name: Fetch tags run: git fetch --tags --force - - name: Determine release tag - id: tag + - name: Determine release lane + id: lane run: | set -euo pipefail # Release lanes (everything else — dev push, PRs — is build-only): - # android-v* tag push -> production release, Play production - # push to main -> auto-bumped android-v* tag, Play production - # push to staging -> android-dev. tag, PRERELEASE, Play internal + # android-v* tag ref -> "tag": production release, Play production. + # Reached by a tag push AND by the + # manual ship run on that tag, which + # is why this arm does not test the + # event name. + # push to main -> "production": android-v tag, NO Play publish + # push to staging -> "dev": android-dev. tag, PRERELEASE, + # Play internal # The dev build is cut on staging, NOT on dev: dev pushes never reach # this release path. The android- prefix keeps these tags separate from # the desktop-v* lane on the Releases page (issue: per-platform tags). - # Bumps seed from a legacy bare v* tag when no android-v* exists yet so - # the version sequence stays continuous with pre-split releases. if [[ "${GITHUB_REF}" == refs/tags/android-v* ]]; then - echo "release_tag=${GITHUB_REF_NAME}" >> "$GITHUB_OUTPUT" - echo "version_name=${GITHUB_REF_NAME#android-v}" >> "$GITHUB_OUTPUT" - echo "should_release=true" >> "$GITHUB_OUTPUT" - echo "is_prerelease=false" >> "$GITHUB_OUTPUT" - echo "play_track=production" >> "$GITHUB_OUTPUT" + lane=tag elif [[ "${GITHUB_EVENT_NAME}" == "push" && "${GITHUB_REF}" == "refs/heads/main" ]]; then - # Auto-bump the last component from the latest android-v* tag (or a - # legacy v* tag for continuity), e.g. android-v1.2 -> android-v1.3. - latest=$(git tag --list 'android-v*' --sort=-v:refname | head -n1) - base="${latest#android-v}" - if [[ -z "$base" ]]; then - legacy=$(git tag --list 'v*' --sort=-v:refname | head -n1) - base="${legacy#v}" - fi - if [[ -z "$base" ]]; then - new_ver="0.1" - else - IFS='.' read -ra parts <<< "$base" - last_idx=$((${#parts[@]} - 1)) - parts[$last_idx]=$((parts[$last_idx] + 1)) - new_ver="$(IFS=.; echo "${parts[*]}")" - fi - new_tag="android-v$new_ver" - echo "Latest android tag: ${latest:-} -> new tag: $new_tag" - echo "release_tag=$new_tag" >> "$GITHUB_OUTPUT" - echo "version_name=$new_ver" >> "$GITHUB_OUTPUT" - echo "should_release=true" >> "$GITHUB_OUTPUT" - echo "is_prerelease=false" >> "$GITHUB_OUTPUT" - echo "play_track=production" >> "$GITHUB_OUTPUT" + lane=production elif [[ "${GITHUB_EVENT_NAME}" == "push" && "${GITHUB_REF}" == "refs/heads/staging" ]]; then - # staging pushes (a dev -> staging merge) cut a run-numbered - # PRERELEASE, published to the Play internal track. Distinct prefix so - # it sorts separately from android-v* and so the Play releaseName - # disambiguates it from production candidates. - new_tag="android-dev.${GITHUB_RUN_NUMBER}" - # Derive the base semver from the latest android-v* (or legacy v*) so - # the APK reads e.g. "1.1.0-dev.42" instead of a bare run number. - latest=$(git tag --list 'android-v*' --sort=-v:refname | head -n1) - base_ver="${latest#android-v}" - if [[ -z "$base_ver" ]]; then - legacy=$(git tag --list 'v*' --sort=-v:refname | head -n1) - base_ver="${legacy#v}" + lane=dev + else + lane=none + fi + echo "Release lane: $lane" + echo "lane=$lane" >> "$GITHUB_OUTPUT" + + - name: Restore the notes of the release this tag already has + id: existing + # The manual production ship re-runs this workflow on an android-v* tag + # whose GitHub Release the main-merge run already created, carrying the + # promoted staging notes in its body. This lane has no candidate step + # and no Claude step, so without this "Write release notes" would send + # a bare "FT8AF " to Play as the what's-new and + # softprops/action-gh-release, updating the existing release, would + # replace its body with that placeholder. Pull the notes back out of + # the body's hidden markers for Play, and keep the whole body so the + # release itself is left exactly as the promotion wrote it. A plain + # tag push with no release yet finds nothing and behaves as before; + # any other lookup failure, and a manual ship whose release has no + # notes between the markers, fails closed rather than risk the overwrite. + if: steps.lane.outputs.lane == 'tag' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ github.ref_name }} + run: | + set -euo pipefail + # This step only runs gh and hands the result over. The decision — + # existing / missing release, lookup failure, dispatch versus tag + # push, and the marker parsing — is release_notes.py, unit-tested by + # test_release_notes.py in the test job above. + set +e + gh release view "$TAG" --json body -q .body > release-body.raw.md 2> gh-release-err.txt + rc=$? + set -e + python3 .github/scripts/release_notes.py restore \ + --event "$GITHUB_EVENT_NAME" --tag "$TAG" \ + --gh-status "$rc" --gh-stderr gh-release-err.txt --body release-body.raw.md \ + --notes-out notes.txt --body-out existing-release-body.md \ + --github-output "$GITHUB_OUTPUT" + + - name: Collect changes since the last production release + id: changes + if: steps.lane.outputs.lane == 'production' || steps.lane.outputs.lane == 'dev' + run: | + set -euo pipefail + # Baseline = the latest android-v* production tag (a legacy bare v* tag + # if no android-v* exists yet, so numbering stays continuous with + # pre-split releases). Everything from there to HEAD is what this + # release ships: on staging that is the dev -> staging promotion (plus + # any earlier promotions not yet shipped to production); on main it is + # the staging -> main promotion. + PREV_TAG=$(git tag --list 'android-v*' --sort=-v:refname | head -n1) + if [[ -z "$PREV_TAG" ]]; then + PREV_TAG=$(git tag --list 'v*' --sort=-v:refname | head -n1) + fi + if [[ -n "$PREV_TAG" ]]; then + PREV_VERSION="${PREV_TAG#android-v}" + PREV_VERSION="${PREV_VERSION#v}" + RANGE="$PREV_TAG..HEAD" + else + # No release tag at all: start from 0.0.0 and use the last 30 + # commits (or the whole history when shorter) as the log. + PREV_VERSION="0.0.0" + BASE=$(git rev-list --max-count=31 HEAD | tail -n 1) + RANGE="$BASE..HEAD" + fi + echo "Previous production release: ${PREV_TAG:-} (version $PREV_VERSION); range $RANGE" + + ALL=$(git diff --name-only "$RANGE") + # Only files that end up in the APK count: ft8af/ (which includes the + # shared native core under ft8af/app/src/main/cpp). ios/, desktop/, + # docs and CI don't — a promotion touching none of ft8af/ builds but + # cuts no release. + ANDROID_FILES=$(printf '%s\n' "$ALL" | grep -E '^ft8af/' || true) + if [[ -z "$ANDROID_FILES" ]]; then + echo "::notice::Nothing under ft8af/ changed since ${PREV_TAG:-the baseline} — building only, not cutting a release." + android_changed=false + else + android_changed=true + fi + + # Commit log, merged PRs (number, branch, title) and size signals for + # the semver decision. The promotion merges themselves (dev -> staging, + # staging -> main) are not PRs worth listing. + # Newest first; capped so a very long range can't balloon the prompt — + # the PR list and size figures carry the overall picture anyway. + # `head` closing the pipe early makes the upstream git/sort take + # SIGPIPE; under `set -o pipefail` that would fail the whole step, so + # drop pipefail inside these truncating substitutions. + LOG=$(set +o pipefail; git log --no-merges --pretty='- %s' "$RANGE" | head -n 400) + LOG_TOTAL=$(git rev-list --no-merges --count "$RANGE") + if (( LOG_TOTAL > 400 )); then + LOG+=$'\n'"- … and $((LOG_TOTAL - 400)) older commits not listed" + fi + PRS="" + while read -r sha; do + subj=$(git show -s --format=%s "$sha") + num_branch=$(sed -nE 's#^Merge pull request (\#[0-9]+) from [^/]+/(.*)$#\1 \2#p' <<< "$subj") + [[ -z "$num_branch" ]] && continue + case "$num_branch" in *" dev"|*" staging") continue ;; esac + title=$(git show -s --format=%b "$sha" | sed -n '1p') + PRS+="- ${num_branch}${title:+: $title}"$'\n' + done < <(git rev-list --merges "$RANGE") + COUNT=$LOG_TOTAL + STAT=$(git diff --shortstat "$RANGE" -- ft8af/ | sed 's/^ *//') + TOTAL=$(printf '%s\n' "$ALL" | grep -c . || true) + ANDROID=$(printf '%s\n' "$ANDROID_FILES" | grep -c . || true) + AREAS=$(set +o pipefail; printf '%s\n' "$ALL" \ + | sed -E 's#^ft8af/app/src/main/(java/com/k1af/ft8af|kotlin/radio/ks3ckc/ft8af)/#app:#; s#^ft8af/app/src/test/(java/com/k1af/ft8af|kotlin/radio/ks3ckc/ft8af)/#app-tests:#; s#^ft8af/app/src/main/cpp/#native:#; s#^ft8af/app/src/main/res/#app-res:#' \ + | awk -F/ '{ print (NF > 1 ? $1 "/" $2 : $1) }' | sort | uniq -c | sort -rn | head -25 \ + | awk '{ printf "- %s (%d files)\n", $2, $1 }') + + { + echo "prev_tag=$PREV_TAG" + echo "prev_version=$PREV_VERSION" + echo "android_changed=$android_changed" + echo "log<> "$GITHUB_OUTPUT" + + - name: Look up the promoted staging candidate + id: candidate + # A main push promotes a staging build that already chose a semver and + # wrote release notes. Reuse them so production ships the version the + # internal testers ran rather than re-rolling Claude's decision. The + # staging prerelease body carries both inside hidden HTML-comment markers + # (see "Write release notes"). Falls through (found=false) for staging + # builds that predate the markers, so Claude decides on main instead. + if: steps.lane.outputs.lane == 'production' && steps.changes.outputs.android_changed == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PREV_TAG: ${{ steps.changes.outputs.prev_tag }} + run: | + set -euo pipefail + cand="" + for tag in $(git tag --list 'android-dev.*' --sort=-v:refname); do + # Newest staging build that is part of this main HEAD and was not + # already shipped by the previous production release. + if git merge-base --is-ancestor "$tag" HEAD 2>/dev/null \ + && { [[ -z "$PREV_TAG" ]] || ! git merge-base --is-ancestor "$tag" "$PREV_TAG" 2>/dev/null; }; then + cand="$tag"; break fi - base_ver="${base_ver:-0.0.0}" - echo "release_tag=$new_tag" >> "$GITHUB_OUTPUT" - echo "version_name=${base_ver}-dev.${GITHUB_RUN_NUMBER}" >> "$GITHUB_OUTPUT" - echo "should_release=true" >> "$GITHUB_OUTPUT" - echo "is_prerelease=true" >> "$GITHUB_OUTPUT" - echo "play_track=internal" >> "$GITHUB_OUTPUT" + done + if [[ -z "$cand" ]]; then + echo "No unshipped android-dev.* build reachable from HEAD — Claude will version this release." + echo "found=false" >> "$GITHUB_OUTPUT"; exit 0 + fi + BODY=$(gh release view "$cand" --json body -q .body 2>/dev/null | tr -d '\r' || true) + VERSION=$(sed -nE 's#.*.*#\1#p' <<< "$BODY" | head -n1) + if [[ -z "$VERSION" ]]; then + echo "Candidate $cand has no version marker (pre-dates AI versioning) — Claude will version this release." + echo "found=false" >> "$GITHUB_OUTPUT"; exit 0 + fi + awk '//{f=1; next} //{f=0} f' <<< "$BODY" > notes.txt + echo "Promoting $cand: version $VERSION" + echo "Notes:"; cat notes.txt + echo "found=true" >> "$GITHUB_OUTPUT" + echo "tag=$cand" >> "$GITHUB_OUTPUT" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + + - name: Generate release notes and semver bump with Claude + id: ai + if: >- + (steps.lane.outputs.lane == 'production' || steps.lane.outputs.lane == 'dev') + && steps.changes.outputs.android_changed == 'true' + && steps.candidate.outputs.found != 'true' + env: + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + PREV_VERSION: ${{ steps.changes.outputs.prev_version }} + COMMIT_LOG: ${{ steps.changes.outputs.log }} + PR_LIST: ${{ steps.changes.outputs.prs }} + CHANGE_SIZE: ${{ steps.changes.outputs.size }} + run: | + set -euo pipefail + # Never block a release on the AI step: with no key or a failed call, + # warn loudly, take a patch bump and use the PR titles as the notes. + fallback() { + echo "::warning title=AI versioning fell back::$1 — using a patch bump and the PR titles as release notes. Set the ANTHROPIC_API_KEY secret / check the step log." + echo "bump=patch" >> "$GITHUB_OUTPUT" + echo "source=fallback" >> "$GITHUB_OUTPUT" + # The PR-title notes are produced by `release_notes.py ensure-notes` + # in "Write release notes", which runs for every producer (including + # a Claude answer whose notes came back blank), so leave nothing here. + : > notes.txt + exit 0 + } + [[ -n "$ANTHROPIC_API_KEY" ]] || fallback "ANTHROPIC_API_KEY is not set" + + SCHEMA='{ + "type": "object", + "properties": { + "bump": { + "type": "string", + "enum": ["major", "minor", "patch"], + "description": "Semver bump implied by the content and size of the change" + }, + "notes": { + "type": "string", + "description": "Google Play release notes, plain text, under 400 characters" + } + }, + "required": ["bump", "notes"], + "additionalProperties": false + }' + cat > prompt.txt < request.json + if ! RESP=$(curl -sSf --max-time 300 https://api.anthropic.com/v1/messages \ + -H "x-api-key: $ANTHROPIC_API_KEY" \ + -H "anthropic-version: 2023-06-01" \ + -H "anthropic-beta: server-side-fallback-2026-07-01" \ + -H "content-type: application/json" \ + -d @request.json); then + fallback "the Claude API request failed" + fi + STOP=$(jq -r '.stop_reason // empty' <<< "$RESP") + if [[ "$STOP" != "end_turn" ]]; then + echo "Response: $RESP" + fallback "Claude did not complete (stop_reason=${STOP:-none})" + fi + # Structured outputs guarantee the text block is valid JSON matching the schema. + TEXT=$(jq -r '[.content[] | select(.type == "text")][0].text' <<< "$RESP") + BUMP=$(jq -er '.bump' <<< "$TEXT") || fallback "Claude's answer had no bump" + # Prompt targets <=400 chars; the hard truncation guards Play's 500-char limit. + jq -r '.notes' <<< "$TEXT" | head -c 500 > notes.txt + echo "bump=$BUMP" >> "$GITHUB_OUTPUT" + echo "source=claude" >> "$GITHUB_OUTPUT" + echo "Claude chose bump: $BUMP" + echo "Notes:"; cat notes.txt + + - name: Compute version and release tag + id: tag + env: + LANE: ${{ steps.lane.outputs.lane }} + ANDROID_CHANGED: ${{ steps.changes.outputs.android_changed }} + PREV_TAG: ${{ steps.changes.outputs.prev_tag }} + PREV_VERSION: ${{ steps.changes.outputs.prev_version }} + CAND_FOUND: ${{ steps.candidate.outputs.found }} + CAND_TAG: ${{ steps.candidate.outputs.tag }} + CAND_VERSION: ${{ steps.candidate.outputs.version }} + BUMP: ${{ steps.ai.outputs.bump }} + BUMP_SOURCE: ${{ steps.ai.outputs.source }} + run: | + set -euo pipefail + release_tag=""; version_name=""; should_release=false; is_prerelease=false; play_track=""; version_source="" + case "$LANE" in + tag) + release_tag="${GITHUB_REF_NAME}" + version_name="${GITHUB_REF_NAME#android-v}" + should_release=true; play_track=production; version_source="release tag ($GITHUB_EVENT_NAME)" + ;; + production|dev) + if [[ "$ANDROID_CHANGED" != "true" ]]; then + echo "No Android changes since ${PREV_TAG:-the baseline}: build only." + else + if [[ "$CAND_FOUND" == "true" ]]; then + version="$CAND_VERSION"; version_source="promoted from $CAND_TAG" + else + version=$(.github/scripts/android-next-version.sh "$PREV_VERSION" "$BUMP") + version_source="$BUMP bump ($BUMP_SOURCE) from ${PREV_VERSION}" + fi + if [[ "$LANE" == "production" ]]; then + # Guard against a version that was already tagged (e.g. the same + # candidate promoted twice): step the patch until the tag is free. + while git rev-parse -q --verify "refs/tags/android-v$version" > /dev/null; do + echo "::warning::Tag android-v$version already exists — stepping the patch version." + version=$(.github/scripts/android-next-version.sh "$version" patch) + done + release_tag="android-v$version" + version_name="$version" + # No Play publish on a staging -> main merge: the merge cuts + # the android-v* tag + GitHub Release only. Shipping it is a + # separate manual act — run this workflow by hand with that + # android-v* tag as the ref, which takes the `tag` lane above + # and uploads to the Play production track. (Not "push the + # tag": this run creates the tag itself through the Releases + # API, so there is nothing left to push.) + play_track="" + else + # staging: a run-numbered PRERELEASE on the Play internal track. + # Distinct tag prefix so it sorts separately from android-v*; + # the APK reads e.g. "0.150.0-dev.1041". + release_tag="android-dev.${GITHUB_RUN_NUMBER}" + version_name="${version}-dev.${GITHUB_RUN_NUMBER}" + is_prerelease=true + play_track=internal + fi + should_release=true + echo "version=$version" >> "$GITHUB_OUTPUT" + fi + ;; + *) + echo "dev push, PR, or other branch — build only, no release." + ;; + esac + echo "Release: tag=${release_tag:-} versionName=${version_name:-} release=$should_release prerelease=$is_prerelease track=${play_track:-} (${version_source:-n/a})" + { + echo "release_tag=$release_tag" + echo "version_name=$version_name" + echo "should_release=$should_release" + echo "is_prerelease=$is_prerelease" + echo "play_track=$play_track" + echo "version_source=$version_source" + } >> "$GITHUB_OUTPUT" + + - name: Write release notes (GitHub body + Play what's-new) + if: steps.tag.outputs.should_release == 'true' + env: + VERSION: ${{ steps.tag.outputs.version }} + VERSION_NAME: ${{ steps.tag.outputs.version_name }} + VERSION_SOURCE: ${{ steps.tag.outputs.version_source }} + LANE: ${{ steps.lane.outputs.lane }} + PR_LIST: ${{ steps.changes.outputs.prs }} + run: | + set -euo pipefail + # notes.txt comes from Claude, the fallback, the promoted staging + # candidate, or (manual ship) the tag's existing release; a plain + # tag push with no release yet has none. + if [[ "$LANE" != "tag" ]]; then + # The producers can leave it blank (Claude's schema allows an empty + # notes string; jq -r writes that as a newline), and a release whose + # markers enclose nothing would later be refused by the manual + # production ship. Guarantee nonblank notes before they go into the + # markers: the PR titles, else "Bug fixes and improvements." + python3 .github/scripts/release_notes.py ensure-notes --notes notes.txt --pr-list-env PR_LIST + fi + mkdir -p distribution/whatsnew + if [[ -s notes.txt ]]; then + head -c 500 notes.txt > distribution/whatsnew/whatsnew-en-US else - # dev push, PR, or other branch — build only, no release - echo "release_tag=" >> "$GITHUB_OUTPUT" - echo "version_name=" >> "$GITHUB_OUTPUT" - echo "should_release=false" >> "$GITHUB_OUTPUT" - echo "is_prerelease=false" >> "$GITHUB_OUTPUT" - echo "play_track=" >> "$GITHUB_OUTPUT" + echo "FT8AF $VERSION_NAME" > distribution/whatsnew/whatsnew-en-US + fi + if [[ -f existing-release-body.md ]]; then + # Manual ship on a tag that already has a release: hand softprops + # the body it already has, byte for byte, so updating the release + # to attach this run's APK does not rewrite the promoted notes. + cp existing-release-body.md release-body.md + echo "::group::release-body.md (kept from the existing release)"; cat release-body.md; echo "::endgroup::" + exit 0 fi + { + if [[ -s notes.txt ]]; then + echo "## Release notes" + echo "" + # Hidden markers: the main-push "candidate" step reads the version + # and notes back out of a staging prerelease through these. + echo "" + cat notes.txt + echo "" + echo "" + echo "" + fi + if [[ -n "$VERSION" ]]; then + echo "" + fi + echo "_Version \`$VERSION_NAME\` — $VERSION_SOURCE._" + echo "" + echo "---" + echo "Vibecoded with spite on I-70 enroute to Dayton Hamvention." + echo "" + } > release-body.md + echo "::group::release-body.md"; cat release-body.md; echo "::endgroup::" - name: Set up JDK 17 uses: actions/setup-java@v4 @@ -577,16 +984,17 @@ jobs: # the Releases page. target_commitish: ${{ github.sha }} files: ft8af/app/build/outputs/apk/release/FT8AF-*.apk - generate_release_notes: true + # The body (Claude's notes + version markers + signature) is PREPENDED + # to GitHub's auto-generated "What's Changed" PR list. + body_path: release-body.md + # Off when the body was kept from an existing release (manual ship): + # that body already ends in the generated list, and asking for it + # again would append a second copy. + generate_release_notes: ${{ steps.existing.outputs.found != 'true' }} # android-dev.* releases are flagged as prerelease so they sort under # the latest android-v* and don't get picked up by downstream tooling # that looks for "latest release". prerelease: ${{ steps.tag.outputs.is_prerelease == 'true' }} - append_body: true - body: | - - --- - Vibecoded with spite on I-70 enroute to Dayton Hamvention. env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -602,7 +1010,7 @@ jobs: # broken build fails earlier, in "Build APK & AAB". - name: Publish AAB to Play id: play_publish - if: steps.tag.outputs.should_release == 'true' + if: steps.tag.outputs.should_release == 'true' && steps.tag.outputs.play_track != '' continue-on-error: true uses: r0adkll/upload-google-play@v1 with: @@ -612,18 +1020,99 @@ jobs: # R8 deobfuscation mapping — clears the Play Console "no deobfuscation # file" warning and makes crash/ANR stack traces readable. mappingFile: ft8af/app/build/outputs/mapping/release/mapping.txt - # staging -> internal track; main / android-v* tag -> production track. + # staging -> internal track; android-v* tag ref (a tag push or the + # manual ship run) -> production track. A push to main leaves the + # track empty, which skips this step entirely. + track: ${{ steps.tag.outputs.play_track }} + status: completed + releaseName: ${{ steps.tag.outputs.release_tag }} + # Claude's release notes (whatsnew-en-US), written by "Write release + # notes" above — shows up as the track's "Release notes" in Console. + whatsNewDirectory: distribution/whatsnew + # Let Google auto-send the release for review (the normal path). See + # the retry step below: whether Google accepts this flag depends on + # Play Console review state, which flips out from under CI, so the + # value here is a first guess, not a setting to hand-tune. + changesNotSentForReview: false + + # The changesNotSentForReview flag is not ours to choose — Google's edit + # commit demands one specific value and rejects the other, and which one + # it wants depends on Play Console review state that changes outside CI: + # + # normal reviewed app -> Google auto-sends changes for review, and the + # commit rejects the flag with "Changes are sent + # for review automatically. The query parameter + # changesNotSentForReview must not be set." + # review-gated changes -> the commit refuses to auto-send and demands + # pending in Console "Please set the query parameter + # changesNotSentForReview to true." + # + # We have been burned in BOTH directions (false broke the android-dev.1231 + # internal upload after an app-level review gate appeared; true broke + # android-dev.1026 once the app went back to normal), so don't hand-flip + # the literal above — retry once with the opposite value instead. The + # first attempt uploaded the AAB into an edit that was never committed, so + # the versionCode is still unused and re-uploading it here is fine. + - name: Retry Play publish with the opposite review flag + id: play_publish_retry + if: steps.tag.outputs.should_release == 'true' && steps.tag.outputs.play_track != '' && steps.play_publish.outcome == 'failure' + continue-on-error: true + uses: r0adkll/upload-google-play@v1 + with: + serviceAccountJsonPlainText: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }} + packageName: radio.ks3ckc.ft8af + releaseFiles: ft8af/app/build/outputs/bundle/release/app-release.aab + mappingFile: ft8af/app/build/outputs/mapping/release/mapping.txt track: ${{ steps.tag.outputs.play_track }} status: completed releaseName: ${{ steps.tag.outputs.release_tag }} + whatsNewDirectory: distribution/whatsnew + changesNotSentForReview: true + + # Committed but NOT sent for review: the release is on the track, and a + # human has to press "Send for review" (or submit the pending app-level + # change) in the Play Console before testers see it. Say so loudly rather + # than letting a green run imply the build shipped. + - name: Note the review-flag retry + if: steps.play_publish_retry.outcome == 'success' + run: | + echo "::warning::Play publish for ${{ steps.tag.outputs.release_tag }} needed changesNotSentForReview=true — the app has review-gated changes pending in the Play Console. The AAB is committed to the '${{ steps.tag.outputs.play_track }}' track but was NOT auto-sent for review: open the Play Console and send the pending changes for review, or testers will not get this build." # Make a swallowed publish failure visible (annotation on the run) without # failing the job — the artifact + GitHub Release already succeeded. - name: Warn if Play publish failed - if: steps.tag.outputs.should_release == 'true' && steps.play_publish.outcome == 'failure' + if: steps.tag.outputs.play_track != '' && steps.play_publish.outcome == 'failure' && steps.play_publish_retry.outcome != 'success' run: | echo "::warning::Play publish (track=${{ steps.tag.outputs.play_track }}) failed for ${{ steps.tag.outputs.release_tag }} (non-blocking). The signed AAB built and the GitHub Release was created; only the Play upload did not complete. Check the 'Publish AAB to Play' step log — common causes are an upgrade-path rejection or an expired Play edit." + - name: Release summary + if: steps.tag.outputs.should_release == 'true' + env: + RELEASE_TAG: ${{ steps.tag.outputs.release_tag }} + VERSION_NAME: ${{ steps.tag.outputs.version_name }} + VERSION_SOURCE: ${{ steps.tag.outputs.version_source }} + PLAY_TRACK: ${{ steps.tag.outputs.play_track }} + # The retry step (opposite changesNotSentForReview) is what + # decides the real outcome whenever the first attempt failed. + PLAY_OUTCOME: ${{ steps.play_publish.outcome == 'success' && 'success' || steps.play_publish_retry.outcome }} + run: | + { + echo "## Android release $RELEASE_TAG" + echo "" + echo "- **versionName:** \`$VERSION_NAME\` ($VERSION_SOURCE)" + echo "- **versionCode:** $((GITHUB_RUN_NUMBER + 1000))" + if [[ -n "$PLAY_TRACK" ]]; then + echo "- **Play track:** \`$PLAY_TRACK\` (publish: $PLAY_OUTCOME)" + else + echo "- **Play track:** _none — not published._ To ship it, run this workflow manually with the \`$RELEASE_TAG\` tag selected as the ref (Actions → Android CI & Release → Run workflow). The tag already exists, so there is nothing to push." + fi + echo "" + echo "### Release notes" + echo "" + if [[ -s notes.txt ]]; then cat notes.txt; else echo "_(none — tag push with no existing release)_"; fi + echo "" + } >> "$GITHUB_STEP_SUMMARY" + android-gate: name: android-gate # Always-run aggregator: the single required status check for Android. diff --git a/.github/workflows/discord-merge-notify.yml b/.github/workflows/discord-merge-notify.yml index a7029d3cc..33d3d3c24 100644 --- a/.github/workflows/discord-merge-notify.yml +++ b/.github/workflows/discord-merge-notify.yml @@ -39,6 +39,9 @@ jobs: sha_short="${SHA:0:7}" first_line=$(printf '%s' "$COMMIT_MSG" | head -n1) body=$(printf '%s' "$COMMIT_MSG" | tail -n +2 | sed -E '/^[[:space:]]*$/d' || true) + # Discord embed limits: title <= 256 chars, description <= 4096 chars. + # A large squash-merge commit body overruns description -> HTTP 400, so + # clamp both fields (with an ellipsis) before building the payload. payload=$(jq -nc \ --arg branch "$BRANCH" \ --arg sha "$sha_short" \ @@ -50,11 +53,12 @@ jobs: --arg repo "$REPO" \ --arg emoji "$emoji" \ --argjson color "$color" \ - '{embeds:[{ - title: ($emoji + " " + $repo + " — merged into " + $branch + ": " + $first), + 'def clamp(max): if (.|length) > max then (.[0:max-1] + "…") else . end; + {embeds:[{ + title: (($emoji + " " + $repo + " — merged into " + $branch + ": " + $first) | clamp(256)), url: $url, color: $color, - description: (if $body == "" then null else $body end), + description: (if $body == "" then null else ($body | clamp(4096)) end), fields: [ {name:"Commit", value:("[`"+$sha+"`]("+$url+")"), inline:true}, {name:"Author", value:$author, inline:true}, diff --git a/.github/workflows/play-listings.yml b/.github/workflows/play-listings.yml new file mode 100644 index 000000000..63cc97624 --- /dev/null +++ b/.github/workflows/play-listings.yml @@ -0,0 +1,147 @@ +name: Play store listings + +# The localized Play Store listing text (title / short description / full +# description, one directory per language) lives in fastlane/metadata/android +# and is published from there by .github/scripts/publish_listings.py. +# +# pull request touching the metadata -> validate only (no secrets, works from forks) +# workflow_dispatch -> pick a mode: dry-run (default), +# check-permissions, or publish +# +# Publishing is DELIBERATE, never automatic: no branch push runs it. Listing +# text lands in the repo on the normal feature -> dev -> staging -> main flow +# and sits there until someone decides the store should say it, then ships by a +# manual run here (or by running .github/scripts/publish_listings.py locally -- +# see docs/store-listings.md). Merging to main touches Google Play in no way at +# all: android.yml uploads no AAB there either. +# +# Graphics (icon, feature graphic, screenshots) are NOT managed here — Play falls +# back to the default language's graphics for locales that have none of their +# own, and screenshots are still uploaded by hand. See docs/store-listings.md. +# +# Release notes are also not managed here: android.yml writes +# distribution/whatsnew/whatsnew-en-US and hands it to the upload action. + +on: + pull_request: + paths: + - 'fastlane/metadata/android/**' + - '.github/scripts/publish_listings.py' + - '.github/scripts/test_publish_listings.py' + - '.github/workflows/play-listings.yml' + # The app's own translations. test_every_app_language_has_a_listing reads + # these to check that every shipping language has a store listing, so a PR + # adding only values-xx/strings_compose.xml has to run this gate — that PR + # is precisely the regression the test exists to catch. + - 'ft8af/app/src/main/res/values*/strings_compose.xml' + workflow_dispatch: + inputs: + mode: + description: 'What to run' + type: choice + default: dry-run + options: + # Read-only. Prints a unified diff of the repo against the live + # listings and sends nothing. + - dry-run + # Proves the service account holds "Manage store presence" by + # writing one listing (a patch of one Play already has, or a put if + # Play has none yet) inside an edit it abandons. Nothing is + # committed. A dry run cannot answer this — it only reads. + - check-permissions + # The real thing: push every changed locale and commit the edit. + - publish + +permissions: + contents: read + +jobs: + # Cheap, secret-free gate: the unit tests also load the checked-in metadata + # tree and assert every locale is complete and within Play's character + # limits, so an over-long translation fails the PR instead of a publish. + validate: + name: Validate listing metadata + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Run publish_listings unit tests + run: python -m unittest discover -s .github/scripts -p 'test_*.py' -v + + publish: + name: Run against Play + needs: validate + # Only from the canonical repo — a fork has no PLAY_SERVICE_ACCOUNT_JSON and + # must never be able to push listing text to the production app. + if: >- + github.repository == 'patrickrb/FT8AF' + && github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + # Google Play allows only one open "edit" per app. android.yml serializes its + # release publishes under this same group; sharing it keeps a listing update + # from colliding with an AAB upload ("This edit has expired"). + concurrency: + group: play-publish + cancel-in-progress: false + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install deps + run: pip install --quiet google-auth requests + + - name: Run + env: + PLAY_SERVICE_ACCOUNT_JSON: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }} + PACKAGE_NAME: radio.ks3ckc.ft8af + # Whatever the manual run picked. Falls back to the read-only dry + # run rather than to `publish`, so a mode that somehow arrives empty + # sends nothing to Play instead of shipping every changed locale. + MODE: ${{ inputs.mode || 'dry-run' }} + run: | + set -euo pipefail + if [[ -z "${PLAY_SERVICE_ACCOUNT_JSON:-}" ]]; then + echo "::error::PLAY_SERVICE_ACCOUNT_JSON is not set — cannot reach the Play API." + exit 1 + fi + + args=() + case "$MODE" in + dry-run) + args+=(--dry-run) + echo "::notice::Dry run — the diff below is what a real run would send." + ;; + check-permissions) + args+=(--check-permissions) + echo "::notice::Permission probe — writes one listing (patch, or put if Play has none yet) inside an edit that is then abandoned. Nothing is published." + ;; + publish) + ;; + *) + echo "::error::Unknown mode '$MODE'." + exit 1 + ;; + esac + + # The probe's exit code is a verdict, not just pass/fail, so read it + # rather than letting -e swallow the distinction. + set +e + python .github/scripts/publish_listings.py "${args[@]}" + rc=$? + set -e + + if [[ "$MODE" == "check-permissions" ]]; then + # The code is a verdict (3 = denied, 4 = inconclusive, anything + # else = the probe did not run). The code -> annotation mapping + # lives in the script (probe_annotation), where it is unit-tested + # against the EXIT_* constants; this only relays it. + python .github/scripts/publish_listings.py --annotate-verdict "$rc" + fi + exit $rc diff --git a/.gitignore b/.gitignore index 0a1eb5582..7bcf97a62 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,11 @@ ft8af/app/src/main/assets/.nightUSGS4Layer.sqlite.icloud # Per-contributor Claude Code notes (machine paths, device serials) - see CLAUDE.md CLAUDE.local.md + +# Accidental scratch/build-artifact trees (see PR #524) +uiaar/ +hamlib-4.7.2/ + +# Python bytecode (.github/scripts unit tests) +__pycache__/ +*.pyc diff --git a/.optio-run-token b/.optio-run-token new file mode 100644 index 000000000..fcbfc5c61 --- /dev/null +++ b/.optio-run-token @@ -0,0 +1 @@ +f6dd7fa3-9965-4b16-b54a-83eb1a49f7bc diff --git a/.optio/task.md b/.optio/task.md new file mode 100644 index 000000000..58c8bea3c --- /dev/null +++ b/.optio/task.md @@ -0,0 +1,49 @@ +# Add WSJT-X-style worked stations flag handling with configurable behavior + +Add WSJT-X-style worked stations flag handling with configurable behavior + +## Summary +Implement WSJT-X-style handling for the “worked stations” flag so users can control when and how worked stations are indicated or cleared. + +## Background +The current behavior in FT8AF should be updated to better match WSJT-X. We should first research exactly how WSJT-X handles worked-station state and clearing behavior, then align FT8AF’s implementation with that model. + +Based on current understanding, WSJT-X appears to support: +- different display behaviors for worked stations: hide, ignore, or highlight +- different time scopes such as today +- different context scopes such as before, on band, and from list + +Because the exact WSJT-X behavior is not yet fully confirmed, this issue should cover both the research needed to verify the behavior and the implementation work required to bring FT8AF in line with it. + +## Proposed work +- Research how WSJT-X tracks and clears worked-station flags +- Document the supported modes and scopes in WSJT-X +- Implement equivalent or closely matching behavior in FT8AF +- Add a user-facing setting to control worked-station handling behavior +- Ensure the setting covers both display mode and scope where applicable + +## Expected settings/options +Provide a configurable setting that allows users to choose how worked stations are treated, likely along these lines: +- hide +- ignore +- highlight + +And allow users to choose the scope or basis for the worked-state logic, based on the WSJT-X model, such as: +- today +- before +- on band +- from list + +## Acceptance criteria +- FT8AF behavior is based on verified WSJT-X behavior rather than assumptions +- Users can configure how worked stations are displayed or filtered +- Users can configure the scope used for worked-station matching/clearing +- The default behavior is sensible and documented +- The implementation is tested against the researched WSJT-X behavior + +## Notes +If WSJT-X behavior differs from the assumptions above, the implementation and UI should follow the researched behavior rather than this preliminary list. + +--- +*Optio Task ID: 9ee11633-19c0-449d-b027-2354acc9008b* +*Source: [github](https://github.com/patrickrb/FT8AF/issues/477)* \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index 25792361b..8ca7a9185 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -202,3 +202,22 @@ duration (12.14 s real time for 12.64 s of audio). Fixed in PR #94 in unaffected because the kernel UAC driver does this math automatically; the bug only bites the direct-libusb path used for car-dash kernels and similar. + +**4. The USB-direct path must force-claim the UAC AudioControl interface, +not just the streaming ones.** Claiming only the AudioStreaming interfaces +is a silent no-op for the kernel's `snd-usb-audio` driver (it binds the card +at the AudioControl interface and treats the streaming interfaces as +owned-but-unused), so the ALSA card survives and Android keeps the rig's +sound card registered as a `usb_headset` sink+source. Every sound Android +then routes there — the app's own QSO-complete alert ding, a Bluetooth +car-kit connecting, a nav prompt — makes the kernel driver flip the playback +interface's alt-setting under our in-flight iso URBs, which the kernel +completes with `-ESHUTDOWN`. Tell from log: `libusb native write FAILED +(rc=5 TRANSFER_NO_DEVICE) after ~280ms` with **no** `usbDetach` and the RX +capture still running, typically 1.9 s after an `ALERT fire` line. The +device is still on the bus; only the endpoint was torn down. Fixed by +`UsbAudioDevice.detachKernelAudioDriver()`, which claims the AudioControl +interface (that runs the real `usb_audio_disconnect`). A genuine bus drop +looks different: `usbDetach` for the hub, serial and audio devices together +and `serial.send: port not open!` — that one is electrical (RF resetting the +hub), not software. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 000000000..8554d4d82 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,89 @@ +# Security Policy + +FT8AF is an amateur-radio application that drives radios over CAT, plays and +records audio, and uploads contact logs to third-party services (Cloudlog, QRZ). +We take security and privacy seriously and appreciate reports that help keep +operators and their stations safe. + +## Supported Platforms + +FT8AF ships in several flavors, all covered by this policy: + +| Platform | Location | Notes | +| ----------------------- | ----------- | -------------------------------------------------- | +| **Android** | `ft8af/` | Kotlin/Java app with native `ft8_lib`/JNI DSP core | +| **Desktop (Windows / macOS / Linux)** | `desktop/` | Tauri app (Rust backend + web UI), rig control via bundled Hamlib | +| **iOS** | `ios/` | Swift app and `FT8AFKit` | + +## Supported Versions + +Security fixes are applied to the latest release only. Please make sure you can +reproduce an issue on the most recent [release](https://github.com/patrickrb/FT8AF/releases) +(or a current build of the `dev` branch) before reporting. + +| Version | Supported | +| ------------------ | ------------------ | +| Latest release | :white_check_mark: | +| Older releases | :x: | + +## Reporting a Vulnerability + +**Please do not report security vulnerabilities through public GitHub issues, +pull requests, or the Discord server.** + +Instead, use GitHub's private vulnerability reporting: + +1. Go to the [Security tab](https://github.com/patrickrb/FT8AF/security) of this + repository. +2. Click **Report a vulnerability** to open a private advisory. + +This routes your report privately to the maintainers. If you are unable to use +GitHub's reporting flow, email [k1af@ft8af.app](mailto:k1af@ft8af.app) to +arrange a private channel. + +### What to include + +To help us triage quickly, please include as much of the following as you can: + +- The affected platform (Android, desktop/Windows/macOS/Linux, or iOS) and + component (app UI, native `ft8_lib`/JNI glue, Tauri/Rust backend, Hamlib rig + control, build/CI workflows). +- Version or commit hash, plus device/OS and radio model if relevant. +- A description of the vulnerability and its potential impact. +- Step-by-step reproduction instructions, proof-of-concept, or logs. + +### Our commitment + +- We will acknowledge your report within **5 business days**. +- We will provide an assessment and expected timeline within **10 business days**. +- We will keep you informed as we work on a fix and will credit you in the + release notes and advisory unless you prefer to remain anonymous. + +## Scope + +Areas of particular interest, across all platforms: + +- Handling of untrusted RF/decoded input in the native FT8 DSP path + (`ft8af/app/src/main/cpp/`) — memory-safety issues in parsing decoded frames. +- CAT / audio device handling: the Android USB CAT and direct-libusb path, and + the desktop Hamlib-based rig control (`desktop/src-tauri/hamlib/`). +- The desktop Tauri/Rust backend and its exposed commands / IPC surface. +- Storage and transmission of credentials for logging services (Cloudlog, QRZ) + on every platform. +- Any code that reads, writes, or uploads user data. + +The vendored [ft8_lib](https://github.com/kgoba/ft8_lib) DSP core is pinned to an +upstream commit (see `ft8af/app/src/main/cpp/ft8_lib/FT8_LIB_PIN.txt`), and the +desktop build bundles [Hamlib](https://github.com/Hamlib/Hamlib). If a +vulnerability originates in one of these upstream projects, please also consider +reporting it there; we will coordinate on picking up the fix. + +## Out of Scope + +- Vulnerabilities in third-party services (QRZ, Cloudlog) themselves — report + those to the respective service. +- Issues requiring a rooted device, physical access plus an unlocked bootloader, + or a compromised host already under attacker control. +- Reports from automated scanners without a demonstrated, exploitable impact. + +Thank you for helping keep FT8AF and the amateur-radio community safe. 73. diff --git a/desktop/README.md b/desktop/README.md index 7162865c2..1e4dab738 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -55,11 +55,27 @@ npm run tauri dev # starts Vite + builds the Rust app + opens the window > runtime `LoadLibrary` (for Hamlib via `libloading`). Sign the binaries, or test > on a machine without Smart App Control. The app itself is unaffected. +> **macOS location service (grid auto-fill, [#471]):** using the OS location +> service needs no extra build prerequisite — CoreLocation ships in the base +> macOS SDK, so the default `clang` + Rust + Node above are enough to *compile* +> it. Two things are required for it to *work* at runtime, though: +> - **`Info.plist` usage-description key.** macOS denies location without +> `NSLocationWhenInUseUsageDescription`. Set it via `tauri.conf.json` +> (`bundle.macOS`); the string is shown to the user in the permission prompt. +> - **Run as a signed, bundled `.app`.** CoreLocation won't grant location to +> the bare `target/release/ft8af` binary, to `npm run tauri dev` / `cargo run`, +> or to an unsigned/ad-hoc build. Test the feature from the packaged, signed +> `FT8AF.app` — the permission prompt never appears otherwise. (Same class of +> caveat as the Windows Smart App Control note above.) + +[#471]: https://github.com/patrickrb/FT8AF/issues/471 + ### Rig control: Hamlib (bundled) Hamlib is the default rig backend and is **bundled with the app on Windows** — the -LGPL Hamlib DLLs live in `src-tauri/hamlib/` and `build.rs` copies them next to -the built exe, so rig support works out of the box with no separate install. +LGPL Hamlib DLLs live in `src-tauri/hamlib//` (`x64/` for 64-bit, `x86/` +for 32-bit) and `build.rs` copies the set matching the build target next to the +built exe, so rig support works out of the box with no separate install. Settings → Rig control → **Hamlib** shows a dropdown of every radio Hamlib supports (~300+); pick yours, choose **Connection: Serial** (COM port + baud) or **Network** (host:port), and Connect. Hamlib handles the model-specific CAT @@ -72,12 +88,13 @@ SmartSDR API). Note DAX is *audio* (set as the audio device), separate from CAT. The backend dynamically loads the library at runtime (`libloading`), so there's no build-time Hamlib dependency. To update Hamlib, replace the DLLs in -`src-tauri/hamlib/` (from a hamlib-w64 release). On Linux/macOS, install the -system `hamlib`/`libhamlib` package; if the library isn't found the rig list is -empty and you get a clean message rather than a crash. +`src-tauri/hamlib/x64/` (from a hamlib-w64 release) — and `src-tauri/hamlib/x86/` +from a hamlib-w32 release for the 32-bit build (see that dir's `README.txt`). On +Linux/macOS, install the system `hamlib`/`libhamlib` package; if the library +isn't found the rig list is empty and you get a clean message rather than a crash. -> If you later enable Tauri bundling (`bundle.active`), add `hamlib/*.dll` to the -> bundle `resources` so they ship in the installer too. +> If you later enable Tauri bundling (`bundle.active`), add `hamlib//*.dll` +> to the bundle `resources` so they ship in the installer too. ## Build / test @@ -89,6 +106,37 @@ cargo build --release # optimized binary (loads the bundled frontend in ../dist) The frontend builds independently with `npm run build` (emits to `desktop/dist`). +### 32-bit Windows (i686) build + +The desktop app also ships a **32-bit (x86 / `i686`) Windows** installer for +users still on 32-bit Windows, built alongside the 64-bit one. CI cross-compiles +it on the `windows-latest` runner as a second Windows matrix leg and attaches the +resulting `FT8AF__x86_en-US.msi` (plus the NSIS `..._x86-setup.exe`) to the +same desktop release as the x64 installers — Tauri's WiX bundler tags the MSI +filename with the arch, so the two never collide. + +To build it locally on a 64-bit Windows host (from `desktop/`): + +``` +rustup target add i686-pc-windows-msvc +npm ci +# compile check (no installer): +npm run tauri build -- --no-bundle --target i686-pc-windows-msvc +# full MSI/NSIS installers: +npm run tauri build -- --config "{\"bundle\":{\"active\":true}}" --target i686-pc-windows-msvc +``` + +What makes the 32-bit build work: + +- **C DSP core** — `build.rs` passes `--target=i686-pc-windows-msvc` to + `clang-cl` so `ft8core` is compiled as 32-bit objects (clang-cl otherwise + defaults to the host x64 arch). See `src-tauri/src/build_support.rs`. +- **Hamlib DLLs** — the vendored win64 DLLs can't load into a 32-bit process, so + `build.rs` copies the arch-matching set from `src-tauri/hamlib/x86/` instead of + `x64/`. Drop the win32 Hamlib release DLLs into `src-tauri/hamlib/x86/` (see + its `README.txt`); if that dir is empty the build still succeeds and rig + control degrades cleanly to "Hamlib unavailable". + ## How it works - **Decode:** audio is captured continuously (cpal), resampled to 12 kHz, and diff --git a/desktop/ci/README.md b/desktop/ci/README.md new file mode 100644 index 000000000..8f4d34ae0 --- /dev/null +++ b/desktop/ci/README.md @@ -0,0 +1,35 @@ +# Desktop CI patch — 32-bit Windows (i686) MSI leg + +`i686-windows-ci.patch` adds a second Windows leg to +`.github/workflows/desktop.yml` so the desktop release also builds and uploads a +32-bit (`i686`) MSI/NSIS installer alongside the x64 one. + +**Why it's a patch and not applied directly:** the automation account that opened +this PR authenticates with a token that lacks the GitHub `workflow` scope, so it +cannot push edits to files under `.github/workflows/`. A maintainer with +`workflow` scope should apply this patch (it's the only piece of the change that +touches a workflow file): + +``` +git apply desktop/ci/i686-windows-ci.patch +git add .github/workflows/desktop.yml +git commit -m "Desktop CI: add 32-bit Windows (i686) MSI build leg" +``` + +## What the patch changes + +The `build` job's matrix becomes an `include` list with a new `windows-x86` leg: + +- installs the `i686-pc-windows-msvc` Rust target (`dtolnay/rust-toolchain` + `targets:`), +- gives each leg a distinct `rust-cache` key so the two `windows-latest` jobs + don't thrash one cache, +- adds `--target i686-pc-windows-msvc` to both the PR compile-check + (`tauri build --no-bundle`) and the release bundle (`tauri-action` `args`), + only on that leg — the other three legs are byte-for-byte unchanged. + +Tauri's WiX bundler tags the MSI filename with the architecture +(`FT8AF__x86_en-US.msi` vs `..._x64_...`), so the two Windows installers +attach to the same release without colliding. All the non-workflow code that +makes the 32-bit build actually compile and bundle (`build.rs` arch selection, +the `hamlib/x86` DLL split) ships in this PR directly. diff --git a/desktop/ci/i686-windows-ci.patch b/desktop/ci/i686-windows-ci.patch new file mode 100644 index 000000000..80d946126 --- /dev/null +++ b/desktop/ci/i686-windows-ci.patch @@ -0,0 +1,80 @@ +diff --git a/.github/workflows/desktop.yml b/.github/workflows/desktop.yml +index 42623e33..8aef49a1 100644 +--- a/.github/workflows/desktop.yml ++++ b/.github/workflows/desktop.yml +@@ -144,13 +144,29 @@ jobs: + fi + + build: +- name: Build (${{ matrix.os }}) ++ name: Build (${{ matrix.label }}) + needs: [detect, version] + if: ${{ needs.detect.outputs.run == 'true' }} ++ # The Windows matrix carries two legs: the default 64-bit host build and a ++ # cross-compiled 32-bit (i686) build for users still on 32-bit Windows. Only ++ # the x86 leg sets `rust-target`; the other three legs build the host arch ++ # exactly as before (`rust-target` is empty -> no `--target` flag), so this ++ # can't clobber the existing x64/macOS/Linux artifacts. Tauri names the WiX ++ # MSI by arch (`..._x86_...` vs `..._x64_...`), so the two Windows installers ++ # land on the release with non-colliding filenames. + strategy: + fail-fast: false + matrix: +- os: [ubuntu-latest, macos-latest, windows-latest] ++ include: ++ - os: ubuntu-latest ++ label: linux ++ - os: macos-latest ++ label: macos ++ - os: windows-latest ++ label: windows-x64 ++ - os: windows-latest ++ label: windows-x86 ++ rust-target: i686-pc-windows-msvc + runs-on: ${{ matrix.os }} + + steps: +@@ -162,13 +178,20 @@ jobs: + with: + node-version: '20' + ++ # `targets` is empty for the host-arch legs (a no-op) and installs the ++ # i686 std/target only for the 32-bit Windows leg. + - name: Set up Rust + uses: dtolnay/rust-toolchain@stable ++ with: ++ targets: ${{ matrix.rust-target }} + + - name: Cache Rust build + uses: Swatinem/rust-cache@v2 + with: + workspaces: desktop/src-tauri ++ # Distinct key per matrix leg so the two windows-latest jobs (x64 vs ++ # x86) don't share/thrash one cache — their target dirs differ. ++ key: ${{ matrix.label }} + + # Tauri v2 Linux system dependencies (webkit2gtk-4.1 + GTK + packaging), + # plus the native libs our crates link against on Linux: +@@ -221,7 +244,8 @@ jobs: + - name: Compile check (no bundle) + if: ${{ needs.version.outputs.should_release != 'true' }} + working-directory: desktop +- run: npm run tauri build -- --no-bundle ++ # `--target` is added only on the 32-bit Windows leg; empty elsewhere. ++ run: npm run tauri build -- --no-bundle ${{ matrix.rust-target && format('--target {0}', matrix.rust-target) || '' }} + + # --- Release: build native bundles and upload to the per-platform release. + # tauri.conf.json has bundle.active=false (keeps local dev builds bundle- +@@ -240,7 +264,11 @@ jobs: + releaseName: ${{ needs.version.outputs.release_tag }} + releaseBody: 'FT8AF desktop build ${{ needs.version.outputs.version_name }}.' + prerelease: ${{ needs.version.outputs.is_prerelease == 'true' }} +- args: --config '{"bundle":{"active":true}}' ++ # On the 32-bit Windows leg, cross-compile + bundle for i686 (Tauri's ++ # WiX bundler emits an x86-arch MSI, plus an x86 NSIS). Other legs keep ++ # the host arch. All legs share one tagName, so tauri-action attaches ++ # every OS/arch's installers to the same release. ++ args: --config '{"bundle":{"active":true}}' ${{ matrix.rust-target && format('--target {0}', matrix.rust-target) || '' }} + + coverage: + name: Rust coverage diff --git a/desktop/linux/run-ft8af.sh b/desktop/linux/run-ft8af.sh new file mode 100755 index 000000000..c983ccd13 --- /dev/null +++ b/desktop/linux/run-ft8af.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +# Launcher for FT8AF on Linux, working around a real Hamlib version conflict +# (see rig.rs's load_hamlib(): a bare dlopen("libhamlib.so.4"), resolved +# against whatever the dynamic linker finds first). +# +# QMX (and other newer rigs) need a newer Hamlib than the distro package +# usually ships -- confirmed on this machine: the system's +# libhamlib.so.4 is Hamlib 4.5.4 (no QMX support at all), while +# /usr/local/lib/libhamlib.so.4 is a separately-built Hamlib 4.7.1 that does +# have it. Both share the same soname, so whichever the linker resolves +# first wins for any process that doesn't override the search path. +# +# We deliberately do NOT upgrade or replace the system Hamlib package -- +# other software on this machine (CQRLOG) depends on it, and installing a +# newer one over it would break that. Instead this script LD_PRELOADs that +# one library into FT8AF's own process: rig.rs's dlopen("libhamlib.so.4") +# then resolves to the already-loaded object, since the soname matches. +# +# LD_PRELOAD rather than prepending /usr/local/lib to LD_LIBRARY_PATH: the +# latter redirects *every* library FT8AF resolves, so a machine that also has +# a locally built libssl/libcurl/libstdc++ under /usr/local/lib would load +# those instead of the distro copies the binary was linked against -- version +# symbol errors or a crash at startup, from a script whose only job is to +# pick a Hamlib. If no newer build exists, this is a no-op and FT8AF falls +# back to whatever the system provides (same as running the binary directly). +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +DEV_BIN="$SCRIPT_DIR/../src-tauri/target/release/ft8af" + +# Prefer the dev tree's build when this is run from a checkout, but fall back +# to an installed ft8af on PATH -- the .deb/AppImage CI produces puts it at +# /usr/bin/ft8af, and this script is meant to be copied out of the repo. +if [ -x "$DEV_BIN" ]; then + BIN="$DEV_BIN" +elif BIN="$(command -v ft8af)"; then + : +else + echo "error: no ft8af binary found -- looked at $DEV_BIN and on PATH." >&2 + echo " Build it with 'npm run tauri build' or install the package." >&2 + exit 1 +fi + +HAMLIB=/usr/local/lib/libhamlib.so.4 +if [ -f "$HAMLIB" ]; then + export LD_PRELOAD="$HAMLIB${LD_PRELOAD:+:$LD_PRELOAD}" +fi + +exec "$BIN" "$@" diff --git a/desktop/package-lock.json b/desktop/package-lock.json index 7643a5ae9..31016e869 100644 --- a/desktop/package-lock.json +++ b/desktop/package-lock.json @@ -18,7 +18,8 @@ "@types/react-dom": "^18.3.0", "@vitejs/plugin-react": "^4.3.1", "typescript": "^5.5.3", - "vite": "^5.4.0" + "vite": "^5.4.0", + "vitest": "^3.2.4" } }, "node_modules/@babel/code-frame": { @@ -1373,6 +1374,24 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -1429,6 +1448,131 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@vitest/expect": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz", + "integrity": "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.7.tgz", + "integrity": "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.7", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.7.tgz", + "integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.7.tgz", + "integrity": "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.7", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.7.tgz", + "integrity": "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.7.tgz", + "integrity": "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.7.tgz", + "integrity": "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/baseline-browser-mapping": { "version": "2.10.33", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.33.tgz", @@ -1476,6 +1620,16 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001793", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz", @@ -1497,6 +1651,33 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -1529,6 +1710,16 @@ } } }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/electron-to-chromium": { "version": "1.5.367", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.367.tgz", @@ -1536,6 +1727,13 @@ "dev": true, "license": "ISC" }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, "node_modules/esbuild": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", @@ -1585,6 +1783,44 @@ "node": ">=6" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -1654,6 +1890,13 @@ "loose-envify": "cli.js" } }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -1664,6 +1907,16 @@ "yallist": "^3.0.2" } }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -1700,6 +1953,23 @@ "node": ">=18" } }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -1707,10 +1977,23 @@ "dev": true, "license": "ISC" }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/postcss": { - "version": "8.5.15", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", - "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "version": "8.5.16", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", + "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", "dev": true, "funding": [ { @@ -1835,6 +2118,13 @@ "semver": "bin/semver.js" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -1845,6 +2135,101 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/strip-literal/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -1950,6 +2335,119 @@ } } }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.7.tgz", + "integrity": "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.7", + "@vitest/mocker": "3.2.7", + "@vitest/pretty-format": "^3.2.7", + "@vitest/runner": "3.2.7", + "@vitest/snapshot": "3.2.7", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.7", + "@vitest/ui": "3.2.7", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/desktop/package.json b/desktop/package.json index 8f292493c..6590429ee 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -7,6 +7,7 @@ "dev": "vite", "build": "tsc && vite build", "preview": "vite preview", + "test": "vitest run", "tauri": "tauri" }, "dependencies": { @@ -20,6 +21,7 @@ "@types/react-dom": "^18.3.0", "@vitejs/plugin-react": "^4.3.1", "typescript": "^5.5.3", - "vite": "^5.4.0" + "vite": "^5.4.0", + "vitest": "^3.2.4" } } diff --git a/desktop/src/styles.css b/desktop/public/styles.css similarity index 61% rename from desktop/src/styles.css rename to desktop/public/styles.css index 230eb2cce..412b33b44 100644 --- a/desktop/src/styles.css +++ b/desktop/public/styles.css @@ -48,6 +48,43 @@ select, input { font-size: 13px; } +/* Scoped test: only the Rig control "Display name" field, targeted by its + placeholder text (no JSX/rebuild needed) -- not touching the global + select/input rule, which broke real keyboard input when modified. */ +input[placeholder="e.g. Flex 6400"] { + color: #fff; +} + +/* Select readability, fixed at the cause rather than per-id. + The original symptom is Linux-only: WebKitGTK draws + // popups render as a separate top-level X window on Linux, so they don't + // show up in a window-scoped screenshot either). + if std::env::args().any(|a| a == "--list-audio") { + let inputs = audio::list_input_devices(); + println!("Input devices ({}):", inputs.len()); + for d in inputs.iter() { + println!( + " {}{} -- {} Hz, {} ch", + if d.is_default { "* " } else { " " }, + d.name, + d.default_sample_rate, + d.channels + ); + } + let outputs = audio::list_output_devices(); + println!("Output devices ({}):", outputs.len()); + for d in outputs.iter() { + println!( + " {}{} -- {} Hz, {} ch", + if d.is_default { "* " } else { " " }, + d.name, + d.default_sample_rate, + d.channels + ); + } + return; + } + + let data_dir = app_data_dir(); let _ = std::fs::create_dir_all(&data_dir); let db = Arc::new( Db::open(data_dir.join("ft8af.sqlite")).expect("failed to open database"), @@ -242,12 +485,16 @@ fn main() { list_serial_ports, list_hamlib_rigs, list_bands, + list_custom_bands, + add_custom_band, + delete_custom_band, start_decode, stop_decode, set_station, set_band, set_base_freq, set_tx_gain, + set_rx_gain, set_input_device, set_output_device, select_rig, @@ -260,6 +507,7 @@ fn main() { stop_tx, free_text, list_log, + search_log, log_count, delete_qso, save_qso, @@ -268,7 +516,99 @@ fn main() { set_config, all_config, set_waterfall_config, + get_custom_css, + set_udp_config, + get_os_location, ]) .run(tauri::generate_context!()) .expect("error running FT8AF"); } + +#[cfg(test)] +mod tests { + use super::{ + fnv1a64, resolve_styles, split_styles_stamp, stamped_default_css, StylesAction, + DEFAULT_STYLES_CSS, + }; + + const OLD: &str = "body { color: red; }\n"; + const NEW: &str = "body { color: blue; }\n"; + + #[test] + fn stamp_round_trips() { + let seeded = stamped_default_css(OLD); + assert_eq!(split_styles_stamp(&seeded), Some((fnv1a64(OLD), OLD))); + } + + #[test] + fn unstamped_content_has_no_stamp() { + assert_eq!(split_styles_stamp(OLD), None); + // A truncated or malformed marker must not be mistaken for a stamp. + assert_eq!(split_styles_stamp("/* ft8af-seeded: zzzz */\nx"), None); + assert_eq!(split_styles_stamp("/* ft8af-seeded: 00ff"), None); + } + + #[test] + fn missing_or_blank_file_is_seeded() { + // No file at all: first run. + assert_eq!(resolve_styles(None, NEW), StylesAction::Seed); + // A 0-byte file from an interrupted first write reads back Ok("") -- + // serving that would inject an empty