From 503d8a8deca9dfb9cf097013e9c82ac4898af5a7 Mon Sep 17 00:00:00 2001 From: James Griffin Date: Thu, 27 Aug 2026 13:19:05 -0300 Subject: [PATCH 1/2] Move the working version on after a release MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit package.json has sat at 0.1.0 since the first commit, through three releases, because nothing read it. That is fine right up until something does — an image label, a health endpoint, a bug report quoting a version — at which point the tree claims to be a version that shipped long ago. A new `bump` job takes the tag that was just published, works out the next patch from it, and commits that to main. After 1.2.0 the tree says 1.2.1: not a version that exists, which is the point. A build from main is then legible as "after 1.2.0" rather than as 1.2.0 itself. It sits in publish.yml rather than a workflow of its own so that it can say `needs: build`. A version that failed to publish has not been released, and moving past it would say that it had. Prereleases are skipped for the same reason -- 1.2.3-rc1 is a candidate for a version that has not shipped, so there is nothing yet to move past. The bump goes through `npm version` rather than editing the file. The version is in the lockfile too, in two places, and a tree where those disagree is worse than one that is merely out of date. Three smaller things. The patch arithmetic forces base ten, because a patch number written 08 is otherwise read as octal and kills the job. The commit carries `[skip ci]`, or pushing it starts another build of the image that was just published. And the committer is a name that is not a person at a reserved address that can never become one, so nothing here names the instance it runs on. Pushing to main needs a token that may write to the repository. The Actions task token can where the instance allows it; where it does not, setting a VERSION_BUMP_TOKEN secret overrides it. A push that is refused fails the job with both of those as the suggestion rather than a bare 403. package.json goes to 1.2.1 here, which is where the job would have left it had it existed when 1.2.0 went out. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017nMQ2eDKnqALYhAibpTKTu --- .gitea/workflows/publish.yml | 97 ++++++++++++++++++++++++++++++++++++ CLAUDE.md | 11 ++++ package-lock.json | 4 +- package.json | 2 +- 4 files changed, 111 insertions(+), 3 deletions(-) diff --git a/.gitea/workflows/publish.yml b/.gitea/workflows/publish.yml index 167aa08..d69aa9e 100644 --- a/.gitea/workflows/publish.yml +++ b/.gitea/workflows/publish.yml @@ -141,3 +141,100 @@ jobs: - name: Log out if: always() && github.event_name != 'pull_request' run: docker logout "${{ vars.REGISTRY }}" || true + + # Once a release is out, the version in package.json has already shipped. + # Moving it on to the next patch means the working tree is never sitting on + # a number that is published and immutable, and that a build from main is + # always identifiable as "after 1.2.0" rather than "1.2.0, but not really". + # + # `needs: build` is the point of putting this here rather than in a workflow + # of its own: a version that failed to publish has not been released, and + # bumping past it would say it had. + bump: + name: Move the working version on + needs: build + # Tags only, and only final ones. A prerelease has not shipped the version + # it is a candidate for, so there is nothing yet to move past. + if: github.ref_type == 'tag' && !contains(github.ref_name, '-') + runs-on: ubuntu-latest + container: + image: node:22 + steps: + # The tag names a commit in main's history, but the bump belongs on the + # branch, so this checks out main rather than the tag. + - uses: actions/checkout@v4 + with: + ref: main + # The task token can push only if the instance allows Actions to + # write to the repository. Where it does not, set VERSION_BUMP_TOKEN + # to a personal access token with write access and it is used + # instead. + token: ${{ secrets.VERSION_BUMP_TOKEN || secrets.GITEA_TOKEN }} + + - name: Work out the next patch version + id: next + run: | + set -euo pipefail + + version="${{ github.ref_name }}" + version="${version#v}" + + major="${version%%.*}" + rest="${version#*.}" + minor="${rest%%.*}" + patch="${rest##*.}" + + # `10#` forces base ten: a patch number written 08 would otherwise be + # read as octal and fail to parse. + next="${major}.${minor}.$((10#${patch} + 1))" + + echo "next=${next}" >> "$GITHUB_OUTPUT" + echo "Released ${version}; the working version becomes ${next}" + + - name: Bump package.json + id: bump + env: + NEXT: ${{ steps.next.outputs.next }} + run: | + set -euo pipefail + + current="$(node -p "require('./package.json').version")" + if [ "${current}" = "${NEXT}" ]; then + echo "package.json is already ${NEXT}; nothing to do." + echo "changed=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + # npm rather than editing the file: the version is in the lockfile + # too, in more than one place, and they have to agree. + npm version "${NEXT}" --no-git-tag-version --allow-same-version >/dev/null + echo "changed=true" >> "$GITHUB_OUTPUT" + echo "package.json ${current} -> ${NEXT}" + + - name: Commit it to main + if: steps.bump.outputs.changed == 'true' + env: + NEXT: ${{ steps.next.outputs.next }} + run: | + set -euo pipefail + + # 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 'release-bot@noreply.invalid' + + git add package.json package-lock.json + # `[skip ci]` because this commit is a number and nothing else: + # without it the push to main starts another build of the very image + # that was just published. + git commit -m "Set the working version to ${NEXT} [skip ci]" + + if ! git push origin HEAD:main; then + echo >&2 + echo "Could not push the version bump to main. Either the Actions" >&2 + echo "token has no write access to this repository, or main is" >&2 + echo "protected against direct pushes. Set VERSION_BUMP_TOKEN to a" >&2 + echo "token that may push to main, or allow that token past the" >&2 + echo "branch protection." >&2 + exit 1 + fi diff --git a/CLAUDE.md b/CLAUDE.md index 3603d7a..c39cd82 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -201,6 +201,17 @@ The registry comes from the `REGISTRY` repository variable, the image name from `IMAGE_NAME` or the repository name, and credentials from `REGISTRY_USER` and the `REGISTRY_TOKEN` secret. Nothing about any particular deployment is committed here. +A release also moves `package.json` on to the next patch version, committed to main by +the `bump` job — so the number in the tree is never one that has already shipped and +been made immutable. It lives in `publish.yml` rather than a workflow of its own so it +can say `needs: build`: a version that failed to publish has not been released, and +bumping past it would claim otherwise. Prereleases are skipped, being candidates for a +version that has not shipped. The bump goes through `npm version` rather than an edit in +place, because the version is in the lockfile too, in more than one place, and the two +have to agree. The commit carries `[skip ci]`, or pushing it would rebuild the image +that was just published. Pushing to main needs a token with write access — +`VERSION_BUMP_TOKEN` overrides the task token where that one cannot. + 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 diff --git a/package-lock.json b/package-lock.json index b278457..bba5132 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "antisocial", - "version": "0.1.0", + "version": "1.2.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "antisocial", - "version": "0.1.0", + "version": "1.2.1", "license": "UNLICENSED", "dependencies": { "@fastify/static": "10.1.3", diff --git a/package.json b/package.json index 641420b..f189419 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "antisocial", - "version": "0.1.0", + "version": "1.2.1", "private": true, "description": "Reads social posts back to you without the app.", "license": "UNLICENSED", From 24db9be5af1e1dfbe0b279f3ec8a7f7252303d46 Mon Sep 17 00:00:00 2001 From: James Griffin Date: Thu, 27 Aug 2026 13:23:47 -0300 Subject: [PATCH 2/2] Make the rewrite rules copyable from the file, not just the render The rules were a Markdown table, and a table cell cannot hold a bare `|`. It has to be written `\|`, which renders as a pipe and copies as a backslash and a pipe. Every one of these rules is an alternation full of pipes, so anyone reading README.md rather than a rendered view -- which, for a self-hosted thing, is most of the time -- got a regex whose alternation had quietly become literal characters. It matches nothing, and nothing about it looks wrong. That is not hypothetical: it is how this came up. The Reddit rule is the longest row, and reading it out of the file gave something that plainly did not work, so the backslashes came out. Which fixed the copy and broke the table -- four pipes turned into column separators, the row became seven cells, the separator row was widened to seven to match, and `(.*)` picked up an escape on the way past. Restoring the row would have left the trap exactly where it was, for the next person or the same one. So the table is now a code block: each rule is a comment naming the platform, then the find field, then the replace field, one per line. Nothing is escaped, the file and the render agree, and each field is a whole line to select. Every rule is checked from both directions. Out of README.md byte for byte with no unescaping step, which is the copy-from-the-file path; and out of the rendered HTML with entities decoded, which is the copy-from-the-page path. Both give all seven rules, both compile, and both rewrite all seventeen sample URLs correctly -- mobile.x.com, threads.net, the TikTok vm. and vt. hosts, five Reddit subdomains, a /s/ share link and a redd.it short code. The two extractions are byte-identical to each other, which is the property that was missing before. Also reformats the rest of the file, and stops calling it a table on the way past. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017nMQ2eDKnqALYhAibpTKTu --- README.md | 65 ++++++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 47 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 69450f7..4bdcf9c 100644 --- a/README.md +++ b/README.md @@ -21,15 +21,43 @@ readable in your history. Replace `antisocial.example.com` with wherever you are running it. -| Platform | Find | Replace | -| --------- | ------------------------------------------------------------- | ------------------------------------------- | -| X | `/^https:\/\/(?:www\.\|mobile\.)?(?:x\|twitter)\.com\/(.*)$/` | `https://antisocial.example.com/x/$1` | -| Threads | `/^https:\/\/(?:www\.)?threads\.(?:net\|com)\/(.*)$/` | `https://antisocial.example.com/threads/$1` | -| Instagram | `/^https:\/\/(?:www\.)?instagram\.com\/(.*)$/` | `https://antisocial.example.com/ig/$1` | -| TikTok | `/^https:\/\/(?:www\.\|vm\.\|vt\.)?tiktok\.com\/(.*)$/` | `https://antisocial.example.com/tiktok/$1` | -| Bluesky | `/^https:\/\/bsky\.app\/(.*)$/` | `https://antisocial.example.com/bsky/$1` | -| Reddit | `/^https:\/\/(?:www\.\|old\.\|new\.\|np\.\|m\.)?reddit\.com\/(.*)$/` | `https://antisocial.example.com/reddit/$1` | -| Reddit | `/^https:\/\/redd\.it\/(.*)$/` | `https://antisocial.example.com/reddit/$1` | +Each rule is two fields. Both are on their own line below, and neither needs any +escaping — copy them straight out of this file. + +```text +# X +/^https:\/\/(?:www\.|mobile\.)?(?:x|twitter)\.com\/(.*)$/ +https://antisocial.example.com/x/$1 + +# Threads +/^https:\/\/(?:www\.)?threads\.(?:net|com)\/(.*)$/ +https://antisocial.example.com/threads/$1 + +# Instagram +/^https:\/\/(?:www\.)?instagram\.com\/(.*)$/ +https://antisocial.example.com/ig/$1 + +# TikTok +/^https:\/\/(?:www\.|vm\.|vt\.)?tiktok\.com\/(.*)$/ +https://antisocial.example.com/tiktok/$1 + +# Bluesky +/^https:\/\/bsky\.app\/(.*)$/ +https://antisocial.example.com/bsky/$1 + +# Reddit +/^https:\/\/(?:www\.|old\.|new\.|np\.|m\.)?reddit\.com\/(.*)$/ +https://antisocial.example.com/reddit/$1 + +# Reddit short links +/^https:\/\/redd\.it\/(.*)$/ +https://antisocial.example.com/reddit/$1 +``` + +A code block rather than a table, because a table cell cannot hold a bare `|` — it has +to be written `\|`, which renders correctly and copies wrongly. The alternation in these +rules is full of them, and a regex whose pipes arrive as literal pipes matches nothing +and says nothing about why. So `https://x.com/user/status/123` becomes `https://antisocial.example.com/x/user/status/123`. @@ -42,7 +70,8 @@ where it came from Reddit. A Reddit `/r//s/` share link is followed t post it points at, and that permalink — not the opaque share code — is what the copy button hands back. -`/` serves this table with the live hostnames, if you'd rather read it there. +`/` serves these rules with the live hostname already filled in, if you'd rather copy +them from there. ## How it works @@ -59,13 +88,13 @@ Each adapter layers its extraction, most structured first: 3. **The rendered DOM** — whatever is actually on screen is real. 4. **Open Graph tags** — the floor, and enough to show something. -| Platform | Loads | Reads | -| --------- | ---------------------------- | -------------------------------------------------------- | -| Bluesky | the public AT Protocol API | `getPostThread`; falls back to the post page | -| X | `platform.twitter.com` embed | the `cdn.syndication.twimg.com/tweet-result` response | -| Instagram | `/embed/captioned/` | `shortcode_media`, then the rendered `