A GitHub Actions setup that runs contrail on a schedule and keeps a log of your flight emissions in your own private repo.
Every day it reads your TripIt calendar feed, prices any new flights using Google's
Travel Impact Model, appends them to
flight_emissions.csv, and commits the result back. No server, no machine to maintain.
Your flight history and your API keys stay in your repo. This template contains no data and no secrets, and contrail itself is installed from PyPI at a pinned version.
Click Use this template → Create a new repository, then:
- Name it
my-contrail. Any name works, but the docs, the issue tracker andAGENTS.mdall usemy-contrailas the example — so if you'reoctocat, you'll end up withoctocat/my-contrailand everything you read will line up. - Set it to Private. This is the important one.
Your flight history is personal, and your new repo will accumulate all of it — every route and date you fly, plus the raw provider responses behind each figure. A repo created from a template can be private even though the template is public; that's different from forking, where a fork of a public repo is forced public.
Don't fork this repo, and don't copy the files by hand. "Use this template" is what gives you a repo you can keep private.
Secrets do not carry over from a template. Your new repo has none, and the first scheduled run will fail without them.
Go to Settings → Secrets and variables → Actions → New repository secret and add both:
TripIt → profile icon → Settings → enable Calendar Sync if needed → Calendar Feed.
Treat this as a password. Anyone holding it can read your itineraries.
console.cloud.google.com → create or pick a project → APIs & Services → Library → search "Travel Impact Model API" → Enable → Credentials → Create Credentials → API key.
Free, no billing required. Restricting the key to just that API is recommended.
Go to the Actions tab → sync → Run workflow.
(If you don't see the button, check that .github/workflows/sync.yml is on your repo's default
branch — that's what makes it appear.)
That first run is your backfill: it reads your whole TripIt feed at once. Expect most of it to
come back as typical_route_average rather than exact — see below.
After that it runs itself, daily.
If you use Flighty, export your history (Settings → Export → Export as
CSV) and commit the file to flighty/. The next sync picks it up.
It's worth doing for two reasons. A TripIt feed only carries recent and upcoming trips, while an export is your whole flying history — so this is how you backfill years at once. And the export is the only source that records the cabin you actually flew; without it every row assumes economy, which understates a long-haul business seat by roughly four times.
Your TripIt feed still owns any flight both describe. The export fills in blanks and leaves its own
key in also_seen_as, the column that joins a row back to the seat, PNR and tail number that only
Flighty holds. Keep the FlightyExport-YYYY-MM-DD.csv name: multiple exports are read newest
first, and the newest wins where two disagree.
Skip this entirely and nothing breaks — an empty flighty/ is an ordinary state, and the sync runs
on TripIt alone. See flighty/README.md for the detail.
flight_emissions.csv, committed back to your repo after every run: one row per flight, sorted
by date, with emissions_kg_actual as the per-flight figure.
Alongside it, flight_emissions.raw.jsonl keeps every response the emissions API
gave in full — the well-to-tank/tank-to-wake split, load factors, distance, data
provenance. TIM refuses to price a flight once it has departed, so whatever isn't
captured while a flight is upcoming is gone for good. It only records an answer
when it differs from the last one, so it grows when something changes, not daily.
There's deliberately no running-total column — the file is a record of your flights, not an analysis of them, so nothing in it goes stale when you edit a row and a backfilled old flight doesn't rewrite every row after it. The workflow log prints the current total on every run, and this gets it from the file:
awk -F, 'NR==1{for(i=1;i<=NF;i++)if($i=="emissions_kg_actual")c=i;next} $c!=""{t+=$c} END{printf "%.1f kg CO2e\n",t}' flight_emissions.csvAlso last_checked.txt, a timestamp touched on every run. It exists to guarantee repository
activity: GitHub disables scheduled workflows after 60 days of inactivity, and a quiet stretch of
travel could easily mean no new flights to commit for that long.
Full column reference is in contrail's README.
While a flight hasn't departed, contrail keeps it up to date: a retimed or rerouted flight is corrected, and it is re-priced on every run, since the exact figure depends on the aircraft and short-haul equipment changes right up to departure.
If an upcoming flight disappears from the feed it is marked cancelled in the
status column rather than deleted. Its emissions figures are kept — TIM will
never price a past flight again — but it stops counting toward your total. If it
comes back, so does the row.
From the day after departure a row is frozen and only you can change it.
You don't need to. Every upcoming flight is re-priced on every run, so the daily sync already captures the best figure available before departure.
Scheduling a run just before takeoff, to catch a last-minute aircraft change,
is unlikely to help: TIM's responses carry a dataset stamp (the +dated part of
model_version) that doesn't move daily, which suggests a late call returns what
the morning's call returned. And GitHub's scheduler is best-effort — routinely
delayed, occasionally skipped — so a precise time isn't available anyway.
The Travel Impact Model returns exact, flight-specific numbers only for flights that haven't departed yet. That's how Google's API works, not a contrail limitation.
So contrail uses both: it asks for the exact figure first, and falls back to a route/market
average for anything that has already flown. The emissions_source column records which one each
row used.
The practical consequence: running daily is the point. Each upcoming flight gets its exact number locked in before it departs. Anything first seen after it flew — your initial backfill, mostly — keeps the route average permanently.
sync.yml runs at 15:37 UTC daily. That's UTC year-round; it does not shift with BST or any
other DST.
The odd time is deliberate: workflows scheduled on the hour queue behind everyone else's and get
delayed. Change the cron: line if you'd rather it ran at another time.
requirements.txt pins a contrail release, and sync.yml, check-instance.yml and
check-template.yml all install from it:
contrails==0.4.0
It's pinned to an exact version rather than tracking main so a change upstream can never
surprise a working setup. See contrail's releases
for what's changed.
Dependabot (.github/dependabot.yml) watches this file and opens a pull request when a newer
version is out. Merging it is still your deliberate step — and because it's an ordinary pull
request, check-instance.yml dry-runs it against your own feed and your own log first, the same
as any other change.
Bumping the pin, adding an export, editing the workflow — those are the changes that can
break a sync, and the scheduled run only tells you the next morning, once they've landed.
So check-instance.yml runs on every pull request in your own repo and does the sync
in advance:
contrail sync --csv-path flight_emissions.csv --dry-runIt installs whatever version your branch's requirements.txt pins, reads your feed and your
exports, reconciles them against the log you already have, and prints what a real sync
would do — new flights, matches across sources, anything it couldn't parse. A broken feed
URL, an export contrail can't read, or a pin bumped across a change that doesn't suit
your data all fail the pull request instead of the next morning's run.
Two things it deliberately does not do:
- It never writes.
--dry-runmakes no changes, and the workflow checks that afterwards. Running it against your real log is safe. - It never calls the emissions API, so the job isn't given
TIM_API_KEYat all. A dry run doesn't price anything, so the key would go unused — and skipping the API is what keeps the check to a few seconds. It does mean the check can't tell you your key still works; the scheduled sync is what tells you that.
It also refuses a pull request that deletes flight data — a row gone from
flight_emissions.csv, a deleted flight_emissions.raw.jsonl, or a deleted export in
flighty/. Editing a row in place is fine and expected; deleting one throws away a
figure that usually can't be fetched again. When a deletion really is what you mean, add
the label allow-data-loss to the pull request.
The job is skipped in atdr/contrail-gh, which has no feed, no secrets and no log to
check.
This repo was created from the atdr/contrail-gh
template. Unlike a fork, it has no ongoing link back to the template, so
there's no automatic way to pull in future changes — you set this up
once, yourself. This should rarely matter: contrail-gh's workflow and
README change infrequently, and most contrail upgrades only need you to
merge the Dependabot pull request bumping the pinned version in
requirements.txt, not touch anything here.
One-time setup, right after creating your repo:
git remote add template https://github.com/atdr/contrail-gh.git
git fetch templateCall it template, not upstream. gh picks which repo a command acts on
by remote name, and it ranks exactly three: upstream, then github, then
origin. Give this remote either of the first two and a bare gh pr create or
gh issue create in your private repo quietly targets this public template
instead. Avoid those two names and origin wins. template also describes the
relationship better: this isn't a fork, and there's nothing upstream of you.
Already added it as upstream or github? See
Troubleshooting.
To check for and pull in updates later:
git fetch template
git diff template/main -- .github/workflows/ requirements.txt \
.markdownlint-cli2.yaml .prettierrc.json .prettierignoreThe files fall into two groups. Everything under .github/workflows/,
requirements.txt, README.md, AGENTS.md, .claude/ and the three
Markdown config files (.markdownlint-cli2.yaml, .prettierrc.json,
.prettierignore) are template-owned and safe to pull.
flight_emissions.csv is yours — your real flight data — and should
never be overwritten from the template; last_checked.txt is regenerated
every run and can be ignored either way.
flighty/ is the one directory holding both: its README.md is
template-owned, while any FlightyExport-*.csv beside it is yours. The
template ships no exports, so pulling can't delete one — but if you
resolve conflicts by taking the template wholesale, keep your exports.
Pull just those files rather than merging the whole branch:
git checkout template/main -- .github/workflows/ requirements.txt \
.markdownlint-cli2.yaml .prettierrc.json .prettierignore
# review the diff and re-apply your own version pin (the "contrails==X.Y.Z"
# in requirements.txt) if the template's copy overwrote it, then:
git add .github/workflows/ requirements.txt \
.markdownlint-cli2.yaml .prettierrc.json .prettierignore
git commit -m "Update workflows from contrail-gh"The config files are named alongside the workflows because markdown.yml reads
them: pull the workflow without them and the Markdown check runs against
markdownlint's defaults instead of the settings it was written for. They are
listed one by one rather than pulling all of .github/, which would restore a
dependabot.yml you had deleted.
requirements.txt is the only one carrying anything of yours. sync.yml,
check-instance.yml and check-template.yml all read the pin out of it rather
than repeating it, and check-template.yml does nothing outside
atdr/contrail-gh — so none of the three needs re-editing after a pull.
A pull can also rename a workflow, which changes what the Actions tab calls
it. README.md isn't in the command above, so yours keeps whatever name it was
written against: if step 3 there sends you to a workflow you can no longer find,
compare it against this repo's copy. The names now match the filenames, so
sync.yml is sync.
A full git merge template/main is possible instead, but needs
--allow-unrelated-histories the first time, and will try to merge
flight_emissions.csv too — which doesn't make sense, since your copy has
real data and the template's is just a header row. If you go this route,
resolve that file by always keeping your own version
(git checkout --ours flight_emissions.csv). Pulling the one file you
actually want is simpler and avoids this entirely.
gh commands here act on atdr/contrail-gh instead of my repo. You named
the template remote upstream or github, both of which gh ranks above
origin. Rename it:
git remote rename upstream template # or: github templateAnything you already created with a bare gh command went to that public repo
rather than this one, so it's worth a look before carrying on.
The first scheduled run failed. Almost always missing secrets — see step 2. Secrets never carry over from a template.
The commit-back step failed with a 403. permissions: contents: write is missing from
sync.yml. GITHUB_TOKEN has been read-only by default since February 2023.
Rows say unparsed. contrail found something that looked like a flight but couldn't
confidently read the carrier, flight number, or airports from it. Check the raw_summary column
and fill the emissions in by hand if you want them counted — the figure counts from the next run
onwards. If you see a pattern of these, it's worth
opening an issue on contrail.
A pull request fails on "must not delete flight data". It removes a row, the raw log,
or a flighty/ export. Usually that's a merge or a rebase gone sideways rather than an
intention, so check the diff first. If you did mean it — replacing a superseded export,
say — add the label allow-data-loss to the pull request and re-run the check.
The workflow stopped running. GitHub disables scheduled workflows after 60 days of no
repository activity. last_checked.txt exists to prevent this, but if it happens, re-enable the
workflow from the Actions tab.
MIT — see LICENSE.