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:
| Job | When | What it does |
|---|---|---|
| plan | every pull request | Shows 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. |
| rebuild | after merge | Plans, 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
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
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.
#!/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$ 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 applyState 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.