Syndicate Content to Social Media #785
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: Syndicate Content to Social Media | |
| on: | |
| # Webhook trigger from Netlify after successful deploy | |
| repository_dispatch: | |
| types: [netlify-deploy-succeeded] | |
| # Manual trigger for testing | |
| workflow_dispatch: | |
| inputs: | |
| content_type: | |
| description: "Content type to syndicate" | |
| required: false | |
| default: "both" | |
| type: choice | |
| options: | |
| - posts | |
| - links | |
| - both | |
| test_mode: | |
| description: "Run in test mode (no actual posts)" | |
| required: false | |
| default: false | |
| type: boolean | |
| bootstrap_cache_before: | |
| description: "Seed cache for items published before this date (YYYY-MM-DD)" | |
| required: false | |
| default: "" | |
| type: string | |
| bootstrap_cache_only: | |
| description: "Only seed the cache and skip syndication" | |
| required: false | |
| default: false | |
| type: boolean | |
| siteName: | |
| description: "Netlify site name (auto-populated)" | |
| required: false | |
| default: "" | |
| type: string | |
| deployPrimeUrl: | |
| description: "Netlify deploy preview URL" | |
| required: false | |
| default: "" | |
| type: string | |
| commit: | |
| description: "Commit SHA for the deploy" | |
| required: false | |
| default: "" | |
| type: string | |
| max_items_per_run: | |
| description: "Max feed items to syndicate per content type per run (0 = no limit)" | |
| required: false | |
| default: "1" | |
| type: string | |
| # Scheduled weekday check at 18:30 UTC (11:30am PDT / 10:30am PST) | |
| schedule: | |
| - cron: "30 18 * * 1-5" | |
| # Only allow one syndication run at a time per branch to prevent concurrent runs | |
| # from reading the same stale cache and sending duplicate posts to social platforms. | |
| # cancel-in-progress: false ensures queued runs wait for the current run to finish | |
| # (rather than being cancelled) so they pick up the updated cache and skip | |
| # already-syndicated items. | |
| concurrency: | |
| group: syndication-${{ github.ref_name }} | |
| cancel-in-progress: false | |
| jobs: | |
| syndicate: | |
| if: github.ref_name == 'main' | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Checkout repository | |
| uses: actions/checkout@v6 | |
| - name: Setup Node.js | |
| uses: actions/setup-node@v6 | |
| with: | |
| node-version: "20" | |
| - name: Install dependencies | |
| run: | | |
| # Only install the minimal dependencies needed for syndication | |
| npm install node-fetch@3 html-to-text | |
| - name: Resolve latest processed items cache key | |
| id: syndication-cache-latest | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| run: | | |
| key_prefix="syndication-cache-${{ github.ref_name }}-" | |
| latest_key=$(curl -fsSL -G \ | |
| -H "Authorization: Bearer $GH_TOKEN" \ | |
| -H "Accept: application/vnd.github+json" \ | |
| --data-urlencode "per_page=100" \ | |
| --data-urlencode "key=${key_prefix}" \ | |
| --data-urlencode "ref=${{ github.ref }}" \ | |
| "${{ github.api_url }}/repos/${{ github.repository }}/actions/caches" | jq -r '.actions_caches | sort_by(.created_at) | last | .key // empty') | |
| if [ -n "$latest_key" ]; then | |
| echo "latest_key=$latest_key" >> "$GITHUB_OUTPUT" | |
| echo "Resolved latest cache key: $latest_key" | |
| else | |
| echo "latest_key=${key_prefix}" >> "$GITHUB_OUTPUT" | |
| echo "No existing cache key found for prefix: $key_prefix" | |
| fi | |
| - name: Setup environment | |
| run: | | |
| echo "SITE_URL=https://www.aaron-gustafson.com" >> $GITHUB_ENV | |
| echo "POSTS_FEED_URL=https://www.aaron-gustafson.com/feeds/latest-posts.json" >> $GITHUB_ENV | |
| echo "LINKS_FEED_URL=https://www.aaron-gustafson.com/feeds/latest-links.json" >> $GITHUB_ENV | |
| echo "TEST_MODE=${{ github.event.inputs.test_mode || 'false' }}" >> $GITHUB_ENV | |
| echo "BOOTSTRAP_CACHE_BEFORE=${{ github.event.inputs.bootstrap_cache_before || '' }}" >> $GITHUB_ENV | |
| echo "BOOTSTRAP_CACHE_CONTENT_TYPE=${{ github.event.inputs.content_type || 'both' }}" >> $GITHUB_ENV | |
| # Safety guard: prevent accidental real posts when running on a non-main branch. | |
| # This can happen when workflow_dispatch is triggered on a feature or Copilot branch | |
| # that has no syndication cache — every feed item would appear "new" and get posted. | |
| - name: Enforce test mode on non-main branches | |
| if: github.ref_name != 'main' | |
| run: | | |
| echo "⚠️ Running on non-main branch ('${{ github.ref_name }}'). Forcing TEST_MODE=true to prevent real posts." | |
| echo "TEST_MODE=true" >> $GITHUB_ENV | |
| # Restore cache before processing to avoid duplicates | |
| - name: Restore processed items cache | |
| id: syndication-cache | |
| uses: actions/cache/restore@v5 | |
| with: | |
| path: .github/cache | |
| key: ${{ steps.syndication-cache-latest.outputs.latest_key }} | |
| restore-keys: | | |
| syndication-cache-${{ github.ref_name }}- | |
| - name: Inspect restored cache | |
| if: always() | |
| run: node .github/scripts/inspect-syndication-cache.js | |
| - name: Seed processed items cache | |
| if: github.event_name == 'workflow_dispatch' && github.event.inputs.bootstrap_cache_before != '' | |
| run: node .github/scripts/bootstrap-syndication-cache.js | |
| - name: Inspect cache after seeding | |
| if: always() && github.event_name == 'workflow_dispatch' && github.event.inputs.bootstrap_cache_before != '' | |
| run: node .github/scripts/inspect-syndication-cache.js | |
| # Check for new posts | |
| - name: Syndicate Posts | |
| if: github.event.inputs.bootstrap_cache_only != 'true' && github.event.inputs.content_type != 'links' | |
| run: node .github/scripts/syndicate-posts.js | |
| env: | |
| # Mastodon API | |
| MASTODON_ACCESS_TOKEN: ${{ secrets.MASTODON_ACCESS_TOKEN }} | |
| MASTODON_SERVER_URL: ${{ secrets.MASTODON_SERVER_URL }} | |
| # Buffer API for Twitter/Bluesky | |
| BUFFER_ACCESS_TOKEN: ${{ secrets.BUFFER_ACCESS_TOKEN }} | |
| BUFFER_TWITTER_PROFILE_ID: ${{ secrets.BUFFER_TWITTER_PROFILE_ID }} | |
| BUFFER_BLUESKY_PROFILE_ID: ${{ secrets.BUFFER_BLUESKY_PROFILE_ID }} | |
| # IFTTT Webhook (fallback) | |
| IFTTT_KEY: ${{ secrets.IFTTT_KEY }} | |
| # Stagger: max items to post per platform per run (0 = no limit) | |
| MAX_ITEMS_PER_RUN: ${{ github.event.inputs.max_items_per_run || '1' }} | |
| # Check for new links | |
| - name: Syndicate Links | |
| if: github.event.inputs.bootstrap_cache_only != 'true' && github.event.inputs.content_type != 'posts' | |
| run: node .github/scripts/syndicate-links.js | |
| env: | |
| # Mastodon API | |
| MASTODON_ACCESS_TOKEN: ${{ secrets.MASTODON_ACCESS_TOKEN }} | |
| MASTODON_SERVER_URL: ${{ secrets.MASTODON_SERVER_URL }} | |
| # Buffer API for Twitter/Bluesky | |
| BUFFER_ACCESS_TOKEN: ${{ secrets.BUFFER_ACCESS_TOKEN }} | |
| BUFFER_TWITTER_PROFILE_ID: ${{ secrets.BUFFER_TWITTER_PROFILE_ID }} | |
| BUFFER_BLUESKY_PROFILE_ID: ${{ secrets.BUFFER_BLUESKY_PROFILE_ID }} | |
| # IFTTT Webhook (fallback) | |
| IFTTT_KEY: ${{ secrets.IFTTT_KEY }} | |
| # Stagger: max items to post per platform per run (0 = no limit) | |
| MAX_ITEMS_PER_RUN: ${{ github.event.inputs.max_items_per_run || '1' }} | |
| # Save updated cache after processing | |
| - name: Compute processed items cache key | |
| id: syndication-cache-key | |
| if: always() | |
| run: | | |
| if [ -f .github/cache/syndication-status.json ]; then | |
| cache_hash=$(sha256sum .github/cache/syndication-status.json | cut -d' ' -f1) | |
| else | |
| cache_hash=empty | |
| fi | |
| echo "key=syndication-cache-${{ github.ref_name }}-${cache_hash}" >> "$GITHUB_OUTPUT" | |
| - name: Inspect cache before save | |
| if: always() | |
| run: | | |
| echo "restored_key=${{ steps.syndication-cache.outputs.cache-matched-key }}" | |
| echo "computed_key=${{ steps.syndication-cache-key.outputs.key }}" | |
| node .github/scripts/inspect-syndication-cache.js | |
| - name: Save processed items cache | |
| uses: actions/cache/save@v5 | |
| if: always() && steps.syndication-cache.outputs.cache-matched-key != steps.syndication-cache-key.outputs.key | |
| with: | |
| path: .github/cache | |
| key: ${{ steps.syndication-cache-key.outputs.key }} |