repocast
Turn any repo's changes into accurate, ready-to-post X posts, and post them safely.
repocast is for people who build bots that post on X about code changes, such as changelog bots, "parameter watch" accounts, or release announcers. You give it structured change data. It writes posts or threads that fit X's weighted 280-character limit and checks that every number in them comes from the data. It then posts them through the X API v2, or writes them to an outbox, or only prints them. The guard rails a bot needs are built in: dedup, caps, a kill switch, quiet hours, an account check, and no retries when the outcome is unknown.
Pure Python standard library (3.10+); no third-party dependency at runtime.
knoblog feed.json / changes.json ─┐
repocast diff (built-in, simple) ─┼─▶ compose ─▶ accuracy gate ─▶ dedup/caps/switches ─▶ X API v2 │ outbox │ dry run
your own items JSON ─┘ (prebuilt text or {placeholder} templates)
It grew out of the @XAlgoChangelog bot. With the config in
examples/x-algorithm/ it reproduces that bot's post text exactly, for all 23 post-worthy commits (this is a test).
Quickstart (CLI)
pip install repocast # or: pip install "repocast[knoblog]"
repocast count "🤖 Nav2 defaults changed → https://example.com" # 51/280
# 1) change data: knoblog's output (preferred) or the built-in diff
knoblog run --config knoblog.yml --out site # writes site/feed.json, changes.json, posts.json
# or
repocast diff --repo . --range v1.4.0..HEAD -o items.json
# 2) look at what would be posted (no state, no caps, posts nothing)
repocast preview -i site/feed.json
# 3) a real run. Without REPOCAST_LIVE=true this is still a dry run and records nothing.
repocast run -c repocast.toml
repocast.toml:
[input]
path = "site/feed.json" # knoblog feed.json / changes.json (+ posts.json), or repocast items JSON
[format]
mode = "auto" # prebuilt (knoblog's text) | template | auto (prebuilt when available)
project = "MyRobot"
disclaimer = "Values are repo defaults."
max_lines = 3 # change lines after the headline
max_posts = 4 # longest thread allowed
[format.templates] # optional; plain {placeholders}, no template engine
header = "🤖 {project} update ({date}): {lead}"
line = "{name} {old} → {new} ({change})"
[filter]
exclude_files = "(^|/)tests?/"
[post]
backend = "x" # x | outbox | dry-run | package.module:Class
account = "MyRobotChanges" # the ONLY account this bot may post as (checked via GET /2/users/me)
state = ".repocast/state.json" # dedup state: commit it or cache it between runs
max_per_run = 1
max_per_day = 3
stale_after_hours = 72
quiet_hours = "22:00-07:00"
timezone = "America/New_York"
Quickstart (GitHub Action)
- uses: fitzyracing1/knoblog@v0 # or any step that writes change data
with: { config: knoblog.yml, out: site }
- id: rc
uses: fitzyracing1/repocast@v0
with: { config: repocast.toml }
env:
REPOCAST_LIVE: ${{ vars.REPOCAST_LIVE }} # 'true' to actually post
REPOCAST_KILL_SWITCH: ${{ vars.REPOCAST_KILL_SWITCH }} # 'true' stops everything
X_API_KEY: ${{ secrets.X_API_KEY }}
X_API_SECRET: ${{ secrets.X_API_SECRET }}
X_ACCESS_TOKEN: ${{ secrets.X_ACCESS_TOKEN }}
X_ACCESS_TOKEN_SECRET: ${{ secrets.X_ACCESS_TOKEN_SECRET }}
# then commit .repocast/state.json when steps.rc.outputs.state_changed == 'true'
A complete scheduled workflow is in examples/workflows/post-changes.yml. It
covers concurrency, the state commit, and the switches. Action outputs: posted, would_post, state_changed.
Each run also writes a step summary showing every post.
Input
| Source | What repocast reads |
|---|---|
knoblog feed.json (preferred) |
per commit: the change records and knoblog's ready-made post text / skip reason |
knoblog changes.json (+ posts.json next to it) |
flat change records grouped by commit; prebuilt posts if posts.json is there |
repocast diff |
KEY = VALUE, KEY: VALUE, #define KEY VALUE and const KEY: T = VALUE; changes per commit. A key that appears twice in one file is dropped as ambiguous. Use knoblog for nested YAML, parameter tables, macros and expressions. |
| your own JSON | {"items": [{"id", "date", "commit_url", "url", "changes": [{"name", "old", "new", "kind", "file"}], "posts"?}]} |
kind is changed, added, removed, moved or renamed. knoblog is an optional extra
(pip install "repocast[knoblog]"). repocast only reads knoblog's files, so either tool can run in a separate step.
Formatting
-
X weighted counting (twitter-text v3 rules). Every URL counts as 23. Domain-like tokens such as
params.rsalso count as 23, and the gate rejects them because X would auto-link them. CJK characters and most symbols (→,▸) weigh 2. An emoji weighs 2 whatever its length: ZWJ families, skin tones, flags and keycaps included. Text is NFC normalised. Runrepocast count "…"to check a text. -
Threads. The headline goes in the first post, then whole change lines, then the footer on the last post. The footer is never left alone in a tiny reply. Each reply is chained to the post before it with
reply.in_reply_to_tweet_id. -
Templates use plain
{placeholders}, written{{/}}for literal braces. An unknown placeholder fails when the config loads, not at 3 a.m. A line whose placeholders are all empty is dropped, so{url}disappears when there is no details page.template placeholders header,footer,more_line{project} {date} {count} {short} {id} {title} {commit_url} {url} {disclaimer} {more} {lead}line,text_line,flag_line,added_line,removed_line,other_line{name} {old} {new} {change} {pct} {ratio} {file} {kind} {type}+{project} {date} {short} {commit_url} {url}bullet(literal prefix for each change line, default -){change}reads like25% higher,7.2x lowerorsign flipped. Highlighted names (highlight = [...]) lead, then the biggest relative changes.name_style = "safe"(the default) turns dotted names that X would link into slash form.display_namesmaps raw names to friendly ones.
The accuracy gate
A post is sent only if every number in it traces back to the change data. A number passes if it is one of these:
- an old or new value of one of the item's changes (
125,000matches125_000); - a correctly rounded quantity derived from one change: percent change, ratio, inverse ratio or difference. Unit
conversions (ms ↔ s ↔ min ↔ h ↔ d, KiB/MiB) count only when the parameter name suggests a unit, as in
timeout_ms; - a count no larger than the number of changes (
+3 more), or part of the item's date; - a number you typed into your own templates. With prebuilt text, numbers in
trusted_textand the disclaimer also pass.
The gate also rejects @mentions and #hashtags (unless allowed), domain-like tokens outside real links, any post
over 280 weighted characters, and threads longer than max_posts. A failed check never goes to X. The item is
skipped and the reason is recorded:
=== 1a2b3c4 SKIP: gate: unverified number '50' in post 1: not in the change data
Prebuilt text, such as knoblog's, goes through the same gate, so a bug or a hand edit upstream cannot put a wrong number on X.
Posting backends
| backend | what it does |
|---|---|
x |
X API v2 POST /2/tweets with OAuth 1.0a user context. The HMAC-SHA1 signer is stdlib and tested against X's documented example. Before the first post of a run it calls GET /2/users/me and aborts the run unless the token belongs to account / account_id. |
outbox |
Never posts. It appends to outbox/outbox.jsonl and also writes outbox.json (JSON Feed) and outbox.txt, for human review or another tool. |
dry-run |
Never posts and records nothing. It prints each post with its weighted length. |
package.module:Class |
Your own backend: subclass repocast.backends.Backend, then implement post_thread(posts, item) -> ids and optionally verify(). |
Safety model
- Off by default. A backend that publishes (
x) runs as a dry run unlessREPOCAST_LIVE=trueand all fourX_*credentials are set. The variable name can be changed withlive_env. - Kill switch. When
REPOCAST_KILL_SWITCHis true, the run does nothing at all. The variable name can be changed withkill_switch_env. In Actions, make it a repository variable so you can flip it from your phone. - No backfill. The first run, and the first live run after dry runs, mark every existing item as handled. Posting starts with the next change.
- Dedup. The state file records every item that was posted or skipped, plus a hash of the text. The same change never posts twice, and neither does the same text. It is written atomically.
- Caps.
max_per_runandmax_per_dayapply, with the day counted intimezone. Items over the cap are deferred, not dropped. Items older thanstale_after_hoursare skipped. - Quiet hours.
quiet_hours = "22:00-07:00"intimezonedefers the whole run. - Never retry an unknown outcome. A timeout, a reset connection or a 5xx response might mean the post exists,
so the item is marked
uncertainand is never retried automatically. A 4xx response means a definite failure, which is retried up tomax_attempts. If a thread fails partway, it is recorded aspartialwith the IDs that were posted. After you check the account,repocast forget <sha> --forcemakes repocast consider an item again. - Right account only. If
GET /2/users/mefails or names another account, the run aborts before any post. Check it yourself withrepocast verify-account --account MyBot. - Exit codes.
0means OK,1means something failed or is uncertain (so CI turns red),2means bad config/input, and3means the account check failed.
Running an automated account on X
X has specific rules for bots, and they change, so read the current versions: Developer Guidelines → Automation rules, X automation rules (Help Center) and the Developer Policy.
The "Automated by" label. Log in as the bot, open Settings and privacy → Your account → Account information → Automation (x.com/settings/account/automation), and set the managing account to the human account responsible for the bot. The profile then shows "Automated by @you".
Cost: Pay Per Use credits. The X API is billed per request from prepaid credits, and creating a post is a
billable write. Prices change, and posts that contain links can be priced differently, so this README quotes no
numbers. Check X API pricing and the Developer Console before
going live, and set a spending limit there. repocast's caps (max_per_run, max_per_day, max_posts) are also
your cost ceiling: a thread of N posts makes N billable create requests. The GET /2/users/me check is a read and
is made only on runs that are about to post. An outbox or dry-run backend makes no API calls at all.
Automated-account checklist
- The bot has its own account. Never use your personal account's tokens.
- The "Automated by @you" label is on, and the bio says it is a bot and who runs it.
- The X app has Read and write permission. The access token was generated after setting that permission (an older token stays read-only).
-
account(oraccount_id) inrepocast.tomlis the bot's handle. Runrepocast verify-accountonce. - Credentials are stored only as CI secrets.
REPOCAST_LIVEandREPOCAST_KILL_SWITCHare repository variables. - The posts are original, informative and not repetitive. There are no unsolicited @mentions or replies and no hashtag stuffing (the gate blocks mentions and hashtags by default).
- Caps are sized to your budget, a spending limit is set in the Developer Console, and quiet hours are set if your audience sleeps.
- Watched
repocast previewand a fewdry-run/outboxruns before flippingREPOCAST_LIVE. - Someone watches the bot's mentions and failed runs (exit code 1 turns CI red), and knows where the kill switch is.
Examples
examples/x-algorithm/: knoblog's x-algorithm feed inprebuiltmode. It reproduces the @XAlgoChangelog post text exactly (expected-posts.json, checked by the tests).examples/px4/: knoblog → X for PX4 firmware defaults, sent to an outbox with quiet hours.examples/nav2-template/: Nav2 parameter changes in your own words, using a custom template with emoji.
🤖 Nav2 defaults changed (Aug 15) ⚙️
▸ velocity_smoother/max_accel[0]: 2.5 → 3.0 (20% higher)
▸ velocity_smoother/max_decel[0]: -2.5 → -3.0 (20% lower)
▸ velocity_smoother/max_accel[2]: 3.2 → 3.5 (9.4% higher)
Development
python -m unittest discover -s tests -v # no network: the X API is mocked
MIT licensed. © 2026 Joshua Almeida.
Metadata
Release files for repocast 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| repocast-0.1.0.tar.gz | 45.5 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| repocast-0.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 80.9 kB
Release files / repocast-0.1.0.tar.gz
| Download URL | repocast-0.1.0.tar.gz |
|---|---|
| Size | 45.5 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
1afe91e4a33d8456c7b7b637e9ef92a9e19d91f3021b59b447297140fde86715
|
|
BLAKE2b-256 checksum How to use checksums |
c17392da7bf099aa12127f707188fb9beae392bee38459543803281a844234f5
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 10, 2026.
Transparency logRelease files / repocast-0.1.0-py3-none-any.whl
| Download URL | repocast-0.1.0-py3-none-any.whl |
|---|---|
| Size | 35.3 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
9ad2aff2f6c8c6e6e4edb11b1ad8a069f0faab2a71dc4f3b1a74b859db4f9272
|
|
BLAKE2b-256 checksum How to use checksums |
214b2d8d16260858a10be7a95c3da8529a48b1069f6991625e57dc09e0df3fc1
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 10, 2026.
Transparency log