From c0fc4fe7d6612ec154d1f88956df8297e2e9c9e6 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 15:20:47 +0000 Subject: [PATCH 1/4] 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 Claude-Session: https://claude.ai/code/session_01JdMNbY5hLR7FjtfEE712xG --- .github/scripts/stage-pages-site.sh | 46 ++++++++++++ .github/workflows/ci.yml | 50 +++++++++++++ .github/workflows/release.yml | 43 ++++++++++- README.md | 26 ++++++- app/src/main/AndroidManifest.xml | 2 +- .../prolibertate/games/update/UpdatePolicy.kt | 4 +- .../org/prolibertate/games/update/Updater.kt | 75 +++++-------------- site/_headers | 7 ++ site/index.html | 34 +++++++++ 9 files changed, 223 insertions(+), 64 deletions(-) create mode 100755 .github/scripts/stage-pages-site.sh create mode 100644 site/_headers create mode 100644 site/index.html diff --git a/.github/scripts/stage-pages-site.sh b/.github/scripts/stage-pages-site.sh new file mode 100755 index 0000000..7ef431d --- /dev/null +++ b/.github/scripts/stage-pages-site.sh @@ -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 +# +# 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 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fc2f564..237edcc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4ef3a4a..bedfa2a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 diff --git a/README.md b/README.md index c7d19c9..a87876c 100644 --- a/README.md +++ b/README.md @@ -198,9 +198,31 @@ production package instead. publishes it as a GitHub Release tagged `v.`. 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..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 ``` diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index bdb6768..6aac16d 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,7 +1,7 @@ - + diff --git a/app/src/main/java/org/prolibertate/games/update/UpdatePolicy.kt b/app/src/main/java/org/prolibertate/games/update/UpdatePolicy.kt index f57aff7..b2e7aa2 100644 --- a/app/src/main/java/org/prolibertate/games/update/UpdatePolicy.kt +++ b/app/src/main/java/org/prolibertate/games/update/UpdatePolicy.kt @@ -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. diff --git a/app/src/main/java/org/prolibertate/games/update/Updater.kt b/app/src/main/java/org/prolibertate/games/update/Updater.kt index 598bf7a..e0ea123 100644 --- a/app/src/main/java/org/prolibertate/games/update/Updater.kt +++ b/app/src/main/java/org/prolibertate/games/update/Updater.kt @@ -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. 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" } } diff --git a/site/_headers b/site/_headers new file mode 100644 index 0000000..7a2fe55 --- /dev/null +++ b/site/_headers @@ -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 diff --git a/site/index.html b/site/index.html new file mode 100644 index 0000000..cd93ac0 --- /dev/null +++ b/site/index.html @@ -0,0 +1,34 @@ + + + + + +Pro Libertate Games + + + +

Pro Libertate Games

+

Android builds. Allow Install unknown apps for your browser to sideload.

+

Production

Loading…

+

Dev

Loading…

+ + + From 4369d15cec6a77d7d9aa1139e18f33e83c164ff8 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 15:39:03 +0000 Subject: [PATCH 2/4] 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 Claude-Session: https://claude.ai/code/session_01JdMNbY5hLR7FjtfEE712xG --- .github/scripts/stage-pages-site.sh | 9 +++++++++ .github/workflows/ci.yml | 7 +++++++ .github/workflows/release.yml | 7 +++++++ README.md | 2 ++ 4 files changed, 25 insertions(+) diff --git a/.github/scripts/stage-pages-site.sh b/.github/scripts/stage-pages-site.sh index 7ef431d..13fa60e 100755 --- a/.github/scripts/stage-pages-site.sh +++ b/.github/scripts/stage-pages-site.sh @@ -25,6 +25,15 @@ 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)" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 237edcc..d41f320 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,6 +16,11 @@ on: pull_request: branches: [ main, dev ] 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. permissions: @@ -162,6 +167,8 @@ jobs: path: staged - name: Assemble site + env: + 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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bedfa2a..91260c3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -6,6 +6,11 @@ on: push: branches: [ main ] 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. permissions: @@ -119,6 +124,8 @@ jobs: path: staged - name: Assemble site + env: + 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 diff --git a/README.md b/README.md index a87876c..cd3414f 100644 --- a/README.md +++ b/README.md @@ -216,6 +216,8 @@ 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. 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. From 2df140a3c1ee031a113d987b53a5501f5ebcb73f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 15:53:15 +0000 Subject: [PATCH 3/4] 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 Claude-Session: https://claude.ai/code/session_01JdMNbY5hLR7FjtfEE712xG --- .github/scripts/stage-pages-site.sh | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/scripts/stage-pages-site.sh b/.github/scripts/stage-pages-site.sh index 13fa60e..f9947f4 100755 --- a/.github/scripts/stage-pages-site.sh +++ b/.github/scripts/stage-pages-site.sh @@ -38,6 +38,12 @@ fi # 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" From 6fbe63f787ec5ac08ea56e81e0778a510005cdce Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 16:08:28 +0000 Subject: [PATCH 4/4] 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 Claude-Session: https://claude.ai/code/session_01JdMNbY5hLR7FjtfEE712xG --- .github/scripts/stage-pages-site.sh | 7 ++++++- site/404.html | 3 +++ 2 files changed, 9 insertions(+), 1 deletion(-) create mode 100644 site/404.html diff --git a/.github/scripts/stage-pages-site.sh b/.github/scripts/stage-pages-site.sh index f9947f4..4521fe9 100755 --- a/.github/scripts/stage-pages-site.sh +++ b/.github/scripts/stage-pages-site.sh @@ -17,7 +17,7 @@ case "$channel" in prod) other=dev ;; dev) other=prod ;; *) echo "bad channel: $ rm -rf "$out" mkdir -p "$out/$channel" "$out/$other" -cp site/index.html site/_headers "$out/" +cp site/index.html site/404.html site/_headers "$out/" name="$(basename "$apk")" cp "$apk" "$out/$channel/$name" @@ -47,6 +47,11 @@ 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" diff --git a/site/404.html b/site/404.html new file mode 100644 index 0000000..356ca3b --- /dev/null +++ b/site/404.html @@ -0,0 +1,3 @@ + +Not found +

404

Not found. Pro Libertate Games downloads