19 Commits
Author SHA1 Message Date
Claude ad30ab9dea Move CI to Gitea: run-number offset, Cloudflare-only publishing
- plgVersionRunOffset=1000: stamped build number is run number + offset, so
  Gitea's restarted run counter cannot produce a versionCode below an
  installed one (GitHub reached prod 30, dev ~172). Tags and versionName use
  the same number.
- Drop the GitHub release steps; plg.wallacenet.ca is the only channel.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01JdMNbY5hLR7FjtfEE712xG
2026-10-14 17:00:01 +00:00
Claude 4c603f6d3d Adopt the GitHub lineage; supersede Gitea-only bootstrap commits
Keeps Gitea's history reachable (also preserved as gitea-pre-github-main)
while the tree stays the GitHub one: package org.prolibertate.games, OTA
from plg.wallacenet.ca. Gitea's package rename and Gitea-releases updater
are intentionally not carried over.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01JdMNbY5hLR7FjtfEE712xG
2026-10-14 16:59:38 +00:00
adrianoftyriel 0927660c15 Merge pull request #43 from adrianoftyriel/dev
Dev
2026-10-14 10:42:42 -06:00
adrianoftyriel 5d0f95361f Merge pull request #42 from adrianoftyriel/cf-real-404
Fix Pages carry-over: real 404 and tolerate 200-with-HTML
2026-10-14 10:18:01 -06:00
Claude 6fbe63f787 Fix carry-over on Pages: add 404.html and tolerate 200-with-HTML
Pages serves index.html with 200 for unknown paths when there is no
404.html, so a never-published channel looked published and jq failed on
the HTML. Add a real 404 page and treat a 200 without a latest.json as
absent.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01JdMNbY5hLR7FjtfEE712xG
2026-10-14 16:08:28 +00:00
adrianoftyriel 12d20565bd Merge pull request #41 from adrianoftyriel/cf-treat-1014-as-absent
Treat Cloudflare 1014 as 'channel not published yet'
2026-10-14 10:02:26 -06:00
Claude 2df140a3c1 Treat Cloudflare error 1014 as 'channel not published yet'
A push-triggered first deploy cannot set the first_deploy input, and the
domain answers 403/1014 until the Pages project exists. 1014 means nothing
exists to preserve, so handle it like a 404. Other 403s still abort.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01JdMNbY5hLR7FjtfEE712xG
2026-10-14 15:53:15 +00:00
adrianoftyriel 0b74b1b2b1 Merge pull request #40 from adrianoftyriel/cf-first-deploy-fix
Fix first Cloudflare deploy: add first_deploy dispatch input
2026-10-14 09:39:58 -06:00
Claude 4369d15cec Add first_deploy dispatch input so the first Cloudflare deploy can bootstrap
The domain answers 403 (error 1014) until the Pages project exists, which
made the carry-over fetch abort the very deploy that would create it.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01JdMNbY5hLR7FjtfEE712xG
2026-10-14 15:39:03 +00:00
adrianoftyriel 1e9908569d Merge pull request #39 from adrianoftyriel/cloudflare-pages-deploy
Publish dev and prod builds to plg.wallacenet.ca (Cloudflare Pages)
2026-10-14 09:32:31 -06:00
Claude c0fc4fe7d6 Publish dev and prod builds to plg.wallacenet.ca (Cloudflare Pages)
Both workflows gain a deploy-pages job that stages their channel into one
Pages site, carrying the other channel over from the live site. The in-app
updater now reads /prod|/dev/latest.json from plg.wallacenet.ca instead of
the GitHub API. GitHub releases are still published for one transition
deployment so existing installs can fetch the new updater.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01JdMNbY5hLR7FjtfEE712xG
2026-10-14 15:20:47 +00:00
adrianoftyrielandPaperclip 4d7cd6182f Rename the installed Android package to ca.wallacenet.prolibertategames
Build and Release APK / Build APK and publish release (push) Failing after 7s
Follows the Gitea repo rename: the applicationId (and its .dev variant)
now matches the repo, the launcher name and the docs, instead of carrying
the abandoned Pilgrims of Katan identity.

The JVM namespace stays org.prolibertate.games, so no source file moves;
only the installed identity changes.

Co-Authored-By: Paperclip <[email protected]>
2026-10-14 14:47:13 +00:00
adrianoftyrielandPaperclip b58b4251dc Rename the Gitea repo references to ProLibertateGames
Build and Release APK / Build APK and publish release (push) Canceled after 0s
The repo was renamed from PilgrimsOfKatan to ProLibertateGames on the
Gitea instance. Update every reference so nothing points at the old name:

- OTA updater: the repo constant and the User-Agent
- publish-release.sh: the default repo, the asset name, the release title
- CI/release workflows: the artifact names and the staged APK name
- settings.gradle.kts: the Gradle project name
- Protocol.kt / LanTransport.kt: the mDNS service label and the Wi-Fi lock tag
- docs (README, CLAUDE.md, docs/CI.md) and the update-policy test fixtures

The installed applicationId is left unchanged in this commit.

Co-Authored-By: Paperclip <[email protected]>
2026-10-14 14:21:43 +00:00
adrianoftyrielandPaperclip 5c797c8807 Restore the Pro Libertate Games title and splash; list Katan under Board Games
Build and Release APK / Build APK and publish release (push) Canceled after 0s
The bootstrap rebranded the app to Pilgrims of Katan (WALA-118 review);
the decision is to keep the upstream's own identity instead.

- Title and splash: the upstream originals restored exactly — the Wallace
  tartan splash under the "Pro Libertate Games" uncial wordmark
  (SplashScreen.kt, Tartan.kt), the tartan launcher icon, and the
  Theme.ProLibertateGames window theme. The game screens go back to the
  upstream WallaceGold/WallaceRed accents the tartan is woven from.
- Names and colours: launcher names back to "Pro Libertate Games" /
  "Pro Libertate Games DEV"; the channel colours back to the clan red /
  hunting green setts (resValue tartan_field and friends). The installed
  identity stays ca.wallacenet.pilgrimsofkatan (+ .dev) — that is the
  package, not the brand.
- Catalogue: Katan is listed under Board Games (3-4 players, original
  blurb, no third-party marks) with available = false, so dev builds show
  it as "Coming soon" and a production build hides it until its engine
  and screen land.
- Docs: README/CLAUDE.md/docs/CI.md updated to the restored identity;
  CatalogTest's invariant now names Katan as the one deliberate exception
  rather than asserting nothing is unfinished.

Structural bootstrap untouched: applicationId, OTA repointed at the Gitea
releases, CI workflows, version stamping.

Verified: ./gradlew assembleDebug and testDebugUnitTest green — 628 tests,
0 failures.

Co-Authored-By: Paperclip <[email protected]>
2026-10-14 02:15:30 +00:00
adrianoftyrielandPaperclip 05c443a4b5 Publish: verify the release's stored state after attaching the APK
Build and Release APK / Build APK and publish release (push) Canceled after 0s
A live publish on git.wallacenet.ca kept coming back invisible to the updater:
the create accepted "draft": false, and the release was still stored as a
draft once the APK was attached — so the anonymous list the app reads came
back empty even though every publish step reported success.

The script now attaches first, then reads the release back and PATCHes the
state if it did not stick, failing loudly if the correction is refused. A
draft is not a channel a preview build can silently become.

Also lists all 22 catalogue games in the README's table; seven were missing,
which read as "the app has 15 games".

Verified against the live instance: anonymous list shows
v1.4.1001-dev as a published prerelease with a tag-named asset, the
production /releases/latest correctly returns 404 while only a prerelease
exists, and an anonymous download through the URL the app builds matches the
built APK byte for byte.

Co-Authored-By: Paperclip <[email protected]>
2026-10-13 22:27:33 +00:00
adrianoftyrielandPaperclip a827a38ae6 OTA: name release assets after their tag, download from one host
Build and Release APK / Build APK and publish release (push) Canceled after 0s
Two things the first live release check on git.wallacenet.ca showed:

- The publish script uploaded the APK under whatever the build called it
  (app-debug.apk), which is the name the updater only finds by its fallback
  rule. The script now names the asset after its tag, so the primary rule —
  "the asset whose name contains the tag" — is the one that fires.
- The instance answers for two host names and reports asset URLs on the one
  it treats as canonical, so the app would have fetched its release list from
  git.wallacenet.ca and its APK from elsewhere. onReportedHost() now keeps the
  path the server chose and puts the download on the host the app already
  trusts, tested in UpdatePolicyTest.

Co-Authored-By: Paperclip <[email protected]>
2026-10-13 22:13:09 +00:00
adrianoftyrielandPaperclip fad4778ad8 Bootstrap Pilgrims of Katan: rebrand, Gitea CI and OTA
Build and Release APK / Build APK and publish release (push) Canceled after 0s
Import of ProLibertateGames (upstream main, GPL-3.0) rebranded for
Wallacenet and repointed at git.wallacenet.ca.

- Identity: applicationId ca.wallacenet.pilgrimsofkatan (+ .dev for the dev
  channel), launcher names "Pilgrims of Katan" / "Pilgrims of Katan DEV",
  neutral hexagon branding replacing the Wallace tartan. Uncial Antiqua keeps
  its OFL licence, and the GPL-3.0 LICENSE plus upstream attribution stay.
- OTA: Updater.kt now reads this repo's own Gitea releases
  (/api/v1/repos/devadmin/PilgrimsOfKatan/releases...) and BuildConfig.
  OTA_ENABLED is on; Gitea answers the same JSON shape the code already parsed.
- Build numbers: versionCode = CI run number + plgVersionRunOffset (1000),
  with the tag and versionName derived from the same figure. The offset is
  permanent, because Gitea Actions restarts run numbers at 1 and a reset would
  otherwise make Android reject the next build as a downgrade.
- CI: ci.yml publishes a dev prerelease from dev, release.yml a production
  release from main, both through scripts/publish-release.sh (Gitea REST API,
  runnable by hand until an Actions runner is registered).
- Docs: docs/CI.md records the channel, numbering and runner decisions;
  docs/README.md seeds the per-game RULES-* conventions; CLAUDE.md carries the
  agent ground rules.

Verified: ./gradlew assembleDebug and testDebugUnitTest green on JDK 17 /
compileSdk 34, both variants stamped, installing side by side under the new
identity.

Co-Authored-By: Paperclip <[email protected]>
2026-10-13 22:03:27 +00:00
adrianoftyriel d27c2db894 Merge pull request #38 from adrianoftyriel/dev
Release: multi-device scorekeeper sharing and tally scoring
2026-10-12 22:04:30 -06:00
adrianoftyriel e6ce1aebd5 Merge pull request #33 from adrianoftyriel/dev
Release 1.4
2026-10-09 14:13:52 -06:00
13 changed files with 288 additions and 97 deletions
+66
View File
@@ -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
+55 -14
View File
@@ -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
@@ -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."
+48 -17
View File
@@ -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.
@@ -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"
+6
View File
@@ -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.
+32 -2
View File
@@ -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
``` ```
+9 -3
View File
@@ -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 -1
View File
@@ -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"
} }
} }
+8
View File
@@ -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
+3
View File
@@ -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>
+7
View File
@@ -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
+34
View File
@@ -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>