Repository navigation
Publish Episodes to ATProto #348
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 }} |