README
¶
feed2sky
Publishes RSS items to a PDS as app.bsky.feed.post records.
The feed carries the record key. bckt computes a
deterministic TID per post and emits it as <atproto:rkey> in the XML
namespace https://github.com/vrypan/bckt/ns/atproto; feed2sky reads
that key and writes the record at it. It never computes record keys of
its own.
The feed may also say which repo a post belongs to, as
<atproto:did>. Together the two identify a record exactly, which is
what lets feed2sky recognise a post that already exists somewhere other
than its own account — see Posts that already
exist.
Install
go install github.com/vrypan/feed2sky@latest # newest release
go install github.com/vrypan/feed2sky@v0.1.0 # a specific release
go install github.com/vrypan/feed2sky@main # current main branch
Check what you are running:
$ feed2sky version
feed2sky v0.1.0
go1.26.5 darwin/arm64
feed2sky --version prints the same thing, and daemon mode logs the
version on startup so it shows up in the journal.
Installed releases report their tag. A local go build reports the
commit it came from, with +dirty if the working tree had uncommitted
changes:
feed2sky v0.0.0-20260802191308-7501b6f7ca8e+dirty
Release builds can stamp an explicit version instead:
go build -ldflags "-X github.com/vrypan/feed2sky/cmd/feed2sky.version=v0.1.0" .
Prebuilt binaries
The Build binaries workflow produces archives for linux/amd64,
linux/arm64 and darwin/arm64. It is manual — nothing is built on
push. Run it from the Actions tab, picking the branch or tag to build in
the "Use workflow from" dropdown.
The binaries are static (CGO_ENABLED=0), so the Linux ones run on a
bare Raspberry Pi OS, Debian or Alpine image with no libc to match. Each
archive carries the binary, README, LICENSE, the example config and the
systemd unit, and the run publishes a SHA256SUMS alongside them.
Leaving the release input unchecked uploads the archives as workflow
artifacts. Ticking it also creates the GitHub release, which requires
the commit being built to carry a tag — either select the tag in the
dropdown, or run from a branch whose HEAD is that tagged commit. On an
untagged commit the workflow stops with an explanatory error rather than
publishing a release named after a branch.
To cross-compile the same thing yourself, e.g. for a 32-bit Pi Zero, which the workflow does not cover:
CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=6 go build -ldflags "-s -w" .
Configure
Copy feed2sky.example.toml to feed2sky.toml:
feed_url = "https://blog.vrypan.net/rss.xml"
pds = "https://pds.vrypan.net"
identifier = "blog.vrypan.net" # handle or DID
app_password = "xxxx-xxxx-xxxx-xxxx"
langs = ["en"]
An app password is enough — ordinary repo writes need no privileged
session. feed2sky.toml is gitignored; keep it that way.
Run
feed2sky --dry-run # print what would be published, write nothing
feed2sky # publish once and exit
feed2sky --update # also overwrite records that already exist
feed2sky --limit 5 # only the newest 5 feed items
feed2sky daemon # keep polling the feed
-config <path> points at a config other than ./feed2sky.toml.
Run --dry-run first. The first real run publishes everything
currently in the feed that has a valid rkey — feed depth is the
backfill boundary.
How it behaves
- Stateless. The PDS is the state. "Already published?" is answered
by an unauthenticated
com.atproto.repo.getRecord. No local database, cursor file or seen-list, so re-running is always safe. - Never overwrites. An existing record is skipped and logged
exists. Editing a blog post does not rewrite the live post unless you pass--update. - Writes with
putRecordat the feed-supplied rkey, so an interrupted run can be retried without double-posting. - Oldest first, so a backfill lands in the order the posts were written.
- Exits non-zero if any item failed, so cron wrappers notice.
Items whose <atproto:rkey> is missing, or is not exactly 13 characters
of the base32-sortable alphabet 234567abcdefghijklmnopqrstuvwxyz, are
skipped with a log line.
Posts that already exist
A blog post does not have to originate on the blog. at2bckt imports a Bluesky post into a bckt blog, and that post already exists as a record — under its original author's DID, at its original record key. Publishing it again would write someone else's post into your repo under their record key.
So the record key is not read alone. bckt emits the owning repo as
<atproto:did>, per item, defaulting to a channel-level
<atproto:did> for the blog's own account:
<channel>
<atproto:did>did:plc:blog</atproto:did>
<item>
<atproto:rkey>3mrdggl4va2ea</atproto:rkey> <!-- ours -->
</item>
<item>
<atproto:rkey>3ms6khrp53s2b</atproto:rkey>
<atproto:did>did:plc:someone-else</atproto:did> <!-- imported -->
</item>
</channel>
An item whose DID is not the account in feed2sky.toml is skipped and
logged:
skip published as did:plc:someone-else https://blog.example/imported/
It is skipped rather than published elsewhere because feed2sky holds credentials for exactly one account and cannot write to another repo in any case.
The DID in feed2sky.toml is the fallback, not the override:
- Item has no
<atproto:did>, and neither does the channel — the post is assumed to be yours and published as before. A feed that carries no DIDs at all behaves exactly as it did before the field existed, and costs no extra lookup. identifieris a handle — it is resolved once, with an unauthenticatedcom.atproto.identity.resolveHandle, and cached. A dry run still needs no session.- The lookup fails — the DIDs are ignored for that cycle and every item is treated as yours, which is the pre-existing behaviour. The per-item existence check still stands between a retry and a double post.
Record shapes
Chosen by whether the item has a <title>:
- Titled article — an
app.bsky.embed.externallink card carrying{uri, title, description}.textis the description, not the title: the card already displays the title, and clients clamp the card's description to about two lines whiletextalways renders in full. Items with no description fall back to the title. - Titleless note (micropost) —
textis the description flattened to plain text with the permalink appended as a link facet. If the item has an image it becomes anapp.bsky.embed.imagesembed, withaspectRatiotaken from the decoded image.
Shared: createdAt is the item's pubDate normalized to UTC RFC 3339;
text is capped at 300 graphemes and truncated on a word boundary;
langs comes from config.
Images
An item's image goes to the card as external.thumb for a titled
article, or becomes an app.bsky.embed.images embed for a titleless
note. The source is read from ordinary feed markup, in order:
<media:thumbnail url="…">(Media RSS)<media:content url="…" medium="image"><enclosure url="…" type="image/…">(RSS 2.0)
So a typical feed works as-is — <atproto:rkey> remains the only
bckt-specific requirement, <atproto:did> being optional. An enclosure
that declares a non-image type (a podcast episode) is ignored without
being fetched.
feed2sky then fetches the image and inspects the bytes, not the
feed's claims: it sniffs the real format, and if the image is already
within the lexicon's 1,000,000-byte cap it is uploaded untouched
(keeping PNG transparency). Oversized images are downscaled to fit a
1200 px box and re-encoded as JPEG, dropping quality and dimensions in
steps until they fit. The blob ref comes back from uploadBlob, so no
CID is ever computed locally.
If an image cannot be fetched or shrunk, it is logged as a warning and the post is published without it — a bad image never costs you the post.
Alt text comes from <media:description>, falling back to
<media:title>. Feeds supplying neither get an empty alt, which the
lexicon permits but screen readers cannot use.
Daemon mode
feed2sky daemon --interval 5m
Polls the feed and publishes anything new. poll_interval in the config
sets the default; --interval overrides it. The minimum is 30s.
It stays cheap and polite:
- Conditional GET. The
ETag/Last-Modifiedfrom the previous response are sent back asIf-None-Match/If-Modified-Since, so an unchanged feed costs a304and the cycle ends immediately. Nothing is logged for an unchanged feed. - One
listRecords, not onegetRecordper item. Existing rkeys are fetched in a single paged call and diffed in memory. IflistRecordsfails it falls back to per-item checks rather than risk republishing. - Sessions are refreshed, not recreated.
createSessionis rate limited (bsky.social documents roughly 30 per five minutes and a few hundred per day), which a polling daemon would otherwise exhaust. The daemon logs in once and then usescom.atproto.server.refreshSession, replaying any write that hit an expired token. A full login happens only if the refresh token itself expires.
Transient failures — feed 503, PDS timeout — are logged and retried with
exponential backoff from 30s to 15m, resetting on success. Only a fatal
error (bad credentials) stops the loop. SIGINT/SIGTERM finish the
item in flight and exit 0, so a deploy never lands between uploadBlob
and putRecord.
Nothing is persisted: cache validators and the session live in memory, and a restart costs one conditional GET and one login. The PDS remains the only record of what has been published.
To run it as a service, see deploy/systemd.
Not in v1
Deletes, and interactions (replies/likes back to the blog — that is
listnr's job). See plans/CLAUDE.md for the reasoning.
License
MIT — see LICENSE.
Develop
go build ./...
go vet ./...
go test ./...
gofmt -l .
The root main.go is a five-line shim so that
go install github.com/vrypan/feed2sky@latest keeps working; everything
else lives in cmd/feed2sky/.