Announce articles from a scheduled pipeline¶
Run keryx publish due from the owning project's CI so an approved
announcement goes out at its due hour with nobody at a keyboard. Approval
stays human (R-POST-2): a person approves each outlet with a due time, in
the studio or with keryx approve --at; the pipeline only sends what is
approved and due. This is the shape spec 0060 D11 settles: one masked
variable per token, non-secret ids committed, nothing else in the repository.
Before you start¶
- Every platform you want sent has been authenticated once on a workstation
(
keryx auth <platform>), so its token exists. - The article's cover is deployed and public before the due hour. The platforms that fetch by URL (Instagram, Threads) are probed against it before a send: an outlet whose image URL does not serve an image is refused for that run and retried on the next, so the platform never fetches a 404 and caches it. A dry run reports the same probe without failing, because a merge-request pipeline has no egress. Schedule the site build before the earliest due hour.
- The record (
social.jsonin the article's bundle) is committed with its approvals. The sweep reads the checkout. - The token expiries are committed too (
platforms.<p>.access_token_expires_atfor Instagram, Threads and LinkedIn, askeryx authrecorded them), because a runner has no user config to read them from. Without them Instagram and Threads refresh on every run and LinkedIn never warns. Re-set them when you re-auth.
1. Commit the ids¶
The non-secret identifiers each publisher needs go in the project's committed
.keryx.yaml, with the platform enabled:
platforms:
bluesky: { identifier: your.handle, enabled: true } # or did: did:plc:…
facebook: { page_id: "1234567890", enabled: true }
instagram: { user_id: "1234567890", enabled: true }
threads: { user_id: "1234567890", enabled: true }
linkedin: { author_urn: "urn:li:person:XXXX", enabled: true }
keryx auth <platform> prints each of these when it finishes; they are also
in ~/.keryx/config.yaml (and the Bluesky ones in ~/.keryx/accounts.yaml)
on the workstation that ran it. A committed value forces, so put only
values here that are right for CI too (never storage.s3.profile, for
example).
2. Set the secrets¶
Settings → CI/CD → Variables, each Masked and Protected:
| Variable | Platform | Also needed for refresh |
|---|---|---|
BLUESKY_APP_PASSWORD |
Bluesky | — (static) |
FACEBOOK_PAGE_TOKEN |
Facebook Page | — (no expiry) |
INSTAGRAM_ACCESS_TOKEN |
INSTAGRAM_APP_SECRET |
|
THREADS_ACCESS_TOKEN |
Threads | THREADS_APP_SECRET |
LINKEDIN_ACCESS_TOKEN |
— (re-auth every ~60 days) | |
MASTODON_ACCESS_TOKEN |
Mastodon | — (static) |
GITLAB_TOKEN |
the refresh write-back and the alert issue | api-scoped project or group access token |
ALERT_DISCORD_WEBHOOK_URL |
the alert channel | a Discord incoming webhook |
The values are the ones keryx auth stored: on the workstation, in the OS
keychain or in ~/.keryx/accounts.yaml. The environment outranks every
store on read, so a variable here is what the job uses, whatever the
checkout says.
3. Add the jobs¶
announce:
stage: notify
image: alpine:3.22
resource_group: announce # two sweeps must never overlap
rules:
- if: '$CI_PIPELINE_SOURCE == "schedule" && $ANNOUNCE_TASK == "due"'
variables:
GIT_DEPTH: "50" # enough history to rebase the record over a merge
before_script:
- apk add --no-cache curl git
- curl -sSfL https://pkg.phpboyscout.uk/keryx/v0.24.0/keryx_Linux_x86_64.tar.gz | tar xz -C /usr/local/bin keryx
script:
- |
set +e
keryx publish due --ci
sent=$?
set -e
git diff --quiet -- content && exit "$sent"
git config user.email "[email protected]"
git config user.name "announce"
git add -A -- content
git commit -q -m "chore(announce): record what the sweep sent [skip ci]"
remote="https://oauth2:${GITLAB_TOKEN}@${CI_SERVER_HOST}/${CI_PROJECT_PATH}.git"
for attempt in 1 2 3; do
git push -q "$remote" "HEAD:${CI_DEFAULT_BRANCH}" && exit "$sent"
git pull -q --rebase "$remote" "$CI_DEFAULT_BRANCH" || break
done
echo "announce: SENT BUT NOT RECORDED; the next sweep will send again. Pause the schedule. ${CI_JOB_URL}"
exit 1
token-refresh:
stage: maintenance
image: alpine:3.22
rules:
- if: '$CI_PIPELINE_SOURCE == "schedule" && $REFRESH_TASK == "1"'
variables:
KERYX_AUTH_WRITEBACK_BACKEND: gitlab
KERYX_AUTH_ALERTS_BACKEND: "discord,gitlab-issue"
KERYX_AUTH_ALERTS_GITLAB_PROJECT: phpboyscout/org
before_script:
- apk add --no-cache curl git
- curl -sSfL https://pkg.phpboyscout.uk/keryx/v0.24.0/keryx_Linux_x86_64.tar.gz | tar xz -C /usr/local/bin keryx
script:
- keryx auth refresh bluesky facebook instagram linkedin mastodon threads --ci
The job names its platforms rather than saying all, because platforms that
post reels from your machine (TikTok, YouTube) are enabled too but hold no
credentials in CI.
The sweep must commit what it sent¶
publish due marks each outlet it sends posted in social.json, in the
job's checkout. A CI checkout is thrown away when the job ends, so unless the
job commits that record back, the next hour's sweep reads the committed file,
finds the same outlets still approved and due, and sends them again, every
hour. The first real send is the first run that can show this: every run
before it finds nothing due and has nothing to record.
So the announce job above does four things after the sweep, and none is
optional:
- Commit the changed records and push to the default branch. The token
needs to push there:
write_repository(orapi) scope and a role that may push to the protected branch. Check that before sending, and refuse to send if not; an unrecordable send is worse than a late one. - Record even when a send failed. A partial run still sent something, so
the commit step runs whatever
publish dueexited with, and the job then exits with the sweep's own status. - Rebase and retry the push. A content commit can land on the branch while the sweep is running.
- Fail loudly when the record cannot be pushed, and page someone: the message says to pause the schedule, because the next run will re-send.
Keep the literal [skip ci] in the record commit only. GitLab honours it
anywhere in a pushed commit's message, so a commit that merely describes the
job in its body (as a feature commit adding this job will) skips its own merge
request pipeline and, once merged, the default branch's too.
Both run from the repository root, where .keryx.yaml and the content live.
--ci means no prompts, no update check. A failed send or a rejected token
exits non-zero, so the scheduled pipeline goes red; add an
alert backend for an active
ping.
Why the write-back backend is an environment variable, not a config key.
GTB ignores the auth.* subtree of a project-local .keryx.yaml unless the
file has been trusted, and CI runs untrusted, by design: a cloned repository
must not be able to redirect where a credential is written. keryx's
environment layer (prefix KERYX_, . → _) sits above the project file and
is never trust-filtered, so KERYX_AUTH_WRITEBACK_BACKEND=gitlab is the
route. The same goes for auth.alerts.*.
4. Schedule them¶
CI/CD → Schedules → New schedule, targeting the protected default branch:
- announce: hourly, five past (
5 * * * *), variableANNOUNCE_TASK=due. Every article is swept; an approved outlet whose hour has come is sent, the rest is a no-op, so an hour with nothing due costs a minute. - token-refresh: weekly, after the site build (
15 3 * * 1), variableREFRESH_TASK=1. Weekly, not daily: Instagram and Threads refuse to refresh a token under 24 hours old, and in CI the expiry is only as fresh as the committed value, so a daily run could ask again too soon.
Gate each job on its own variable so neither runs on a push or on another schedule.
5. Prove it with nothing due¶
Run the announce schedule by hand once with no outlet due. It should install
keryx, sweep, and report nothing to send. That proves the install and the
credentials, not the record: only a run that sends commits anything, so read
the first real send's job log for the push, and check the record on the
default branch shows the outlets posted before the next hour. Then approve the next article's
outlets with a due hour after the site build, commit the record, and let the
hour come.
keryx publish due --dry-run from the same job is the smoke test for the
variables: it reports credentials: present or absent per platform without
sending.
What each send needs, per platform¶
| Platform | Image | Link | Notes |
|---|---|---|---|
| Bluesky | uploaded as bytes | on the card | app password; sign-in by DID |
| uploaded as bytes | in the message | Page token, no expiry | |
| fetched from its public URL | in the caption (not clickable) | JPEG only; pre-flighted | |
| Threads | fetched from its public URL | in the text with an image, else a card | pre-flighted; first hashtag is the topic |
| uploaded as bytes, thumbnail of the article card | on the card | token expires ~60 days, no refresh | |
| Mastodon | uploaded as bytes | in the status | the instance's own cap |