Skip to content

Publish Episodes to ATProto #348

Publish Episodes to ATProto

Publish Episodes to ATProto #348

name: Publish Episodes to ATProto
on:
# Poll the RSS feed for new episodes. Offset from the top of the hour —
# GitHub delays cron runs scheduled at :00/:30 the most.
schedule:
- cron: "7,37 * * * *"
# Allow manual trigger (skips the feed-changed gate)
workflow_dispatch:
concurrency:
group: publish-episodes
cancel-in-progress: false
jobs:
# Gate: skip publishing entirely if standard.site/ATProto secrets aren't
# configured (so forks without standard.site set up don't fail this
# workflow), and skip scheduled runs when the feed hasn't changed since the
# last successful publish.
gate:
runs-on: ubuntu-latest
outputs:
configured: ${{ steps.config.outputs.configured }}
changed: ${{ steps.feed.outputs.changed }}
feed-hash: ${{ steps.feed.outputs.hash }}
rebuild-triggered: ${{ steps.rebuild.outputs.triggered }}
steps:
- name: Check ATProto configuration
id: config
env:
ATPROTO_HANDLE: ${{ secrets.ATPROTO_HANDLE }}
ATPROTO_APP_PASSWORD: ${{ secrets.ATPROTO_APP_PASSWORD }}
STANDARD_SITE_URL: ${{ secrets.STANDARD_SITE_URL }}
STANDARD_SITE_PUBLICATION_RKEY: ${{ secrets.STANDARD_SITE_PUBLICATION_RKEY }}
run: |
if [ -n "$ATPROTO_HANDLE" ] && [ -n "$ATPROTO_APP_PASSWORD" ] && [ -n "$STANDARD_SITE_URL" ] && [ -n "$STANDARD_SITE_PUBLICATION_RKEY" ]; then
echo "configured=true" >> "$GITHUB_OUTPUT"
else
echo "configured=false" >> "$GITHUB_OUTPUT"
echo "::notice::standard.site/ATProto secrets not set — skipping episode publishing."
fi
- uses: actions/checkout@v4
if: steps.config.outputs.configured == 'true'
- name: Restore last published feed hash
if: steps.config.outputs.configured == 'true'
uses: actions/cache/restore@v4
with:
path: .feed-hash
key: feed-hash-
restore-keys: |
feed-hash-
- name: Check feed for changes
if: steps.config.outputs.configured == 'true'
id: feed
run: |
set -euo pipefail
FEED_URL=$(sed -n "s/.*rssFeed: *['\"]\([^'\"]*\)['\"].*/\1/p" starpod.config.ts | head -1)
if [ -z "$FEED_URL" ]; then
echo "::warning::Could not extract rssFeed from starpod.config.ts — publishing unconditionally."
echo "changed=true" >> "$GITHUB_OUTPUT"
exit 0
fi
if ! FEED_BODY=$(curl -sfL "$FEED_URL"); then
echo "::warning::Could not fetch RSS feed — publishing unconditionally."
echo "changed=true" >> "$GITHUB_OUTPUT"
exit 0
fi
HASH=$(printf '%s' "$FEED_BODY" | sha256sum | cut -d' ' -f1)
echo "hash=$HASH" >> "$GITHUB_OUTPUT"
PREV=$(cat .feed-hash 2>/dev/null || true)
if [ "$HASH" = "$PREV" ]; then
echo "Feed unchanged since last publish."
echo "changed=false" >> "$GITHUB_OUTPUT"
else
echo "Feed changed (or no previous hash) — publishing."
echo "changed=true" >> "$GITHUB_OUTPUT"
fi
# Kick off the Vercel rebuild as soon as a feed change is detected, so
# the site build runs while the publish job is installing dependencies.
# The publish script then waits for the new episode pages to be live
# before publishing documents that link to them.
#
# The rebuild-hash cache tracks which feed content we already rebuilt
# for. The publish-success hash (.feed-hash) is intentionally separate:
# a persistently failing publish must keep retrying, but must not
# trigger a fresh Vercel build on every 30-minute poll.
- name: Restore last rebuild trigger hash
if: steps.config.outputs.configured == 'true'
uses: actions/cache/restore@v4
with:
path: .rebuild-hash
key: rebuild-hash-
restore-keys: |
rebuild-hash-
- name: Trigger site rebuild
if: steps.config.outputs.configured == 'true' && steps.feed.outputs.changed == 'true'
id: rebuild
env:
REBUILD_WEBHOOK: ${{ secrets.REBUILD_WEBHOOK }}
FEED_HASH: ${{ steps.feed.outputs.hash }}
run: |
if [ -z "$REBUILD_WEBHOOK" ]; then
echo "triggered=false" >> "$GITHUB_OUTPUT"
echo "fired=false" >> "$GITHUB_OUTPUT"
echo "::notice::REBUILD_WEBHOOK not set — publishing without waiting for a site rebuild."
exit 0
fi
if [ -n "$FEED_HASH" ] && [ "$FEED_HASH" = "$(cat .rebuild-hash 2>/dev/null || true)" ]; then
# triggered=true so the publish script still waits for the pages
# from that earlier build before publishing.
echo "triggered=true" >> "$GITHUB_OUTPUT"
echo "fired=false" >> "$GITHUB_OUTPUT"
echo "Site already rebuilt for this feed content — not triggering again."
exit 0
fi
curl -sf -X POST "$REBUILD_WEBHOOK" > /dev/null
echo "triggered=true" >> "$GITHUB_OUTPUT"
echo "fired=true" >> "$GITHUB_OUTPUT"
echo "Triggered site rebuild."
- name: Record rebuild trigger hash
if: steps.rebuild.outputs.fired == 'true' && steps.feed.outputs.hash != ''
run: printf '%s' "${{ steps.feed.outputs.hash }}" > .rebuild-hash
- name: Save rebuild trigger hash
if: steps.rebuild.outputs.fired == 'true' && steps.feed.outputs.hash != ''
uses: actions/cache/save@v4
with:
path: .rebuild-hash
key: rebuild-hash-${{ steps.feed.outputs.hash }}-${{ github.run_id }}-${{ github.run_attempt }}
publish:
needs: gate
if: ${{ needs.gate.outputs.configured == 'true' && (github.event_name == 'workflow_dispatch' || needs.gate.outputs.changed == 'true') }}
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- uses: pnpm/action-setup@v4
- name: Get pnpm store directory
shell: bash
run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- uses: actions/cache@v4
name: Setup pnpm cache
with:
path: ${{ env.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Install dependencies
run: pnpm install
- name: Publish new episodes
run: pnpm tsx scripts/publish-atproto-episodes.ts
env:
ATPROTO_HANDLE: ${{ secrets.ATPROTO_HANDLE }}
ATPROTO_APP_PASSWORD: ${{ secrets.ATPROTO_APP_PASSWORD }}
STANDARD_SITE_URL: ${{ secrets.STANDARD_SITE_URL }}
STANDARD_SITE_PUBLICATION_RKEY: ${{ secrets.STANDARD_SITE_PUBLICATION_RKEY }}
WAIT_FOR_SITE: ${{ needs.gate.outputs.rebuild-triggered }}
# Record the feed hash only after a successful publish, so a failed run
# is retried on the next scheduled poll.
- name: Record published feed hash
if: ${{ needs.gate.outputs.feed-hash != '' }}
run: printf '%s' "${{ needs.gate.outputs.feed-hash }}" > .feed-hash
- name: Save feed hash
if: ${{ needs.gate.outputs.feed-hash != '' }}
uses: actions/cache/save@v4
with:
path: .feed-hash
# Include run id/attempt so re-publishing a previously seen hash
# still saves (cache keys are immutable); the prefix restore picks
# the most recently created entry.
key: feed-hash-${{ needs.gate.outputs.feed-hash }}-${{ github.run_id }}-${{ github.run_attempt }}