Skip to content

peterklingelhofer/carbon-aware-dispatcher

Use this GitHub action with your project
Add this Action to an existing workflow or create a new one
View on Marketplace

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

152 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Carbon-Aware Dispatcher

tests Providers Zones CI Platforms

Run your CI/CD only when the energy grid is clean. One file, no API keys, no configuration.

# .github/workflows/carbon-aware-build.yml
name: Carbon-Aware Build
on:
  schedule:
    - cron: '0 * * * *'
  workflow_dispatch:

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: peterklingelhofer/carbon-aware-dispatcher@v1
        id: carbon

      - if: steps.carbon.outputs.grid_clean == 'true'
        uses: actions/checkout@v5

      - if: steps.carbon.outputs.grid_clean == 'true'
        run: |
          echo "Running on clean energy in ${{ steps.carbon.outputs.grid_zone }}!"
          # your build/test/deploy commands here

The action auto-detects your cloud region (AWS, GCP, Azure) or checks zones across free providers worldwide. Replace the echo with your build commands.

How it works

  1. Runs on a schedule (e.g. hourly)
  2. Fetches real-time fuel mix and computes carbon intensity (gCO2eq/kWh)
  3. Below your threshold: sets grid_clean=true, the build runs
  4. Above it: sets grid_clean=false, skips the build, reports the next green window

Best for non-urgent jobs that can wait for clean energy: ML training, batch processing, media rendering, database migrations.

Try it risk-free (report-only)

Not ready to gate your builds yet? Add the action with dry_run: 'true' and it changes nothing about your workflow: it measures the grid, reports what it would have done, and estimates the CO2 you'd save, all in the job summary. Run it for a week, see the numbers, then turn enforcement on.

- uses: peterklingelhofer/carbon-aware-dispatcher@v1
  id: carbon
  with:
    grid_zones: 'auto:green'
    dry_run: 'true'          # report-only: never blocks the build

In report-only mode grid_clean is always true (so existing gates keep passing); read the would_defer output to see the real verdict.

Presets

Use a preset instead of looking up zone codes:

Preset What It Does
(no input) Auto-detects cloud region, falls back to checking all free zones worldwide
auto:detect Detects AWS/GCP/Azure region from environment variables
auto:nearest Picks zones closest to your timezone
auto:green 10 curated green zones across 5 continents (free providers only)
auto:cleanest Checks all free-provider zones, picks the single cleanest
auto:green:full 21 zones including EU/Canada/NZ (requires API tokens)
auto:escape-coal Routes jobs away from coal-heavy grids to clean alternatives
auto:escape-coal:IN Escape from a specific dirty zone (IN, CN, PL, ZA, DE...)
- uses: peterklingelhofer/carbon-aware-dispatcher@v1
  with:
    grid_zones: 'auto:green'           # or any preset above
    max_carbon_intensity: '200'        # gCO2eq/kWh threshold (default: 250)

API keys

None required. US, UK, Australia, India, Brazil, South Africa, and the worldwide Open-Meteo fallback work with no setup, which covers the auto:* presets. Optional free tokens add coverage: entsoe_token (EU, 44 zones), electricity_maps_token (one registered zone on the free tier), gridstatus_api_key (US forecasts). eia_api_key is optional too, only to raise the built-in US demo key's rate limit. See Inputs.

Quick Setup Options

One-liner (generates workflow file for you)

curl -fsSL https://raw.githubusercontent.com/peterklingelhofer/carbon-aware-dispatcher/main/setup.sh | bash

Options: --threshold 200, --zones "auto:green", --strategy queue, --cron "0 6 * * *". Run with --help for details.

Reusable workflow (no files to copy)

Call the carbon check directly from another workflow:

jobs:
  green-check:
    uses: peterklingelhofer/carbon-aware-dispatcher/.github/workflows/carbon-check.yml@v1
    with:
      max_carbon_intensity: '200'

  build:
    needs: green-check
    if: needs.green-check.outputs.grid_clean == 'true'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v5
      - run: echo "Building on clean energy!"

Specific zone (e.g., US, UK, EU)

- uses: peterklingelhofer/carbon-aware-dispatcher@v1
  id: carbon
  with:
    grid_zone: 'CISO'                 # California ISO (see Supported Zones below)
    max_carbon_intensity: '200'

US, UK, Australia, India, Brazil, and South Africa need no keys. EU zones use a free entsoe_token; other global zones use a free electricity_maps_token.

Dispatch mode (trigger a separate workflow)

A gatekeeper pattern that triggers a separate heavy workflow when green:

- uses: peterklingelhofer/carbon-aware-dispatcher@v1
  with:
    grid_zone: 'CISO'
    max_carbon_intensity: '200'
    workflow_id: 'heavy-batch.yml'        # triggers this workflow when green
    github_token: ${{ secrets.GITHUB_TOKEN }}

The target workflow needs a workflow_dispatch trigger. Inline mode (the default, shown at the top) is simpler for most users.

Routing to a clean region

Deploy to the greenest region (no special runners)

The most common case: keep CI on a standard GitHub-hosted runner, but send the deployment or workload to whichever region is cleanest. The action always outputs cloud_region / gcp_region / azure_region, so feed them straight into your deploy step:

- uses: peterklingelhofer/carbon-aware-dispatcher@v1
  id: carbon
  with:
    grid_zones: 'CISO,PJM,GB'
    max_carbon_intensity: '200'

- if: steps.carbon.outputs.grid_clean == 'true'
  run: |
    aws s3 sync ./dist s3://my-bucket --region ${{ steps.carbon.outputs.cloud_region }}
    # or: terraform apply -var region=${{ steps.carbon.outputs.cloud_region }}
    # or: gcloud run deploy --region ${{ steps.carbon.outputs.gcp_region }}
Output Example
cloud_region us-west-1 (AWS)
gcp_region us-west1 (GCP)
azure_region westus2 (Azure)

This is usually what matters most: the CI runner is a short-lived machine, while the deployed service or batch job is where the real energy is spent.

Relocating the CI runner itself

To run the build job in a specific region, set runs-on from the action's runner_label. This needs a two-job pattern, since runs-on is fixed at job-definition time:

jobs:
  pick-region:
    runs-on: ubuntu-latest
    outputs:
      runner: ${{ steps.carbon.outputs.runner_label }}
      clean: ${{ steps.carbon.outputs.grid_clean }}
    steps:
      - uses: peterklingelhofer/carbon-aware-dispatcher@v1
        id: carbon
        with:
          grid_zones: 'CISO:us-west-runner,PJM:us-east-runner,GB:uk-runner'
          max_carbon_intensity: '200'

  build:
    needs: pick-region
    if: needs.pick-region.outputs.clean == 'true'
    runs-on: ${{ needs.pick-region.outputs.runner }}
    steps:
      - uses: actions/checkout@v5
      - run: echo "Building in ${{ needs.pick-region.outputs.runner }}"

The zone:label syntax maps each zone to a runner label. This only works if the label matches a runner that exists. GitHub-hosted runners (ubuntu-latest etc.) have no region concept, so use self-hosted runners registered with those labels, or RunsOn. With GitHub-hosted runners only, prefer the deploy-region pattern above.

RunsOn integration

RunsOn supports per-job AWS region selection. Set runner_provider: 'runson' for automatic region-aware labels:

- uses: peterklingelhofer/carbon-aware-dispatcher@v1
  id: carbon
  with:
    grid_zones: 'CISO,BPAT,PJM,GB'
    runner_provider: 'runson'
    runner_spec: '2cpu-linux-x64'       # optional, this is the default

The runner_label output will be a RunsOn-compatible label like runs-on=12345/runner=2cpu-linux-x64/region=us-west-1.

Targeting reliably renewable regions

Standard GitHub-hosted runners (ubuntu-latest etc.) run on Azure in a region you cannot choose. GitHub's renewable commitment is annual REC matching, not real-time — so gating and scheduling are your only levers on standard runners. To actually execute on clean electrons consistently, use a runner provider that lets you pick the region.

Greenest regions by provider:

Provider Region Grid zone Why
AWS (RunsOn) eu-north-1 — Stockholm SE-SE3 Nordic hydro + wind; AWS 100% renewable committed; cleanest year-round
AWS (RunsOn) eu-west-1 — Ireland IE Wind-heavy; AWS 100% renewable committed
AWS (RunsOn) us-west-2 — Oregon BPAT Pacific NW hydro; AWS 100% renewable committed
Azure (GitHub larger runners) swedencentral SE-SE3 Sweden ~95%+ renewable grid
Azure (GitHub larger runners) norwayeast NO-NO1 Norway ~98% hydro

For RunsOn, pass the grid zone codes above to grid_zones so the action picks the cleanest of the candidates and routes the job there:

grid_zones: 'SE-SE3,IE,BPAT'   # Stockholm, Ireland, Oregon — all AWS 100% renewable
runner_provider: 'runson'

For GitHub larger runners (Teams/Enterprise), create a runner group targeting swedencentral or norwayeast in your organization settings, then set runs-on to that group's label.

Cost + carbon routing

Among multiple candidate zones, pick one that is both clean and cheap. Set cost_weight (0–1) to blend each zone's carbon intensity with a representative cloud price from the public Azure Retail Prices API (no key, keyed off each zone's nearest Azure region):

- uses: peterklingelhofer/carbon-aware-dispatcher@v1
  id: carbon
  with:
    grid_zones: 'CISO,GB,FR,AU-NSW'
    cost_weight: '0.5'   # 0 = cleanest only, 1 = cheapest only, 0.5 = balance

The chosen zone minimizes cost_weight x price + (1 - cost_weight) x carbon (both min-max normalized across the candidates). selected_cost_usd_hr reports the winner's price. If pricing is unavailable it falls back to carbon-only. No effect in single-zone mode. See examples/cost-aware-routing.yml.

Other clouds (AWS/GCP/on-prem): only Azure has a free live pricing API, so to price any other cloud, supply your own rates via cost_price_map — a JSON object of zone -> USD/hour. Mapped zones use your prices; the rest fall back to live Azure:

with:
  grid_zones: 'CISO,GB,FR'
  cost_weight: '0.5'
  cost_price_map: '{"CISO":"0.096","GB":"0.101","FR":"0.088"}'

Escape coal-heavy grids

Route jobs from a coal-dependent region to the nearest clean alternative:

- uses: peterklingelhofer/carbon-aware-dispatcher@v1
  with:
    grid_zones: 'auto:escape-coal'       # global clean routing
    # Or escape from a specific zone:
    # grid_zones: 'auto:escape-coal:IN'  # India → Iceland, Norway, France
    # grid_zones: 'auto:escape-coal:CN'  # China → NZ, Tasmania, Pacific NW
    # grid_zones: 'auto:escape-coal:PL'  # Poland → Nordic clean
    # grid_zones: 'auto:escape-coal:ZA'  # South Africa → Iceland, Norway
    max_carbon_intensity: '150'

Well-known dirty grids use curated routes (tuned for latency/region). Any other zone with known coordinates is routed automatically to its geographically nearest clean grids — no hand-curated mapping needed, so coverage extends to any locatable origin (e.g. auto:escape-coal:VN).

Smart wait & queue strategy

Wait for a green window

Wait up to N minutes for the grid to become clean:

- uses: peterklingelhofer/carbon-aware-dispatcher@v1
  with:
    grid_zone: 'CISO'
    max_wait: '120'                      # wait up to 2 hours
    enable_forecast: 'true'

Forecast data is used to sleep efficiently. Note: GitHub Actions bills for wait time.

Find the optimal green window

strategy: queue searches forecasts across all zones for the best time within your deadline:

- uses: peterklingelhofer/carbon-aware-dispatcher@v1
  id: carbon
  with:
    grid_zones: 'auto:cleanest'
    strategy: 'queue'
    deadline_hours: '24'                 # find best window in next 24h
    max_wait: '120'                      # actually wait if window is within 2h

Outputs optimal_dispatch_at (ISO 8601) and optimal_zone. Good for nightly ML training or weekly reports.

Carbon-adaptive CI (the dial)

Skipping builds is a blunt instrument — teams want their CI to run. Instead of a binary gate, the action classifies the grid into a carbon_tier so downstream jobs can right-size their work: full matrix when green, critical-path when amber, smoke test when red. CI always makes progress; the heaviest compute shifts to the cleanest hours.

- uses: peterklingelhofer/carbon-aware-dispatcher@v1
  id: carbon
  with:
    tier_thresholds: '120,280'   # green <=120, amber <=280, red above

carbon_tier is green, amber, or red (and carbon_tier_reason explains why). Use it to drive matrix includes, conditional steps, or job-level if:. See examples/adaptive-ci.yml for a full matrix that scales test suites to the tier.

Carbon-aware autoscaling (the continuous dial)

The three tiers are coarse; carbon_scale is the smooth version — a factor in [scale_min, scale_max] that ramps linearly from scale_max at or below the green boundary down to scale_min at or above the amber boundary. Multiply your replica count, batch parallelism, or test-shard count by it so the heaviest compute concentrates in the cleanest hours instead of toggling fully on/off (CarbonScaler-style). It fails open to scale_max when intensity is unknown, so missing data never throttles you.

- uses: peterklingelhofer/carbon-aware-dispatcher@v1
  id: carbon
  with:
    tier_thresholds: '150,300'   # ramp endpoints
    scale_min: '0.25'            # floor: still make progress when dirty

From the CLI (for KEDA/HPA, a Ray driver, or a cron that resizes a fleet):

carbon-aware scale --zones GB                      # -> 0.625
carbon-aware scale --zones GB --max-replicas 16    # -> 10  (ceil(0.625 x 16))

Splitting divisible work across regions

For divisible batch or inference, the cleanest single region isn't enough if it can't hold the whole load. split water-fills N shards into the cleanest reachable zones first, respecting optional per-zone capacity — the emissions-optimal allocation for linear per-shard cost:

# Place 100 shards, cleanest-first, capped per region; show the saving vs even
carbon-aware split --zones CISO,GB,FR --shards 100 \
  --capacity '{"FR":40,"GB":40,"CISO":40}' --energy-kwh 0.5
#  -> FR: 40  GB: 40  CISO: 20   emits ~X g vs ~Y g even split (saves ~Z g)

Green SLA

Commit to a carbon target and prove it. Set green_sla_target to the percent of runs that must run on a clean grid; with the ledger enabled, the action tracks the green-run share and exposes sla_status (compliant / warning / breached / unknown), sla_compliance_pct, and sla_breached:

- uses: peterklingelhofer/carbon-aware-dispatcher@v1
  id: carbon
  with:
    ledger: 'gist:${{ vars.CARBON_LEDGER_GIST }}'
    gist_token: ${{ secrets.GIST_TOKEN }}
    green_sla_target: '95'   # 95% of runs must run clean this month

Gate releases on it, or just report:

release:
  needs: carbon
  if: needs.carbon.outputs.sla_breached != 'true'

Check compliance anytime from the CLI: carbon-aware sla --target 95 (reads the same ledger; exit 0 compliant, 1 breached, 2 not enough data). It's an uptime-style SLA for carbon: the share of your compute that ran clean, attested over time.

Carbon budgets as code

Cap how much CO2 your CI is allowed to emit per month. With the ledger enabled, the action tracks month-to-date emissions and exposes budget_exceeded, so non-essential builds pause once you hit the cap and resume next month.

- uses: peterklingelhofer/carbon-aware-dispatcher@v1
  id: carbon
  with:
    ledger: 'gist:${{ vars.CARBON_LEDGER_GIST }}'
    gist_token: ${{ secrets.GIST_TOKEN }}
    monthly_budget_grams: '2000'   # 2 kg CO2eq/month

Then gate downstream work on it:

build:
  needs: carbon
  if: needs.carbon.outputs.budget_exceeded != 'true'

Outputs: budget_used_pct, budget_remaining_grams, budget_exceeded, and budget_state (ok / warning at 80% / exceeded). Budgeting needs the ledger input — that is where month-to-date spend is tracked. See examples/carbon-budget.yml.

Doctor mode (diagnostics)

Not sure a zone works, or whether your token is wired up? Run the action in mode: doctor for a one-click health check. It probes each configured zone, shows which provider handles it, whether a required token is set or missing, and whether live data actually came back — plus which optional features are on.

- uses: peterklingelhofer/carbon-aware-dispatcher@v1
  with:
    mode: 'doctor'
    grid_zones: 'GB,FR,CISO'   # blank probes a keyless sample

Output (job summary):

Zone Provider Token Status Detail
GB uk_carbon_intensity n/a OK 203 gCO2eq/kWh
FR open_meteo n/a OK 56 gCO2eq/kWh (estimated)

See examples/doctor.yml.

Marginal emissions (WattTime)

Average grid intensity tells you how clean the grid is overall; marginal emissions (MOER) tell you the emissions of the generator that responds to your added load — the signal that actually matters for deciding when to shift flexible compute. With free WattTime credentials, the action emits a marginal_percentile (0–100, lower = cleaner) and a marginal_clean flag:

- uses: peterklingelhofer/carbon-aware-dispatcher@v1
  id: carbon
  with:
    watttime_username: ${{ secrets.WATTTIME_USERNAME }}
    watttime_password: ${{ secrets.WATTTIME_PASSWORD }}
    marginal_max_percentile: '33'   # clean = cleanest third of the last 2 weeks

Gate deferrable work on marginal_clean == 'true'. WattTime's free tier covers CAISO_NORTH; other regions need WattTime Pro. See examples/marginal-timing.yml.

From the CLI, carbon-aware marginal reports the same signal and composes via exit codes, so a batch job can run on the marginal metric (the one that reflects real avoided emissions) rather than average intensity:

carbon-aware marginal --region CAISO_NORTH --max-percentile 33 && ./train.sh

Free marginal estimate (no WattTime, US/EIA zones)

WattTime's free tier only covers CAISO_NORTH. For any EIA (US) zone you can estimate the marginal rate for free: marginal-estimate regresses the change in emissions on the change in generation across recent hours (the marginal generator is the one that moves to meet a load change), so you get a marginal number without a key. The r_squared says how much the load change explains — i.e. how much to trust it.

carbon-aware marginal-estimate --zones PJM --json
#  -> {"marginal": 510, "average": 360, "r_squared": 0.78, "n": 96}

It's an estimate, not a measurement: the honest, free middle ground between the average (which overstates avoided emissions) and WattTime's measured marginal.

Weekly digest

Run the action in mode: digest on a schedule to post (and keep updating) a single GitHub issue summarizing your CI's carbon impact — builds, CO2 saved/emitted over the last 7 and 30 days, a daily-savings sparkline, lifetime total, and budget status. It reads the same ledger your build workflow writes.

permissions:
  issues: write
jobs:
  digest:
    runs-on: ubuntu-latest
    steps:
      - uses: peterklingelhofer/carbon-aware-dispatcher@v1
        with:
          mode: 'digest'
          ledger: 'gist:${{ vars.CARBON_LEDGER_GIST }}'
          gist_token: ${{ secrets.GIST_TOKEN }}
          github_token: ${{ secrets.GITHUB_TOKEN }}

See examples/weekly-digest.yml.

Notifications

Get pinged when something actionable happens — the grid goes clean, a build is deferred, or your carbon budget is blown. Point notify_webhook at a Slack, Discord, or generic webhook (the payload shape is auto-detected from the URL):

- uses: peterklingelhofer/carbon-aware-dispatcher@v1
  with:
    notify_webhook: ${{ secrets.SLACK_WEBHOOK }}
    notify_on: 'green,exceeded'   # green | dirty | exceeded | always

Notifications never fail the build — a webhook error degrades to a warning.

Organization-wide defaults

Drop a .github/carbon-policy.yml in your repo:

# .github/carbon-policy.yml
max_carbon_intensity: 150
grid_zones: auto:cleanest
enable_forecast: true
strategy: queue
deadline_hours: 24

Action inputs override policy values, letting platform teams set green CI defaults across all workflows.

Other CI platforms

The core Python script runs on any CI platform. Templates in ci-templates/:

Platform Template How It Works
GitLab CI gitlab-ci.yml Extend .carbon-aware-job in your jobs
Bitbucket bitbucket-pipelines.yml Artifact-based result passing
CircleCI circleci-config.yml Workspace persistence between jobs

Set GRID_ZONE, MAX_CARBON, and optional API tokens as environment variables.

Integrations

CI is a small load. The real carbon wins are large, deferrable workloads — ML training, batch inference, ETL, HPC jobs. The same engine plugs into the tools that orchestrate them, so a long run lands on clean energy with no manual timing.

Each adapter uses a lazy/optional import and runs against the framework you already have installed — you don't install this package to get Airflow or Ray, you add a carbon gate to your existing one. So the base install stays tiny (just requests) and there are no heavy extras to manage.

Framework Import Example
PyTorch Lightning integrations.lightning_carbon.CarbonAwareCallback lightning_carbon_training.py
Hugging Face Trainer integrations.huggingface_carbon.CarbonAwareTrainerCallback huggingface_carbon_training.py
Airflow integrations.airflow_carbon.CarbonAwareSensor airflow_carbon_dag.py
Prefect integrations.prefect_carbon.carbon_gate prefect_carbon_flow.py
Dagster integrations.dagster_carbon.carbon_gate dagster_carbon_job.py
Ray integrations.ray_carbon.run_when_clean ray_carbon_job.py
Inference routing integrations.inference_router.cleanest_endpoint inference_routing.py
KEDA (k8s) wait-for-green initContainer keda-scaledjob.yaml
Slurm (HPC) wait-for-green submit wrapper slurm-carbon-submit.sh

Sections below cover each in more detail.

Carbon-aware ML training (PyTorch Lightning)

A training run lasts hours or days, so shifting it onto clean-grid windows saves far more real carbon than gating CI. Drop in the Lightning callback to pause at epoch boundaries while the grid is dirty:

from integrations.lightning_carbon import CarbonAwareCallback

trainer = Trainer(callbacks=[CarbonAwareCallback(zones="auto:green", max_carbon=200)])

No Lightning? Use the gate in any loop: from integrations.lightning_carbon import wait_until_clean; wait_until_clean(zones="auto:green", max_carbon=200). See examples/standalone/lightning_carbon_training.py.

Hugging Face Trainer has the same gate as a TrainerCallback:

from integrations.huggingface_carbon import CarbonAwareTrainerCallback

trainer = Trainer(..., callbacks=[CarbonAwareTrainerCallback(zones="auto:green")])

See examples/standalone/huggingface_carbon_training.py.

Carbon-aware Airflow

Batch ETL, retrains, and report jobs are the deferrable loads Airflow already orchestrates. Gate any DAG on grid cleanliness with a sensor:

from integrations.airflow_carbon import CarbonAwareSensor

gate = CarbonAwareSensor(
    task_id="wait_for_green", zones="auto:green", max_carbon=200,
    mode="reschedule", poke_interval=900, timeout=6 * 3600,
)
gate >> heavy_training_task

mode="reschedule" frees the worker slot between pokes. See examples/standalone/airflow_carbon_dag.py.

Carbon-aware Prefect

Gate a Prefect flow with carbon_gate, which blocks until a target zone is clean:

from prefect import flow, task
from integrations.prefect_carbon import carbon_gate


@flow
def pipeline():
    task(carbon_gate)(zones="auto:green", max_carbon=200)
    retrain()

See examples/standalone/prefect_carbon_flow.py.

Carbon-aware Dagster

Gate a Dagster op with carbon_gate, or only launch runs when clean with a sensor on grid_is_clean:

from integrations.dagster_carbon import carbon_gate, grid_is_clean

See examples/standalone/dagster_carbon_job.py.

Carbon-aware KEDA

Pair event-driven autoscaling with clean-energy timing: a KEDA ScaledJob scales on your queue, and an initContainer running carbon-aware wait-for-green holds each job until the grid is clean. See examples/standalone/keda-scaledjob.yaml.

Carbon-aware inference routing

Inference is a fast-growing, often latency-tolerant load. Route async/batch requests to the cleanest available region in real time:

from integrations.inference_router import cleanest_endpoint

endpoints = [
    {"name": "us-west", "zone": "CISO", "url": "https://us-west/infer"},
    {"name": "norway", "zone": "NO-NO1", "url": "https://no/infer"},
]
target = cleanest_endpoint(endpoints)   # re-rank hourly, not per request

See examples/standalone/inference_routing.py.

Use outside GitHub Actions (CLI & container)

CI is a small load. The real carbon wins are large, deferrable workloads — nightly ML training, ETL, batch inference. The same engine ships as a standalone carbon-aware CLI so any scheduler (cron, systemd timers, Kubernetes CronJobs, Airflow, Nomad) can gate or time that work. It composes through exit codes, so no glue code is needed:

pipx install carbon-aware-dispatcher        # or use the container (below)

# Run a batch job only if the grid is clean right now
carbon-aware check --zones auto:green --max-carbon 200 && ./train.sh

# Or block until a green window opens (up to 6h), then run
carbon-aware wait-for-green --zones GB,CISO --max-carbon 200 --max-wait 6h && ./train.sh

# Add --energy-kwh for optimal stopping: it runs now instead of blocking when
# idling for the cleaner forecast window would emit more carbon than it saves
carbon-aware wait-for-green --zones GB --max-wait 6h --energy-kwh 0.2 && ./job.sh

# Plan ahead: print the cleanest upcoming window from forecasts
carbon-aware best-window --zones GB --hours 24 --json

# Emit an SCI carbon report for sustainability reporting (energy/PUE/embodied)
carbon-aware report --zones GB --energy-kwh 12 --pue 1.12 --embodied-grams 40 > sci.json

# Best of all: shift a recurring job to its cleanest hour, once
carbon-aware suggest-cron --zones GB --energy-kwh 12
#  -> Suggested schedule: 0 12 * * *   (~150 kg CO2/yr cleaner than your average run time)

# For a multi-hour batch job, target the cleanest contiguous window
carbon-aware suggest-cron --zones GB --duration-hours 4 --energy-kwh 20
#  -> start a 4h job at 11:00 UTC (cleanest 4h window)

# For a weekly job, also pick the cleanest day of week (weekends often cleaner)
carbon-aware suggest-cron --zones GB --weekly
#  -> 0 12 * * 6   (weekly on Sat at 12:00 UTC)

# WHERE often beats WHEN: move a flexible workload to the cleanest region
carbon-aware suggest-region --zones CISO,PJM,GB,FR --current PJM --energy-kwh 12
#  -> Run in CISO instead of PJM: ~N kg CO2/yr saved (mind latency/egress)

# Both at once: the cleanest (region, hour) across candidates
carbon-aware plan --zones CISO,GB,FR --current PJM --energy-kwh 12
#  -> Run your job in CISO at 03:00 UTC (cron: 0 3 * * *)

# Audit a whole repo: rank every shiftable schedule by savings
carbon-aware audit --zones GB --dir .github/workflows --energy-kwh 5
#  -> ranked list of crons to shift + total potential kg CO2/yr

# Use less: rank scheduled jobs by annual emissions (frequency x per-run)
carbon-aware schedule-cost --zones GB --dir .github/workflows --energy-kwh 5
#  -> hourly job: 24x/day ~5 t/yr — throttle the heaviest

# Grade your repo's carbon posture (A-F) and write a README badge
carbon-aware score --zones GB --badge-file carbon-posture.json
#  -> Carbon posture: B (84% of schedulable savings captured)

# One command, the whole plan: ranked actions across every lever
carbon-aware advise --zones GB --dir .github/workflows --energy-kwh 5
#  -> 1. [shift] ...  2. [throttle] ...  with kg/yr each

# Grade our own forecasts over time: run on a schedule; it resolves past
# predictions against reality and reports the bias (the offset to trust less)
carbon-aware forecast-accuracy --zones GB --store .carbon/forecast-log.json
#  -> n=42, MAE 18.4, bias +6.1 gCO2eq/kWh; subtract 6.1 from forecasts

# Inspect the hour-of-day curve the recommendation is based on
carbon-aware curve --zones GB

# Honest gut-check: is scheduling even worth it here? (flat grids: no)
# Where raw history exists (GB), this runs a one-way ANOVA F-test, so a curve
# that only looks bumpy from a few noisy samples is called out as not worth it.
# `curve` adds a robust (median) cleanest hour and a 95% confidence band per hour.
carbon-aware worth-it --zones GB    # exit 0 worth it, 1 not, 2 can't assess

report writes a machine-readable SCI record per run — energy, intensity, PUE, embodied, and total emitted — that aggregates for CSRD / GHG-Protocol reporting.

Shared curve commons

Where no free historical API exists, the curve self-accumulates per zone in your ledger. Pool it: carbon-aware export-curves --output mine.json writes your zones' hour-of-day aggregates (anonymous sum/count cells only), and carbon-aware merge-curves a.json b.json --output pool.json sums many contributors' files into one volume-weighted pool. Point COMMUNITY_CURVE at a pool — a local file or a published URL — and suggest-cron/worth-it/curve gain a profile for any zone others have sampled, even with no local history:

export COMMUNITY_CURVE="https://raw.githubusercontent.com/peterklingelhofer/carbon-aware-dispatcher/community-data/community-curve.json"
carbon-aware worth-it --zones FR

The pool carries both axes — hour of day and day of week — so a zone gains a weekend-vs-weekday profile (often a real second saving, since weekend grids run cleaner) as well as a diurnal one. Contribute via PR to community-curves/; the publish workflow pools every contribution with the self-sampled zones and publishes the result to the community-data branch automatically. Coverage compounds with adoption.

Shift, don't wait

For recurring jobs, blocking with wait-for-green keeps the machine powered on while it polls — that idle energy is itself carbon. The higher-impact move is to shift the schedule once to the grid's cleanest hour: it saves on every future run with zero idle waste. suggest-cron recommends that cron expression from the best signal available — a multi-day historical hour-of-day curve, else the live forecast, else a per-zone heuristic. The curve comes from a free historical API where one exists (GB today), and otherwise builds itself: with a ledger configured, each run records its (hour, intensity) into a tiny per-zone aggregate, so curve / worth-it / suggest-cron start working for any zone once ~6 different hours have been sampled. (A job that only ever runs at one fixed hour won't fill the curve — the hourly self-check pattern samples across the day.) Inspect it with carbon-aware curve. Reserve wait-for-green for one-off, deadline-bound work.

Or let it open the PR for you. Run the action in mode: suggest (on a schedule) pointed at a workflow file, and it opens a pull request moving that workflow's daily cron to the cleanest hour — you just review and merge:

permissions:
  contents: write
  pull-requests: write
jobs:
  suggest:
    runs-on: ubuntu-latest
    steps:
      - uses: peterklingelhofer/carbon-aware-dispatcher@v1
        with:
          mode: 'suggest'
          grid_zones: 'GB'
          suggest_target: '.github/workflows/nightly-batch.yml'
          github_token: ${{ secrets.GITHUB_TOKEN }}

Only simple daily crons are rewritten (cadence and minute preserved). See examples/suggest-cron-pr.yml.

And before adding any of this, ask carbon-aware worth-it: on a flat, baseload-dominated grid the intensity barely moves across the day, so shifting saves little — the tool will say so plainly rather than have you add complexity for nothing.

Exit codes: 0 green/clean, 1 dirty or timed out, 2 no data. Info logs go to stderr; stdout carries only the result (add --json for machine output).

Grid feeds only refresh every 5-30 min, so composed runs can reuse a recent reading instead of re-fetching: pass --cache-ttl 300 (or set CARBON_CACHE_TTL=300) to cache reads for 5 minutes on the host. The container and GitHub Action enable a 5-minute cache by default (set 0 to disable). It caches only the public reading, never the token-bearing URL. When a cached entry expires, it revalidates with If-None-Match / If-Modified-Since, so a provider that supports ETags answers with a tiny 304 Not Modified instead of resending the whole payload — saving bandwidth and energy on both ends.

Container (no Python needed) — pull the published image or build locally:

docker run --rm ghcr.io/peterklingelhofer/carbon-aware-dispatcher:latest \
  check --zones GB,CISO --max-carbon 200

# or build it yourself
docker build -t carbon-aware . && docker run --rm carbon-aware check --zones GB

Ready-to-copy schedulers: a Kubernetes CronJob (carbon-gated via an initContainer), a cron/systemd wrapper, and a Slurm submit wrapper for HPC jobs.

For distributed training/batch on Ray, gate the driver before submitting work:

from integrations.ray_carbon import run_when_clean

run_when_clean(run_batch, zones="auto:green", max_carbon=200)  # waits, then submits

See examples/standalone/ray_carbon_job.py.

Example workflows

Ready-to-copy files in examples/:

Example Description
zero-config.yml Simplest setup, no inputs needed
multi-cloud-routing.yml Route to greenest AWS/GCP/Azure region
queue-strategy.yml Find optimal green window within a deadline
escape-coal.yml Escape dirty grids (India, China, Poland, SA)
track-impact.yml All-in-one: lifetime ledger, live badge, and sticky PR comment
adaptive-ci.yml Scale the test matrix to the carbon tier (green/amber/red)
carbon-budget.yml Cap monthly CO2 and pause non-essential builds over budget
cost-aware-routing.yml Pick a zone that is both clean and cheap
weekly-digest.yml Weekly impact issue from the ledger
marginal-timing.yml Gate flexible compute on WattTime marginal emissions
doctor.yml One-click diagnostic of zones, tokens, and live data
suggest-cron-pr.yml Auto-open a PR shifting a workflow to the cleanest hour

Inputs

Input Default Description
grid_zone auto:detect Single zone or preset. See Presets and Supported zones.
grid_zones auto:detect Comma-separated zones with optional runner labels: CISO:runner-cal,GB:runner-uk. Or a preset.
max_carbon_intensity 250 Maximum gCO2eq/kWh to allow dispatch.
workflow_id Workflow to dispatch when green. Omit for inline mode (recommended).
github_token Required when workflow_id is set.
eia_api_key Higher rate limits for US zones. Free registration. Built-in demo key works for basic use.
electricity_maps_token One zone per free token (chosen at registration), 50 req/hr. Paid plans cover 200+ zones. Register.
entsoe_token EU coverage (36 countries). Free registration, 400 req/min.
gridstatus_api_key US forecasts (7 ISOs). Free registration, 1M rows/month.
max_wait 0 Minutes to wait for green energy. Max 360. Billable time.
enable_forecast false Fetch forecast when dirty. Free for UK, India, Brazil, SA, Open-Meteo. US needs GridStatus key.
strategy check check: dispatch if green now. queue: find optimal window within deadline_hours.
deadline_hours 24 Hours to search ahead for green windows (queue strategy).
runner_provider Set to runson for automatic AWS region-based runner labels.
runner_spec 2cpu-linux-x64 Machine spec for RunsOn.
target_ref main Git ref for dispatched workflows.
fail_on_api_error false Fail the action on API errors instead of skipping silently.
carbon_policy_path .github/carbon-policy.yml Path to org-wide carbon policy.
dry_run false Report-only mode. Measures and reports but never gates the build (grid_clean stays true). See Try it risk-free.
consumption_based false Use flow-traced consumption intensity for EU zones (single-zone, needs entsoe_token). See Consumption-based intensity.
ledger Persist cumulative savings: gist:<id> (live badge + dashboard, needs gist_token) or file:<path>. See Watch your impact.
gist_token Token with gist scope for the gist: ledger backend. Store as a secret.
pr_comment false Post a sticky carbon-verdict comment on pull requests. Needs pull-requests: write.
tier_thresholds 150,300 Two gCO2eq/kWh boundaries green,amber for the carbon_tier dial. See Carbon-adaptive CI.
monthly_budget_grams Monthly carbon cap in gCO2eq. Needs ledger. See Carbon budgets.
cost_weight 0 Blend cloud cost with carbon when choosing among zones (0 = clean only, 1 = cheap only). See Cost + carbon routing.
notify_webhook Webhook URL for carbon-event notifications (Slack/Discord/generic). See Notifications.
notify_on green,exceeded Events to notify on: green, dirty, exceeded, or always.

Outputs

Output Description
grid_clean true if a zone was clean enough, false otherwise.
carbon_intensity Intensity in gCO2eq/kWh, or unknown on error.
carbon_tier Adaptive-CI dial: green / amber / red (plus carbon_tier_reason).
carbon_scale Continuous autoscaling factor in [scale_min, scale_max] — multiply your replica/parallelism count by it. See Carbon-aware autoscaling.
budget_used_pct / budget_remaining_grams / budget_exceeded / budget_state Monthly carbon budget status (needs monthly_budget_grams + ledger).
selected_cost_usd_hr Representative price of the selected zone when cost_weight > 0.
grid_zone Selected zone.
runner_label Runner label for the selected zone.
cloud_region / gcp_region / azure_region Nearest region for each cloud provider. Always set.
intensity_trend decreasing, increasing, or stable.
forecast_green_at ISO 8601 timestamp of next predicted green window.
forecast_intensity Predicted intensity at the green window.
co2_saved_grams Estimated grams CO2 saved vs. global average (450 gCO2eq/kWh).
co2_saved_equivalent Human-relatable phrase for this run's saving, e.g. ~1.8 km not driven.
carbon_badge_url Shields.io badge URL for READMEs: ![carbon](url)
co2_saved_total_grams Cumulative grams saved across all runs (requires the ledger input).
co2_saved_total_equivalent Human-relatable phrase for the lifetime saving (requires ledger).
lifetime_badge_url Live shields.io badge URL for lifetime CO2 saved (requires ledger: gist:<id>).
status_badge_url Live shields.io badge URL for the current grid zone/intensity/tier (requires ledger: gist:<id>).
optimal_dispatch_at Best green window (queue strategy). now if already green.
optimal_zone Zone for the optimal window (queue strategy).
suggested_cron Suggested cron schedule for green builds based on zone energy type.
dry_run true when the action ran in report-only mode.
would_defer In dry_run mode, true if the grid was dirty and the build would have been deferred under enforcement.

Methodology & honest accounting

Carbon claims are easy to inflate, so here is exactly what the numbers mean:

  • co2_emitted_grams (trust this one). What the run actually produced: carbon intensity × estimated energy. This is the Green Software Foundation SCI operational term (embodied hardware excluded) and maps to GHG Protocol Scope 2 (location-based). It is the figure to report.
  • co2_saved_grams (a benchmark, not a reduction). A comparison against a fixed global-average grid (450 gCO2eq/kWh) — "how much cleaner than a world-average grid was this run". It is not marginal or additional avoided emissions: on a grid where shifted load just rides baseload, the real avoided emissions can be far lower. The basis is stated in the co2_saved_basis output so it travels with the number.
  • Why not marginal everywhere? The metric that reflects true avoided emissions from shifting load is marginal intensity, which is free only for CAISO_NORTH (via WattTime — see Marginal emissions). We don't fake it for other regions.

Bottom line: use co2_emitted_grams for reporting and co2_saved_grams as a directional benchmark, not an offset claim.

Provenance on every reading. The action emits data_source (the provider that actually produced the number, e.g. uk_carbon_intensity, energy_charts, rte) and data_confidence (measured for grid-operator data, estimated for weather-modeled Open-Meteo readings). It honors the Open-Meteo fallback, so a number is never labeled measured when it was really an estimate. Gate on it if you only trust measured data (if: steps.X.outputs.data_confidence == 'measured').

co2_avoided_total_grams — verifiable avoided emissions. With a ledger, each run is compared against this zone's own typical hour (from the accumulated curve), and the difference accrues into a lifetime total. Unlike the global-average benchmark, this is a real, self-referential measure of how much cleaner you ran by scheduling well — it starts accruing once the curve has enough hours.

Make the energy figure real. By default emitted assumes a typical CI job (50 W for 15 min). For actual workloads — a GPU training run, an ETL batch — set your real energy so the number means something:

with:
  job_energy_kwh: '12'        # measured kWh (best); overrides the estimate
  # or describe it:
  # job_power_watts: '300'
  # job_duration_minutes: '120'

For a fuller SCI total, add datacenter overhead and embodied hardware carbon (both opt-in, default off):

with:
  pue: '1.12'             # facility energy / IT energy (cloud DCs run ~1.1-1.2)
  embodied_grams: '40'    # amortized manufacturing CO2 for this run

co2_emitted_grams then equals energy x intensity x PUE + embodied.

Watch your impact

Every run estimates the CO2 it saved, but a number that vanishes after one build is easy to ignore. These features make the impact meaningful, persistent, and visible to everyone, not just whoever opens the Actions tab.

Human-relatable equivalents

The job summary, the co2_saved_equivalent output, and the PR comment translate grams into things people feel: ~1.8 km not driven, ~14 phone charges. Factors come from the US EPA Greenhouse Gas Equivalencies Calculator. Nothing to enable.

Lifetime ledger and live badge

Set the ledger input to accumulate savings across every run into a lifetime total, exposed via co2_saved_total_grams and a live, self-updating badge.

- uses: peterklingelhofer/carbon-aware-dispatcher@v1
  with:
    ledger: gist:YOUR_GIST_ID        # or file:.carbon/ledger.json
    gist_token: ${{ secrets.GIST_TOKEN }}

One-time setup for the gist backend:

  1. Create a public gist (any placeholder content) and copy its id from the URL.
  2. Create a personal access token with the gist scope and store it as a secret named GIST_TOKEN. (The built-in GITHUB_TOKEN cannot write gists.)
  3. The action writes carbon-ledger.json (full data) and carbon-badge.json (a shields.io endpoint badge) to the gist on every run.

Embed the live lifetime badge in your README:

![CO2 saved](https://img.shields.io/endpoint?url=https://gist.githubusercontent.com/YOUR_USER/YOUR_GIST_ID/raw/carbon-badge.json)

The file: backend needs no token and writes a local JSON file, handy for self-hosted runners or if you commit the ledger yourself.

A second, live current-grid badge is published alongside it (carbon-now.json), showing the latest zone, intensity, and tier color, and exposed via the status_badge_url output:

![grid now](https://img.shields.io/endpoint?url=https://gist.githubusercontent.com/YOUR_USER/YOUR_GIST_ID/raw/carbon-now.json)

Impact dashboard (GitHub Pages)

dashboard/index.html is a self-contained, no-build, no-CDN page that reads your ledger gist and renders the lifetime total, real-world equivalents, and a savings-over-time chart. Drop the dashboard/ folder on GitHub Pages and open it with ?gist=<id>:

https://YOUR_USER.github.io/YOUR_REPO/?gist=YOUR_GIST_ID

It reads the gist through the CORS-enabled GitHub REST API, so it works for any public ledger gist with zero server-side code.

Sticky PR comment

Set pr_comment: 'true' to post the carbon verdict as a single comment on the pull request, updated in place on each run, so reviewers see whether the build ran on clean energy (and how much it saved) without opening the Actions tab.

permissions:
  pull-requests: write
# ...
- uses: peterklingelhofer/carbon-aware-dispatcher@v1
  with:
    pr_comment: 'true'

Supported zones & providers

The action picks the best provider per zone, checking free providers first.

Provider Coverage API Key Zones
EIA US (60+ regions) Free built-in CISO, ERCO, PJM, BPAT, NYIS, MISO, ISNE, SWPP...
UK Carbon Intensity UK (18 regions) None GB, GB-1..GB-17
AEMO Australia (5 states) None AU-NSW, AU-QLD, AU-VIC, AU-SA, AU-TAS
Grid India India (5 regions) None IN-NO, IN-SO, IN-EA, IN-WE, IN-NE (geo-restricted, see note)
ONS Brazil Brazil (5 regions) None BR-S, BR-SE, BR-CS, BR-NE, BR-N
Eskom South Africa None ZA
IESO / AESO / Hydro-Quebec Canada (ON, AB, QC) None CA-ON, CA-AB, CA-QC
CAMMESA Argentina (national) None AR
Taipower Taiwan None TW
EirGrid Ireland (ROI, NI, all-island) None IE, IE-ROI, IE-NI, IE-ALL
Energinet Denmark (DK1, DK2) None DK-DK1, DK-DK2
RTE eco2mix France None FR
Energy-Charts (Fraunhofer ISE) EU (DE, ES, IT, NL, BE, AT, CH, PL, PT, CZ, FI, GR, HU, RO, and more) None DE, ES, IT, NL, ...
ENTSO-E EU (36 countries) Free token DE, FR, ES, NL, NO-NO1, SE-SE1..SE-SE4, DK-DK1...
Electricity Maps 1 zone (free tier) / 200+ (paid) Token The single zone registered to your token; see their map
Open-Meteo Worldwide (90+) None Auto-fallback for any zone with known coordinates
GridStatus US forecasts (7 ISOs) Free token CISO, ERCO, ISNE, MISO, NYIS, PJM, SWPP

Provider priority: UK > EIA > AEMO > Grid India > ONS Brazil > Eskom > Canada > Taiwan > ENTSO-E (with token) > Open-Meteo (with coordinates) > Electricity Maps (last resort; free tier is one registered zone). If a primary provider fails, the action automatically falls back to Open-Meteo weather-based estimation.

Reliability notes:

  • Grid India is reachable only from Indian IPs, so it always fails from GitHub-hosted (US/EU) runners. India zones are therefore left out of the curated auto:* presets. They still work if you pass grid_zones: 'IN-SO' explicitly from a runner inside India.
  • auto:detect needs a cloud-region environment variable, which GitHub-hosted runners don't provide. On those runners it falls back to auto:cleanest (greenest free zone worldwide) and says so in the log. Set grid_zones explicitly to pin a region.

Forecasts

Region Source Details
UK Carbon Intensity API 48h free forecast, automatic
US GridStatus.io Solar/wind/load forecasts. Requires gridstatus_api_key.
EU ENTSO-E Real day-ahead forecast: wind+solar (A69) and load (A65) forecasts give the hourly renewable share. Requires entsoe_token.
India Heuristic Solar peak 10am–4pm IST. Southern grid (IN-SO) cleanest. Automatic.
Brazil Heuristic Hydro off-peak cleanest. Evening peak 17–21h BRT dirtier. Automatic.
South Africa Heuristic Coal-dominant, rarely < 650 gCO2eq/kWh. Recommends escape-coal.
Other Open-Meteo 48h solar/wind weather forecast. Automatic for 90+ zones.

Heuristic and Open-Meteo forecasts are time-of-day or weather estimates, not measured day-ahead forecasts. They are labeled "(estimated)" in the job summary so they are not confused with the measured UK / ENTSO-E / GridStatus forecasts.

Choosing a threshold

Region Typical Range (gCO2eq/kWh) Suggested Threshold
Norway, Iceland, Quebec, Paraguay 10–30 50
France, Sweden, Ontario, Brazil (hydro) 30–80 100
California (midday), Costa Rica 0–150 150200
UK, New Zealand 100–300 200
Germany, US average 200–500 300
Poland, Australia (coal states) 400–800 500 or use auto:escape-coal
India, South Africa 600–900 Use auto:escape-coal:IN / auto:escape-coal:ZA

How carbon intensity is calculated

Fuel-mix providers (EIA, AEMO, ENTSO-E, Grid India, ONS Brazil, Canada, Taipower) weight each source by its IPCC AR5 lifecycle factor in gCO2eq/kWh: coal 820, lignite 1050, gas 490, oil 650, biomass 230, solar 45, geothermal 38, hydro 24, wind 12, nuclear 12. Storage (battery, pumped hydro) is excluded. The UK API returns a pre-calculated value; Electricity Maps returns intensity directly; Open-Meteo modulates each zone's approximate annual-average intensity (a per-zone prior from public yearly data) by real-time solar irradiance and wind speed, so a structurally clean grid (e.g. nuclear France, hydro Norway) reads clean rather than defaulting to a fossil average.

Consumption-based intensity (EU)

By default the action reports production-based intensity (a zone's own generation mix). Set consumption_based: 'true' (single-zone mode, with an entsoe_token) to instead get consumption-based intensity, which flow-traces imports and exports across the European network so a zone importing clean French nuclear reads cleaner, and one importing German coal reads dirtier:

- uses: peterklingelhofer/carbon-aware-dispatcher@v1
  with:
    grid_zone: 'IT-NO'           # Italy North, a heavy importer
    consumption_based: 'true'
    entsoe_token: ${{ secrets.ENTSOE_TOKEN }}

It uses ENTSO-E physical cross-border flows (documentType A11) and solves the flow-tracing linear system (Tranberg et al., 2019) with Gauss-Seidel iteration, no extra dependencies. Covered zones: FR, DE, NL, BE, CH, AT, ES, PT, IT-NO, PL, CZ, GB, IE, DK-DK1. Zones outside this traced network fall back to production intensity. Note: this costs extra ENTSO-E calls (one per traced zone plus its borders), so enable it only when the import/export correction matters.

Known limitations

  • Coverage is best where a free grid-operator API exists. US, UK, EU (with a free ENTSO-E token), Australia, Canada, Taiwan, Brazil, India, and South Africa use real grid data. Other zones fall back to an Open-Meteo weather estimate, or to Electricity Maps if a token is set. Some regions (e.g. Japan, South Korea, Singapore) have no clean free real-time feed, so measured data there requires an Electricity Maps token.
  • Consumption-based intensity is EU-only and opt-in (see above). Other regions report production-based intensity. For global consumption-based data, use a commercial source such as Electricity Maps.
  • Some forecasts are heuristic (see Forecasts), labeled as estimates in the job summary.

Setup wizard

Validate configuration before deploying:

# Test common zones
uv run setup_wizard.py

# Test specific zones
uv run setup_wizard.py --zone CISO
uv run setup_wizard.py --zones "CISO,GB,DE,AU-NSW"

# With API keys
uv run setup_wizard.py --zones "DE,FR" --electricity-maps-token YOUR_TOKEN

Why carbon-aware CI/CD?

GitHub Actions alone produced an estimated ~457 metric tons of CO2e in 2024 (Saavedra et al., 2025). Grid intensity swings widely: California ranges from 400+ gCO2eq/kWh (evening gas) to near-zero (midday solar). Shifting when and where batch jobs run yields 20-50% carbon reductions with no code changes.

Study Key Finding
Claßen et al., 2023 Analyzed 7,392 GitHub Actions workflows. Scheduling CI/CD based on grid intensity effectively reduces emissions.
Saavedra et al., 2025 2.2M runs across 18K repos. Recommends deploying runners in cleaner regions (France, UK).
CarbonScaler (Hanafy et al., 2023) Up to 51% carbon savings by adjusting compute based on real-time grid intensity.
Sukprasert et al., 2023 Even simple scheduling policies capture most achievable carbon reductions.
Yang et al., 2025 Survey of 50+ works reports 10–51% emission reductions from carbon-aware scheduling.

Troubleshooting

Problem Solution
carbon_intensity = unknown API unreachable. Check API key/network. Set fail_on_api_error: 'true' to surface errors.
forecast_green_at = none_in_forecast Grid won't go below threshold in forecast horizon. Raise threshold or use multi-zone / auto:green.
EIA 429 errors Hitting demo key limit (~30 req/hr). Register free for 1,000 req/hr.
Zones silently skipped Zone needs API token that isn't set. Check logs for "Skipping zone" messages.
Zone not found (Electricity Maps) Zone codes are case-sensitive. Check app.electricitymaps.com/map.

Skipped-zone reasons

In multi-zone mode, the job summary lists any skipped zones with a reason so you know whether to act:

Reason Meaning What to do
auth failed The provider rejected the API key/token (HTTP 401/403). Check the secret is set and valid.
rate limited Hit the provider's rate limit (HTTP 429), even after retries. Transient; add a paid/registered key, or it clears on its own.
network error Could not reach the provider after retries. Usually transient; the zone is retried next run.
HTTP <code> An unexpected non-retryable response. Check the provider's status; the zone code may be wrong.
no electricity_maps_token Zone needs an Electricity Maps token and none was set. Add electricity_maps_token, or use a keyless zone.

A clean run never blocks on a skipped zone: it routes to the cleanest zone that did respond.

All timestamps are UTC (ISO 8601).

Development

Install the pre-commit hooks once so a commit can never fail CI's linter:

uv run --extra dev pre-commit install
uv run --extra dev pre-commit install --hook-type pre-push

On commit they run ruff check --fix and ruff format; on push they run mypy and the test suite — the same checks as CI, via uv run so the tool versions match exactly. Run them manually anytime with uv run --extra dev pre-commit run --all-files.

License

MIT

About

GitHub Action that gates CI/CD jobs on real-time grid carbon intensity. Auto-detects your region, checks 10 providers, optionally routes to the greenest zone.

Topics

Resources

License

Security policy

Stars

Watchers

Forks

Packages

 
 
 

Contributors

Languages