Merge pull request #39 from adrianoftyriel/cloudflare-pages-deploy

Publish dev and prod builds to plg.wallacenet.ca (Cloudflare Pages)
This commit is contained in:
adrianoftyriel
2026-10-14 09:32:31 -06:00
committed by GitHub
9 changed files with 223 additions and 64 deletions
+46
View File
@@ -0,0 +1,46 @@
#!/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/_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"
# 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}"
if [ "$code" = 404 ]; then
echo "No $other channel published yet; 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
+50
View File
@@ -30,6 +30,8 @@ jobs:
build:
name: Build and check
runs-on: ubuntu-latest
outputs:
version: ${{ steps.stage.outputs.version }}
env:
# 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
@@ -108,6 +110,15 @@ jobs:
echo "version=$version" >> "$GITHUB_OUTPUT"
echo "apk=$dest" >> "$GITHUB_OUTPUT"
- name: Upload staged dev APK for the Pages deploy
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
- name: Publish dev channel prerelease
if: github.ref == 'refs/heads/dev'
env:
@@ -125,3 +136,42 @@ jobs:
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."
# Publishes the build to https://plg.wallacenet.ca (Cloudflare Pages). Runs
# alongside the GitHub release above for one deployment so already-installed
# apps, which still read GitHub, can fetch the updater that reads Cloudflare.
#
# 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
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: |
set -euo pipefail
# Creates the project on first run; fails harmlessly if it exists.
npx --yes wrangler@3 pages project create plg --production-branch main || true
# --branch main makes this the production deployment, which is what
# plg.wallacenet.ca serves, whichever branch the workflow ran on.
npx --yes wrangler@3 pages deploy pages-out --project-name plg --branch main --commit-dirty=true
+42 -1
View File
@@ -24,6 +24,8 @@ jobs:
# anything else is a no-op rather than shipping unreviewed code to everyone.
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
outputs:
version: ${{ steps.stage.outputs.version }}
env:
# Stamps the APK as a production-channel build, versioned with the run
# number that matches the tag published below.
@@ -69,7 +71,7 @@ jobs:
- name: Upload APK as workflow artifact
uses: actions/upload-artifact@v4
with:
name: ProLibertateGames-apk
name: plg-prod-staged
path: ${{ steps.stage.outputs.apk }}
if-no-files-found: error
@@ -91,3 +93,42 @@ jobs:
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"
# Publishes the build to https://plg.wallacenet.ca (Cloudflare Pages). Runs
# alongside the GitHub release above for one deployment so already-installed
# apps, which still read GitHub, can fetch the updater that reads Cloudflare.
#
# 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
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: |
set -euo pipefail
# Creates the project on first run; fails harmlessly if it exists.
npx --yes wrangler@3 pages project create plg --production-branch main || true
# --branch main makes this the production deployment, which is what
# plg.wallacenet.ca serves, whichever branch the workflow ran on.
npx --yes wrangler@3 pages deploy pages-out --project-name plg --branch main --commit-dirty=true
+24 -2
View File
@@ -198,9 +198,31 @@ production package instead.
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.
### 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.
The in-app updater reads these `latest.json` files. GitHub Releases are still
published for one transition deployment so installs that predate this change can
fetch it; after that, remove the GitHub release steps from both workflows.
### 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 remains during the transition:
**https://github.com/adrianoftyriel/ProLibertateGames/releases/latest/download/ProLibertateGames.apk**
@@ -367,7 +389,7 @@ game/ rules engines and AI — pure Kotlin, no Android, unit-tested
net/ wire protocol, LAN transport, lobby, match driver
score/ scorekeeper sheet — pure Kotlin, unit-tested — and its store
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
```
+1 -1
View File
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<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.ACCESS_NETWORK_STATE" />
<!-- 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. */
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(
val tag: String,
val versionCode: Int,
@@ -70,7 +70,7 @@ fun versionCodeOfTag(tag: String): Int? =
* nothing at all.
*
* 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
* single launch. The button in Settings *is* asking a question, and reports
* every answer including those two.
@@ -8,19 +8,17 @@ import android.provider.Settings
import androidx.core.content.FileProvider
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.json.JSONArray
import org.json.JSONObject
import java.io.File
import java.net.HttpURLConnection
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
* 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
* embedded in the app, and GitHub blocks anonymous access to private assets.
* Everything there is public — no token is embedded in the app.
*
* 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
@@ -48,65 +46,37 @@ class Updater(private val activity: Activity) {
}
/**
* Production reads GitHub's "latest release", which by definition skips
* prereleases. Dev walks the release list for the newest prerelease, which
* is what the CI pipeline publishes from the dev branch.
* Each channel publishes a `latest.json` next to its APK on Cloudflare
* Pages: `/prod/latest.json` and `/dev/latest.json`. The two live in one
* site but separate paths, so a build can only ever look down its own line.
*/
private fun fetchLatestRelease(channel: UpdateChannel): Release? = when (channel) {
UpdateChannel.PRODUCTION ->
getJsonObject("https://api.github.com/repos/$OWNER/$REPO/releases/latest")
?.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 fetchLatestRelease(channel: UpdateChannel): Release? {
val path = if (channel == UpdateChannel.DEV) "dev" else "prod"
return getJsonObject("$BASE_URL/$path/latest.json")?.let { toRelease(it) }
}
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 channel = channelOfTag(tag, json.optBoolean("prerelease"))
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
val apkUrl = json.optString("apkUrl").ifBlank { return null }
return Release(
tag = tag,
versionCode = versionCode,
apkUrl = chosen.getString("browser_download_url"),
apkName = chosen.optString("name"),
channel = channel,
apkUrl = apkUrl,
apkName = json.optString("apkName"),
channel = channelOfTag(tag, prerelease = false),
)
}
private fun openConnection(url: String): HttpURLConnection =
(URL(url).openConnection() as HttpURLConnection).apply {
private fun getJsonObject(url: String): JSONObject? {
val connection = (URL(url).openConnection() as HttpURLConnection).apply {
requestMethod = "GET"
setRequestProperty("Accept", "application/vnd.github+json")
setRequestProperty("User-Agent", "ProLibertateGames-Updater")
connectTimeout = 15_000
readTimeout = 15_000
}
private fun getJsonObject(url: String): JSONObject? {
val connection = openConnection(url)
try {
// 404 means the channel has nothing published yet.
if (connection.responseCode != 200) return null
return JSONObject(connection.inputStream.bufferedReader().use { it.readText() })
} 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. */
fun installedChannel(): UpdateChannel = channelOfVersionName(installedVersionName())
@@ -175,7 +135,6 @@ class Updater(private val activity: Activity) {
activity.packageManager.getPackageInfo(activity.packageName, 0).versionName ?: "unknown"
private companion object {
const val OWNER = "adrianoftyriel"
const val REPO = "ProLibertateGames"
const val BASE_URL = "https://plg.wallacenet.ca"
}
}
+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>