Skip to content

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.json in 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_at for Instagram, Threads and LinkedIn, as keryx auth recorded 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 INSTAGRAM_APP_SECRET
THREADS_ACCESS_TOKEN Threads THREADS_APP_SECRET
LINKEDIN_ACCESS_TOKEN LinkedIn — (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 * * * *), variable ANNOUNCE_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), variable REFRESH_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
Facebook uploaded as bytes in the message Page token, no expiry
Instagram 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
LinkedIn 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