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
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:
| Component | Cost | Notes |
|---|---|---|
| GitHub Actions | Free | Around 2 minutes per run, against a 2,000 minute monthly free tier |
| BigQuery | $0.01 to $0.05 per run | Depends on how much data each query scans |
| Analysis with a small model | About $0.01 per run | Five reports at roughly $0.002 each |
| Analysis with a large model | About $0.15 per run | Five reports at roughly $0.03 each |
| Slack | Free | Bot tokens cost nothing |
| Free at low volume | SendGrid'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.
Related Documentation
- CLI reference - the flags used in these workflows
- Report delivery - configuring the Slack and email targets
--deliveruses - Report alarms - alarm state and
alarm_onlydelivery - Managing multiple merchants - matrix builds across several accounts