Compare commits
7
Commits
1.2.3
...
51dfade4f9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
51dfade4f9
|
||
|
|
6e687cdddc | ||
|
|
4e45456097 | ||
|
|
05455a86da | ||
|
|
4c78ce8a27
|
||
|
|
d9fa2f5768 | ||
|
|
dfabfc18d6 |
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
Generated
+2
-2
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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 -------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user