Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e726542f96 | ||
|
|
e0427a3bb6 | ||
|
|
02a5bdb965 | ||
|
|
09b9ee8f53 | ||
|
|
43e3c7b69f |
@@ -0,0 +1,95 @@
|
||||
# Move a versionCode that is ALREADY on Google Play between tracks — no rebuild.
|
||||
#
|
||||
# Why this is separate from android.yml: promotion must not rebuild. A rebuild produces a fresh
|
||||
# versionCode (github.run_number) from possibly-newer sources, so it ships something nobody tested;
|
||||
# promoting assigns the byte-identical artifact the testers already ran. Bolting this onto
|
||||
# android.yml would mean an `if:` on all ten of its build steps.
|
||||
#
|
||||
# What it is for:
|
||||
# * promote a tested build up a track (alpha -> production)
|
||||
# * roll production back by re-pointing it at an older versionCode (to_track=production,
|
||||
# version_code=<the good one>, from_track blank)
|
||||
# * halt a rollout (status=halted)
|
||||
#
|
||||
# Defaults are deliberately the safe ones: dry_run starts TRUE, so a mis-typed versionCode
|
||||
# validates and deletes the edit instead of publishing. Flip it to false only when the dry run
|
||||
# printed what you meant.
|
||||
name: android-promote
|
||||
|
||||
# Two concurrent promotions would race on the same Play edit; the loser fails with a stale-edit
|
||||
# error. One at a time, and never cancel one mid-flight — a half-applied track change is worse
|
||||
# than a queued one.
|
||||
concurrency:
|
||||
group: android-promote
|
||||
cancel-in-progress: false
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version_code:
|
||||
description: 'versionCode already on Play (e.g. 10816)'
|
||||
required: true
|
||||
to_track:
|
||||
description: 'destination track'
|
||||
required: true
|
||||
default: 'production'
|
||||
from_track:
|
||||
description: 'track to verify it is on, then clear (blank = touch nothing else)'
|
||||
required: false
|
||||
default: 'alpha'
|
||||
notes_tag:
|
||||
description: "tag whose docs/releases/whatsnew/<tag>.txt to attach, e.g. v0.23.0 (blank = none)"
|
||||
required: false
|
||||
default: ''
|
||||
status:
|
||||
description: 'completed (100%) | inProgress (needs user_fraction) | halted | draft'
|
||||
required: true
|
||||
default: 'completed'
|
||||
user_fraction:
|
||||
description: 'staged rollout fraction for inProgress, e.g. 0.2 (blank otherwise)'
|
||||
required: false
|
||||
default: ''
|
||||
dry_run:
|
||||
description: 'validate only, publish nothing'
|
||||
required: true
|
||||
default: 'true'
|
||||
|
||||
jobs:
|
||||
promote:
|
||||
runs-on: ubuntu-24.04
|
||||
# Same image as android.yml purely for python3 + openssl (play-upload.py's only deps); it is
|
||||
# already warm on the runner. Nothing here builds.
|
||||
container:
|
||||
image: 192.168.1.58:5010/punktfunk-android-ci:latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Promote
|
||||
env:
|
||||
SERVICE_ACCOUNT_JSON: ${{ secrets.SERVICE_ACCOUNT_JSON }}
|
||||
VERSION_CODE: ${{ inputs.version_code }}
|
||||
TO_TRACK: ${{ inputs.to_track }}
|
||||
FROM_TRACK: ${{ inputs.from_track }}
|
||||
NOTES_TAG: ${{ inputs.notes_tag }}
|
||||
STATUS: ${{ inputs.status }}
|
||||
USER_FRACTION: ${{ inputs.user_fraction }}
|
||||
DRY_RUN: ${{ inputs.dry_run }}
|
||||
run: |
|
||||
set -- --package io.unom.punktfunk \
|
||||
--promote "$VERSION_CODE" \
|
||||
--track "$TO_TRACK" --status "$STATUS"
|
||||
# Explicit `if`, not `[ ] && …`: under `sh -e` a false AND-OR list that ends up LAST in
|
||||
# the script aborts the step, and these get reordered.
|
||||
if [ -n "$FROM_TRACK" ]; then set -- "$@" --promote-from "$FROM_TRACK"; fi
|
||||
if [ -n "$USER_FRACTION" ]; then set -- "$@" --user-fraction "$USER_FRACTION"; fi
|
||||
if [ -n "$NOTES_TAG" ]; then
|
||||
NOTES="docs/releases/whatsnew/${NOTES_TAG}.txt"
|
||||
# Fail loudly rather than silently publishing with the PREVIOUS release's text still
|
||||
# showing on the store listing.
|
||||
[ -f "$NOTES" ] || { echo "ERROR: no such notes file: $NOTES"; exit 1; }
|
||||
set -- "$@" --release-notes-file "$NOTES"
|
||||
fi
|
||||
if [ "$DRY_RUN" = "true" ]; then set -- "$@" --no-commit; fi
|
||||
echo "promoting versionCode=$VERSION_CODE -> $TO_TRACK (dry_run=$DRY_RUN)"
|
||||
python3 clients/android/ci/play-upload.py "$@"
|
||||
@@ -34,9 +34,10 @@ on:
|
||||
- 'rust-toolchain.toml'
|
||||
- 'scripts/ci/**'
|
||||
- '.gitea/workflows/android.yml'
|
||||
# Single project version: a `vX.Y.Z` tag is THE release (uploads to Play's `alpha` closed
|
||||
# track for manual promotion + attaches the .aab/.apk to the unified Gitea Release). A main
|
||||
# push is canary (Play `internal`).
|
||||
# Single project version: a `vX.Y.Z` tag is THE release (publishes to Play `production` at
|
||||
# 100% + attaches the .aab/.apk to the unified Gitea Release). A main push is canary
|
||||
# (Play `internal`). Production access was granted 2026-08-01; before that a tag could only
|
||||
# reach `alpha` and someone had to promote it by hand in the Console.
|
||||
tags: ['v*']
|
||||
pull_request:
|
||||
paths:
|
||||
@@ -75,6 +76,56 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# FIRST, because it costs a second and everything after it costs ten minutes.
|
||||
#
|
||||
# A release tag MUST carry its own Play "What's new". If the file is absent Play does not
|
||||
# show nothing — it carries the PREVIOUS release's text onto this version, so production
|
||||
# users read notes for a build they are not getting. That is the same defect the v0.22.3
|
||||
# notes shipped (see docs/releases/README.md), and it is invisible until someone reads the
|
||||
# store listing. Failing here also means a missing file cannot leave a half-published
|
||||
# release: nothing is built, nothing is attached to the Gitea release, nothing reaches Play.
|
||||
#
|
||||
# Canary is exempt on purpose: it has no curated notes, and Play reusing text for internal
|
||||
# testers costs nothing.
|
||||
- name: Play release notes gate (tags only)
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
run: |
|
||||
NOTES="docs/releases/whatsnew/${GITHUB_REF_NAME}.txt"
|
||||
if [ ! -f "$NOTES" ]; then
|
||||
echo "ERROR: $NOTES does not exist."
|
||||
echo "A production release needs its own Play 'What's new' (<=500 chars, written for"
|
||||
echo "phone/TV users). Without it Play reuses the previous release's text."
|
||||
echo "See docs/releases/README.md; copy docs/releases/whatsnew/TEMPLATE.txt."
|
||||
exit 1
|
||||
fi
|
||||
# A verbatim copy of another release's file is the same bug wearing a hat: the store
|
||||
# listing still describes the wrong build. Cheap to check, and only ever trips on an
|
||||
# actual copy-paste that was never edited.
|
||||
for other in docs/releases/whatsnew/*.txt; do
|
||||
if [ "$other" != "$NOTES" ] && [ "$other" != "docs/releases/whatsnew/TEMPLATE.txt" ]; then
|
||||
if cmp -s "$NOTES" "$other"; then
|
||||
echo "ERROR: $NOTES is byte-identical to $other."
|
||||
echo "Write notes describing THIS release, not the one before it."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
done
|
||||
# Length is checked here as well as in play-upload.py. Not redundant: the uploader is
|
||||
# the last line of defence (and the only one android-promote.yml gets), but it runs at
|
||||
# step 9 — this catches an unedited TEMPLATE copy at step 1 instead of after the build.
|
||||
# Must count CHARACTERS, not bytes: Play's cap is 500 chars and `•` is 3 bytes in UTF-8,
|
||||
# so `wc -c` would reject a file that is comfortably legal.
|
||||
python3 - "$NOTES" <<'PY'
|
||||
import sys
|
||||
path = sys.argv[1]
|
||||
text = open(path, encoding="utf-8").read().strip()
|
||||
if not text:
|
||||
sys.exit(f"ERROR: {path} is empty.")
|
||||
if len(text) > 500:
|
||||
sys.exit(f"ERROR: {path} is {len(text)} chars; Play allows 500. Trim it.")
|
||||
print(f"Play release notes OK: {path} ({len(text)}/500 chars)")
|
||||
PY
|
||||
|
||||
# Everything below the checkout used to be four download steps (JDK, SDK,
|
||||
# NDK+CMake, cargo-ndk — the flakiest, heaviest part of the job); it is all baked
|
||||
# into the image now. This guard only re-asserts the Android targets so a
|
||||
@@ -122,11 +173,20 @@ jobs:
|
||||
run: |
|
||||
eval "$(bash scripts/ci/pf-version.sh)" # -> PF_BASE (one minor ahead of the latest stable tag)
|
||||
case "$GITHUB_REF" in
|
||||
refs/tags/v*) VN="${GITHUB_REF_NAME#v}"; TRACK="alpha" ;; # alpha = built-in closed testing
|
||||
refs/tags/v*) VN="${GITHUB_REF_NAME#v}"; TRACK="production" ;;
|
||||
*) VN="${PF_BASE}-ci${GITHUB_RUN_NUMBER}"; TRACK="internal" ;;
|
||||
esac
|
||||
echo "VERSION_NAME=$VN" >> "$GITHUB_ENV"
|
||||
echo "PLAY_TRACK=$TRACK" >> "$GITHUB_ENV"
|
||||
# Play's own "What's new" (500-char cap, its own file — the vX.Y.Z.md body is ~34 KB).
|
||||
# On a tag the gate step above already proved this exists, so the else branch is only
|
||||
# ever the canary path. See docs/releases/README.md.
|
||||
NOTES="docs/releases/whatsnew/${GITHUB_REF_NAME}.txt"
|
||||
if [ -f "$NOTES" ]; then
|
||||
echo "PLAY_NOTES=$NOTES" >> "$GITHUB_ENV"
|
||||
else
|
||||
echo "no Play release notes at $NOTES (canary — Play keeps the previous text)"
|
||||
fi
|
||||
echo "android version $VN -> Play track '$TRACK'"
|
||||
|
||||
- name: Build Release (signed AAB + universal APK)
|
||||
@@ -199,15 +259,21 @@ jobs:
|
||||
# Direct Publishing-API upload instead of r0adkll/upload-google-play — that action hides the
|
||||
# real API error behind "Unknown error occurred."; this prints it. stdlib + openssl only (no
|
||||
# pip), reuses SERVICE_ACCOUNT_JSON (raw JSON or base64), auto-handles changesNotSentForReview.
|
||||
# Track: canary main -> `internal`; a vX.Y.Z release -> `alpha` (closed testing) for manual
|
||||
# promotion to production in the Play console.
|
||||
# Track: canary main -> `internal`; a vX.Y.Z release -> `production` at 100% (`completed`).
|
||||
#
|
||||
# A tag therefore ships to real users with no further click. Two things keep that honest:
|
||||
# the tag is only pushed once every platform is green, and Play reviews each production
|
||||
# release before it reaches anyone. To ramp instead of going straight to 100%, this is
|
||||
# `--status inProgress --user-fraction 0.2`; to undo a bad one, halt or roll back from the
|
||||
# Console (or `android-promote.yml`, which can re-point production at an older versionCode).
|
||||
- name: Upload to Google Play
|
||||
if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v'))
|
||||
env:
|
||||
SERVICE_ACCOUNT_JSON: ${{ secrets.SERVICE_ACCOUNT_JSON }}
|
||||
run: |
|
||||
echo "uploading to Play track '$PLAY_TRACK'"
|
||||
python3 clients/android/ci/play-upload.py \
|
||||
--package io.unom.punktfunk \
|
||||
--aab clients/android/app/build/outputs/bundle/release/app-release.aab \
|
||||
--track "$PLAY_TRACK" --status completed
|
||||
set -- --package io.unom.punktfunk \
|
||||
--aab clients/android/app/build/outputs/bundle/release/app-release.aab \
|
||||
--track "$PLAY_TRACK" --status completed
|
||||
if [ -n "${PLAY_NOTES:-}" ]; then set -- "$@" --release-notes-file "$PLAY_NOTES"; fi
|
||||
python3 clients/android/ci/play-upload.py "$@"
|
||||
|
||||
@@ -6,13 +6,23 @@ Why hand-rolled: stdlib + `openssl` only (no pip on the runner), and it prints G
|
||||
error at the stage it fails instead of a catch-all. Reuses the SERVICE_ACCOUNT_JSON secret and
|
||||
tolerates it being raw JSON *or* base64-encoded JSON.
|
||||
|
||||
Usage:
|
||||
Usage (upload a new build):
|
||||
SERVICE_ACCOUNT_JSON='<raw-or-base64 SA key>' \
|
||||
python3 play-upload.py --package io.unom.punktfunk \
|
||||
--aab path/to/app-release.aab --track internal --status completed [--no-commit]
|
||||
|
||||
--no-commit: do insert -> upload -> track-update -> validate, then delete the edit (publishes
|
||||
nothing). Use it to dry-run the credentials/AAB without touching the live track.
|
||||
Usage (promote a build that is already on Play, no rebuild):
|
||||
python3 play-upload.py --package io.unom.punktfunk \
|
||||
--promote 10816 --promote-from alpha --track production
|
||||
|
||||
Promotion assigns an EXISTING versionCode to another track, so what ships to production is the
|
||||
byte-identical artifact the testers ran — rebuilding would burn a fresh versionCode and ship
|
||||
something nobody has tested. --promote-from additionally asserts the code really is on that track
|
||||
(catches a typo'd versionCode before it reaches production) and empties it in the SAME edit, so
|
||||
the move is atomic: testers are never left pinned to a code that production also serves.
|
||||
|
||||
--no-commit: do insert -> upload/assign -> track-update -> validate, then delete the edit
|
||||
(publishes nothing). Use it to dry-run the credentials/AAB/notes without touching the live track.
|
||||
"""
|
||||
import argparse, base64, json, os, subprocess, sys, tempfile, time
|
||||
import urllib.request, urllib.parse, urllib.error
|
||||
@@ -104,18 +114,79 @@ def access_token(sa) -> str:
|
||||
return tok["access_token"]
|
||||
|
||||
|
||||
# Play's "What's new" is capped at 500 characters per language. The cap lives in the Console
|
||||
# (the REST reference does not state it) and the API rejects longer text at commit — i.e. AFTER
|
||||
# the AAB has uploaded — so check it up front and print the actual count.
|
||||
NOTES_MAX = 500
|
||||
|
||||
|
||||
def load_release_notes(path, language):
|
||||
with open(path, encoding="utf-8") as f:
|
||||
text = f.read().strip()
|
||||
if not text:
|
||||
sys.exit(f"ERROR: release-notes file is empty: {path}")
|
||||
if len(text) > NOTES_MAX:
|
||||
sys.exit(f"ERROR: release notes are {len(text)} chars, Play allows {NOTES_MAX}: {path}")
|
||||
print(f"release notes: {len(text)}/{NOTES_MAX} chars ({language})")
|
||||
return [{"language": language, "text": text}]
|
||||
|
||||
|
||||
def put_track(app, edit, tok, track, version_codes, status, user_fraction=None, notes=None):
|
||||
"""PUT one track. An empty version_codes list clears the track (what promotion does to the
|
||||
track it promoted OUT of)."""
|
||||
release = {"status": status, "versionCodes": [str(v) for v in version_codes]}
|
||||
if user_fraction is not None:
|
||||
release["userFraction"] = user_fraction
|
||||
if notes:
|
||||
release["releaseNotes"] = notes
|
||||
# Clearing a track means "no active releases", not "an empty release".
|
||||
body = {"track": track, "releases": [release] if version_codes else []}
|
||||
call("PUT", f"{app}/edits/{edit}/tracks/{track}", token=tok,
|
||||
data=json.dumps(body).encode(), content_type="application/json")
|
||||
|
||||
|
||||
def assert_on_track(app, edit, tok, track, vc):
|
||||
"""Fail before anything is written if --promote names a versionCode that is not actually on
|
||||
the track we claim to be promoting out of."""
|
||||
got = call("GET", f"{app}/edits/{edit}/tracks/{track}", token=tok)
|
||||
live = [c for r in got.get("releases", []) for c in r.get("versionCodes", [])]
|
||||
if str(vc) not in live:
|
||||
sys.exit(f"ERROR: versionCode {vc} is not on track '{track}' (it has: {live or 'nothing'})")
|
||||
print(f"verified versionCode={vc} is live on '{track}'")
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--package", required=True)
|
||||
ap.add_argument("--aab", required=True)
|
||||
ap.add_argument("--aab", help="upload this bundle (mutually exclusive with --promote)")
|
||||
ap.add_argument("--promote", type=int, metavar="VERSIONCODE",
|
||||
help="assign an already-uploaded versionCode instead of uploading")
|
||||
ap.add_argument("--promote-from", metavar="TRACK",
|
||||
help="with --promote: assert the code is on TRACK, then clear TRACK")
|
||||
ap.add_argument("--track", default="internal")
|
||||
ap.add_argument("--status", default="completed")
|
||||
ap.add_argument("--user-fraction", type=float,
|
||||
help="staged rollout fraction, 0<f<1; required by --status inProgress")
|
||||
ap.add_argument("--release-notes-file", help="Play 'What's new' text (<=500 chars)")
|
||||
ap.add_argument("--release-notes-language", default="en-US")
|
||||
ap.add_argument("--no-commit", action="store_true")
|
||||
a = ap.parse_args()
|
||||
|
||||
if not os.path.isfile(a.aab):
|
||||
if bool(a.aab) == bool(a.promote):
|
||||
sys.exit("ERROR: pass exactly one of --aab (upload) or --promote (assign an existing code)")
|
||||
if a.promote_from and not a.promote:
|
||||
sys.exit("ERROR: --promote-from only applies to --promote")
|
||||
# inProgress without a fraction is an API error; halted/completed with one is also rejected.
|
||||
if a.status == "inProgress" and a.user_fraction is None:
|
||||
sys.exit("ERROR: --status inProgress requires --user-fraction")
|
||||
if a.user_fraction is not None and not (0 < a.user_fraction < 1):
|
||||
sys.exit(f"ERROR: --user-fraction must be strictly between 0 and 1 (got {a.user_fraction})")
|
||||
if a.aab and not os.path.isfile(a.aab):
|
||||
sys.exit(f"ERROR: AAB not found: {a.aab}")
|
||||
|
||||
notes = load_release_notes(a.release_notes_file, a.release_notes_language) \
|
||||
if a.release_notes_file else None
|
||||
|
||||
sa = load_sa()
|
||||
tok = access_token(sa)
|
||||
print(f"authenticated as {sa['client_email']} (project {sa.get('project_id')})")
|
||||
@@ -123,17 +194,25 @@ def main():
|
||||
|
||||
try:
|
||||
edit = call("POST", f"{app}/edits", token=tok)["id"]
|
||||
with open(a.aab, "rb") as f:
|
||||
blob = f.read()
|
||||
print(f"uploading {a.aab} ({len(blob)} bytes) ...")
|
||||
vc = call("POST", f"{UPLOAD}/{a.package}/edits/{edit}/bundles?uploadType=media",
|
||||
token=tok, data=blob, content_type="application/octet-stream")["versionCode"]
|
||||
print(f"uploaded versionCode={vc}")
|
||||
call("PUT", f"{app}/edits/{edit}/tracks/{a.track}", token=tok,
|
||||
data=json.dumps({"track": a.track,
|
||||
"releases": [{"status": a.status, "versionCodes": [str(vc)]}]}).encode(),
|
||||
content_type="application/json")
|
||||
print(f"assigned versionCode={vc} -> track={a.track} status={a.status}")
|
||||
if a.promote:
|
||||
vc = a.promote
|
||||
if a.promote_from:
|
||||
assert_on_track(app, edit, tok, a.promote_from, vc)
|
||||
else:
|
||||
with open(a.aab, "rb") as f:
|
||||
blob = f.read()
|
||||
print(f"uploading {a.aab} ({len(blob)} bytes) ...")
|
||||
vc = call("POST", f"{UPLOAD}/{a.package}/edits/{edit}/bundles?uploadType=media",
|
||||
token=tok, data=blob, content_type="application/octet-stream")["versionCode"]
|
||||
print(f"uploaded versionCode={vc}")
|
||||
|
||||
put_track(app, edit, tok, a.track, [vc], a.status, a.user_fraction, notes)
|
||||
print(f"assigned versionCode={vc} -> track={a.track} status={a.status}"
|
||||
+ (f" userFraction={a.user_fraction}" if a.user_fraction is not None else ""))
|
||||
# Same edit as the assignment above, so the code is never active on both tracks at once.
|
||||
if a.promote_from:
|
||||
put_track(app, edit, tok, a.promote_from, [], a.status)
|
||||
print(f"cleared track '{a.promote_from}'")
|
||||
|
||||
if a.no_commit:
|
||||
call("POST", f"{app}/edits/{edit}:validate", token=tok)
|
||||
|
||||
@@ -35,10 +35,14 @@ pub const DS_FEATURE_PAIRING: &[u8] = &[ // report 0x09 (pairing info: MAC at by
|
||||
0x00, 0x00, 0x00, 0x00,
|
||||
];
|
||||
#[rustfmt::skip]
|
||||
pub const DS_FEATURE_FIRMWARE: &[u8] = &[ // report 0x20 (firmware info / build date)
|
||||
pub const DS_FEATURE_FIRMWARE: &[u8] = &[ // report 0x20 (firmware info / build date); bytes 44..46
|
||||
// = update version, kept ABOVE Sony's real releases (0x0630 as of 2026-08) — an older value
|
||||
// makes PlayStation Accessories and libScePad titles demand a firmware update the virtual pad
|
||||
// cannot take ("can't complete the update"), and ≥ 0x0224 is what puts writers on the
|
||||
// COMPATIBLE_VIBRATION2 convention parse_ds_output accepts alongside flag0.
|
||||
0x20, 0x4A, 0x75, 0x6E, 0x20, 0x31, 0x39, 0x20, 0x32, 0x30, 0x32, 0x33, 0x31, 0x34, 0x3A, 0x34,
|
||||
0x37, 0x3A, 0x33, 0x34, 0x03, 0x00, 0x44, 0x00, 0x08, 0x02, 0x00, 0x01, 0x36, 0x00, 0x00, 0x01,
|
||||
0xC1, 0xC8, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x54, 0x01, 0x00, 0x00,
|
||||
0xC1, 0xC8, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x99, 0x09, 0x00, 0x00,
|
||||
0x14, 0x00, 0x00, 0x00, 0x0B, 0x00, 0x01, 0x00, 0x06, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
];
|
||||
|
||||
@@ -494,10 +498,13 @@ pub fn parse_ds_output(pad: u8, data: &[u8], fb: &mut DsFeedback) {
|
||||
// data[4]. Scale 0..255 → 0..0xFFFF, same (low, high) convention as the uinput pad's mixer,
|
||||
// and route to the universal rumble plane (0xCA).
|
||||
// Writers on firmware ≥ 2.24 signal rumble via COMPATIBLE_VIBRATION2 in valid_flag2
|
||||
// (data[39] BIT2) instead of flag0 BIT0. Our feature report advertises 0x0154 so the
|
||||
// kernel and SDL stay on the flag0 convention, but a writer that hardcodes v2 would
|
||||
// otherwise have its rumble — including stops — silently ignored, and a missed stop
|
||||
// buzzes for the rest of the session (the 500 ms refresh re-sends stale state forever).
|
||||
// (data[39] BIT2) instead of flag0 BIT0. Our feature report advertises a version
|
||||
// above 2.24 (DS_FEATURE_FIRMWARE bytes 44..46, chosen to keep Sony's updater
|
||||
// quiet), so the kernel and SDL write the v2 flag — while older writers, and any
|
||||
// that never read the version, stay on flag0. Both conventions must land here: a
|
||||
// rumble dropped on either — including stops — is silently ignored, and a missed
|
||||
// stop buzzes for the rest of the session (the 500 ms refresh re-sends stale state
|
||||
// forever).
|
||||
if flag0 & 0x03 != 0 || data[39] & 0x04 != 0 {
|
||||
let high = (data[3] as u16) << 8;
|
||||
let low = (data[4] as u16) << 8;
|
||||
|
||||
@@ -31,6 +31,35 @@ even across a tag re-point.
|
||||
Canary / `-rc` builds have **no** file here on purpose: they get no curated body and are not
|
||||
announced.
|
||||
|
||||
## Google Play "What's new": `whatsnew/vX.Y.Z.txt`
|
||||
|
||||
Play shows its own release notes on the Play Store listing and in the Play Store app, and caps
|
||||
them at **500 characters per language** — the `vX.Y.Z.md` body is two orders of magnitude too
|
||||
long, so it gets its own short file: `docs/releases/whatsnew/vX.Y.Z.txt`.
|
||||
|
||||
Write it for a **phone/TV user**, not a host operator: only what changed in the Android app is
|
||||
worth their 500 characters. Plain text (Play renders no markdown), one `•` bullet per line, same
|
||||
voice rules as below. Copy `whatsnew/TEMPLATE.txt`.
|
||||
|
||||
**A `vX.Y.Z` tag without this file fails the android job before it builds.** This is a hard gate,
|
||||
not a warning, because the failure it prevents is silent: when the file is missing Play does not
|
||||
show an empty "What's new" — it **carries the previous release's text onto the new version**, so
|
||||
the store listing describes a build nobody is getting, and nothing surfaces that but reading the
|
||||
listing. It is the same shape as the v0.22.3 notes announcing a feature that release never
|
||||
contained. The gate also rejects a file byte-identical to another release's, which is that bug
|
||||
reached by copy-paste instead of by omission.
|
||||
|
||||
The gate runs first in the job, so a miss costs a second and leaves nothing half-published —
|
||||
no build, no assets on the Gitea release, nothing on Play. Two more checks sit downstream:
|
||||
`play-upload.py` refuses text over the 500-char cap (printing the real count) before it uploads,
|
||||
because the API only rejects oversized notes at commit, by which point the AAB is already on Play.
|
||||
|
||||
Canary is exempt: it has no curated notes, and Play reusing text for internal testers costs
|
||||
nothing.
|
||||
|
||||
Same freeze rule as the notes: once the tag exists, this file is the record of what that
|
||||
versionCode shipped.
|
||||
|
||||
## Voice & format
|
||||
|
||||
**Write for the people who USE Punktfunk to stream their games and desktops — not for the people who
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
• <The headline change, in what the user gets — not what was built.>
|
||||
• <Second thing. Name devices they recognize: a phone, an Android TV, a DualSense.>
|
||||
• <Third. Stop at what fits; 500 characters is the hard cap, and it is small.>
|
||||
|
||||
HOW TO USE THIS FILE
|
||||
Copy to vX.Y.Z.txt, replace every line, delete this block. It is skipped by the CI gate by
|
||||
name, so leaving it in place never blocks a release — but a copy that still says "<The
|
||||
headline change>" will ship to the Play Store exactly as written. Nothing checks your prose.
|
||||
|
||||
WHAT BELONGS HERE
|
||||
Only what changed in the ANDROID APP. Host-side work — Windows installers, gamescope, the web
|
||||
console — means nothing to someone reading a phone app's store listing, and it is spending
|
||||
characters you do not have. The full release notes in ../vX.Y.Z.md are where that lives.
|
||||
|
||||
RULES THE CI GATE ENFORCES (.gitea/workflows/android.yml)
|
||||
* The file must exist for a vX.Y.Z tag, or the release fails before it builds. Play does not
|
||||
show "no notes" when the file is missing — it carries the PREVIOUS release's text onto the
|
||||
new version, and nobody notices until they read the store listing.
|
||||
* It must not be byte-identical to another release's file.
|
||||
* play-upload.py refuses anything over 500 characters and prints the real count.
|
||||
|
||||
RULES NOTHING ENFORCES
|
||||
* Plain text. No markdown — Play renders none of it, so `**bold**` ships as asterisks.
|
||||
* One `•` bullet per line.
|
||||
* Same voice as ../README.md: lead with what the user can now do, no internal vocabulary.
|
||||
* Frozen once the tag exists. Editing it later does not change what already shipped; it only
|
||||
misleads the next person who reads it as the record of that versionCode.
|
||||
@@ -0,0 +1,4 @@
|
||||
• Much lower latency: a new frame scheduler cut glass-to-glass delay by about a third on the phones we measured, and the host can now line its capture up with your screen.
|
||||
• Microphone: mute it mid-stream, and echo cancellation is on by default.
|
||||
• DualSense controllers work over USB.
|
||||
• AV1 is picked automatically when your phone and host both support it.
|
||||
@@ -109,10 +109,13 @@ static DS_FEATURE_PAIRING: [u8; 20] = [ // 0x09 pairing info (MAC at 1..7)
|
||||
0x00, 0x00, 0x00, 0x00,
|
||||
];
|
||||
#[rustfmt::skip]
|
||||
static DS_FEATURE_FIRMWARE: [u8; 64] = [ // 0x20 firmware info
|
||||
static DS_FEATURE_FIRMWARE: [u8; 64] = [ // 0x20 firmware info; bytes 44..46 = update version,
|
||||
// kept ABOVE Sony's real releases (0x0630 as of 2026-08) — an older value makes PlayStation
|
||||
// Accessories and libScePad titles demand a firmware update the virtual pad cannot take.
|
||||
// Mirrors inject/proto/dualsense_proto.rs DS_FEATURE_FIRMWARE; keep the two in sync.
|
||||
0x20, 0x4A, 0x75, 0x6E, 0x20, 0x31, 0x39, 0x20, 0x32, 0x30, 0x32, 0x33, 0x31, 0x34, 0x3A, 0x34,
|
||||
0x37, 0x3A, 0x33, 0x34, 0x03, 0x00, 0x44, 0x00, 0x08, 0x02, 0x00, 0x01, 0x36, 0x00, 0x00, 0x01,
|
||||
0xC1, 0xC8, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x54, 0x01, 0x00, 0x00,
|
||||
0xC1, 0xC8, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x99, 0x09, 0x00, 0x00,
|
||||
0x14, 0x00, 0x00, 0x00, 0x0B, 0x00, 0x01, 0x00, 0x06, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
];
|
||||
|
||||
|
||||
@@ -108,16 +108,34 @@ trusted certificate, which is what the edge vhost provides in production.
|
||||
|
||||
1. **DNS** — `winget.punktfunk.unom.io` → the unom-1 hcloud box, in the `unom.io` Cloudflare zone
|
||||
(DNS-only, same as `docs`). ✅ *done*
|
||||
2. **Caddy vhost** on unom-1, next to the existing `docs.punktfunk.unom.io` block:
|
||||
2. **Caddy vhost** — in **`unom/infra`, `caddy/Caddyfile`**, next to the existing
|
||||
`docs.punktfunk.unom.io` block. ✅ *done*
|
||||
|
||||
```caddyfile
|
||||
winget.punktfunk.unom.io {
|
||||
reverse_proxy 127.0.0.1:3240
|
||||
import security_headers
|
||||
reverse_proxy localhost:3240
|
||||
}
|
||||
```
|
||||
|
||||
Until this exists the hostname resolves but the TLS handshake fails — Caddy has no certificate
|
||||
for a name it does not serve. That is the expected state, not a broken deploy.
|
||||
⚠ **Not by hand on the box.** `~/caddy/Caddyfile` on unom-1 looks like the config but is a copy
|
||||
that `deploy-all.sh` rsyncs over from `unom/infra`, and there is no `.git` there to warn you. A
|
||||
vhost added only on the box survives until the next deploy and no longer: this one was
|
||||
hand-added on 2026-07-26, and the 2026-07-31 hardening commit rewrote the file from the repo's
|
||||
own copy and deleted it — its message says "all 7 vhosts" when there were 8 live.
|
||||
|
||||
Until the vhost exists the hostname resolves but the TLS handshake fails — Caddy has no
|
||||
certificate for a name it does not serve. That is the expected state on first setup, not a
|
||||
broken deploy, but it is also exactly how the clobber presents later: an `internal_error`
|
||||
(alert 80) with no peer certificate, which winget reports as
|
||||
`WINHTTP_CALLBACK_STATUS_FLAG_SECURITY_CHANNEL_ERROR` / `0x8a15003b` and which looks like a
|
||||
client bug. Diagnose by SNI, not by port 80 — Caddy 308s *every* Host to https, including names
|
||||
it has never heard of, so a redirect there proves nothing:
|
||||
|
||||
```bash
|
||||
openssl s_client -connect winget.punktfunk.unom.io:443 \
|
||||
-servername winget.punktfunk.unom.io </dev/null 2>&1 | grep -E '^subject=|alert'
|
||||
```
|
||||
3. Run `deploy-services.yml` to place the compose file and start the container. It serves 503 until
|
||||
a catalogue exists — expected, and visible on `/healthz`.
|
||||
4. Publish a stable tag, or build and ship the catalogue by hand:
|
||||
|
||||
Reference in New Issue
Block a user