Skip to main content
How-to

Schedule Reports with cron and GitHub Actions

Overview

uc-bq run and uc-bq run-all are ordinary Node.js. They need no browser session, no Claude Code, and no model API key unless you want written analysis, which makes them safe to put behind a scheduler.

Two things have to change compared with running commands by hand. Authentication moves from your personal Google credentials to a service account, and secrets move into whatever secret store your scheduler provides.

Authenticate with a service account

A scheduled run cannot open a browser, so the interactive gcloud auth application-default login flow from Getting started is not an option.

Create a service account in your own Google Cloud project, which is separate from UltraCart's:

gcloud iam service-accounts create uc-bq-reader \
--display-name="UltraCart BQ Reader" \
--project=YOUR_PROJECT_ID

gcloud iam service-accounts keys create ./sa-key.json \
--iam-account=uc-bq-reader@YOUR_PROJECT_ID.iam.gserviceaccount.com

Register the service account email in the UltraCart dashboard the same way you registered your own Google account, so UltraCart provisions BigQuery access and assigns it a taxonomy level. See Providing Users Access to the Data Warehouse.

Point the CLI at the key file:

export GOOGLE_APPLICATION_CREDENTIALS="/path/to/sa-key.json"
uc-bq schema --list
warning

The key file is a credential. Keep it out of the repository, add it to .gitignore, and store it in your scheduler's secret store rather than on disk wherever you can.

Schedule with cron

Once the service account works, a crontab entry is enough:

# Refresh every report each Monday at 6am, charts and data only
0 6 * * 1 cd /path/to/reports && uc-bq run-all --no-analysis --start_date=-7d --end_date=today

# The same run, with executive analysis
0 6 * * 1 cd /path/to/reports && ANTHROPIC_API_KEY=sk-ant-... uc-bq run-all --deliver

cd into the directory holding .ultracart-bq.json first, because the CLI resolves the config and the report directories relative to the working directory. GOOGLE_APPLICATION_CREDENTIALS has to be set for the cron environment, which does not inherit your shell profile.

Set up a repository for scheduled runs

For anything beyond a single machine, keep the reports in a private Git repository. Committing the definitions and ignoring the output keeps diffs readable:

mkdir my-ultracart-reports
cd my-ultracart-reports
git init

cp /path/to/project/.ultracart-bq.json .
cp -r /path/to/project/reports/ ./reports/

cat > .gitignore << 'EOF'
node_modules/
.ultracart-bq-cache/
*.png
*.pdf
data.json
report.md
EOF

git add .
git commit -m "Add report definitions"

Committed: .ultracart-bq.json, and each report's report.yaml, query.sql, chart.js, and analysis_prompt.md. None of these contain secrets. Slack channel IDs and email recipients live in the manifests, which is fine, because neither is a credential.

Not committed: rendered charts, PDFs, query results, and analysis text, all of which are rebuilt on every run.

Store the secrets

In GitHub, open Settings, then Secrets and variables, then Actions.

One secret is always required:

  • GCP_SA_KEY, holding the entire contents of the service account JSON key file.

Add whichever delivery secrets your reports use: SLACK_BOT_TOKEN, and one of SENDGRID_API_KEY, POSTMARK_API_KEY, MAILGUN_API_KEY, or RESEND_API_KEY. Mailgun also needs MAILGUN_DOMAIN.

Add one LLM key only if you want analysis regenerated on each run, matching the provider in your config: ANTHROPIC_API_KEY, OPENAI_API_KEY, XAI_API_KEY, or GOOGLE_API_KEY. AWS Bedrock uses the AWS credential chain instead.

Under the Variables tab rather than Secrets, set EMAIL_FROM to your sender address. It is not sensitive, and keeping it visible makes the workflow easier to read.

Create the workflow

Save this as .github/workflows/weekly-reports.yml:

name: Weekly Reports

on:
schedule:
# Every Monday at 11:00 UTC (6am ET)
- cron: '0 11 * * 1'
workflow_dispatch:

jobs:
generate-reports:
runs-on: ubuntu-latest
timeout-minutes: 15

steps:
- name: Checkout report definitions
uses: actions/checkout@v4

- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '24'

- name: Install uc-bq
run: npm install -g @ultracart/bq-skill

- name: Authenticate to Google Cloud
uses: google-github-actions/auth@v2
with:
credentials_json: ${{ secrets.GCP_SA_KEY }}

- name: Run and deliver all reports
env:
SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }}
EMAIL_FROM: ${{ vars.EMAIL_FROM }}
SENDGRID_API_KEY: ${{ secrets.SENDGRID_API_KEY }}
run: uc-bq run-all --deliver --no-analysis

- name: Upload reports as artifacts
uses: actions/upload-artifact@v4
with:
name: weekly-reports-${{ github.run_number }}
path: |
reports/**/chart.png
reports/**/report.pdf
reports/**/data.json
retention-days: 30

Node 24 or later is required by the package. Trigger the workflow manually from the Actions tab the first time rather than waiting for Monday.

Adding a report later needs no workflow change: uc-bq run-all --deliver discovers every report directory and delivers the ones whose manifest has a delivery section.

Add analysis to a scheduled run

Drop --no-analysis and supply the key for your configured provider:

- name: Run and deliver all reports with analysis
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }}
EMAIL_FROM: ${{ vars.EMAIL_FROM }}
SENDGRID_API_KEY: ${{ secrets.SENDGRID_API_KEY }}
run: uc-bq run-all --deliver --analysis-model=claude-haiku-4-5-20251001

Switch provider for one run with --llm-provider=openai and the matching key, without touching the config file. Scheduled runs are a good place for the smaller models, since the analysis is a few paragraphs summarising a chart rather than open-ended reasoning. Provider defaults are listed in the Configuration reference.

Cache the browser download

Chart rendering uses Puppeteer, which works on ubuntu-latest without extra system packages but downloads roughly 400 MB of Chromium each time it installs. Cache it:

- name: Cache Puppeteer browsers
uses: actions/cache@v4
with:
path: ~/.cache/puppeteer
key: puppeteer-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
restore-keys: puppeteer-${{ runner.os }}-

- run: npm install -g @ultracart/bq-skill

The first run downloads Chromium in about thirty seconds and later runs skip it. If a particular runner has trouble with the bundled browser, point Puppeteer at the system one instead:

env:
PUPPETEER_EXECUTABLE_PATH: /usr/bin/chromium-browser

Keep alarm state between runs

Alarms work in CI with no workflow changes, but percent-change comparisons and cooldowns depend on alarm_state.json, and a fresh checkout has none. Without it, every percent-change alarm is treated as a first run and skipped. Commit the state back after each run:

- name: Commit alarm state
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add reports/**/alarm_state.json
git diff --cached --quiet || git commit -m "Update alarm state [skip ci]"
git push

Caching the files as workflow artifacts works too. Committing is simpler and leaves an audit trail of what each run saw. See Report alarms.

For a monitor that stays silent until something breaks, set the reports to alarm_only delivery mode, give them alarms, and schedule uc-bq run-all --deliver --no-analysis daily. No PDFs, no analysis, nothing sent unless an alarm fires.

What it costs to run

For a merchant running five reports weekly:

ComponentCostNotes
GitHub ActionsFreeAround 2 minutes per run, against a 2,000 minute monthly free tier
BigQuery$0.01 to $0.05 per runDepends on how much data each query scans
Analysis with a small modelAbout $0.01 per runFive reports at roughly $0.002 each
Analysis with a large modelAbout $0.15 per runFive reports at roughly $0.03 each
SlackFreeBot tokens cost nothing
EmailFree at low volumeSendGrid's free tier covers 100 messages per day

That works out to roughly $2.60 a year with no analysis, or about $10 a year with a large model writing commentary every week.

Was this page helpful?