Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7b5b041c42 | ||
|
|
e63c2cf7ac | ||
|
|
2314f5f842 | ||
|
|
2a193f8d5b | ||
|
|
ad30ab9dea | ||
|
|
4c603f6d3d | ||
|
|
0927660c15 | ||
|
|
5d0f95361f | ||
|
|
6fbe63f787 | ||
|
|
12d20565bd | ||
|
|
2df140a3c1 | ||
|
|
0b74b1b2b1 | ||
|
|
4369d15cec | ||
|
|
1e9908569d | ||
|
|
c0fc4fe7d6 | ||
|
|
4d7cd6182f | ||
|
|
b58b4251dc | ||
|
|
5c797c8807 | ||
|
|
05c443a4b5 | ||
|
|
a827a38ae6 | ||
|
|
fad4778ad8 | ||
|
|
d27c2db894 | ||
|
|
e6ce1aebd5 |
Executable
+66
@@ -0,0 +1,66 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Assembles the Cloudflare Pages site for plg.wallacenet.ca into ./pages-out.
|
||||||
|
#
|
||||||
|
# Usage: stage-pages-site.sh <prod|dev> <tag> <apk-path>
|
||||||
|
#
|
||||||
|
# A Pages deploy replaces the whole site, and both channels live in one site,
|
||||||
|
# so this stages the channel being published and then re-fetches the *other*
|
||||||
|
# channel's current files from the live site so the deploy does not erase them.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
channel="$1"; tag="$2"; apk="$3"
|
||||||
|
base="${PAGES_BASE_URL:-https://plg.wallacenet.ca}"
|
||||||
|
out="pages-out"
|
||||||
|
limit=$((25 * 1024 * 1024)) # Cloudflare Pages: 25 MiB per file
|
||||||
|
|
||||||
|
case "$channel" in prod) other=dev ;; dev) other=prod ;; *) echo "bad channel: $channel" >&2; exit 2 ;; esac
|
||||||
|
|
||||||
|
rm -rf "$out"
|
||||||
|
mkdir -p "$out/$channel" "$out/$other"
|
||||||
|
cp site/index.html site/404.html site/_headers "$out/"
|
||||||
|
|
||||||
|
name="$(basename "$apk")"
|
||||||
|
cp "$apk" "$out/$channel/$name"
|
||||||
|
cp "$apk" "$out/$channel/ProLibertateGames.apk"
|
||||||
|
jq -n --arg tag "$tag" --arg name "$name" --arg ch "$channel" --arg url "$base/$channel/$name" \
|
||||||
|
'{tag: $tag, apkName: $name, apkUrl: $url, channel: $ch}' > "$out/$channel/latest.json"
|
||||||
|
|
||||||
|
# PLG_SKIP_OTHER=1 is for the very first deploy, when the site does not exist
|
||||||
|
# yet and the domain answers 403 (Cloudflare error 1014) instead of 404.
|
||||||
|
if [ -n "${PLG_SKIP_OTHER:-}" ]; then
|
||||||
|
echo "PLG_SKIP_OTHER set; not carrying over $other."
|
||||||
|
rm -rf "$out/$other"
|
||||||
|
find "$out" -type f | sort
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Carry the other channel over. 404 means it was never published; any other
|
||||||
|
# failure must stop the deploy rather than silently dropping that channel.
|
||||||
|
code="$(curl -sS -o "$out/$other/latest.json" -w '%{http_code}' "$base/$other/latest.json" || true)"
|
||||||
|
: "${code:=000}"
|
||||||
|
# Cloudflare error 1014 (403 + "error code: 1014") means the domain's CNAME
|
||||||
|
# points at a Pages project that does not exist yet, so there is nothing to
|
||||||
|
# preserve — the same as a 404, and what a push-triggered first deploy sees.
|
||||||
|
if [ "$code" = 403 ] && grep -q 'error code: 1014' "$out/$other/latest.json" 2>/dev/null; then
|
||||||
|
code=404
|
||||||
|
fi
|
||||||
|
if [ "$code" = 404 ]; then
|
||||||
|
echo "No $other channel published yet; skipping."
|
||||||
|
rm -rf "$out/$other"
|
||||||
|
elif [ "$code" = 200 ] && ! jq -e .apkName "$out/$other/latest.json" >/dev/null 2>&1; then
|
||||||
|
# A 200 that is not our JSON: Pages without a 404.html answers unknown paths
|
||||||
|
# with index.html. Nothing is published on that channel.
|
||||||
|
echo "No $other channel published yet (200 but no latest.json); skipping."
|
||||||
|
rm -rf "$out/$other"
|
||||||
|
elif [ "$code" = 200 ]; then
|
||||||
|
other_name="$(jq -er .apkName "$out/$other/latest.json")"
|
||||||
|
curl -fsS -o "$out/$other/$other_name" "$base/$other/$other_name"
|
||||||
|
cp "$out/$other/$other_name" "$out/$other/ProLibertateGames.apk"
|
||||||
|
else
|
||||||
|
echo "Fetching $other/latest.json returned HTTP $code; refusing to deploy." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
find "$out" -name '*.apk' -size +"${limit}c" -print | grep . >&2 && {
|
||||||
|
echo "APK exceeds Cloudflare Pages' 25 MiB per-file limit." >&2; exit 1; } || true
|
||||||
|
find "$out" -type f | sort
|
||||||
+56
-15
@@ -16,6 +16,11 @@ on:
|
|||||||
pull_request:
|
pull_request:
|
||||||
branches: [ main, dev ]
|
branches: [ main, dev ]
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
first_deploy:
|
||||||
|
description: "First Cloudflare deploy: skip carrying over the other channel (the site does not exist yet)"
|
||||||
|
type: boolean
|
||||||
|
default: false
|
||||||
|
|
||||||
# Needed to publish the dev-channel prerelease below.
|
# Needed to publish the dev-channel prerelease below.
|
||||||
permissions:
|
permissions:
|
||||||
@@ -30,6 +35,8 @@ jobs:
|
|||||||
build:
|
build:
|
||||||
name: Build and check
|
name: Build and check
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
outputs:
|
||||||
|
version: ${{ steps.stage.outputs.version }}
|
||||||
env:
|
env:
|
||||||
# Stamp the APK as a dev-channel build exactly when it is a build of the
|
# Stamp the APK as a dev-channel build exactly when it is a build of the
|
||||||
# dev branch, which is exactly when it can be published below. Pull
|
# dev branch, which is exactly when it can be published below. Pull
|
||||||
@@ -49,7 +56,7 @@ jobs:
|
|||||||
- name: Set up JDK 17
|
- name: Set up JDK 17
|
||||||
uses: actions/setup-java@v4
|
uses: actions/setup-java@v4
|
||||||
with:
|
with:
|
||||||
distribution: temurin
|
distribution: zulu
|
||||||
java-version: '17'
|
java-version: '17'
|
||||||
cache: gradle
|
cache: gradle
|
||||||
|
|
||||||
@@ -101,27 +108,61 @@ jobs:
|
|||||||
# it cannot name different versions.
|
# it cannot name different versions.
|
||||||
series="$(sed -n 's/^plgVersionSeries=//p' gradle.properties)"
|
series="$(sed -n 's/^plgVersionSeries=//p' gradle.properties)"
|
||||||
test -n "$series"
|
test -n "$series"
|
||||||
version="v${series}.${{ github.run_number }}-dev"
|
offset="$(sed -n 's/^plgVersionRunOffset=//p' gradle.properties)"
|
||||||
|
test -n "$offset"
|
||||||
|
# Same arithmetic as build.gradle.kts, so the tag names the APK's own number.
|
||||||
|
version="v${series}.$(( ${{ github.run_number }} + offset ))-dev"
|
||||||
src="app/build/outputs/apk/debug/app-debug.apk"
|
src="app/build/outputs/apk/debug/app-debug.apk"
|
||||||
dest="ProLibertateGames-${version}.apk"
|
dest="ProLibertateGames-${version}.apk"
|
||||||
cp "$src" "$dest"
|
cp "$src" "$dest"
|
||||||
echo "version=$version" >> "$GITHUB_OUTPUT"
|
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||||
echo "apk=$dest" >> "$GITHUB_OUTPUT"
|
echo "apk=$dest" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
- name: Publish dev channel prerelease
|
- name: Upload staged dev APK for the Pages deploy
|
||||||
if: github.ref == 'refs/heads/dev'
|
if: github.ref == 'refs/heads/dev'
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: plg-dev-staged
|
||||||
|
path: ${{ steps.stage.outputs.apk }}
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 1
|
||||||
|
|
||||||
|
# Publishes the build to https://plg.wallacenet.ca (Cloudflare Pages), which is
|
||||||
|
# the only release channel: the in-app updater reads latest.json from there.
|
||||||
|
#
|
||||||
|
# Both workflows deploy into one Pages site, and a deploy replaces the whole
|
||||||
|
# site, so they share a concurrency group and never run at the same time.
|
||||||
|
deploy-pages:
|
||||||
|
name: Deploy to Cloudflare Pages
|
||||||
|
needs: build
|
||||||
|
if: github.ref == 'refs/heads/dev'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
concurrency:
|
||||||
|
group: plg-pages-deploy
|
||||||
|
cancel-in-progress: false
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Download staged APK
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: plg-dev-staged
|
||||||
|
path: staged
|
||||||
|
|
||||||
|
- name: Assemble site
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ github.token }}
|
PLG_SKIP_OTHER: ${{ inputs.first_deploy && '1' || '' }}
|
||||||
|
run: .github/scripts/stage-pages-site.sh dev "${{ needs.build.outputs.version }}" "staged/ProLibertateGames-${{ needs.build.outputs.version }}.apk"
|
||||||
|
|
||||||
|
- name: Deploy
|
||||||
|
env:
|
||||||
|
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||||
|
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
gh release create "${{ steps.stage.outputs.version }}" \
|
# Creates the project on first run; fails harmlessly if it exists.
|
||||||
"${{ steps.stage.outputs.apk }}" \
|
npx --yes wrangler@3 pages project create plg --production-branch main || true
|
||||||
--repo "${{ github.repository }}" \
|
# --branch main makes this the production deployment, which is what
|
||||||
--target "${{ github.sha }}" \
|
# plg.wallacenet.ca serves, whichever branch the workflow ran on.
|
||||||
--title "ProLibertateGames ${{ steps.stage.outputs.version }}" \
|
npx --yes wrangler@3 pages deploy pages-out --project-name plg --branch main --commit-dirty=true
|
||||||
--prerelease \
|
|
||||||
--notes "Automated **dev channel** build from \`${{ github.sha }}\` on \`dev\`.
|
|
||||||
|
|
||||||
Preview build: newer than production, and less tested. Switch to the
|
|
||||||
Dev update channel in Settings to be offered these. Marked as a
|
|
||||||
prerelease so production-channel installs never see it."
|
|
||||||
|
|||||||
@@ -6,6 +6,11 @@ on:
|
|||||||
push:
|
push:
|
||||||
branches: [ main ]
|
branches: [ main ]
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
first_deploy:
|
||||||
|
description: "First Cloudflare deploy: skip carrying over the other channel (the site does not exist yet)"
|
||||||
|
type: boolean
|
||||||
|
default: false
|
||||||
|
|
||||||
# Allow the built-in token to create releases and upload the APK.
|
# Allow the built-in token to create releases and upload the APK.
|
||||||
permissions:
|
permissions:
|
||||||
@@ -24,6 +29,8 @@ jobs:
|
|||||||
# anything else is a no-op rather than shipping unreviewed code to everyone.
|
# anything else is a no-op rather than shipping unreviewed code to everyone.
|
||||||
if: github.ref == 'refs/heads/main'
|
if: github.ref == 'refs/heads/main'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
outputs:
|
||||||
|
version: ${{ steps.stage.outputs.version }}
|
||||||
env:
|
env:
|
||||||
# Stamps the APK as a production-channel build, versioned with the run
|
# Stamps the APK as a production-channel build, versioned with the run
|
||||||
# number that matches the tag published below.
|
# number that matches the tag published below.
|
||||||
@@ -35,7 +42,7 @@ jobs:
|
|||||||
- name: Set up JDK 17
|
- name: Set up JDK 17
|
||||||
uses: actions/setup-java@v4
|
uses: actions/setup-java@v4
|
||||||
with:
|
with:
|
||||||
distribution: temurin
|
distribution: zulu
|
||||||
java-version: '17'
|
java-version: '17'
|
||||||
cache: gradle
|
cache: gradle
|
||||||
|
|
||||||
@@ -54,7 +61,10 @@ jobs:
|
|||||||
# it cannot name different versions.
|
# it cannot name different versions.
|
||||||
series="$(sed -n 's/^plgVersionSeries=//p' gradle.properties)"
|
series="$(sed -n 's/^plgVersionSeries=//p' gradle.properties)"
|
||||||
test -n "$series"
|
test -n "$series"
|
||||||
version="v${series}.${{ github.run_number }}"
|
offset="$(sed -n 's/^plgVersionRunOffset=//p' gradle.properties)"
|
||||||
|
test -n "$offset"
|
||||||
|
# Same arithmetic as build.gradle.kts, so the tag names the APK's own number.
|
||||||
|
version="v${series}.$(( ${{ github.run_number }} + offset ))"
|
||||||
src="app/build/outputs/apk/debug/app-debug.apk"
|
src="app/build/outputs/apk/debug/app-debug.apk"
|
||||||
dest="ProLibertateGames-${version}.apk"
|
dest="ProLibertateGames-${version}.apk"
|
||||||
cp "$src" "$dest"
|
cp "$src" "$dest"
|
||||||
@@ -69,25 +79,46 @@ jobs:
|
|||||||
- name: Upload APK as workflow artifact
|
- name: Upload APK as workflow artifact
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: ProLibertateGames-apk
|
name: plg-prod-staged
|
||||||
path: ${{ steps.stage.outputs.apk }}
|
path: ${{ steps.stage.outputs.apk }}
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
|
|
||||||
- name: Create GitHub release
|
# Publishes the build to https://plg.wallacenet.ca (Cloudflare Pages), which is
|
||||||
|
# the only release channel: the in-app updater reads latest.json from there.
|
||||||
|
#
|
||||||
|
# Both workflows deploy into one Pages site, and a deploy replaces the whole
|
||||||
|
# site, so they share a concurrency group and never run at the same time.
|
||||||
|
deploy-pages:
|
||||||
|
name: Deploy to Cloudflare Pages
|
||||||
|
needs: build
|
||||||
|
if: github.ref == 'refs/heads/main'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
concurrency:
|
||||||
|
group: plg-pages-deploy
|
||||||
|
cancel-in-progress: false
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Download staged APK
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: plg-prod-staged
|
||||||
|
path: staged
|
||||||
|
|
||||||
|
- name: Assemble site
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ github.token }}
|
PLG_SKIP_OTHER: ${{ inputs.first_deploy && '1' || '' }}
|
||||||
|
run: .github/scripts/stage-pages-site.sh prod "${{ needs.build.outputs.version }}" "staged/ProLibertateGames-${{ needs.build.outputs.version }}.apk"
|
||||||
|
|
||||||
|
- name: Deploy
|
||||||
|
env:
|
||||||
|
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||||
|
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
gh release create "${{ steps.stage.outputs.version }}" \
|
# Creates the project on first run; fails harmlessly if it exists.
|
||||||
"${{ steps.stage.outputs.apk }}" \
|
npx --yes wrangler@3 pages project create plg --production-branch main || true
|
||||||
"ProLibertateGames.apk" \
|
# --branch main makes this the production deployment, which is what
|
||||||
--repo "${{ github.repository }}" \
|
# plg.wallacenet.ca serves, whichever branch the workflow ran on.
|
||||||
--target "${{ github.sha }}" \
|
npx --yes wrangler@3 pages deploy pages-out --project-name plg --branch main --commit-dirty=true
|
||||||
--title "ProLibertateGames ${{ steps.stage.outputs.version }}" \
|
|
||||||
--notes "Automated release built from \`${{ github.sha }}\` on push to \`main\`.
|
|
||||||
|
|
||||||
Download **${{ steps.stage.outputs.apk }}** below and install it on an Android device (you may need to allow *Install unknown apps*). This is a debug-signed build.
|
|
||||||
|
|
||||||
Both files below are the same build. \`ProLibertateGames.apk\` is a copy under a fixed name, so this link always fetches the newest release and can be bookmarked or shared:
|
|
||||||
|
|
||||||
https://github.com/${{ github.repository }}/releases/latest/download/ProLibertateGames.apk"
|
|
||||||
|
|||||||
@@ -1,3 +1,9 @@
|
|||||||
|
> **Moved to Gitea.** CI now runs on Gitea Actions at
|
||||||
|
> `git.wallacenet.ca/devadmin/ProLibertateGames`, publishing to Cloudflare Pages
|
||||||
|
> (`plg.wallacenet.ca`) only. The sandbox notes below describe the earlier GitHub
|
||||||
|
> setup (no CI run from session pushes, `workflow_dispatch` as the workaround);
|
||||||
|
> re-verify them against Gitea before relying on them.
|
||||||
|
|
||||||
# Working on this repo with Claude
|
# Working on this repo with Claude
|
||||||
|
|
||||||
## Nothing a session does triggers CI. Dispatch it explicitly.
|
## Nothing a session does triggers CI. Dispatch it explicitly.
|
||||||
|
|||||||
@@ -198,9 +198,39 @@ production package instead.
|
|||||||
publishes it as a GitHub Release tagged `v<series>.<run-number>`. The in-app updater
|
publishes it as a GitHub Release tagged `v<series>.<run-number>`. The in-app updater
|
||||||
reads that tag, which is why the APK's `versionCode` is the same run number.
|
reads that tag, which is why the APK's `versionCode` is the same run number.
|
||||||
|
|
||||||
|
### Hosting: plg.wallacenet.ca
|
||||||
|
|
||||||
|
Both channels are also deployed to one Cloudflare Pages project (`plg`), served at
|
||||||
|
`https://plg.wallacenet.ca`:
|
||||||
|
|
||||||
|
```
|
||||||
|
/ landing page (reads both latest.json files)
|
||||||
|
/prod/latest.json {tag, apkName, apkUrl, channel} — what the updater reads
|
||||||
|
/prod/ProLibertateGames.apk, /prod/ProLibertateGames-v<series>.<n>.apk
|
||||||
|
/dev/... same, for the dev channel
|
||||||
|
```
|
||||||
|
|
||||||
|
Each workflow's `deploy-pages` job runs `.github/scripts/stage-pages-site.sh`,
|
||||||
|
which stages its own channel and re-fetches the other channel from the live site,
|
||||||
|
because a Pages deploy replaces everything. The two share a concurrency group so
|
||||||
|
they cannot race. Needs repo secrets `CLOUDFLARE_API_TOKEN` (Account → Cloudflare
|
||||||
|
Pages → Edit) and `CLOUDFLARE_ACCOUNT_ID`. Pages caps files at 25 MiB.
|
||||||
|
|
||||||
|
**First deploy:** the site does not exist yet, so the domain answers 403 (Cloudflare error 1014) and the carry-over fetch would abort. Dispatch the workflow once with `first_deploy` ticked, then attach `plg.wallacenet.ca` as a custom domain on the new `plg` Pages project. Later runs need no input.
|
||||||
|
|
||||||
|
The in-app updater reads these `latest.json` files, and this is the only release
|
||||||
|
channel: the workflows no longer create GitHub or Gitea releases. They run on
|
||||||
|
Gitea Actions (`https://git.wallacenet.ca/devadmin/ProLibertateGames`); the GitHub
|
||||||
|
repository is frozen and should stay public with its last releases, because
|
||||||
|
installs that predate the Cloudflare updater still read them to reach it.
|
||||||
|
|
||||||
|
Build numbers are `run number + plgVersionRunOffset` (1000, in `gradle.properties`),
|
||||||
|
so moving CI hosts, which restarts run numbers, can never publish a `versionCode`
|
||||||
|
lower than one already installed.
|
||||||
|
|
||||||
### Installing without the app
|
### Installing without the app
|
||||||
|
|
||||||
The newest production build is always at this link, which never changes:
|
The newest production build is always at **https://plg.wallacenet.ca/prod/ProLibertateGames.apk** (dev: `/dev/ProLibertateGames.apk`). The GitHub link below is the frozen pre-migration release:
|
||||||
|
|
||||||
**https://github.com/adrianoftyriel/ProLibertateGames/releases/latest/download/ProLibertateGames.apk**
|
**https://github.com/adrianoftyriel/ProLibertateGames/releases/latest/download/ProLibertateGames.apk**
|
||||||
|
|
||||||
@@ -367,7 +397,7 @@ game/ rules engines and AI — pure Kotlin, no Android, unit-tested
|
|||||||
net/ wire protocol, LAN transport, lobby, match driver
|
net/ wire protocol, LAN transport, lobby, match driver
|
||||||
score/ scorekeeper sheet — pure Kotlin, unit-tested — and its store
|
score/ scorekeeper sheet — pure Kotlin, unit-tested — and its store
|
||||||
settings/ DataStore-backed preferences
|
settings/ DataStore-backed preferences
|
||||||
update/ GitHub Releases OTA updater, and the channel rule — pure, tested
|
update/ Cloudflare Pages OTA updater, and the channel rule — pure, tested
|
||||||
ui/ Compose screens
|
ui/ Compose screens
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,12 @@ val buildChannel = System.getenv("PLG_CHANNEL").orEmpty()
|
|||||||
val runNumber = (System.getenv("GITHUB_RUN_NUMBER") ?: "").toIntOrNull() ?: 0
|
val runNumber = (System.getenv("GITHUB_RUN_NUMBER") ?: "").toIntOrNull() ?: 0
|
||||||
val stampVersion = buildChannel.isNotEmpty() && runNumber > 0
|
val stampVersion = buildChannel.isNotEmpty() && runNumber > 0
|
||||||
|
|
||||||
|
// See plgVersionRunOffset in gradle.properties: the stamped build number is the
|
||||||
|
// CI run number plus a permanent floor, so a counter that restarts on a new CI
|
||||||
|
// host can never produce a lower versionCode than one already installed.
|
||||||
|
val runOffset = (project.findProperty("plgVersionRunOffset") as String?)?.toIntOrNull() ?: 0
|
||||||
|
val buildNumber = runNumber + runOffset
|
||||||
|
|
||||||
// The version series, from gradle.properties. Both workflows read the same
|
// The version series, from gradle.properties. Both workflows read the same
|
||||||
// property when they name a release tag, so the tag and the APK inside it
|
// property when they name a release tag, so the tag and the APK inside it
|
||||||
// cannot disagree about which series they belong to.
|
// cannot disagree about which series they belong to.
|
||||||
@@ -51,10 +57,10 @@ android {
|
|||||||
// versionCode stays the run number and nothing else, so it keeps
|
// versionCode stays the run number and nothing else, so it keeps
|
||||||
// climbing across a change of series: bumping 1.0 to 1.1 must not make
|
// climbing across a change of series: bumping 1.0 to 1.1 must not make
|
||||||
// an update look like a downgrade to Android.
|
// an update look like a downgrade to Android.
|
||||||
versionCode = if (stampVersion) runNumber else 1
|
versionCode = if (stampVersion) buildNumber else 1
|
||||||
versionName = when {
|
versionName = when {
|
||||||
stampVersion && buildChannel == "production" -> "$versionSeries.$runNumber"
|
stampVersion && buildChannel == "production" -> "$versionSeries.$buildNumber"
|
||||||
stampVersion -> "$versionSeries.$runNumber-dev"
|
stampVersion -> "$versionSeries.$buildNumber-dev"
|
||||||
isDevBuild -> "$versionSeries.0-dev"
|
isDevBuild -> "$versionSeries.0-dev"
|
||||||
else -> "$versionSeries.0"
|
else -> "$versionSeries.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
<!-- OTA updates from GitHub Releases, plus LAN play over TCP sockets. -->
|
<!-- OTA updates from plg.wallacenet.ca, plus LAN play over TCP sockets. -->
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
<!-- Network Service Discovery needs multicast to see hosts on the LAN. -->
|
<!-- Network Service Discovery needs multicast to see hosts on the LAN. -->
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ enum class UpdateChannel(val label: String, val blurb: String) {
|
|||||||
/** Marks a tag or a version name as belonging to the dev channel. */
|
/** Marks a tag or a version name as belonging to the dev channel. */
|
||||||
const val DEV_SUFFIX = "-dev"
|
const val DEV_SUFFIX = "-dev"
|
||||||
|
|
||||||
/** One published build, as read off a GitHub release. */
|
/** One published build, as read off a channel's `latest.json`. */
|
||||||
data class Release(
|
data class Release(
|
||||||
val tag: String,
|
val tag: String,
|
||||||
val versionCode: Int,
|
val versionCode: Int,
|
||||||
@@ -70,7 +70,7 @@ fun versionCodeOfTag(tag: String): Int? =
|
|||||||
* nothing at all.
|
* nothing at all.
|
||||||
*
|
*
|
||||||
* Only an actual update is worth a word. Opening the app is not asking a
|
* Only an actual update is worth a word. Opening the app is not asking a
|
||||||
* question, so a check that finds nothing — or that cannot reach GitHub at all
|
* question, so a check that finds nothing — or that cannot reach the update server at all
|
||||||
* — stays quiet; a phone on a train would otherwise be told off for it every
|
* — stays quiet; a phone on a train would otherwise be told off for it every
|
||||||
* single launch. The button in Settings *is* asking a question, and reports
|
* single launch. The button in Settings *is* asking a question, and reports
|
||||||
* every answer including those two.
|
* every answer including those two.
|
||||||
|
|||||||
@@ -8,19 +8,17 @@ import android.provider.Settings
|
|||||||
import androidx.core.content.FileProvider
|
import androidx.core.content.FileProvider
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import org.json.JSONArray
|
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.net.HttpURLConnection
|
import java.net.HttpURLConnection
|
||||||
import java.net.URL
|
import java.net.URL
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Over-the-air updates from the repository's GitHub Releases.
|
* Over-the-air updates from plg.wallacenet.ca (Cloudflare Pages).
|
||||||
*
|
*
|
||||||
* Release tags are v<series>.<run-number> and the APK's versionCode is that same run
|
* Release tags are v<series>.<run-number> and the APK's versionCode is that same run
|
||||||
* number, so comparing the two is enough to tell whether a newer build exists.
|
* number, so comparing the two is enough to tell whether a newer build exists.
|
||||||
* The repository's releases must be publicly downloadable — no token is
|
* Everything there is public — no token is embedded in the app.
|
||||||
* embedded in the app, and GitHub blocks anonymous access to private assets.
|
|
||||||
*
|
*
|
||||||
* There is nothing to choose here. The channel comes from the running APK, so
|
* There is nothing to choose here. The channel comes from the running APK, so
|
||||||
* a dev install looks only at dev builds and a production install only at
|
* a dev install looks only at dev builds and a production install only at
|
||||||
@@ -48,65 +46,37 @@ class Updater(private val activity: Activity) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Production reads GitHub's "latest release", which by definition skips
|
* Each channel publishes a `latest.json` next to its APK on Cloudflare
|
||||||
* prereleases. Dev walks the release list for the newest prerelease, which
|
* Pages: `/prod/latest.json` and `/dev/latest.json`. The two live in one
|
||||||
* is what the CI pipeline publishes from the dev branch.
|
* site but separate paths, so a build can only ever look down its own line.
|
||||||
*/
|
*/
|
||||||
private fun fetchLatestRelease(channel: UpdateChannel): Release? = when (channel) {
|
private fun fetchLatestRelease(channel: UpdateChannel): Release? {
|
||||||
UpdateChannel.PRODUCTION ->
|
val path = if (channel == UpdateChannel.DEV) "dev" else "prod"
|
||||||
getJsonObject("https://api.github.com/repos/$OWNER/$REPO/releases/latest")
|
return getJsonObject("$BASE_URL/$path/latest.json")?.let { toRelease(it) }
|
||||||
?.let { toRelease(it) }
|
|
||||||
|
|
||||||
UpdateChannel.DEV ->
|
|
||||||
getJsonArray("https://api.github.com/repos/$OWNER/$REPO/releases?per_page=30")
|
|
||||||
?.let { releases ->
|
|
||||||
// GitHub returns newest first.
|
|
||||||
(0 until releases.length())
|
|
||||||
.map { releases.getJSONObject(it) }
|
|
||||||
.firstOrNull { it.optBoolean("prerelease") && !it.optBoolean("draft") }
|
|
||||||
?.let { toRelease(it) }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun toRelease(json: JSONObject): Release? {
|
private fun toRelease(json: JSONObject): Release? {
|
||||||
val tag = json.optString("tag_name").ifBlank { return null }
|
val tag = json.optString("tag").ifBlank { return null }
|
||||||
val versionCode = versionCodeOfTag(tag) ?: return null
|
val versionCode = versionCodeOfTag(tag) ?: return null
|
||||||
val channel = channelOfTag(tag, json.optBoolean("prerelease"))
|
val apkUrl = json.optString("apkUrl").ifBlank { return null }
|
||||||
|
|
||||||
val assets = json.optJSONArray("assets") ?: return null
|
|
||||||
val apks = (0 until assets.length())
|
|
||||||
.map { assets.getJSONObject(it) }
|
|
||||||
.filter { it.optString("name").endsWith(".apk") }
|
|
||||||
|
|
||||||
// A production release carries the same build twice: once named for its
|
|
||||||
// version, and once under a fixed name so a permanent download link
|
|
||||||
// exists. Pick the versioned one explicitly rather than whichever was
|
|
||||||
// uploaded first, so what gets installed is traceable to its tag.
|
|
||||||
val chosen = apks.firstOrNull { it.optString("name").contains(tag) }
|
|
||||||
?: apks.firstOrNull()
|
|
||||||
?: return null
|
|
||||||
|
|
||||||
return Release(
|
return Release(
|
||||||
tag = tag,
|
tag = tag,
|
||||||
versionCode = versionCode,
|
versionCode = versionCode,
|
||||||
apkUrl = chosen.getString("browser_download_url"),
|
apkUrl = apkUrl,
|
||||||
apkName = chosen.optString("name"),
|
apkName = json.optString("apkName"),
|
||||||
channel = channel,
|
channel = channelOfTag(tag, prerelease = false),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun openConnection(url: String): HttpURLConnection =
|
private fun getJsonObject(url: String): JSONObject? {
|
||||||
(URL(url).openConnection() as HttpURLConnection).apply {
|
val connection = (URL(url).openConnection() as HttpURLConnection).apply {
|
||||||
requestMethod = "GET"
|
requestMethod = "GET"
|
||||||
setRequestProperty("Accept", "application/vnd.github+json")
|
|
||||||
setRequestProperty("User-Agent", "ProLibertateGames-Updater")
|
setRequestProperty("User-Agent", "ProLibertateGames-Updater")
|
||||||
connectTimeout = 15_000
|
connectTimeout = 15_000
|
||||||
readTimeout = 15_000
|
readTimeout = 15_000
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getJsonObject(url: String): JSONObject? {
|
|
||||||
val connection = openConnection(url)
|
|
||||||
try {
|
try {
|
||||||
|
// 404 means the channel has nothing published yet.
|
||||||
if (connection.responseCode != 200) return null
|
if (connection.responseCode != 200) return null
|
||||||
return JSONObject(connection.inputStream.bufferedReader().use { it.readText() })
|
return JSONObject(connection.inputStream.bufferedReader().use { it.readText() })
|
||||||
} finally {
|
} finally {
|
||||||
@@ -114,16 +84,6 @@ class Updater(private val activity: Activity) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getJsonArray(url: String): JSONArray? {
|
|
||||||
val connection = openConnection(url)
|
|
||||||
try {
|
|
||||||
if (connection.responseCode != 200) return null
|
|
||||||
return JSONArray(connection.inputStream.bufferedReader().use { it.readText() })
|
|
||||||
} finally {
|
|
||||||
connection.disconnect()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Read back from the installed APK's own version name. */
|
/** Read back from the installed APK's own version name. */
|
||||||
fun installedChannel(): UpdateChannel = channelOfVersionName(installedVersionName())
|
fun installedChannel(): UpdateChannel = channelOfVersionName(installedVersionName())
|
||||||
|
|
||||||
@@ -175,7 +135,6 @@ class Updater(private val activity: Activity) {
|
|||||||
activity.packageManager.getPackageInfo(activity.packageName, 0).versionName ?: "unknown"
|
activity.packageManager.getPackageInfo(activity.packageName, 0).versionName ?: "unknown"
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
const val OWNER = "adrianoftyriel"
|
const val BASE_URL = "https://plg.wallacenet.ca"
|
||||||
const val REPO = "ProLibertateGames"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -164,6 +164,9 @@ class ScorekeeperSessionTest {
|
|||||||
|
|
||||||
await("the guest to be let in") { guest.state.value.connected }
|
await("the guest to be let in") { guest.state.value.connected }
|
||||||
assertFalse(guest.state.value.awaiting)
|
assertFalse(guest.state.value.awaiting)
|
||||||
|
// The sheet follows the connection over the socket, so on a slow runner
|
||||||
|
// it can still be in flight when `connected` flips: wait for it.
|
||||||
|
await("the guest to receive the sheet") { guest.state.value.sheet == host.state.sheet }
|
||||||
assertEquals(host.state.sheet, guest.state.value.sheet)
|
assertEquals(host.state.sheet, guest.state.value.sheet)
|
||||||
assertTrue(guest.state.value.sheet.started)
|
assertTrue(guest.state.value.sheet.started)
|
||||||
assertTrue(host.state.requests.isEmpty())
|
assertTrue(host.state.requests.isEmpty())
|
||||||
|
|||||||
@@ -23,3 +23,11 @@ android.nonTransitiveRClass=true
|
|||||||
# it keeps climbing on its own and an APK's versionCode never goes backwards
|
# it keeps climbing on its own and an APK's versionCode never goes backwards
|
||||||
# when this changes.
|
# when this changes.
|
||||||
plgVersionSeries=1.4
|
plgVersionSeries=1.4
|
||||||
|
|
||||||
|
# The permanent floor under every stamped build number: versionCode, versionName
|
||||||
|
# and the release tag are all `run number + this`. Run numbers restart at 1 on a
|
||||||
|
# new CI host (Gitea Actions), and Android refuses any versionCode lower than the
|
||||||
|
# one installed, so without a floor the first build after a move would look like
|
||||||
|
# a downgrade. 1000 sits above every number GitHub reached (prod 30, dev ~172).
|
||||||
|
# Set once and never changed.
|
||||||
|
plgVersionRunOffset=1000
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en"><head><meta charset="utf-8"><title>Not found</title></head>
|
||||||
|
<body><h1>404</h1><p>Not found. <a href="/">Pro Libertate Games downloads</a></p></body></html>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
/*/latest.json
|
||||||
|
Cache-Control: no-cache
|
||||||
|
Access-Control-Allow-Origin: *
|
||||||
|
|
||||||
|
/*.apk
|
||||||
|
Content-Type: application/vnd.android.package-archive
|
||||||
|
Cache-Control: public, max-age=300
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Pro Libertate Games</title>
|
||||||
|
<style>
|
||||||
|
:root { color-scheme: light dark; font-family: system-ui, sans-serif; }
|
||||||
|
body { max-width: 32rem; margin: 3rem auto; padding: 0 1rem; line-height: 1.5; }
|
||||||
|
.card { border: 1px solid #8884; border-radius: .5rem; padding: 1rem; margin: 1rem 0; }
|
||||||
|
a.dl { display: inline-block; padding: .5rem 1rem; border-radius: .4rem; background: #2563eb; color: #fff; text-decoration: none; }
|
||||||
|
small { opacity: .7; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Pro Libertate Games</h1>
|
||||||
|
<p>Android builds. Allow <em>Install unknown apps</em> for your browser to sideload.</p>
|
||||||
|
<div class="card" id="production"><h2>Production</h2><p>Loading…</p></div>
|
||||||
|
<div class="card" id="dev"><h2>Dev</h2><p>Loading…</p></div>
|
||||||
|
<script>
|
||||||
|
for (const [channel, path] of [["production", "prod"], ["dev", "dev"]]) {
|
||||||
|
const box = document.getElementById(channel);
|
||||||
|
const title = box.querySelector("h2").outerHTML;
|
||||||
|
fetch(`/${path}/latest.json`, { cache: "no-cache" })
|
||||||
|
.then(r => r.ok ? r.json() : Promise.reject())
|
||||||
|
.then(j => {
|
||||||
|
box.innerHTML = `${title}<p><a class="dl" href="${j.apkUrl}">Download ${j.tag}</a></p>` +
|
||||||
|
`<small>Always newest: <a href="/${path}/ProLibertateGames.apk">/${path}/ProLibertateGames.apk</a></small>`;
|
||||||
|
})
|
||||||
|
.catch(() => { box.innerHTML = `${title}<p>No build published yet.</p>`; });
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user