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?