<rss xmlns:atom="http://www.w3.org/2005/Atom" version="2.0">
    <channel>
        <title>posse - Tag - botwerks</title>
        <link>http://botwerks.net/tags/posse/</link>
        <description>posse - Tag - botwerks</description>
        <generator>Hugo -- gohugo.io</generator><language>en</language><managingEditor>sulrich@botwerks.org (steve ulrich)</managingEditor>
            <webMaster>sulrich@botwerks.org (steve ulrich)</webMaster><lastBuildDate>Sat, 30 May 2026 21:39:53 -0500</lastBuildDate><atom:link href="http://botwerks.net/tags/posse/" rel="self" type="application/rss+xml" /><item>
    <title>syndicating botwerks via POSSE</title>
    <link>http://botwerks.net/2026/05/20260530-213953/</link>
    <pubDate>Sat, 30 May 2026 21:39:53 -0500</pubDate>
    <author>steve ulrich</author>
    <guid>http://botwerks.net/2026/05/20260530-213953/</guid>
    <description><![CDATA[<h2 id="what-is-posse">what is POSSE?</h2>
<p>POSSE stands for &ldquo;Publish on your Own Site, Syndicate Elsewhere&rdquo;.  it&rsquo;s an indieweb pattern where your own site is the
canonical source of truth for what you write, and the social platforms get pointers/copies that link back home.  the
inverse of the more typical mode, where you write on twitter/mastodon/bluesky and your blog (if you even have one) is an
afterthought.</p>
<p>i&rsquo;ve been meaning to wire this up for botwerks for a while.  the goal is simple: when i merge a new post to <code>main</code>,
something picks it up, waits for the deploy to settle, and pushes a short link to both mastodon and bluesky.  that&rsquo;s the
whole thing.  no fancy webmentions, no rich embeds, no salmention shiz.  just a link with a title and (when it fits) a
description.</p>
<h2 id="the-moving-parts">the moving parts</h2>
<p>there are three elements:</p>
<ol>
<li>a python script that does the syndicating: <code>bin/posse.py</code></li>
<li>a github action that runs that script on every push to <code>main</code></li>
<li>a state file at <code>data/syndicated.json</code> that tracks what&rsquo;s already been posted so nothing gets double-posted</li>
</ol>
<p>i&rsquo;ll provide an overview of all of these elements.</p>
<h2 id="the-script">the script</h2>
<p><code>bin/posse.py</code> is a single-file python script that uses <code>uv</code> for dependency management.  the inline script metadata at
the top of the file declares the runtime requirements - <code>atproto</code>, <code>Mastodon.py</code>, <code>python-frontmatter</code>, <code>grapheme</code>, and
<code>requests</code>.  no <code>requirements.txt</code>, no virtualenv setup, just <code>uv run bin/posse.py</code>.  this is one of the nicer python
developments of the last couple of years.</p>
<p>note: the following is an embedded <a href="https://gist.github.com/sulrich/48ea1b2d2d1ccaa3d46f27e2e2088a9b" target="_blank" rel="noopener noreffer ">gist</a></p>
<hr>
<script src="https://gist.github.com/sulrich/48ea1b2d2d1ccaa3d46f27e2e2088a9b.js"></script>

<hr>
<p>at a high level it does this:</p>
<ol>
<li>walks <code>content/posts</code>, <code>content/til</code>, and <code>content/links</code> looking for markdown files</li>
<li>filters out drafts, future-dated entries, and anything with <code>syndicate: false</code> in the frontmatter</li>
<li>for <code>content/links/</code> entries it also requires a <code>syndicate</code> tag.  most links are ephemeral and don&rsquo;t deserve a social
post by default</li>
<li>builds the canonical permalink from the file&rsquo;s date and basename to match hugo&rsquo;s permalink config as defined in my
<code>hugo.toml</code>
(<code>:year/:month/:contentbasename</code>)</li>
<li>compares that against <code>data/syndicated.json</code> to find new entries</li>
<li>for each new entry it polls <code>feed.json</code> until the entry actually appears live (so we don&rsquo;t post a link before
cloudflare has propagated the build), then posts to mastodon and bluesky</li>
<li>updates the state file after each successful post</li>
</ol>
<p>a couple of details worth calling out:</p>
<ul>
<li>bluesky has a hard 300 grapheme (yeah, i had to dig into this. a grapheme is a user perceived character.) limit on
post length.  the script measures with the <code>grapheme</code> package rather than <code>len()</code> because emoji and combining
characters will throw off naive character counting.  if a post would overflow, the description gets truncated with an
ellipsis until the whole thing fits.</li>
<li>mastodon doesn&rsquo;t have the same tight limit (500 chars by default, higher on my instance), but i format both posts
identically for consistency.</li>
<li>bluesky needs an explicit text builder with a URL facet to make the link clickable.  mastodon parses URLs from text
automatically.</li>
<li>the script writes the state file atomically (write to <code>.tmp</code>, then rename).  an interrupted run can&rsquo;t leave a
half-written JSON blob behind.</li>
</ul>
<h2 id="the-github-action">the github action</h2>
<p>the workflow lives at <code>.github/workflows/posse.yml</code>.  it triggers on push to <code>main</code> when any of these paths change:</p>
<ul>
<li><code>content/posts/**</code></li>
<li><code>content/til/**</code></li>
<li><code>content/links/**</code></li>
<li><code>bin/posse.py</code></li>
<li>the workflow itself</li>
</ul>
<p>note: the following is an embedded <a href="https://gist.github.com/sulrich/0f0c80f0844ca39b906fca98cc5f9ceb" target="_blank" rel="noopener noreffer ">gist</a>:</p>
<hr>
<script src="https://gist.github.com/sulrich/0f0c80f0844ca39b906fca98cc5f9ceb.js"></script>

<hr>
<p>before running the script the workflow sleeps for 120 seconds.  that&rsquo;s a buffer for the hugo build on cloudflare pages
plus cloudflare&rsquo;s edge propagation.  it&rsquo;s not strictly necessary - the script polls <code>feed.json</code> for each entry before
posting anyway - but it cuts down on wasted polls.</p>
<p>the workflow uses a github environment to gate access to the social account credentials.  the environment holds:</p>
<ul>
<li><code>MASTODON_INSTANCE_URL</code> (variable)</li>
<li><code>MASTODON_ACCESS_TOKEN</code> (secret)</li>
<li><code>BLUESKY_HANDLE</code> (variable)</li>
<li><code>BLUESKY_APP_PASSWORD</code> (secret)</li>
</ul>
<p>after a successful run, if <code>data/syndicated.json</code> has been modified the workflow commits it back to the repo.  the
commit message ends with <code>[skip ci]</code> so the resulting push doesn&rsquo;t trigger another run of the workflow.  without the
<code>[skip ci]</code> token the workflow would loop on its own state commits, burning CI minutes for no reason.</p>
<p>there&rsquo;s also a <code>workflow_dispatch</code> trigger with a <code>dry_run</code> input.  this lets me run the workflow from the github UI in
a non-destructive mode if i want to verify what would happen without actually posting.</p>
<h2 id="state-management">state management</h2>
<p><code>data/syndicated.json</code> is the source of truth for what&rsquo;s already been syndicated.  it looks roughly like this:</p>
<div class="code-block code-line-numbers open" style="counter-reset: code-block 0">
    <div class="code-header language-json">
        <span class="code-title"><i class="arrow fas fa-angle-right fa-fw" aria-hidden="true"></i></span>
        <span class="ellipses"><i class="fas fa-ellipsis-h fa-fw" aria-hidden="true"></i></span>
        <span class="copy" title="Copy to clipboard"><i class="far fa-copy fa-fw" aria-hidden="true"></i></span>
    </div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="cl"><span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;entries&#34;</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;https://botwerks.net/2026/05/20260522-130616/&#34;</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">      <span class="nt">&#34;posted_at&#34;</span><span class="p">:</span> <span class="s2">&#34;2026-05-22T18:30:42+00:00&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="nt">&#34;mastodon_id&#34;</span><span class="p">:</span> <span class="s2">&#34;112345678901234567&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="nt">&#34;bluesky_uri&#34;</span><span class="p">:</span> <span class="s2">&#34;at://did:plc:abc.../app.bsky.feed.post/xyz...&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="p">},</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;https://botwerks.net/2009/01/2009-01-01-some-old-post/&#34;</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">      <span class="nt">&#34;init&#34;</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="nt">&#34;posted_at&#34;</span><span class="p">:</span> <span class="s2">&#34;2026-05-30T17:49:36+00:00&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div></div>
<p>the canonical URL is the primary key.  there are two flavors of entry:</p>
<ul>
<li>normal entries have <code>posted_at</code>, <code>mastodon_id</code>, and <code>bluesky_uri</code></li>
<li>init entries (marked with <code>init: true</code>) were bulk-seeded when POSSE was first turned on.  these exist purely to
prevent the script from going back and posting every entry in the archive at once.  no sense spamming folks with old
news.</li>
</ul>
<p>the file is sorted alphabetically by key on write so diffs are readable when the workflow commits state updates.  you
can scroll the file and see the chronology of new posts at the bottom (sort by key happens to roughly track sort by
date because of the date-based slugs).</p>
<h2 id="opting-out">opting out</h2>
<p>there are a few knobs to keep something from being syndicated:</p>
<ul>
<li><code>draft: true</code> in frontmatter (also keeps it out of the build, obviously)</li>
<li><code>syndicate: false</code> in frontmatter for a specific entry</li>
<li>for <code>content/links/</code> entries: leave off the <code>syndicate</code> tag.  by default link posts don&rsquo;t syndicate</li>
</ul>
<p>future-dated posts also don&rsquo;t get syndicated until they&rsquo;re actually live.  this matters because i sometimes write
something now and schedule it for later.  (ha! in theory)</p>
<h2 id="bootstrap">bootstrap</h2>
<p>the script has an <code>--init</code> flag.  this is what i used to backfill the state file with every existing post on the site
before turning on the action.  without that step, the first real run would have tried to syndicate seventeen years of
archive in one go.</p>
<div class="code-block code-line-numbers open" style="counter-reset: code-block 0">
    <div class="code-header language-">
        <span class="code-title"><i class="arrow fas fa-angle-right fa-fw" aria-hidden="true"></i></span>
        <span class="ellipses"><i class="fas fa-ellipsis-h fa-fw" aria-hidden="true"></i></span>
        <span class="copy" title="Copy to clipboard"><i class="far fa-copy fa-fw" aria-hidden="true"></i></span>
    </div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-fallback" data-lang="fallback"><span class="line"><span class="cl">uv run bin/posse.py --init</span></span></code></pre></div></div>
<p>the script also takes <code>--dry-run</code> which prints what it would post without making API calls or modifying state.  i
leaned on this heavily during setup.</p>
<h2 id="what-id-change">what i&rsquo;d change</h2>
<p>honestly, i&rsquo;m not sure.  it&rsquo;s all a little new and i don&rsquo;t really anticipate &ldquo;syndicating&rdquo; much.  the polling-for-live
behavior is the part i&rsquo;m least sure about.  it would be cleaner to have the deploy notify the script directly when a new
entry is live, rather than the script polling for the entry to appear. but the polling is cheap, it works, and it
doesn&rsquo;t require me to run a service somewhere to receive the webhook. simple wins.</p>
<p>it might also be nice to surface failures more loudly.  if a post fails to syndicate the workflow run gets marked as
failed, but i have to actually go look at github actions to notice.  some kind of notification would be useful, but i&rsquo;d
rather not add more infrastructure for what is a very low-volume tool.</p>
]]></description>
</item>
</channel>
</rss>
