Careers jobs sync: GitHub setup

For Oleg. You do all of this on your own GitHub; no one else needs access. It takes about 10 minutes and is free.

What this does

The careers page loads its jobs from Ashby with JavaScript, so AI crawlers see an empty list. A small GitHub Action runs every 6 hours, reads the open jobs from Ashby's public job board, and writes them into the Jobs Snapshot field of our Framer component. Framer then puts the jobs into the page HTML.

Fast way: let Claude do it

Copy the prompt, paste it into Claude (or any agent with terminal access) on your machine. It creates the private repo, adds the two files, sets the secret and runs the first sync. The key is typed into a hidden prompt, never into the chat. Replace PASTE_FRAMER_PROJECT_LINK_HERE first, or tell Claude the link when it asks.
I need to set up an automatic job sync on my own GitHub. Use the GitHub CLI (gh) in a terminal on my machine. If gh or git is missing or not logged in, help me fix that first (gh auth login). Do the steps one at a time and confirm each result before moving on.

Framer project link: PASTE_FRAMER_PROJECT_LINK_HERE (if this still says PASTE_, ask me for the link before step 3).

1. Work out OWNER first (gh api user --jq .login, or my organization if I say so). Create a PRIVATE repository named careers-jobs-sync with a README, under my account or my company's organization (ask me which owner if it is not obvious), then clone it locally.
   gh repo create OWNER/careers-jobs-sync --private --add-readme --clone
2. In the repo root create sync-ashby-snapshot.mjs with the exact contents of FILE 1 below.
3. Create .github/workflows/sync-ashby-jobs.yml with the exact contents of FILE 2 below, with PASTE_FRAMER_PROJECT_LINK_HERE replaced by the Framer project link. Commit both files and push to the default branch (git add, git commit, git push). Confirm with gh that both files exist on the default branch.
4. Add the repository secret FRAMER_API_KEY. Run this and let me paste the key at its hidden prompt myself:
   gh secret set FRAMER_API_KEY --repo OWNER/careers-jobs-sync
   Never ask me to paste the key into this chat, never print it, never write it to a file. If you cannot run an interactive command, tell me the exact command and I will run it in my own terminal.
5. Check Actions is enabled for the repo (gh api repos/OWNER/careers-jobs-sync/actions/permissions). If it is disabled, or blocked by an organization policy, tell me exactly what to change.
   Then run the workflow once and watch it:
   gh workflow run sync-ashby-jobs.yml --repo OWNER/careers-jobs-sync
   Wait about 10 seconds, find the run with: gh run list --workflow sync-ashby-jobs.yml --repo OWNER/careers-jobs-sync --limit 1
   Then: gh run watch RUN_ID --repo OWNER/careers-jobs-sync --exit-status
   Show me the final status. If it fails, show me the log (gh run view --log-failed) and help me fix it.
6. When the run is green, tell me to message Sam: "done, first run is green".

===== FILE 1: sync-ashby-snapshot.mjs =====
// Keeps the AI-crawler copy of the Careers jobs up to date, with no copy and paste.
//
// Every run it reads the live jobs from Ashby and writes them into the
// "Jobs Snapshot" field of every ResponsiveJobBoard on the Framer site.
// It NEVER publishes, so half-finished edits in Framer never go live because of it.
// Whenever anyone publishes the site as normal, the HTML that AI crawlers read
// already has the latest jobs (at most one run old). Visitors always see live jobs.
//
// Env: FRAMER_PROJECT_URL, FRAMER_API_KEY (Framer > Site Settings > General > API)
//      AUTO_PUBLISH=true (optional) also publishes the site when the jobs changed.

import { connect } from "framer-api"

// --- sync logic start ---
async function syncJobBoards(framer, log = console.log) {
    const boards = (await framer.getNodesWithType("ComponentInstanceNode")).filter(
        (n) => n.controls && "jobsSnapshot" in n.controls
    )
    if (boards.length === 0) throw new Error("No ResponsiveJobBoard found in the project")

    let updated = 0
    for (const board of boards) {
        const name = board.controls?.jobBoardName
        if (!name) {
            log(`Skip ${board.id}: Job Board Name is empty`)
            continue
        }
        const res = await fetch(
            `https://api.ashbyhq.com/posting-api/job-board/${encodeURIComponent(name)}?includeCompensation=true`
        )
        if (!res.ok) throw new Error(`Ashby API ${res.status} for "${name}"`)
        const data = await res.json()
        const listed = (data.jobs || []).filter((j) => j.isListed !== false)

        // An Ashby hiccup returning nothing shouldn't wipe the crawler copy.
        if (listed.length === 0) {
            log(`Skip ${board.id}: Ashby returned 0 roles for "${name}"`)
            continue
        }

        // Compact format the component reads from Jobs Snapshot.
        const snapshot = JSON.stringify({
            jobs: listed.map((j) => ({
                title: j.title,
                location: j.location || "Remote",
                applyUrl: j.applyUrl || j.jobUrl || "#",
                isListed: true,
            })),
        })

        if (board.controls?.jobsSnapshot === snapshot) {
            log(`No change for "${name}" (${listed.length} roles)`)
            continue
        }
        await board.setAttributes({ controls: { jobsSnapshot: snapshot } })
        updated++
        log(`Updated "${name}": ${listed.length} roles -> ${listed.map((j) => j.title).join(" | ")}`)
    }
    return updated
}
// --- sync logic end ---

const framer = await connect(process.env.FRAMER_PROJECT_URL, process.env.FRAMER_API_KEY)
try {
    const n = await syncJobBoards(framer)
    if (n && process.env.AUTO_PUBLISH === "true") {
        const { deployment } = await framer.publish()
        await framer.deploy(deployment.id)
        console.log("Published the site.")
    }
    console.log(n ? `Done. ${n} board(s) updated.` : "Done. Already up to date.")
} finally {
    await framer.disconnect()
}

===== FILE 2: .github/workflows/sync-ashby-jobs.yml =====
# Put this file at .github/workflows/sync-ashby-jobs.yml in a private GitHub repo,
# with sync-ashby-snapshot.mjs at the repo root.
# One repo secret is needed: FRAMER_API_KEY (Settings > Secrets and variables > Actions).
name: Sync Ashby jobs into Framer
on:
  schedule:
    - cron: "0 */6 * * *" # every 6 hours (GitHub may start it 5-30 min late)
  workflow_dispatch: {} # the "Run workflow" button, for a manual run
jobs:
  sync:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
      - run: npm i framer-api@5.1.0
      - run: node sync-ashby-snapshot.mjs
        env:
          FRAMER_PROJECT_URL: PASTE_FRAMER_PROJECT_LINK_HERE
          FRAMER_API_KEY: ${{ secrets.FRAMER_API_KEY }}
          AUTO_PUBLISH: "false" # "true" = also publish the site when jobs change
===== END OF FILES =====

Manual steps

  1. On GitHub, create a new private repository named careers-jobs-sync. Tick “Add a README”. (It must be private: public repos pause scheduled runs after 60 days without activity.)
  2. In the repo: Add file > Create new file. Name it sync-ashby-snapshot.mjs, paste File 1, and commit.
  3. Add file > Create new file. Name it .github/workflows/sync-ashby-jobs.yml (typing the slashes creates the folders), paste File 2, and replace PASTE_FRAMER_PROJECT_LINK_HERE with the Framer project link Sam gave you. Commit.
  4. Settings > Secrets and variables > Actions > New repository secret. Name: FRAMER_API_KEY. Value: the key from Sam's Slack message. Save.
  5. Actions tab > “Sync Ashby jobs into Framer” > Run workflow. (If Actions shows a button to enable workflows, click it first.)
  6. Wait about a minute. A green check means it worked. Then tell Sam: “done, first run is green”.
If the run is red: open it, copy the error text from the failed step and send it to Sam. The usual causes are a wrong project link or a typo in the secret name.

Files

sync-ashby-snapshot.mjs

Repo root.

// Keeps the AI-crawler copy of the Careers jobs up to date, with no copy and paste.
//
// Every run it reads the live jobs from Ashby and writes them into the
// "Jobs Snapshot" field of every ResponsiveJobBoard on the Framer site.
// It NEVER publishes, so half-finished edits in Framer never go live because of it.
// Whenever anyone publishes the site as normal, the HTML that AI crawlers read
// already has the latest jobs (at most one run old). Visitors always see live jobs.
//
// Env: FRAMER_PROJECT_URL, FRAMER_API_KEY (Framer > Site Settings > General > API)
//      AUTO_PUBLISH=true (optional) also publishes the site when the jobs changed.

import { connect } from "framer-api"

// --- sync logic start ---
async function syncJobBoards(framer, log = console.log) {
    const boards = (await framer.getNodesWithType("ComponentInstanceNode")).filter(
        (n) => n.controls && "jobsSnapshot" in n.controls
    )
    if (boards.length === 0) throw new Error("No ResponsiveJobBoard found in the project")

    let updated = 0
    for (const board of boards) {
        const name = board.controls?.jobBoardName
        if (!name) {
            log(`Skip ${board.id}: Job Board Name is empty`)
            continue
        }
        const res = await fetch(
            `https://api.ashbyhq.com/posting-api/job-board/${encodeURIComponent(name)}?includeCompensation=true`
        )
        if (!res.ok) throw new Error(`Ashby API ${res.status} for "${name}"`)
        const data = await res.json()
        const listed = (data.jobs || []).filter((j) => j.isListed !== false)

        // An Ashby hiccup returning nothing shouldn't wipe the crawler copy.
        if (listed.length === 0) {
            log(`Skip ${board.id}: Ashby returned 0 roles for "${name}"`)
            continue
        }

        // Compact format the component reads from Jobs Snapshot.
        const snapshot = JSON.stringify({
            jobs: listed.map((j) => ({
                title: j.title,
                location: j.location || "Remote",
                applyUrl: j.applyUrl || j.jobUrl || "#",
                isListed: true,
            })),
        })

        if (board.controls?.jobsSnapshot === snapshot) {
            log(`No change for "${name}" (${listed.length} roles)`)
            continue
        }
        await board.setAttributes({ controls: { jobsSnapshot: snapshot } })
        updated++
        log(`Updated "${name}": ${listed.length} roles -> ${listed.map((j) => j.title).join(" | ")}`)
    }
    return updated
}
// --- sync logic end ---

const framer = await connect(process.env.FRAMER_PROJECT_URL, process.env.FRAMER_API_KEY)
try {
    const n = await syncJobBoards(framer)
    if (n && process.env.AUTO_PUBLISH === "true") {
        const { deployment } = await framer.publish()
        await framer.deploy(deployment.id)
        console.log("Published the site.")
    }
    console.log(n ? `Done. ${n} board(s) updated.` : "Done. Already up to date.")
} finally {
    await framer.disconnect()
}

.github/workflows/sync-ashby-jobs.yml

Replace the project link before committing.

# Put this file at .github/workflows/sync-ashby-jobs.yml in a private GitHub repo,
# with sync-ashby-snapshot.mjs at the repo root.
# One repo secret is needed: FRAMER_API_KEY (Settings > Secrets and variables > Actions).
name: Sync Ashby jobs into Framer
on:
  schedule:
    - cron: "0 */6 * * *" # every 6 hours (GitHub may start it 5-30 min late)
  workflow_dispatch: {} # the "Run workflow" button, for a manual run
jobs:
  sync:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
      - run: npm i framer-api@5.1.0
      - run: node sync-ashby-snapshot.mjs
        env:
          FRAMER_PROJECT_URL: PASTE_FRAMER_PROJECT_LINK_HERE
          FRAMER_API_KEY: ${{ secrets.FRAMER_API_KEY }}
          AUTO_PUBLISH: "false" # "true" = also publish the site when jobs change

After setup