CI/CD

Plan every pull request, then rebuild and commit after merge. Examples for GitHub Actions, GitLab CI and any other CI.

Two jobs

Every setup below has the same two jobs:

JobWhenWhat it does
planevery pull requestShows what the change rebuilds, and fails the PR if it costs more than --budget. Read-only: it never commits, so it needs a token without state:commit.
rebuildafter mergePlans, runs the plan, and commits with --expect-version only if the run succeeded. One at a time; exit 3 means another commit landed first, so re-plan.

The examples run the plan's SQL with scripts/run-plan.sh from Run it on your cluster. Swap that step for freshet submit or your Airflow trigger; the commit step stays the same.

GitHub Actions

.github/workflows/freshet.yml
# .github/workflows/freshet.yml
name: freshet
on:
  pull_request:
    paths: ["models/**"]
  push:
    branches: [main]
    paths: ["models/**"]

env:
  FRESHET_VERSION: "0.1.0"
  STATE: https://<your-edge>/state/v1        # or a file path; see "State files in CI"

jobs:
  # Every PR: what would this rebuild, and does it fit the budget? Never commits.
  plan:
    if: github.event_name == 'pull_request'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Install freshet
        run: |
          curl -sSL "https://github.com/lucheeseng827/freshet/releases/download/v$FRESHET_VERSION/freshet-v$FRESHET_VERSION-x86_64-unknown-linux-musl.tar.gz" | tar xz
          echo "$PWD/freshet-v$FRESHET_VERSION-x86_64-unknown-linux-musl" >> "$GITHUB_PATH"
      - name: Plan (fails over budget)
        env:
          FRESHET_STATE_TOKEN: ${{ secrets.FRESHET_READ_TOKEN }}   # no state:commit
        run: |
          set -o pipefail
          echo '```text' >> "$GITHUB_STEP_SUMMARY"
          freshet plan --project models --state "$STATE" --budget 50 | tee -a "$GITHUB_STEP_SUMMARY"
          echo '```' >> "$GITHUB_STEP_SUMMARY"

  # main: plan, run, then commit what actually ran.
  rebuild:
    if: github.event_name == 'push'
    runs-on: ubuntu-latest
    concurrency: freshet-rebuild               # one rebuild at a time
    environment: production
    env:
      FRESHET_STATE_TOKEN: ${{ secrets.FRESHET_STATE_TOKEN }}   # with state:commit
    steps:
      - uses: actions/checkout@v4
      - name: Install freshet
        run: |
          curl -sSL "https://github.com/lucheeseng827/freshet/releases/download/v$FRESHET_VERSION/freshet-v$FRESHET_VERSION-x86_64-unknown-linux-musl.tar.gz" | tar xz
          echo "$PWD/freshet-v$FRESHET_VERSION-x86_64-unknown-linux-musl" >> "$GITHUB_PATH"
      - name: Plan
        id: plan
        run: |
          freshet plan --project models --state "$STATE" --sql postgres --json > plan.json 2> plan.err
          cat plan.err
          echo "version=$(grep -oE 'version ([0-9]+|none)' plan.err | head -1 | cut -d' ' -f2 || true)" >> "$GITHUB_OUTPUT"
      - name: Run
        env:
          DATABASE_URL: ${{ secrets.DATABASE_URL }}
        run: ./scripts/run-plan.sh plan.json   # or freshet submit, or your Airflow trigger
      - name: Commit (only after the run succeeded)
        env:
          VERSION: ${{ steps.plan.outputs.version }}   # empty for a state file
        run: freshet plan --project models --state "$STATE" --commit ${VERSION:+--expect-version "$VERSION"}

Pull requests from forks don't receive secrets, so the plan job can't read hosted state there. Run it on branches in the repository, or give it a state file.

GitLab CI

.gitlab-ci.yml
# .gitlab-ci.yml
variables:
  FRESHET_VERSION: "0.1.0"
  STATE: https://<your-edge>/state/v1

.freshet:
  image: debian:bookworm-slim
  before_script:
    - apt-get update -q && apt-get install -y -q curl ca-certificates jq postgresql-client
    - curl -sSL "https://github.com/lucheeseng827/freshet/releases/download/v$FRESHET_VERSION/freshet-v$FRESHET_VERSION-x86_64-unknown-linux-musl.tar.gz" | tar xz
    - export PATH="$PWD/freshet-v$FRESHET_VERSION-x86_64-unknown-linux-musl:$PATH"

freshet:plan:
  extends: .freshet
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
      changes: [models/**/*]
  variables:
    FRESHET_STATE_TOKEN: $FRESHET_READ_TOKEN      # masked CI/CD variable, no state:commit
  script:
    - freshet plan --project models --state "$STATE" --budget 50

freshet:rebuild:
  extends: .freshet
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
      changes: [models/**/*]
  resource_group: freshet-state                 # one rebuild at a time
  environment: production
  script:
    - freshet plan --project models --state "$STATE" --sql postgres --json > plan.json 2> plan.err
    - cat plan.err
    - VERSION=$(grep -oE 'version ([0-9]+|none)' plan.err | head -1 | cut -d' ' -f2 || true)
    - ./scripts/run-plan.sh plan.json
    - freshet plan --project models --state "$STATE" --commit ${VERSION:+--expect-version "$VERSION"}

resource_group serialises rebuilds the way concurrency does on GitHub. Store both tokens as masked CI/CD variables.

Any other CI

The same two jobs as one script. apply re-plans and re-runs up to three times if another commit lands while it runs, and passes any other failure straight through.

scripts/freshet-ci.sh
#!/usr/bin/env bash
# freshet-ci.sh plan|apply — the same two jobs for any CI (Jenkins, Buildkite, CircleCI…).
#   FRESHET_STATE    a state file, or https://<your-edge>/state/v1 (+ FRESHET_STATE_TOKEN)
#   FRESHET_PROJECT  the models directory (default: models)
#   FRESHET_BUDGET   optional CU cap for `plan`
#   FRESHET_DIALECT  --sql dialect for `apply` (default: postgres)
#   FRESHET_RUNNER   the command that runs plan.json, and exits non-zero if the run failed
set -euo pipefail
case "${1:-}" in plan|apply) ;; *) echo "usage: $0 plan|apply" >&2; exit 2 ;; esac
: "${FRESHET_STATE:?set FRESHET_STATE}" "${FRESHET_RUNNER:=./scripts/run-plan.sh}"
project=${FRESHET_PROJECT:-models}

case "$1" in
  plan)
    freshet plan --project "$project" --state "$FRESHET_STATE" ${FRESHET_BUDGET:+--budget "$FRESHET_BUDGET"}
    ;;
  apply)
    for attempt in 1 2 3; do
      freshet plan --project "$project" --state "$FRESHET_STATE" \
        --sql "${FRESHET_DIALECT:-postgres}" --json > plan.json 2> plan.err
      cat plan.err >&2
      # hosted state prints the version it planned against; a state file prints none
      version=$(grep -oE 'version ([0-9]+|none)' plan.err | head -1 | cut -d' ' -f2 || true)
      # A failed run exits 1 whatever the runner returned (psql uses 3), so 3 below
      # can only mean "state moved".
      "$FRESHET_RUNNER" plan.json || { echo "run failed; nothing committed" >&2; exit 1; }
      rc=0
      freshet plan --project "$project" --state "$FRESHET_STATE" --commit \
        ${version:+--expect-version "$version"} || rc=$?
      [ "$rc" = 3 ] || exit "$rc"        # 0: committed; anything but 3: a real failure
      echo "state moved while this ran; re-planning (attempt $attempt)" >&2
    done
    exit 3
    ;;
esac
use it
$ FRESHET_STATE=https://<your-edge>/state/v1 FRESHET_BUDGET=50 ./scripts/freshet-ci.sh plan
$ FRESHET_STATE=https://<your-edge>/state/v1 ./scripts/freshet-ci.sh apply

State files in CI

With --state state.json instead of hosted state, the file has to outlive the job: commit it back to the repository or keep it in a bucket, and let only one rebuild run at a time (concurrency / resource_group). A file is last-writer-wins, so --expect-version doesn't apply, and the script above skips it. Two teams, or two pipelines, writing the same state is the point to move to hosted state.