repocast 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
repocast-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Joshua Almeida
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,258 @@
1
+ Metadata-Version: 2.4
2
+ Name: repocast
3
+ Version: 0.1.0
4
+ Summary: Turn any repo's changes into accurate, ready-to-post X posts, and post them safely (OAuth 1.0a, threads, dedup, caps, kill switch).
5
+ Author: Joshua Almeida
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/fitzyracing1/repocast
8
+ Project-URL: Issues, https://github.com/fitzyracing1/repocast/issues
9
+ Keywords: x,twitter,bot,changelog,github-action,release-notes,automation
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Topic :: Software Development :: Build Tools
13
+ Classifier: Topic :: Communications
14
+ Requires-Python: >=3.10
15
+ Description-Content-Type: text/markdown
16
+ License-File: LICENSE
17
+ Requires-Dist: tomli>=2; python_version < "3.11"
18
+ Provides-Extra: knoblog
19
+ Requires-Dist: knoblog>=0.1; extra == "knoblog"
20
+ Dynamic: license-file
21
+
22
+ # repocast
23
+
24
+ **Turn any repo's changes into accurate, ready-to-post X posts, and post them safely.**
25
+
26
+ repocast is for people who build bots that post on X about code changes, such as changelog bots, "parameter
27
+ watch" accounts, or release announcers. You give it structured change data. It writes posts or threads that fit X's
28
+ weighted 280-character limit and checks that every number in them comes from the data. It then posts them through
29
+ the X API v2, or writes them to an outbox, or only prints them. The guard rails a bot needs are built in: dedup,
30
+ caps, a kill switch, quiet hours, an account check, and no retries when the outcome is unknown.
31
+
32
+ Pure Python standard library (3.10+); no third-party dependency at runtime.
33
+
34
+ ```
35
+ knoblog feed.json / changes.json ─┐
36
+ repocast diff (built-in, simple) ─┼─▶ compose ─▶ accuracy gate ─▶ dedup/caps/switches ─▶ X API v2 │ outbox │ dry run
37
+ your own items JSON ─┘ (prebuilt text or {placeholder} templates)
38
+ ```
39
+
40
+ It grew out of the [@XAlgoChangelog](https://github.com/fitzyracing1/x-algorithm-changelog) bot. With the config in
41
+ `examples/x-algorithm/` it reproduces that bot's post text exactly, for all 23 post-worthy commits (this is a test).
42
+
43
+ ## Quickstart (CLI)
44
+
45
+ ```bash
46
+ pip install repocast # or: pip install "repocast[knoblog]"
47
+ repocast count "🤖 Nav2 defaults changed → https://example.com" # 51/280
48
+
49
+ # 1) change data: knoblog's output (preferred) or the built-in diff
50
+ knoblog run --config knoblog.yml --out site # writes site/feed.json, changes.json, posts.json
51
+ # or
52
+ repocast diff --repo . --range v1.4.0..HEAD -o items.json
53
+
54
+ # 2) look at what would be posted (no state, no caps, posts nothing)
55
+ repocast preview -i site/feed.json
56
+
57
+ # 3) a real run. Without REPOCAST_LIVE=true this is still a dry run and records nothing.
58
+ repocast run -c repocast.toml
59
+ ```
60
+
61
+ `repocast.toml`:
62
+
63
+ ```toml
64
+ [input]
65
+ path = "site/feed.json" # knoblog feed.json / changes.json (+ posts.json), or repocast items JSON
66
+
67
+ [format]
68
+ mode = "auto" # prebuilt (knoblog's text) | template | auto (prebuilt when available)
69
+ project = "MyRobot"
70
+ disclaimer = "Values are repo defaults."
71
+ max_lines = 3 # change lines after the headline
72
+ max_posts = 4 # longest thread allowed
73
+
74
+ [format.templates] # optional; plain {placeholders}, no template engine
75
+ header = "🤖 {project} update ({date}): {lead}"
76
+ line = "{name} {old} → {new} ({change})"
77
+
78
+ [filter]
79
+ exclude_files = "(^|/)tests?/"
80
+
81
+ [post]
82
+ backend = "x" # x | outbox | dry-run | package.module:Class
83
+ account = "MyRobotChanges" # the ONLY account this bot may post as (checked via GET /2/users/me)
84
+ state = ".repocast/state.json" # dedup state: commit it or cache it between runs
85
+ max_per_run = 1
86
+ max_per_day = 3
87
+ stale_after_hours = 72
88
+ quiet_hours = "22:00-07:00"
89
+ timezone = "America/New_York"
90
+ ```
91
+
92
+ ## Quickstart (GitHub Action)
93
+
94
+ ```yaml
95
+ - uses: fitzyracing1/knoblog@v0 # or any step that writes change data
96
+ with: { config: knoblog.yml, out: site }
97
+ - id: rc
98
+ uses: fitzyracing1/repocast@v0
99
+ with: { config: repocast.toml }
100
+ env:
101
+ REPOCAST_LIVE: ${{ vars.REPOCAST_LIVE }} # 'true' to actually post
102
+ REPOCAST_KILL_SWITCH: ${{ vars.REPOCAST_KILL_SWITCH }} # 'true' stops everything
103
+ X_API_KEY: ${{ secrets.X_API_KEY }}
104
+ X_API_SECRET: ${{ secrets.X_API_SECRET }}
105
+ X_ACCESS_TOKEN: ${{ secrets.X_ACCESS_TOKEN }}
106
+ X_ACCESS_TOKEN_SECRET: ${{ secrets.X_ACCESS_TOKEN_SECRET }}
107
+ # then commit .repocast/state.json when steps.rc.outputs.state_changed == 'true'
108
+ ```
109
+
110
+ A complete scheduled workflow is in [`examples/workflows/post-changes.yml`](examples/workflows/post-changes.yml). It
111
+ covers concurrency, the state commit, and the switches. Action outputs: `posted`, `would_post`, `state_changed`.
112
+ Each run also writes a step summary showing every post.
113
+
114
+ ## Input
115
+
116
+ | Source | What repocast reads |
117
+ | --- | --- |
118
+ | knoblog `feed.json` (preferred) | per commit: the change records **and** knoblog's ready-made post text / skip reason |
119
+ | knoblog `changes.json` (+ `posts.json` next to it) | flat change records grouped by commit; prebuilt posts if `posts.json` is there |
120
+ | `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. |
121
+ | your own JSON | `{"items": [{"id", "date", "commit_url", "url", "changes": [{"name", "old", "new", "kind", "file"}], "posts"?}]}` |
122
+
123
+ `kind` is `changed`, `added`, `removed`, `moved` or `renamed`. knoblog is an optional extra
124
+ (`pip install "repocast[knoblog]"`). repocast only reads knoblog's files, so either tool can run in a separate step.
125
+
126
+ ## Formatting
127
+
128
+ * **X weighted counting** (twitter-text v3 rules). Every URL counts as 23. Domain-like tokens such as `params.rs` also
129
+ count as 23, and the gate rejects them because X would auto-link them. CJK characters and most symbols (`→`, `▸`)
130
+ weigh 2. An emoji weighs 2 whatever its length: ZWJ families, skin tones, flags and keycaps included. Text is NFC
131
+ normalised. Run `repocast count "…"` to check a text.
132
+ * **Threads.** The headline goes in the first post, then whole change lines, then the footer on the last post. The
133
+ footer is never left alone in a tiny reply. Each reply is chained to the post before it with
134
+ `reply.in_reply_to_tweet_id`.
135
+ * **Templates** use plain `{placeholders}`, written `{{`/`}}` for literal braces. An unknown placeholder fails when
136
+ the config loads, not at 3 a.m. A line whose placeholders are all empty is dropped, so `{url}` disappears when
137
+ there is no details page.
138
+
139
+ | template | placeholders |
140
+ | --- | --- |
141
+ | `header`, `footer`, `more_line` | `{project} {date} {count} {short} {id} {title} {commit_url} {url} {disclaimer} {more} {lead}` |
142
+ | `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}` |
143
+ | `bullet` | (literal prefix for each change line, default `- `) |
144
+
145
+ `{change}` reads like `25% higher`, `7.2x lower` or `sign flipped`. Highlighted names (`highlight = [...]`) lead,
146
+ then the biggest relative changes. `name_style = "safe"` (the default) turns dotted names that X would link into
147
+ slash form. `display_names` maps raw names to friendly ones.
148
+
149
+ ## The accuracy gate
150
+
151
+ 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:
152
+
153
+ * an old or new value of one of the item's changes (`125,000` matches `125_000`);
154
+ * a correctly rounded quantity derived from one change: percent change, ratio, inverse ratio or difference. Unit
155
+ conversions (ms ↔ s ↔ min ↔ h ↔ d, KiB/MiB) count only when the parameter name suggests a unit, as in `timeout_ms`;
156
+ * a count no larger than the number of changes (`+3 more`), or part of the item's date;
157
+ * a number you typed into your own templates. With prebuilt text, numbers in `trusted_text` and the disclaimer
158
+ also pass.
159
+
160
+ The gate also rejects @mentions and #hashtags (unless allowed), domain-like tokens outside real links, any post
161
+ over 280 weighted characters, and threads longer than `max_posts`. A failed check never goes to X. The item is
162
+ skipped and the reason is recorded:
163
+
164
+ ```
165
+ === 1a2b3c4 SKIP: gate: unverified number '50' in post 1: not in the change data
166
+ ```
167
+
168
+ Prebuilt text, such as knoblog's, goes through the same gate, so a bug or a hand edit upstream cannot put a wrong
169
+ number on X.
170
+
171
+ ## Posting backends
172
+
173
+ | backend | what it does |
174
+ | --- | --- |
175
+ | `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`. |
176
+ | `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. |
177
+ | `dry-run` | Never posts and records nothing. It prints each post with its weighted length. |
178
+ | `package.module:Class` | Your own backend: subclass `repocast.backends.Backend`, then implement `post_thread(posts, item) -> ids` and optionally `verify()`. |
179
+
180
+ ## Safety model
181
+
182
+ * **Off by default.** A backend that publishes (`x`) runs as a dry run unless `REPOCAST_LIVE=true` and all four
183
+ `X_*` credentials are set. The variable name can be changed with `live_env`.
184
+ * **Kill switch.** When `REPOCAST_KILL_SWITCH` is true, the run does nothing at all. The variable name can be changed
185
+ with `kill_switch_env`. In Actions, make it a repository *variable* so you can flip it from your phone.
186
+ * **No backfill.** The first run, and the first live run after dry runs, mark every existing item as handled.
187
+ Posting starts with the next change.
188
+ * **Dedup.** The state file records every item that was posted or skipped, plus a hash of the text. The same
189
+ change never posts twice, and neither does the same text. It is written atomically.
190
+ * **Caps.** `max_per_run` and `max_per_day` apply, with the day counted in `timezone`. Items over the cap are
191
+ deferred, not dropped. Items older than `stale_after_hours` are skipped.
192
+ * **Quiet hours.** `quiet_hours = "22:00-07:00"` in `timezone` defers the whole run.
193
+ * **Never retry an unknown outcome.** A timeout, a reset connection or a 5xx response might mean the post exists,
194
+ so the item is marked `uncertain` and is never retried automatically. A 4xx response means a definite failure,
195
+ which is retried up to `max_attempts`. If a thread fails partway, it is recorded as `partial` with the IDs that
196
+ were posted. After you check the account, `repocast forget <sha> --force` makes repocast consider an item again.
197
+ * **Right account only.** If `GET /2/users/me` fails or names another account, the run aborts before any post.
198
+ Check it yourself with `repocast verify-account --account MyBot`.
199
+ * **Exit codes.** `0` means OK, `1` means something failed or is uncertain (so CI turns red), `2` means bad
200
+ config/input, and `3` means the account check failed.
201
+
202
+ ## Running an automated account on X
203
+
204
+ X has specific rules for bots, and they change, so read the current versions:
205
+ [Developer Guidelines → Automation rules](https://docs.x.com/developer-guidelines),
206
+ [X automation rules (Help Center)](https://help.x.com/en/rules-and-policies/x-automation) and the
207
+ [Developer Policy](https://developer.x.com/en/developer-terms/policy).
208
+
209
+ **The "Automated by" label.** Log in as the bot, open *Settings and privacy → Your account → Account information →
210
+ Automation* ([x.com/settings/account/automation](https://x.com/settings/account/automation)), and set the managing
211
+ account to the human account responsible for the bot. The profile then shows "Automated by @you".
212
+
213
+ **Cost: Pay Per Use credits.** The X API is billed per request from prepaid credits, and creating a post is a
214
+ billable write. **Prices change, and posts that contain links can be priced differently,** so this README quotes no
215
+ numbers. Check [X API pricing](https://docs.x.com/x-api/getting-started/pricing) and the Developer Console before
216
+ going live, and set a spending limit there. repocast's caps (`max_per_run`, `max_per_day`, `max_posts`) are also
217
+ your cost ceiling: a thread of N posts makes N billable create requests. The `GET /2/users/me` check is a read and
218
+ is made only on runs that are about to post. An `outbox` or `dry-run` backend makes no API calls at all.
219
+
220
+ **Automated-account checklist**
221
+
222
+ - [ ] The bot has its own account. Never use your personal account's tokens.
223
+ - [ ] The "Automated by @you" label is on, and the bio says it is a bot and who runs it.
224
+ - [ ] The X app has *Read and write* permission. The access token was generated **after** setting that permission
225
+ (an older token stays read-only).
226
+ - [ ] `account` (or `account_id`) in `repocast.toml` is the bot's handle. Run `repocast verify-account` once.
227
+ - [ ] Credentials are stored only as CI secrets. `REPOCAST_LIVE` and `REPOCAST_KILL_SWITCH` are repository
228
+ variables.
229
+ - [ ] The posts are original, informative and not repetitive. There are no unsolicited @mentions or replies and no
230
+ hashtag stuffing (the gate blocks mentions and hashtags by default).
231
+ - [ ] Caps are sized to your budget, a spending limit is set in the Developer Console, and quiet hours are set if
232
+ your audience sleeps.
233
+ - [ ] Watched `repocast preview` and a few `dry-run`/`outbox` runs before flipping `REPOCAST_LIVE`.
234
+ - [ ] Someone watches the bot's mentions and failed runs (exit code 1 turns CI red), and knows where the kill switch
235
+ is.
236
+
237
+ ## Examples
238
+
239
+ * [`examples/x-algorithm/`](examples/x-algorithm/): knoblog's x-algorithm feed in `prebuilt` mode. It reproduces the
240
+ @XAlgoChangelog post text exactly (`expected-posts.json`, checked by the tests).
241
+ * [`examples/px4/`](examples/px4/): knoblog → X for PX4 firmware defaults, sent to an outbox with quiet hours.
242
+ * [`examples/nav2-template/`](examples/nav2-template/): Nav2 parameter changes in your own words, using a custom
243
+ template with emoji.
244
+
245
+ ```text
246
+ 🤖 Nav2 defaults changed (Aug 15) ⚙️
247
+ ▸ velocity_smoother/max_accel[0]: 2.5 → 3.0 (20% higher)
248
+ ▸ velocity_smoother/max_decel[0]: -2.5 → -3.0 (20% lower)
249
+ ▸ velocity_smoother/max_accel[2]: 3.2 → 3.5 (9.4% higher)
250
+ ```
251
+
252
+ ## Development
253
+
254
+ ```bash
255
+ python -m unittest discover -s tests -v # no network: the X API is mocked
256
+ ```
257
+
258
+ MIT licensed. © 2026 Joshua Almeida.
@@ -0,0 +1,237 @@
1
+ # repocast
2
+
3
+ **Turn any repo's changes into accurate, ready-to-post X posts, and post them safely.**
4
+
5
+ repocast is for people who build bots that post on X about code changes, such as changelog bots, "parameter
6
+ watch" accounts, or release announcers. You give it structured change data. It writes posts or threads that fit X's
7
+ weighted 280-character limit and checks that every number in them comes from the data. It then posts them through
8
+ the X API v2, or writes them to an outbox, or only prints them. The guard rails a bot needs are built in: dedup,
9
+ caps, a kill switch, quiet hours, an account check, and no retries when the outcome is unknown.
10
+
11
+ Pure Python standard library (3.10+); no third-party dependency at runtime.
12
+
13
+ ```
14
+ knoblog feed.json / changes.json ─┐
15
+ repocast diff (built-in, simple) ─┼─▶ compose ─▶ accuracy gate ─▶ dedup/caps/switches ─▶ X API v2 │ outbox │ dry run
16
+ your own items JSON ─┘ (prebuilt text or {placeholder} templates)
17
+ ```
18
+
19
+ It grew out of the [@XAlgoChangelog](https://github.com/fitzyracing1/x-algorithm-changelog) bot. With the config in
20
+ `examples/x-algorithm/` it reproduces that bot's post text exactly, for all 23 post-worthy commits (this is a test).
21
+
22
+ ## Quickstart (CLI)
23
+
24
+ ```bash
25
+ pip install repocast # or: pip install "repocast[knoblog]"
26
+ repocast count "🤖 Nav2 defaults changed → https://example.com" # 51/280
27
+
28
+ # 1) change data: knoblog's output (preferred) or the built-in diff
29
+ knoblog run --config knoblog.yml --out site # writes site/feed.json, changes.json, posts.json
30
+ # or
31
+ repocast diff --repo . --range v1.4.0..HEAD -o items.json
32
+
33
+ # 2) look at what would be posted (no state, no caps, posts nothing)
34
+ repocast preview -i site/feed.json
35
+
36
+ # 3) a real run. Without REPOCAST_LIVE=true this is still a dry run and records nothing.
37
+ repocast run -c repocast.toml
38
+ ```
39
+
40
+ `repocast.toml`:
41
+
42
+ ```toml
43
+ [input]
44
+ path = "site/feed.json" # knoblog feed.json / changes.json (+ posts.json), or repocast items JSON
45
+
46
+ [format]
47
+ mode = "auto" # prebuilt (knoblog's text) | template | auto (prebuilt when available)
48
+ project = "MyRobot"
49
+ disclaimer = "Values are repo defaults."
50
+ max_lines = 3 # change lines after the headline
51
+ max_posts = 4 # longest thread allowed
52
+
53
+ [format.templates] # optional; plain {placeholders}, no template engine
54
+ header = "🤖 {project} update ({date}): {lead}"
55
+ line = "{name} {old} → {new} ({change})"
56
+
57
+ [filter]
58
+ exclude_files = "(^|/)tests?/"
59
+
60
+ [post]
61
+ backend = "x" # x | outbox | dry-run | package.module:Class
62
+ account = "MyRobotChanges" # the ONLY account this bot may post as (checked via GET /2/users/me)
63
+ state = ".repocast/state.json" # dedup state: commit it or cache it between runs
64
+ max_per_run = 1
65
+ max_per_day = 3
66
+ stale_after_hours = 72
67
+ quiet_hours = "22:00-07:00"
68
+ timezone = "America/New_York"
69
+ ```
70
+
71
+ ## Quickstart (GitHub Action)
72
+
73
+ ```yaml
74
+ - uses: fitzyracing1/knoblog@v0 # or any step that writes change data
75
+ with: { config: knoblog.yml, out: site }
76
+ - id: rc
77
+ uses: fitzyracing1/repocast@v0
78
+ with: { config: repocast.toml }
79
+ env:
80
+ REPOCAST_LIVE: ${{ vars.REPOCAST_LIVE }} # 'true' to actually post
81
+ REPOCAST_KILL_SWITCH: ${{ vars.REPOCAST_KILL_SWITCH }} # 'true' stops everything
82
+ X_API_KEY: ${{ secrets.X_API_KEY }}
83
+ X_API_SECRET: ${{ secrets.X_API_SECRET }}
84
+ X_ACCESS_TOKEN: ${{ secrets.X_ACCESS_TOKEN }}
85
+ X_ACCESS_TOKEN_SECRET: ${{ secrets.X_ACCESS_TOKEN_SECRET }}
86
+ # then commit .repocast/state.json when steps.rc.outputs.state_changed == 'true'
87
+ ```
88
+
89
+ A complete scheduled workflow is in [`examples/workflows/post-changes.yml`](examples/workflows/post-changes.yml). It
90
+ covers concurrency, the state commit, and the switches. Action outputs: `posted`, `would_post`, `state_changed`.
91
+ Each run also writes a step summary showing every post.
92
+
93
+ ## Input
94
+
95
+ | Source | What repocast reads |
96
+ | --- | --- |
97
+ | knoblog `feed.json` (preferred) | per commit: the change records **and** knoblog's ready-made post text / skip reason |
98
+ | knoblog `changes.json` (+ `posts.json` next to it) | flat change records grouped by commit; prebuilt posts if `posts.json` is there |
99
+ | `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. |
100
+ | your own JSON | `{"items": [{"id", "date", "commit_url", "url", "changes": [{"name", "old", "new", "kind", "file"}], "posts"?}]}` |
101
+
102
+ `kind` is `changed`, `added`, `removed`, `moved` or `renamed`. knoblog is an optional extra
103
+ (`pip install "repocast[knoblog]"`). repocast only reads knoblog's files, so either tool can run in a separate step.
104
+
105
+ ## Formatting
106
+
107
+ * **X weighted counting** (twitter-text v3 rules). Every URL counts as 23. Domain-like tokens such as `params.rs` also
108
+ count as 23, and the gate rejects them because X would auto-link them. CJK characters and most symbols (`→`, `▸`)
109
+ weigh 2. An emoji weighs 2 whatever its length: ZWJ families, skin tones, flags and keycaps included. Text is NFC
110
+ normalised. Run `repocast count "…"` to check a text.
111
+ * **Threads.** The headline goes in the first post, then whole change lines, then the footer on the last post. The
112
+ footer is never left alone in a tiny reply. Each reply is chained to the post before it with
113
+ `reply.in_reply_to_tweet_id`.
114
+ * **Templates** use plain `{placeholders}`, written `{{`/`}}` for literal braces. An unknown placeholder fails when
115
+ the config loads, not at 3 a.m. A line whose placeholders are all empty is dropped, so `{url}` disappears when
116
+ there is no details page.
117
+
118
+ | template | placeholders |
119
+ | --- | --- |
120
+ | `header`, `footer`, `more_line` | `{project} {date} {count} {short} {id} {title} {commit_url} {url} {disclaimer} {more} {lead}` |
121
+ | `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}` |
122
+ | `bullet` | (literal prefix for each change line, default `- `) |
123
+
124
+ `{change}` reads like `25% higher`, `7.2x lower` or `sign flipped`. Highlighted names (`highlight = [...]`) lead,
125
+ then the biggest relative changes. `name_style = "safe"` (the default) turns dotted names that X would link into
126
+ slash form. `display_names` maps raw names to friendly ones.
127
+
128
+ ## The accuracy gate
129
+
130
+ 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:
131
+
132
+ * an old or new value of one of the item's changes (`125,000` matches `125_000`);
133
+ * a correctly rounded quantity derived from one change: percent change, ratio, inverse ratio or difference. Unit
134
+ conversions (ms ↔ s ↔ min ↔ h ↔ d, KiB/MiB) count only when the parameter name suggests a unit, as in `timeout_ms`;
135
+ * a count no larger than the number of changes (`+3 more`), or part of the item's date;
136
+ * a number you typed into your own templates. With prebuilt text, numbers in `trusted_text` and the disclaimer
137
+ also pass.
138
+
139
+ The gate also rejects @mentions and #hashtags (unless allowed), domain-like tokens outside real links, any post
140
+ over 280 weighted characters, and threads longer than `max_posts`. A failed check never goes to X. The item is
141
+ skipped and the reason is recorded:
142
+
143
+ ```
144
+ === 1a2b3c4 SKIP: gate: unverified number '50' in post 1: not in the change data
145
+ ```
146
+
147
+ Prebuilt text, such as knoblog's, goes through the same gate, so a bug or a hand edit upstream cannot put a wrong
148
+ number on X.
149
+
150
+ ## Posting backends
151
+
152
+ | backend | what it does |
153
+ | --- | --- |
154
+ | `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`. |
155
+ | `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. |
156
+ | `dry-run` | Never posts and records nothing. It prints each post with its weighted length. |
157
+ | `package.module:Class` | Your own backend: subclass `repocast.backends.Backend`, then implement `post_thread(posts, item) -> ids` and optionally `verify()`. |
158
+
159
+ ## Safety model
160
+
161
+ * **Off by default.** A backend that publishes (`x`) runs as a dry run unless `REPOCAST_LIVE=true` and all four
162
+ `X_*` credentials are set. The variable name can be changed with `live_env`.
163
+ * **Kill switch.** When `REPOCAST_KILL_SWITCH` is true, the run does nothing at all. The variable name can be changed
164
+ with `kill_switch_env`. In Actions, make it a repository *variable* so you can flip it from your phone.
165
+ * **No backfill.** The first run, and the first live run after dry runs, mark every existing item as handled.
166
+ Posting starts with the next change.
167
+ * **Dedup.** The state file records every item that was posted or skipped, plus a hash of the text. The same
168
+ change never posts twice, and neither does the same text. It is written atomically.
169
+ * **Caps.** `max_per_run` and `max_per_day` apply, with the day counted in `timezone`. Items over the cap are
170
+ deferred, not dropped. Items older than `stale_after_hours` are skipped.
171
+ * **Quiet hours.** `quiet_hours = "22:00-07:00"` in `timezone` defers the whole run.
172
+ * **Never retry an unknown outcome.** A timeout, a reset connection or a 5xx response might mean the post exists,
173
+ so the item is marked `uncertain` and is never retried automatically. A 4xx response means a definite failure,
174
+ which is retried up to `max_attempts`. If a thread fails partway, it is recorded as `partial` with the IDs that
175
+ were posted. After you check the account, `repocast forget <sha> --force` makes repocast consider an item again.
176
+ * **Right account only.** If `GET /2/users/me` fails or names another account, the run aborts before any post.
177
+ Check it yourself with `repocast verify-account --account MyBot`.
178
+ * **Exit codes.** `0` means OK, `1` means something failed or is uncertain (so CI turns red), `2` means bad
179
+ config/input, and `3` means the account check failed.
180
+
181
+ ## Running an automated account on X
182
+
183
+ X has specific rules for bots, and they change, so read the current versions:
184
+ [Developer Guidelines → Automation rules](https://docs.x.com/developer-guidelines),
185
+ [X automation rules (Help Center)](https://help.x.com/en/rules-and-policies/x-automation) and the
186
+ [Developer Policy](https://developer.x.com/en/developer-terms/policy).
187
+
188
+ **The "Automated by" label.** Log in as the bot, open *Settings and privacy → Your account → Account information →
189
+ Automation* ([x.com/settings/account/automation](https://x.com/settings/account/automation)), and set the managing
190
+ account to the human account responsible for the bot. The profile then shows "Automated by @you".
191
+
192
+ **Cost: Pay Per Use credits.** The X API is billed per request from prepaid credits, and creating a post is a
193
+ billable write. **Prices change, and posts that contain links can be priced differently,** so this README quotes no
194
+ numbers. Check [X API pricing](https://docs.x.com/x-api/getting-started/pricing) and the Developer Console before
195
+ going live, and set a spending limit there. repocast's caps (`max_per_run`, `max_per_day`, `max_posts`) are also
196
+ your cost ceiling: a thread of N posts makes N billable create requests. The `GET /2/users/me` check is a read and
197
+ is made only on runs that are about to post. An `outbox` or `dry-run` backend makes no API calls at all.
198
+
199
+ **Automated-account checklist**
200
+
201
+ - [ ] The bot has its own account. Never use your personal account's tokens.
202
+ - [ ] The "Automated by @you" label is on, and the bio says it is a bot and who runs it.
203
+ - [ ] The X app has *Read and write* permission. The access token was generated **after** setting that permission
204
+ (an older token stays read-only).
205
+ - [ ] `account` (or `account_id`) in `repocast.toml` is the bot's handle. Run `repocast verify-account` once.
206
+ - [ ] Credentials are stored only as CI secrets. `REPOCAST_LIVE` and `REPOCAST_KILL_SWITCH` are repository
207
+ variables.
208
+ - [ ] The posts are original, informative and not repetitive. There are no unsolicited @mentions or replies and no
209
+ hashtag stuffing (the gate blocks mentions and hashtags by default).
210
+ - [ ] Caps are sized to your budget, a spending limit is set in the Developer Console, and quiet hours are set if
211
+ your audience sleeps.
212
+ - [ ] Watched `repocast preview` and a few `dry-run`/`outbox` runs before flipping `REPOCAST_LIVE`.
213
+ - [ ] Someone watches the bot's mentions and failed runs (exit code 1 turns CI red), and knows where the kill switch
214
+ is.
215
+
216
+ ## Examples
217
+
218
+ * [`examples/x-algorithm/`](examples/x-algorithm/): knoblog's x-algorithm feed in `prebuilt` mode. It reproduces the
219
+ @XAlgoChangelog post text exactly (`expected-posts.json`, checked by the tests).
220
+ * [`examples/px4/`](examples/px4/): knoblog → X for PX4 firmware defaults, sent to an outbox with quiet hours.
221
+ * [`examples/nav2-template/`](examples/nav2-template/): Nav2 parameter changes in your own words, using a custom
222
+ template with emoji.
223
+
224
+ ```text
225
+ 🤖 Nav2 defaults changed (Aug 15) ⚙️
226
+ ▸ velocity_smoother/max_accel[0]: 2.5 → 3.0 (20% higher)
227
+ ▸ velocity_smoother/max_decel[0]: -2.5 → -3.0 (20% lower)
228
+ ▸ velocity_smoother/max_accel[2]: 3.2 → 3.5 (9.4% higher)
229
+ ```
230
+
231
+ ## Development
232
+
233
+ ```bash
234
+ python -m unittest discover -s tests -v # no network: the X API is mocked
235
+ ```
236
+
237
+ MIT licensed. © 2026 Joshua Almeida.
@@ -0,0 +1,37 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "repocast"
7
+ dynamic = ["version"]
8
+ description = "Turn any repo's changes into accurate, ready-to-post X posts, and post them safely (OAuth 1.0a, threads, dedup, caps, kill switch)."
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
12
+ authors = [{ name = "Joshua Almeida" }]
13
+ requires-python = ">=3.10"
14
+ dependencies = ["tomli>=2; python_version < '3.11'"]
15
+ keywords = ["x", "twitter", "bot", "changelog", "github-action", "release-notes", "automation"]
16
+ classifiers = [
17
+ "Programming Language :: Python :: 3",
18
+ "Operating System :: OS Independent",
19
+ "Topic :: Software Development :: Build Tools",
20
+ "Topic :: Communications",
21
+ ]
22
+
23
+ [project.optional-dependencies]
24
+ knoblog = ["knoblog>=0.1"]
25
+
26
+ [project.urls]
27
+ Homepage = "https://github.com/fitzyracing1/repocast"
28
+ Issues = "https://github.com/fitzyracing1/repocast/issues"
29
+
30
+ [project.scripts]
31
+ repocast = "repocast.cli:main"
32
+
33
+ [tool.setuptools]
34
+ packages = ["repocast"]
35
+
36
+ [tool.setuptools.dynamic]
37
+ version = { attr = "repocast.__version__" }
@@ -0,0 +1,2 @@
1
+ """repocast: turn a repository's changes into accurate, ready-to-post X posts, and post them safely."""
2
+ __version__ = "0.1.0"
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+ import sys
3
+ sys.exit(main())