# GitHub Actions

Running ghchronicle as a GitHub Action: the composite Action, its four modes, and the two things a hosted runner does not keep.

Source: https://docs.jmrp.io/ghchronicle/install/actions/

The repository ships a composite Action, so a workflow needs no Go toolchain:
it downloads a release binary and calls it.

```yaml
- uses: jmrplens/ghchronicle@v2
  with:
    token: ${{ secrets.GHCHRONICLE_TOKEN }}
    mode: once
    config: .github/ghchronicle.yaml
```

## Inputs

| Input             | Default              | What it does                                                                                                      |
| ----------------- | -------------------- | ----------------------------------------------------------------------------------------------------------------- |
| `token`           | required             | A personal access token. The automatic `GITHUB_TOKEN` is not enough                                               |
| `config`          | `""`                 | Path to a configuration file. Omit to run with defaults built from `user`, whose state lasts one run and cannot be cached |
| `user`            | the repository owner | The account to collect when no config file is given                                                               |
| `mode`            | `once`               | `once`, `backfill`, `card` or `migrate`                                                                           |
| `backfill-since`  | `""`                 | Bound for `backfill`: a date, `90d`, `2y` or a Go duration. Empty means no bound                                  |
| `card`            | `""`                 | Path of the SVG to write. Empty means no card                                                                     |
| `card-layout`     | `summary`            | One of the thirteen registered layouts                                                                                 |
| `card-theme`      | `auto`               | `dark`, `light`, `auto`, or `both` for a light card and its `_dark` twin                                          |
| `card-fields`     | `""`                 | Comma-separated fields. Empty means the layout's default                                                          |
| `card-motion`     | `once`               | `once`, `loop` or `off`; `loop` changes only `terminal` and `ticker`                                                                 |
| `card-width`      | `""`                 | Card width in pixels. Empty draws the layout at its own width; each one draws between two ends of its own, stated in its [section](https://docs.jmrp.io/ghchronicle/card/layouts/). Only `activity-heatmap` spends the room on data, a whole year of the calendar at its far end |
| `card-speed`      | `""`                 | How fast an animated layout plays, as a decimal from 0 to 1. Empty means 0.5, the pace every card has always been drawn at; below it the card is slower, above it faster, and every animated layout scales together. 0 is the slowest animation and not a still card, `card-motion: off` is |
| `include-private` | `false`              | `true` counts private repositories when no config file is given. See the warning below |
| `version`         | `latest`             | The release to install: `latest` for the newest, or a release with or without its `v`, so `2.6.5` and `v2.6.5` are the same one. The major tag `v2` is what `uses:` takes, not a release, and is refused |

## The four modes

- **once**

  One sweep against a configuration file you supply, which is how a workflow
  feeds a database.

  ```yaml
  name: Collect
  on:
    schedule:
      - cron: "*/30 * * * *"
    workflow_dispatch:

  jobs:
    collect:
      runs-on: ubuntu-latest
      steps:
        - uses: actions/checkout@v7
        - uses: jmrplens/ghchronicle@v2
          with:
            token: ${{ secrets.GHCHRONICLE_TOKEN }}
            mode: once
            config: .github/ghchronicle.yaml
          env:
            INFLUX_TOKEN: ${{ secrets.INFLUX_TOKEN }}
  ```

  The config file refers to the database credentials as `${VAR}`, so they go
  in as secrets and never into the repository.

- **card**

  One sweep and one SVG, and nothing written to any database. This is the
  only mode that needs no store configured at all.

  ```yaml
  name: Profile card
  on:
    schedule:
      - cron: "17 6 * * *"
    workflow_dispatch:

  permissions:
    contents: write

  jobs:
    card:
      runs-on: ubuntu-latest
      steps:
        - uses: actions/checkout@v7
        - uses: jmrplens/ghchronicle@v2
          with:
            token: ${{ secrets.GHCHRONICLE_TOKEN }}
            mode: card
            card: generated/github-stats.svg
            card-layout: github-stats
            card-theme: both
        - name: Commit if it changed
          run: |
            git config user.name "github-actions[bot]"
            git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
            git add generated/
            git diff --staged --quiet || git commit -m "Update the profile card"
            git push
  ```

  The SVG is byte-identical for the same input, so a day with no change
  produces no commit.

- **backfill**

  Reaches as far back as GitHub allows and waits for the rate limit rather
  than stopping. Run it once, by hand.

  ```yaml
  name: Backfill
  on:
    workflow_dispatch:
      inputs:
        since:
          description: "A date, 90d, 2y, or empty for no bound"
          default: "2y"

  jobs:
    backfill:
      runs-on: ubuntu-latest
      timeout-minutes: 360
      steps:
        - uses: actions/checkout@v7
        - uses: jmrplens/ghchronicle@v2
          with:
            token: ${{ secrets.GHCHRONICLE_TOKEN }}
            mode: backfill
            backfill-since: ${{ inputs.since }}
            config: .github/ghchronicle.yaml
  ```

  Give it a generous `timeout-minutes`: an unbounded backfill parks at every
  rate limit reset, and a job that is killed half way has spent the quota and
  kept part of the benefit.

- **migrate**

  Brings the stores the configuration writes along after an upgrade: it runs
  `-migrate -yes`, which applies every change [the
  plan](https://docs.jmrp.io/ghchronicle/install/upgrading/#migrations) finds pending, the ones
  that need somebody's word too, and reads what it cleared again. Run it by
  hand, once, after reading what `-migrate` says about the same
  configuration.

  ```yaml
  name: Migrate
  on:
    workflow_dispatch:

  jobs:
    migrate:
      runs-on: ubuntu-latest
      timeout-minutes: 60
      steps:
        - uses: actions/checkout@v7
        - uses: jmrplens/ghchronicle@v2
          with:
            token: ${{ secrets.GHCHRONICLE_TOKEN }}
            mode: migrate
            config: .github/ghchronicle.yaml
  ```

  It takes no card. A store holding rows of accounts this configuration does
  not collect, or whose rows could not be compared with it, is held back and
  the step fails, since only `-migrate-others` clears it, and that is a
  decision to take at a terminal, not in a workflow. Without a state file
  [restored between runs](https://docs.jmrp.io/ghchronicle/install/actions/#two-things-a-hosted-runner-does-not-keep), every
  run of the Action starts from a new one, so a store that cannot be asked, a
  SQL file, a Graphite or whatever is behind a Telegraf, has no history here
  and is never taken to need a change; with one restored, those follow its
  record as they would on a host. A job that timed out while reading the
  history again leaves nothing the next job can resume from, since
  `actions/cache` saves nothing for a job that did not succeed: run
  `mode: backfill` for the same configuration after it.

In `once` and `backfill`, a start checks the stores the same way and warns
about what is pending. On a new state file, which is every run without one
restored, it applies nothing on its own even under `migrate: auto`: the state
file goes with the runner, and with it the record that a refill is still owed,
so a refill that failed or a job cancelled half way would leave a store cleared
and nothing saying so. With a state file restored it applies what is safe to
apply unattended, as a host does. The Action turns every line that says a
change is pending, one was applied at the start, one failed or a refill is
owed into an annotation on the run as the line is written, so it shows on the
workflow's summary page rather than only in its log, a job cancelled half way
included.

## A card in your profile README

GitHub shows the README of the repository named after your account, `<you>/<you>`,
at the top of your profile. The card lives in that repository as a file, the
README points at it once, and a scheduled workflow replaces the file. The README
itself is never rewritten.

1. Create a personal access token (see [the token](https://docs.jmrp.io/ghchronicle/start/token/)) and
   store it in `<you>/<you>` as the secret `GHCHRONICLE_TOKEN`. The automatic
   `GITHUB_TOKEN` will not do, even for public numbers: it is not a user, so the
   Action cannot list your repositories with it and the card comes out empty.
2. Add `.github/workflows/card.yml`:

   ```yaml
   name: Profile card
   on:
     schedule:
       - cron: "17 6 * * *"
     workflow_dispatch:

   permissions:
     contents: write

   # One run at a time: two that overlap would race each other to push.
   concurrency:
     group: profile-card
     cancel-in-progress: false

   jobs:
     card:
       runs-on: ubuntu-latest
       steps:
         - uses: actions/checkout@v7
         - uses: jmrplens/ghchronicle@v2
           with:
             token: ${{ secrets.GHCHRONICLE_TOKEN }}
             mode: card
             card: generated/card.svg
             card-layout: animated-counters
             card-theme: both
             card-motion: once
         - name: Commit if it changed
           run: |
             git config user.name "github-actions[bot]"
             git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
             git add generated/
             git diff --staged --quiet || git commit -m "Update the profile card"
             git push
   ```

3. Paste this line into `README.md` wherever the card should appear, once:

   ```html
   <picture><source media="(prefers-color-scheme: dark)" srcset="generated/card_dark.svg"><img src="generated/card.svg" alt="My GitHub statistics"></picture>
   ```

4. Run the workflow by hand from the Actions tab the first time, so the files
   exist before the first scheduled run.

**More than one card.** Repeat the `uses: jmrplens/ghchronicle@v2` step with
another `card` path and layout, and paste one `<picture>` per card. Each step is
a sweep of its own, and each draws every number again: a run that writes a card
collects every family whatever its cadence says, and one in `card` mode writes
nothing back to the state file. Several card steps can therefore share one
`config`, and so one `state_file`, and each card is the whole account. That is
also what each one costs: a card is a cold sweep of every family, so N cards
are N sweeps of [the per-family price](https://docs.jmrp.io/ghchronicle/api/cost/).

**A README that is not at the root.** This is for other repositories, since
GitHub shows a profile's README only from the root of `<you>/<you>`. The paths
in the `<picture>` are relative to the README, so a `docs/README.md` points at
`../generated/card.svg`.

> **Private repositories**
>
> Without a config file, stars, forks, languages, traffic and repository names
> come from public repositories only, while contribution, commit and pull
> request counts are account totals, as GitHub reports them on your profile.
> With `include-private: true` the card adds the stars, forks,
> languages and traffic of your private repositories, and the layouts that list repositories
> **publish their names**: `repo-list` and `summary` (the Action's default
> layout) list them by default, and so does any layout given `top_repos` in
> `card-fields`. To count them without naming them, pick the fields yourself and leave
> `top_repos` out, for example `card-fields: stars,forks,followers,contributions`.

## Two things a hosted runner does not keep

> **The token must be a personal access token**
>
> Traffic needs push access to *every* repository being collected and, on a
> fine-grained token, the repository permission Administration (read); the
> automatic `GITHUB_TOKEN` cannot be granted Administration, so it gets a 403
> even in the repository the workflow runs in. It also has no
> `security_events`, no `read:packages`, and is not a user, so nothing
> account-wide works with it. See [the token](https://docs.jmrp.io/ghchronicle/start/token/).

**The state file does not survive between runs.** Without it every run is a
first run. Every family runs, whatever its cadence says, because nothing
remembers when it last did. The full stargazer walk is done again, and every
repository's daily star history is read whole, a page per thirty weeks of its
life. `achievements` walks the account's merged pull requests whole for its
co-authored count, which was 35 queries and 23.7 MB on an account with 2,315
of them. And with no [cache file](https://docs.jmrp.io/ghchronicle/configuration/#the-cache-beside-it)
beside the state, nothing is asked with an ETag, so no answer is a free 304. On
a small account that is a handful of calls; on an account with many stars, old
repositories or a long history, cache it.

Caching needs a `config:` file. Without one the Action writes a configuration
of its own and puts the state in a new temporary directory under `RUNNER_TEMP`
on every run, which no cache step can name. With one, point `state_file` at the
home directory:

```yaml
state_file: ~/.ghchronicle/state.json
```

and restore and save that directory with a step before the ghchronicle one:

```yaml
- uses: actions/cache@v6
  with:
    path: ~/.ghchronicle
    key: ghchronicle-state-${{ github.run_id }}
    restore-keys: ghchronicle-state-
```

A `~` in `state_file` is the home directory from 2.6.1 on. An older release
reads it as written, a directory called `~` in the checkout that the cache step
never sees, so with a `version` older than 2.6.1 write the path out:
`/home/runner/.ghchronicle/state.json` on a GitHub-hosted Ubuntu runner.

The directory then holds the cache file as well, which a `once` step writes and
a `backfill` step only reads, so a run restored from it also asks GitHub with
the validators the last one stored, and pays only for what changed. A `card` mode run reads a restored state file, which
is what lets it skip both walks, and never writes one back, nor the cache file
beside it: it delivers its points to the card and to no store, so nothing it
learned may tell the next collection that a family is already done. What fills
the `actions/cache` entry is a `once` step, with both files, or a `backfill`
step, with the state file alone. No step keeps a write ledger there: a run that
ends with its sweep opens none.

## Without the Action

The same thing by hand, if you would rather not depend on it:

```yaml
- uses: actions/setup-go@v7
  with:
    go-version: stable
- run: go install github.com/jmrplens/ghchronicle/v2/cmd/ghchronicle@latest
- run: ghchronicle -config .github/ghchronicle.yaml -card profile.svg -card-only
  env:
    GITHUB_TOKEN: ${{ secrets.GHCHRONICLE_TOKEN }}
```
