5 Commits
Author SHA1 Message Date
Kydoimosandanthropic/claude-opus-4-8 f968ac749c Fall through to the post page when a reel embeds only its cover frame
CI / Typecheck, test, build (pull_request) Successful in 48s
The captioned embed stopped shipping a reel's video_url and never draws a
logged-out <video>, so both the payload and the DOM hand back the cover
frame alone. Because that still counts as media, the post-page fallback
never fired and a reel resolved to a still image.

Trigger the fallback when the URL is a reel (or the payload declared a
video) but no video was found, so it reaches the post page, whose payload
still carries the progressive file in video_versions. The embed's
caption, handle and avatar are carried forward, since the post-page
payload does not include the owner.

Co-authored-by: anthropic/claude-opus-4-8
2026-09-16 20:54:39 -03:00
thatguygriff 2782ba41b3 Merge pull request 'Set the working version to 1.3.2' (#19) from release/bump-1.3.2 into main
CI / Typecheck, test, build (push) Successful in 12s
Publish / Build and push (push) Successful in 1m41s
Publish / Move the working version on (push) Skipped
Reviewed-on: #19
Reviewed-by: James Griffin-Allwood <[email protected]>
2026-09-12 15:11:56 +00:00
Release Bot 658639c0b6 Set the working version to 1.3.2 2026-09-12 15:06:53 +00:00
thatguygriff bdc51df25f Merge pull request 'Sign the automated version bump commit in CI' (#16) from ci/sign-version-bump-commit into main
CI / Typecheck, test, build (push) Successful in 51s
Publish / Build and push (push) Successful in 7s
Publish / Move the working version on (push) Successful in 20s
Reviewed-on: #16
Reviewed-by: James Griffin-Allwood <[email protected]>
2026-09-12 15:03:42 +00:00
KydoimosandClaude Opus 5 0a196454d1 Sign the automated version bump commit
Publish / Build and push (pull_request) Successful in 3s
Publish / Move the working version on (pull_request) Skipped
CI / Typecheck, test, build (pull_request) Successful in 15s
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-12 11:58:47 -03:00
7 changed files with 181 additions and 11 deletions
+60 -5
View File
@@ -13,6 +13,7 @@ name: Publish
# vars.IMAGE_NAME optional, defaults to this repository's owner/name
# vars.REGISTRY_USER optional, defaults to the actor running the workflow
# 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
# proxy that caps request bodies: a browser image has layers well over 100MB,
@@ -218,6 +219,56 @@ jobs:
echo "changed=true" >> "$GITHUB_OUTPUT"
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
# main. Pushing a branch asks nothing of the task token beyond ordinary
# write access, so nothing here depends on being allowed past whatever
@@ -236,14 +287,18 @@ jobs:
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 add package.json package-lock.json
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}"
# node rather than jq to build the request body: jq is not in this
+36
View File
@@ -279,6 +279,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
`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:
- **Chromium needs more than the default 64Mi `/dev/shm`** or it crashes. Mount a
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "antisocial",
"version": "1.2.5",
"version": "1.3.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "antisocial",
"version": "1.2.5",
"version": "1.3.2",
"license": "UNLICENSED",
"dependencies": {
"@fastify/static": "10.1.3",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "antisocial",
"version": "1.2.5",
"version": "1.3.2",
"private": true,
"description": "Reads social posts back to you without the app.",
"license": "UNLICENSED",
+19 -3
View File
@@ -254,15 +254,31 @@ async function resolve(ctx: ResolveContext): Promise<Post> {
if (scraped && !scraped.text && dom.text) scraped.text = dom.text;
if (scraped && !scraped.handle && dom.handle) scraped.handle = dom.handle;
if (!scraped?.media.length) {
// The embed no longer ships a reel's `video_url`, and its player never
// draws a `<video>` logged out — so both payload and DOM hand back only
// the cover frame. An image where a video belongs is a miss, not a result:
// fall through to the post page, whose payload still carries
// `video_versions`. Keep the embed's caption/handle/avatar either way.
const hasVideo = scraped?.media.some((item) => item.kind === 'video') === true;
if (!scraped?.media.length || (expectsVideo && !hasVideo)) {
// The embed refuses some posts outright ("the link may be broken").
// Try the post itself: its payload first, then its link preview.
const carry = scraped;
await page.goto(originalUrl, { waitUntil: 'domcontentloaded' }).catch(() => undefined);
scraped = fromPayloads(await scriptTexts(ctx)) ?? scraped;
const fromPost = fromPayloads(await scriptTexts(ctx));
if (fromPost?.media.length) {
scraped = {
...fromPost,
...(fromPost.text ? {} : carry?.text ? { text: carry.text } : {}),
...(fromPost.handle ? {} : carry?.handle ? { handle: carry.handle } : {}),
...(fromPost.avatar ? {} : carry?.avatar ? { avatar: carry.avatar } : {}),
...(fromPost.displayName ? {} : carry?.displayName ? { displayName: carry.displayName } : {}),
};
}
if (!scraped?.media.length) {
const og = await fromOpenGraph(ctx);
if (og.media.length) scraped = { ...og, ...(scraped?.handle ? { handle: scraped.handle } : {}) };
if (og.media.length) scraped = { ...og, ...(carry?.handle ? { handle: carry.handle } : {}) };
}
}
+44
View File
@@ -0,0 +1,44 @@
{
"__typename": "XIGPolarisVideoMedia",
"pk": "3983940149641276516",
"code": "DdJzHVRyOxk",
"media_type": 2,
"product_type": "clips",
"caption": {
"text": "I just quit my job at the BPAF and things are not looking good you guys"
},
"accessibility_caption": "Video by Vinny Thomas on September 11, 2026.",
"original_height": 1280,
"original_width": 720,
"video_duration": 60.486,
"image_versions2": {
"candidates": [
{
"url": "https://scontent-lga3-1.cdninstagram.com/v/t51.82787-15/806299489_18624728518024548_8282126995775909321_n.jpg?stp=dst-jpg_e15_tt6&_nc_cat=109&ig_cache_key=abc.3-ccb7-5&efg=cover_frame&width=720",
"height": 1280,
"width": 720
},
{
"url": "https://scontent-lga3-1.cdninstagram.com/v/t51.82787-15/806299489_18624728518024548_8282126995775909321_n.jpg?stp=dst-jpg_e15_s640x640_tt6&_nc_cat=109&ig_cache_key=abc.3-ccb7-5&efg=cover_frame&width=640",
"height": 640,
"width": 640
},
{
"url": "https://scontent-lga3-1.cdninstagram.com/v/t51.82787-15/806299489_18624728518024548_8282126995775909321_n.jpg?stp=dst-jpg_e15_s320x320_tt6&_nc_cat=109&ig_cache_key=abc.3-ccb7-5&efg=cover_frame&width=320",
"height": 320,
"width": 320
}
]
},
"has_audio": true,
"video_versions": [
{
"type": 101,
"url": "https://scontent-lga3-3.cdninstagram.com/o1/v/t2/f2/m86/AQOsyEB80vwVJZ1X8Ang509fDOvIQYYT5Sw8ILSanSG2u_RQcsqL4IXxAs-sjup.mp4?_nc_cat=102&_nc_sid=5e9851&efg=xpv_progressive_720&width=720"
},
{
"type": 102,
"url": "https://scontent-lga3-3.cdninstagram.com/o1/v/t2/f2/m86/AQOsyEB80vwVJZ1X8Ang509fDOvIQYYT5Sw8ILSanSG2u_RQcsqL4IXxAs-sjup.mp4?_nc_cat=102&_nc_sid=5e9851&efg=xpv_progressive_480&width=480"
}
]
}
+19
View File
@@ -1,6 +1,7 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { fromGraph } from '../src/platforms/instagram.ts';
import { findMetaMedia, mediaFromMetaNode } from '../src/platforms/meta-media.ts';
import { fixture } from './helpers.ts';
test('a carousel becomes one media entry per child, in order', () => {
@@ -26,6 +27,24 @@ test('a reel becomes a progressive video with its cover frame as the poster', ()
assert.ok(item?.width && item.height, 'expected dimensions for the aspect ratio');
});
test('a reel whose embed only ships a cover frame still finds its video on the post page', () => {
// The captioned embed stopped shipping a reel's `video_url` and never draws
// a logged-out `<video>`, so both payload and DOM hand back the poster only.
// The post page still carries the progressive file in `video_versions`
// (the meta-media schema), which is where the adapter now falls through to.
const node = findMetaMedia(fixture('instagram/reel-video-versions.json'));
assert.ok(node, 'expected to find the media node in the post payload');
const media = mediaFromMetaNode(node!, 'https://www.instagram.com/');
assert.equal(media.length, 1);
const [item] = media;
assert.equal(item?.kind, 'video', 'a reel is a video, not its cover frame');
assert.match(item?.url ?? '', /^https:\/\/.*\.mp4/);
assert.ok(!item?.url.includes('.m3u8'), 'the native player needs a real file, not HLS');
// The cover frame becomes the poster, not the media itself.
assert.ok(item?.kind === 'video' && item.poster?.url.includes('t51.82787-15'), 'expected the cover frame as poster');
assert.ok(item?.width === 720 && item.height === 1280, 'expected dimensions for the aspect ratio');
});
test('captions keep their line breaks', () => {
const scraped = fromGraph(fixture('instagram/carousel.json'));
assert.ok(scraped.text?.includes('\n'), 'expected the caption to keep paragraph breaks');