7 Commits
Author SHA1 Message Date
KydoimosandClaude Opus 5 51dfade4f9 Sign the automated version bump commit
CI / Typecheck, test, build (pull_request) Successful in 38s
Publish / Build and push (pull_request) Successful in 1m48s
Publish / Move the working version on (pull_request) Skipped
main requires signed commits, so the pull request the bump job opens after a
release cannot be merged while the commit in it is unsigned. The key the server
signs merge commits with is not reachable from a runner, so the job signs with a
dedicated release-bot SSH key that the instance trusts through
TRUSTED_SSH_KEYS — no bot account, because an account key is only consulted
after the web Verify flow and that flow has no API.

Inert until the key is trusted and RELEASE_BOT_SIGNING_KEY is set, and loudly so:
the step checks the secret and ssh-keygen before it starts, runs the key through
ssh-keygen -y so a truncated or re-wrapped one is caught as itself rather than as
"gpg failed to sign the data", and the commit is re-read for a gpgsig header
before it is pushed.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-05 20:30:09 -03:00
thatguygriff 6e687cdddc Merge pull request 'Set the working version to 1.2.5' (#15) from release/bump-1.2.5 into main
CI / Typecheck, test, build (push) Successful in 17s
Publish / Build and push (push) Successful in 35s
Publish / Move the working version on (push) Skipped
Reviewed-on: #15
2026-09-03 00:58:52 +00:00
Release bot 4e45456097 Set the working version to 1.2.5 2026-09-03 00:58:17 +00:00
thatguygriff 05455a86da Merge pull request 'Ask iOS for the playback audio session on a page with a video' (#14) from video-audio-session-on-ios into main
CI / Typecheck, test, build (push) Successful in 46s
Publish / Build and push (push) Successful in 28s
Publish / Move the working version on (push) Successful in 4s
Reviewed-on: #14
2026-09-03 00:56:04 +00:00
thatguygriffandClaude Opus 5 4c78ce8a27 Ask iOS for the playback audio session on a page with a video
CI / Typecheck, test, build (pull_request) Successful in 12s
A TikTok post played perfectly and said nothing on an iPhone. The file was
not the problem: every rendition TikTok offers for it carries a full AAC
track, and the page as served measures loud audio in WebKit and Chromium
alike, straight through the /m/ proxy.

iOS is the problem. A video playing inline gets the "ambient" audio session,
which the Ring/Silent switch mutes; only going fullscreen gets the sound
back. Claiming "playback" says what is true of this page -- the sound is the
point, not decoration -- and the switch stops applying.

Declared only where there is a video, so an ordinary text post never claims
it, and the session activates when something plays rather than on load, so
it interrupts nothing on a page nobody presses play on.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01B3RUMiXa6eAW3okw9nB9NF
2026-09-02 21:06:12 -03:00
thatguygriff d9fa2f5768 Merge pull request 'Set the working version to 1.2.4' (#12) from release/bump-1.2.4 into main
CI / Typecheck, test, build (push) Successful in 17s
Publish / Build and push (push) Successful in 35s
Publish / Move the working version on (push) Skipped
Reviewed-on: #12
2026-08-31 02:44:07 +00:00
Release bot dfabfc18d6 Set the working version to 1.2.4 2026-08-31 02:42:23 +00:00
6 changed files with 122 additions and 8 deletions
+60 -5
View File
@@ -13,6 +13,7 @@ name: Publish
# vars.IMAGE_NAME optional, defaults to this repository's owner/name # vars.IMAGE_NAME optional, defaults to this repository's owner/name
# vars.REGISTRY_USER optional, defaults to the actor running the workflow # vars.REGISTRY_USER optional, defaults to the actor running the workflow
# secrets.REGISTRY_TOKEN required to push # secrets.REGISTRY_TOKEN required to push
# secrets.RELEASE_BOT_SIGNING_KEY required to sign the version bump commit
# #
# Point REGISTRY at a host the runner reaches directly, without an intermediate # Point REGISTRY at a host the runner reaches directly, without an intermediate
# proxy that caps request bodies: a browser image has layers well over 100MB, # proxy that caps request bodies: a browser image has layers well over 100MB,
@@ -218,6 +219,56 @@ jobs:
echo "changed=true" >> "$GITHUB_OUTPUT" echo "changed=true" >> "$GITHUB_OUTPUT"
echo "package.json ${current} -> ${NEXT}" echo "package.json ${current} -> ${NEXT}"
# main requires signed commits, and a pull request carrying an unsigned
# one cannot be merged. The key the server signs merge commits with lives
# on the server and no runner can reach it, so the bump commit is signed
# here with a dedicated key the instance trusts through
# `[repository.signing] TRUSTED_SSH_KEYS`. Setting that up is in
# CLAUDE.md; nothing about it is committed here.
- name: Configure signing as the release bot
if: steps.bump.outputs.changed == 'true'
env:
SIGNING_KEY: ${{ secrets.RELEASE_BOT_SIGNING_KEY }}
run: |
set -euo pipefail
if [ -z "${SIGNING_KEY}" ]; then
echo "RELEASE_BOT_SIGNING_KEY is not set; the bump commit would be unsigned and unmergeable." >&2
exit 1
fi
if ! command -v ssh-keygen >/dev/null; then
echo "ssh-keygen is missing from this image; git cannot make SSH signatures without it." >&2
exit 1
fi
# The secret holds an OpenSSH private key. git signs by shelling out
# to ssh-keygen, which wants the key on disk beside the `.pub` it is
# pointed at, readable only by us, and rejects it unless the trailing
# newline survived the round trip through the secret store.
keydir="${RUNNER_TEMP:-${TMPDIR:-/tmp}}/release-bot-signing"
install -m 700 -d "${keydir}"
printf '%s\n' "${SIGNING_KEY}" | tr -d '\r' > "${keydir}/key"
chmod 600 "${keydir}/key"
# Doubles as a format check: a truncated, re-wrapped or
# passphrase-protected key fails here rather than as "gpg failed to
# sign the data" three steps later.
if ! ssh-keygen -y -f "${keydir}/key" </dev/null > "${keydir}/key.pub"; then
echo "RELEASE_BOT_SIGNING_KEY is not a usable OpenSSH private key (passphrase-protected, truncated, or re-wrapped on paste)." >&2
exit 1
fi
# A name that is not a person, and an address no account backs: the
# signature verifies against the trusted key rather than against a
# user, so this is a label on the commit and not an identity.
git config user.name 'Release Bot'
git config user.email '[email protected]'
# `gpg.format` is the historical name; `ssh` is what switches git to
# signing with the key above rather than with a GPG key.
git config gpg.format ssh
git config user.signingkey "${keydir}/key.pub"
git config commit.gpgsign true
# The bump arrives as a pull request rather than as a commit straight to # The bump arrives as a pull request rather than as a commit straight to
# main. Pushing a branch asks nothing of the task token beyond ordinary # main. Pushing a branch asks nothing of the task token beyond ordinary
# write access, so nothing here depends on being allowed past whatever # write access, so nothing here depends on being allowed past whatever
@@ -236,14 +287,18 @@ jobs:
branch="release/bump-${NEXT}" branch="release/bump-${NEXT}"
# A name that is not a person, and a reserved address that can never
# resolve to one. Nothing here names the instance it runs on.
git config user.name 'Release bot'
git config user.email '[email protected]'
git checkout -b "${branch}" git checkout -b "${branch}"
git add package.json package-lock.json git add package.json package-lock.json
git commit -m "Set the working version to ${NEXT}" git commit -m "Set the working version to ${NEXT}"
# An unsigned commit would go unnoticed until someone tried to merge
# the pull request, so it fails here instead, where the cause is in
# front of you.
if ! grep -q "^gpgsig" <<<"$(git cat-file commit HEAD)"; then
echo "The bump commit came out unsigned; refusing to push it." >&2
exit 1
fi
git push origin "${branch}" git push origin "${branch}"
# node rather than jq to build the request body: jq is not in this # node rather than jq to build the request body: jq is not in this
+41
View File
@@ -100,6 +100,11 @@ Things worth knowing before editing:
`<video>` with no data has a natural size of 300x150, and WebKit sizes it from that — `<video>` with no data has a natural size of 300x150, and WebKit sizes it from that —
so one with no poster of its own gets an empty SVG of the right shape as a stand-in, so one with no poster of its own gets an empty SVG of the right shape as a stand-in,
without which a portrait video sits in a squat landscape box until you press play. without which a portrait video sits in a squat landscape box until you press play.
- **A page with a video claims the `playback` audio session** (`public/app.js`). iOS
hands inline video the ambient session, which the Ring/Silent switch mutes, so
without this the video plays and says nothing until it goes fullscreen — and the
report that arrives is "this one platform has no sound". The session only activates
once something plays, so declaring it costs nothing on a page nobody presses play on.
- **Never a bare error page.** A failed resolve renders a card carrying the platform, the - **Never a bare error page.** A failed resolve renders a card carrying the platform, the
original URL and the copy button. A broken adapter must still leave the link one tap original URL and the copy button. A broken adapter must still leave the link one tap
away. away.
@@ -241,6 +246,42 @@ image just published. The job is the only one that runs in a container (`node:22
npm), and a job in a container is handed `sh`, not bash — hence the explicit npm), and a job in a container is handed `sh`, not bash — hence the explicit
`shell: bash`, without which `set -o pipefail` fails the first line of the first step. `shell: bash`, without which `set -o pipefail` fails the first line of the first step.
### Signing the bump commit
main requires signed commits, and a pull request carrying an unsigned one cannot be
merged — so the bump job signs the commit it makes. Not with the key the server signs
merge commits with: that one lives on the server and no runner can reach it. It uses a
dedicated release-bot SSH key instead, which also means it can be rotated on its own if
the secret ever leaks.
There is deliberately no release-bot account. A key attached to an account is only
consulted for signature checking once it has been through the web *Verify* flow, and
that flow has no API, so a bot account would need an interactive login to be worth
anything. Listing the key under `[repository.signing] TRUSTED_SSH_KEYS` instead makes
the signature verify with no account lookup at all, which is all the protected branch
asks for. `[email protected]` is therefore a label and not an identity, and
the signature is attributed to the instance's `SIGNING_NAME`/`SIGNING_EMAIL` rather
than to it. The other side of trusting a key instance-wide: a commit signed with it
verifies in *every* repository on that instance, because the trust is in the key and not
in a user whose permissions you could scope.
Set up once per instance, and again only on rotation:
1. Generate a passphrase-less key — it has to be usable unattended:
`ssh-keygen -t ed25519 -C release-bot -f release-bot -N ''`.
2. Add the public half to `TRUSTED_SSH_KEYS` in the server config and restart.
3. Store the private half as the `RELEASE_BOT_SIGNING_KEY` Actions secret — the whole
file verbatim, `-----BEGIN OPENSSH PRIVATE KEY-----` and footer included, not the
`.pub` and not a GPG export. An organisation secret covers every repository at once.
Delete both local files afterwards.
Until both are in place the bump job fails, loudly and on purpose: it checks the secret
is set and that `ssh-keygen` exists before it starts, feeds the key through
`ssh-keygen -y` so a truncated or re-wrapped one is caught as itself rather than as
"gpg failed to sign the data", and re-reads the commit for a `gpgsig` header before
pushing. Nothing else in the pipeline signs anything — release tags are made by hand,
and the merge commit is signed by the server.
Two things any deployment has to get right, both learned the hard way: Two things any deployment has to get right, both learned the hard way:
- **Chromium needs more than the default 64Mi `/dev/shm`** or it crashes. Mount a - **Chromium needs more than the default 64Mi `/dev/shm`** or it crashes. Mount a
+4
View File
@@ -171,6 +171,10 @@ The one exception is Bluesky video, which is an HLS playlist — proxying it wou
rewriting the manifest and every segment, so it is linked directly. Safari plays HLS rewriting the manifest and every segment, so it is linked directly. Safari plays HLS
natively; other browsers show a note. natively; other browsers show a note.
A page carrying a video asks iOS for the playback audio session. Without it a video
playing inline is treated as ambience and the Ring/Silent switch mutes it, so the post
plays perfectly and says nothing unless you go fullscreen.
Resolved posts are cached in memory for an hour, so a reload or a back button doesn't Resolved posts are cached in memory for an hour, so a reload or a back button doesn't
drive the browser again. drive the browser again.
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "antisocial", "name": "antisocial",
"version": "1.2.3", "version": "1.2.5",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "antisocial", "name": "antisocial",
"version": "1.2.3", "version": "1.2.5",
"license": "UNLICENSED", "license": "UNLICENSED",
"dependencies": { "dependencies": {
"@fastify/static": "10.1.3", "@fastify/static": "10.1.3",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "antisocial", "name": "antisocial",
"version": "1.2.3", "version": "1.2.5",
"private": true, "private": true,
"description": "Reads social posts back to you without the app.", "description": "Reads social posts back to you without the app.",
"license": "UNLICENSED", "license": "UNLICENSED",
+14
View File
@@ -155,6 +155,19 @@ function setupMedia(media) {
} }
} }
// On iOS a video playing inline gets the "ambient" audio session, which the
// Ring/Silent switch mutes -- so the post plays perfectly and says nothing,
// and the only way to hear it is to go fullscreen. Claiming "playback" says
// what is true here: the sound is the point, not decoration. The session is
// not activated until something actually plays, so declaring it up front
// interrupts nothing; it is declared only on a page that has a video so an
// ordinary text post never claims it at all.
function setupAudioSession() {
if (!('audioSession' in navigator)) return;
if (!document.querySelector('video')) return;
navigator.audioSession.type = 'playback';
}
// Every comment is a <details open>, so folding one already works with this // Every comment is a <details open>, so folding one already works with this
// file missing. All this adds is doing the whole page at once, which is why // file missing. All this adds is doing the whole page at once, which is why
// the button ships hidden and is only revealed here. // the button ships hidden and is only revealed here.
@@ -189,6 +202,7 @@ setupCopy();
applyBrowser(storedBrowser()); applyBrowser(storedBrowser());
setupBrowserPicker(); setupBrowserPicker();
for (const media of document.querySelectorAll('.media')) setupMedia(media); for (const media of document.querySelectorAll('.media')) setupMedia(media);
setupAudioSession();
setupComments(); setupComments();
// --- Verification puzzles ------------------------------------------------- // --- Verification puzzles -------------------------------------------------