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
rules:
- if: '$CI_PIPELINE_SOURCE == "schedule" && $ANNOUNCE_TASK == "due"'
before_script:
- apk add --no-cache curl git
- curl -sSfL https://pkg.phpboyscout.uk/keryx/v0.23.0/keryx_Linux_x86_64.tar.gz | tar xz -C /usr/local/bin keryx
script:
- keryx publish due --ci
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.23.0/keryx_Linux_x86_64.tar.gz | tar xz -C /usr/local/bin keryx
script:
- keryx auth refresh all --ci
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. 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 |