Sequoia and GitOps: Publishing to the ATmosphere from CI

atprotodevops

I wanted this blog to exist in two places: as static HTML on S3/CloudFront, and as standard.site documents on AT Protocol. The first part is a solved problem. The second part — publishing to the ATmosphere from a CI pipeline — took some figuring out.

Sequoia is the CLI that bridges the gap. It reads your markdown, extracts frontmatter, and publishes site.standard.document records to your PDS. Locally, it works great. Run sequoia publish, records appear on your PDS, readers like Leaflet can discover them. Done.

The trouble starts when you want push-to-publish.

The state problem

Sequoia tracks what it's published in two places:

  1. .sequoia-state.json — maps each post file to its AT Protocol URI and a content hash
  2. atUri in frontmatter — Sequoia writes the AT URI back into each post's YAML frontmatter after publishing

When content changes, Sequoia checks the atUri field in frontmatter to decide: update the existing record, or create a new one. No atUri? New record. This is fine when you're publishing from your laptop — the frontmatter gets modified in place and you commit it. In CI, the filesystem is ephemeral. Every run starts from a fresh checkout.

If your CI pipeline publishes with Sequoia but doesn't commit the changes back, the next run has no atUri fields, no state file, and Sequoia happily creates a second copy of every post. And a third. I can confirm this from experience.

The fix

Two things make it work:

1. Commit the state back. After Sequoia publishes, commit both .sequoia-state.json and the updated post files (which now contain atUri fields) back to the repo. Tag the commit with [skip ci] to avoid an infinite loop.

2. Serialize publishes. Use a concurrency group so only one publish runs at a time. Without this, rapid pushes can race — two CI runs checkout the same state, both create records, both try to commit back.

Here's the workflow:

name: Publish

on:
  push:
    branches: [main]

permissions:
  contents: write

concurrency:
  group: publish
  cancel-in-progress: true

jobs:
  publish:
    runs-on: ubuntu-latest
    if: "!contains(github.event.head_commit.message, '[skip ci]')"
    steps:
      - uses: actions/checkout@v4
        with:
          token: $
          ref: main
      - uses: actions/setup-node@v4
        with:
          node-version: 20
      - run: npm ci

      - name: Build and deploy
        run: |
          npx @11ty/eleventy
          aws s3 sync _site/ s3://$AWS_BUCKET_NAME --delete
          aws cloudfront create-invalidation \
            --distribution-id $AWS_DISTRIBUTION_ID --paths "/*"
        env:
          AWS_ACCESS_KEY_ID: $
          AWS_SECRET_ACCESS_KEY: $
          AWS_DEFAULT_REGION: us-east-1
          AWS_BUCKET_NAME: $
          AWS_DISTRIBUTION_ID: $

      - name: Publish to AT Protocol
        run: npx sequoia publish
        env:
          ATP_IDENTIFIER: $
          ATP_APP_PASSWORD: $
          PDS_URL: $

      - name: Commit Sequoia state
        run: |
          git config user.name "github-actions[bot]"
          git config user.email "github-actions[bot]@users.noreply.github.com"
          git add .sequoia-state.json src/posts/ || true
          if ! git diff --cached --quiet; then
            git commit -m "Update Sequoia state [skip ci]"
            git push
          fi

The critical line is git add .sequoia-state.json src/posts/. The state file alone isn't enough — Sequoia's update-vs-create decision depends on the atUri field in each post's frontmatter.

Sequoia config for nested content

If your content directory uses nested paths (like src/posts/2026/03/10-my-post.md), Sequoia's default slug derivation will include the directory structure. You need pathTemplate with a frontmatter slugField to produce clean URLs:

{
  "siteUrl": "https://example.com",
  "contentDir": "src/posts",
  "pathTemplate": "/posts/{year}-{month}-{day}-{slug}",
  "frontmatter": {
    "publishDate": "date",
    "slugField": "slug"
  }
}

Each post needs a slug field in frontmatter that matches its URL slug. Without this, a file at src/posts/2026/03/10-my-post.md would get the path /posts/2026-03-10-2026/03/10-my-post — the date tokens expand from the date field, then {slug} appends the full relative file path.

Auth in CI

Sequoia reads ATP_IDENTIFIER and ATP_APP_PASSWORD from environment variables. One gotcha: if your Bluesky account is on a specific PDS (not bsky.social), you also need PDS_URL. Without it, Sequoia tries to authenticate against bsky.social and fails with "Invalid identifier or password" even though the credentials are correct.

You can find your PDS URL by resolving your DID:

curl https://plc.directory/did:plc:your-did-here | jq '.service[0].serviceEndpoint'

Use your DID (not your handle) as the ATP_IDENTIFIER for the most reliable authentication.

The result

Push to main, everything publishes. Edit a post, push again, the existing AT Protocol record updates in place. The blog exists as HTML at its domain and as discoverable documents in the ATmosphere. Readers on Leaflet or any future standard.site-compatible app can find and follow it.

The setup is rougher than it should be — the duplicate record problem in particular is a trap that anyone running Sequoia in CI will hit. But the underlying model is right: your content lives in git, your PDS gets the structured version, and CI is the glue.