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

Repository files navigation

contribution-life

Your contribution graph, running Conway's Game of Life.

Pure SVG and CSS keyframes — no JavaScript, no GIF, no runtime. Drop one workflow in and it regenerates every day.

test marketplace license dependencies: none

A GitHub contribution graph running Conway's Game of Life

The loop holds your real graph, runs Life on it, and cuts back.


Use it

Add .github/workflows/contribution-life.yml to your profile repository:

name: contribution-life
on:
  schedule: [{ cron: "17 3 * * *" }]
  workflow_dispatch:
permissions:
  contents: write
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      # required: the publish step below needs to be inside a git checkout
      - uses: actions/checkout@v7

      - uses: satomasahiro2005/contribution-life@v1
        with:
          github_token: ${{ secrets.GITHUB_TOKEN }}

          # everything below is optional; the value shown is the default
          # login:      octocat   # whose graph to render; defaults to the repo owner
          # layout:     calendar  # calendar 53x7 | split 27x7 twice | square 19x19
          # rule:       B3/S23    # any Life-like B../S.. ; B34/S34 is busier, B36/S23 calmer
          # gens:       0         # generations per loop; 0 fits it to how long the board lasts
          # seed_level: 0         # lowest contribution level that counts as alive, 1-4; 0 = auto
          # color:      hybrid    # hybrid | density | age | gene
          # hold:       5         # frames your real graph is held before Life starts
          # fade:       2         # frames the levels below seed_level fade out over
          # frame_ms:   150       # milliseconds per frame
          # edges:      torus     # torus | dead | auto (auto tries both, keeps the better)
          # out_dir:    dist      # directory the SVGs are written to
          # name:       contribution-life   # produces NAME.svg and NAME-dark.svg
      - name: Publish the SVGs to an orphan branch
        run: |
          set -euo pipefail
          staging=$(mktemp -d) && cp dist/*.svg "$staging/"
          git config user.name  "github-actions[bot]"
          git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
          git checkout --orphan output
          git rm -rf . >/dev/null 2>&1 || true
          cp "$staging"/*.svg .
          git add ./*.svg
          git commit -m "contribution-life $(date -u +%F)"
          git push -f origin output

The actions/checkout step is what people miss: the action itself does not need it, but git push at the end has nothing to push from without it, and the job fails with fatal: not in a git directory.

Run it once, then put this in your README:

<picture>
  <source media="(prefers-color-scheme: dark)"
          srcset="https://raw.githubusercontent.com/USER/REPO/output/contribution-life-dark.svg">
  <img alt="Contribution graph running Conway's Game of Life"
       src="https://raw.githubusercontent.com/USER/REPO/output/contribution-life.svg">
</picture>

Or run it locally — standard library only, nothing to install:

GITHUB_TOKEN=$(gh auth token) python glife.py fetch --login YOUR_NAME
python glife.py render

Pick a shape

Seven rows is very thin for Life. layout reshapes the same year of data, and it changes how long the colony survives more than anything else does.

calendar   53×7  ·  the familiar one

calendar layout

split   27×7 twice  ·  half the year per band

split layout

square   19×19 = 361  ·  the last 361 days packed row-major

square layout

Population of B3/S23 over 100 generations, same seed, edges wrapped:

layout shape median second half
calendar 53×7 9% 5% thins out past ~gen 45
split 27×14 3% 3% thins out faster
square 19×19 12% 16% never collapses, recovers

It tunes itself

gens and seed_level default to auto. Every threshold is actually simulated and cut once the board empties or its exact state starts repeating; the one that runs longest wins. Contribution graphs vary enormously and no fixed setting works for all of them:

graph picked population
a very quiet year 6% full L1+ 15–35
a quiet year 13% full L1+ 13–51
a normal year 27% full L1+ 11–103
a very busy year 95% full L2+ 24–121

That last row is the one that matters. At 95% density every cell has 7 or 8 neighbours, none of which are in S23, so an untouched board dies of overpopulation on the very first step. Raising the threshold thins it back into a range where Life works — and the levels being dropped fade out on screen during the intro, so nothing is quietly removed behind your back.

Repetition is the only signal used for "nothing new is happening", and population deliberately is not. A sparse graph starts near zero, so any measure of how full the board is would cut those runs off immediately however much was still moving. A glider crossing a torus does not repeat until it has come all the way round, so it keeps running.

edges defaults to torus, which is the right call for two of the three layouts. edges: auto runs both and keeps the better, and it is worth setting on split:

layout torus dead
calendar 100 gens 43 gens the 53×7 strip needs the wrap
split 49 gens 100 gens the wrap folds the two bands into each other
square 100 gens 100 gens either works

Is it really Game of Life?

The rule engine is plain B3/S23 with simultaneous updates. test_rules.py checks that against published patterns instead of asking you to trust it:

  • five still lifes stay fixed; blinker, toad and beacon have period 2; the pulsar has period 3
  • a glider keeps its shape and moves exactly (1,1) every four generations
  • r-pentomino and acorn match an independent sparse-set implementation for 150 generations, cell for cell
  • diehard vanishes at generation 130, as it must
python test_rules.py

Colour is the only thing layered on top, and it never feeds back into birth or death. A coloured cell is always a live cell — dead cells get no fading trail, so what you see is exactly the state of the board.

How dark a cell gets

GitHub's palette has four live levels, and the obvious ways to choose one are worse than they look. Share of each level actually used:

color L1 L2 L3 L4
age — generations survived 41% 22% 17% 18% washed out; most live cells are newborns
gene — majority vote of the three parents 84% 0% 0% 14% collapses, because 75% of a real graph is level 1
density — live cells in the surrounding 5×5 31% 21% 31% 16% bright cores, dark fringes
hybrid — all three 26% 30% 21% 21% default

Whichever metric you pick, its raw values are gathered across every frame and split at their own quartiles, so all four levels stay in use no matter the rule.

Other rules

rule takes any Life-like B../S.. string. Measured over 120 generations:

rule median churn
B3/S23 9% 8% Conway, the default
B36/S23 12% 12% HighLife; replicators spread into empty regions
B36/S125 13% 12% steady, and much livelier on square
B34/S34 39% 42% busy, fills the board indefinitely
B35/S236 37% 37% busy but structureless
B368/S238 18% 20% Day & Night; grows into a blob
B3/S1234 48% 2% freezes solid

Preview and tune

python serve.py     # http://localhost:8765

Type any username, scrub the animation frame by frame, and watch the population curve and palette usage react. It renders through the same pipeline the action does, so what you see is what gets published. Fetched calendars are cached under cache/.

sweep.py regenerates the rule and layout tables above for your own graph, and filmstrip.py dumps selected frames as ASCII.

Inputs

action input CLI flag default
github_token — — GITHUB_TOKEN sees public contributions only
login --login repo owner
layout --layout calendar calendar / split / square
rule --rule B3/S23 any B../S..
gens --gens 0 generations per loop, 0 = auto
seed_level --seed-level 0 minimum level counted as alive, 0 = auto
color --color hybrid hybrid / density / age / gene
hold --hold 5 frames the real graph is held
fade --fade 2 frames the dropped levels fade over
frame_ms --frame-ms 150
edges --edges torus torus / dead / auto
out_dir / name --out / --name dist / contribution-life

Why it has to be baked

README images are served through GitHub's camo proxy and rendered in an <img> context, so scripts never run. Only CSS animations and SMIL work. Every generation is therefore simulated ahead of time and written out as keyframes: unchanging cells become plain static rects, changing cells get one stop per change, identical timelines are shared, and step-end holds each stop until the next so generations stay discrete. The intro fade is the one exception — those keyframes switch to linear so the dropped cells dissolve instead of blinking out.

A typical loop is around 100 frames and 125 KB, which is about 11 KB over the wire once camo has gzipped it.

Versions

v1 is a moving alias for the newest 1.x, and is what the examples above use. Pin an exact release like @v1.1.0 if you would rather freeze the behaviour — gens and seed_level are fitted by simulation, so a change to how a run is cut short can change what your graph looks like.

Notes

  • A scheduled workflow stops after 60 quiet days. GitHub disables schedule on a public repository when nothing has happened there for 60 days, and the daily commit this workflow makes does not count as something happening. You get an email before it goes, and one button in the Actions tab brings it back; running it by hand from time to time also resets the clock. Nothing breaks when it stops — the graph just stays on the last day it rendered.
  • The default GITHUB_TOKEN only sees public contributions. For a graph that matches your profile, use a PAT with read:user.
  • camo caches images; a push to the repository clears it.
  • prefers-color-scheme inside an <img> follows the OS theme rather than GitHub's own theme setting. That is a limitation of <picture> on GitHub generally, not of this tool.

License

MIT

Releases

Packages

Contributors

Languages