Skip to content
· 21 min read
A searchable CI failure log on GitHub Actions — user manual

A searchable CI failure log on GitHub Actions — user manual

Every time a build breaks, GitHub keeps the logs — for 90 days, buried three clicks deep, one run at a time, with no way to ask "how often has *this* test flaked this month?" A log-aggregator subscription fixes the searching and charges you for retention you didn't ask for.

There's a smaller move. A GitHub Actions step that fires **only when the build fails** and `curl`s a one-line summary into a Fresh Jots note named after the repo. The note *is* the failure history: one repo, one append-only stream, every red build a line, addressable forever, searchable from your account, bounded only by storage — not by a 90-day clock or a per-seat bill.

This post assumes you've never added a step to a workflow and have never called the Fresh Jots API. It's the whole thing, click by click. If you already run crons against Fresh Jots, this is the CI-on-failure cousin of Write to Fresh Jots from a cron job.

You'll need a Fresh Jots API token. The API is a **Pro or Team** feature — or a free **14-day trial** that behaves exactly like Pro. Two easy steps to set up everything, and get you going is five minutes; come back here.

1. What you're building, in one sentence

When a workflow fails, one extra step runs, `POST`s a line like `FAIL tests #214 main a1b2c3d — <link to the run>` to `https://freshjots.com/api/v1/notes/by-filename/ci-failures-<repo>/append`, and Fresh Jots **creates the note on the first failure and appends a line on every failure after**. No note to pre-create, no ID to store, no server of your own.

2. Put the token where Actions can read it

A workflow can't see your shell environment or a file on your laptop. It reads secrets you store on the repository. Never paste a token into the workflow file itself — the file is committed to git, and anyone who can read the repo can read the token.

In your repository on github.com:

1. **Settings** (the repo's own Settings tab, not your account's).
2. Left sidebar → **Secrets and variables****Actions**.
3. **New repository secret**.
4. **Name:** `FRESHJOTS_TOKEN` (exactly this — the examples below reference it by this name).
5. **Secret:** paste your `mn_…` token. Save.

That's it. GitHub encrypts it, masks it in every log line, and exposes it to workflows only as `${{ secrets.FRESHJOTS_TOKEN }}`.

**Worth knowing — forks can't see it.** If your CI runs on pull requests *from forks* (common in open-source repos), those runs **do not** get repository secrets — GitHub withholds them so a stranger's PR can't exfiltrate your credentials. A fork-PR build that fails therefore logs nothing, and that's correct, not a bug. The step below is written so a missing token is a clean skip, not a second red X.

3. Where the workflow file lives

GitHub Actions workflows are YAML files in `.github/workflows/` at the root of your repo. You either already have one (e.g. `.github/workflows/ci.yml` — that's the thing running your tests) or you don't.
- **You already have one** → you're adding *one step* to it. Skip to "Add the step."
- **You don't, or you want this isolated** → drop in the complete standalone file further down.
A *step* is one item in a job's `steps:` list. The whole trick here is a step with an `if:` condition that makes it run **only when an earlier step failed**.

4. TL;DR — just apply this

If you already have an automated deploy and want the full picture wired up in one file — auto-deploy when CI is green, plus a forever-log of every outcome (a successful deploy, a deploy that broke mid-flight, *and* the red-CI runs where deploy was correctly skipped) — save the file below as `.github/workflows/deploy.yml`. The repository secrets it references all go in the same place you put `FRESHJOTS_TOKEN` in Step 1: add `DEPLOY_HOST` and `DEPLOY_SSH_KEY` alongside it (or substitute whatever your deployer needs). Every event then lands as one line in a `deploys-<repo>` Fresh Jots note.

```yaml
name: Deploy

on:
  workflow_run:
    workflows: ["CI"]            # the `name:` of your existing test workflow
    types: [completed]
    branches: [master]           # restrict to CI runs on your default branch
  workflow_dispatch:             # optional "Run workflow" button in the Actions UI

concurrency:
  group: deploy-production
  cancel-in-progress: false

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - name: Deploy via SSH
        if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }}
        id: ssh
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.DEPLOY_HOST }}
          username: deploy
          key: ${{ secrets.DEPLOY_SSH_KEY }}
          script: /var/www/my-app/bin/deploy

      - name: Log deploy to Fresh Jots
        if: always()
        uses: Goran-Arsov/freshjots-append@v1
        with:
          note: deploys-${{ github.event.repository.name }}
          text: >-
            ${{ github.sha }} —
            CI ${{ github.event.workflow_run.conclusion || 'manual' }},
            deploy ${{ steps.ssh.outcome }}
        env:
          FRESHJOTS_TOKEN: ${{ secrets.FRESHJOTS_TOKEN }}
```

The `appleboy/ssh-action` step is the placeholder — swap it for Capistrano, Kamal, `scp-action`, or whatever platform-specific deploy hook you actually use. Three lines stay no matter what you swap in: `id: ssh` so the log step can read its outcome, the `if:` *on the step* (not on the job), and the log step underneath with `if: always()` reading `steps.ssh.outcome`. Putting that `if:` on the *job* instead would skip the log step too, so the red-CI runs you most wanted logged would leave no trace; the long-form explanation of why is in *Gated jobs (deploys): when `always()` doesn't reach*, further down under *Patterns that work*.

Don't have an automated deploy? Skip to *Add the step — the easy way (published Action)* below — that's the minimal within-CI recipe for failure-only logging, without the deploy machinery.

5. Add the step — the easy way (published Action)

There's a maintained GitHub Action that does the `curl` for you. Add this as the **last step** of the job that runs your build:

```yaml
      - name: Log CI failure to Fresh Jots
        if: ${{ failure() }}
        uses: Goran-Arsov/freshjots-append@v1
        with:
          note: ci-failures-${{ github.event.repository.name }}
          text: >-
            FAIL ${{ github.workflow }} #${{ github.run_number }}
            ${{ github.ref_name }} ${{ github.sha }} —
            ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
        env:
          FRESHJOTS_TOKEN: ${{ secrets.FRESHJOTS_TOKEN }}
```

Five things in there that are doing real work:

- **`if: ${{ failure() }}`** is the whole point. By default a step runs only if every previous step *succeeded*; the moment one fails, GitHub skips the rest. `failure()` inverts that: this step runs **only** when something earlier in the job has already failed. Drop this line and the step runs on green builds too — sometimes what you want (see Patterns), usually not.
- **`note: ci-failures-${{ github.event.repository.name }}`** is the per-repo stream. `github.event.repository.name` is the bare repo name (`my-app`). The note is created on the first failure and reused forever after — the repo name is, in effect, the tag.
- **Use `repository.name`, not `github.repository`.** `github.repository` is `owner/my-app` *with a slash*, and the API rejects slashes in filenames outright — you'll get a `422 validation_failed` ("filename must be 1..200 bytes, contain no slashes") on every red build, so the failure log itself never logs. `github.event.repository.name` is the bare segment (`my-app`) and passes cleanly. This is the one easy mistake; make it once here and never again.
- **`env: FRESHJOTS_TOKEN:`** hands the secret to the Action as an environment variable. The Action deliberately accepts the token *only* this way and never as a `with:` input — a token-shaped `with:` value ends up readable in the workflow file or run metadata. Env-only makes the safe way the only way.
- **The Action fails loudly on a missing token** with a message naming the exact secret to add — except on fork PRs, where there's simply no secret and the step is a no-op. You won't get a mystery 401.

The `note-id` of the note is exposed as a step output (`steps.<id>.outputs.note-id`) if you want to chain on it; most people never need it.

6. Pin it if you care about supply chain

`@v1` follows the v1 major tag. If your security posture requires immutable third-party Actions, pin to the commit SHA instead (`uses: Goran-Arsov/freshjots-append@<full-40-char-sha>`) and bump it deliberately. Same advice you'd apply to any third-party Action.

7. Add the step — the transparent way (raw curl, no Action)

Some teams won't add a third-party Action, full stop. The endpoint is plain HTTPS; you don't need one. This step is exactly equivalent:

```yaml
      - name: Log CI failure to Fresh Jots
        if: ${{ failure() }}
        env:
          FRESHJOTS_TOKEN: ${{ secrets.FRESHJOTS_TOKEN }}
          REPO: ${{ github.event.repository.name }}
          SUMMARY: "FAIL ${{ github.workflow }} #${{ github.run_number }} ${{ github.ref_name }} ${{ github.sha }} — ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
        run: |
          if [ -z "${FRESHJOTS_TOKEN:-}" ]; then
            echo "::warning::FRESHJOTS_TOKEN not set (fork PR?) — skipping failure log"
            exit 0
          fi
          body=$(jq -nc --arg t "$SUMMARY" '{text:$t}')
          curl -fsS -X POST \
            -H "Authorization: Bearer ${FRESHJOTS_TOKEN}" \
            -H "Content-Type: application/json" \
            -d "$body" \
            "https://freshjots.com/api/v1/notes/by-filename/ci-failures-${REPO}/append"
```

Notes on the choices:

- **`jq -nc --arg t "$SUMMARY"`** builds the JSON body safely. Commit messages and branch names contain quotes, backticks, and `$`; hand-rolled `-d '{"text":"'"$SUMMARY"'"}'` breaks the first time someone names a branch `fix/"weird"`. `jq` is preinstalled on GitHub's `ubuntu-latest` runners.
- **The missing-token guard** mirrors the Action: on a fork PR the secret is empty, so the step prints a warning and exits 0 instead of failing red. A failed build that *also* can't log shouldn't compound into two failures.
- **`curl -fsS`** — `-f` makes curl exit non-zero on an HTTP 4xx/5xx so a real problem (bad token, tier expired) surfaces in the log; `-sS` hides the progress bar but keeps error messages.

8. The complete standalone workflow

If you don't have a workflow yet, or you want the failure log decoupled from your test pipeline, save this as `.github/workflows/ci-failure-log.yml`. It watches your *real* CI workflow and logs when it concludes in failure:

```yaml
name: CI failure log

on:
  workflow_run:
    workflows: ["CI"]          # <-- the `name:` of your existing test workflow
    types: [completed]

jobs:
  log-failure:
    if: ${{ github.event.workflow_run.conclusion == 'failure' }}
    runs-on: ubuntu-latest
    steps:
      - name: Append failure to Fresh Jots
        uses: Goran-Arsov/freshjots-append@v1
        with:
          note: ci-failures-${{ github.event.repository.name }}
          text: >-
            FAIL ${{ github.event.workflow_run.name }}
            run #${{ github.event.workflow_run.run_number }}
            ${{ github.event.workflow_run.head_branch }}
            ${{ github.event.workflow_run.head_sha }} —
            ${{ github.event.workflow_run.html_url }}
        env:
          FRESHJOTS_TOKEN: ${{ secrets.FRESHJOTS_TOKEN }}
```

Change `workflows: ["CI"]` to match the `name:` line of the workflow you actually want to watch. One real difference worth knowing: a `workflow_run` workflow runs from the **default branch's** copy and always has access to secrets — *including* for fork PRs, because it runs in your repo's trust context, not the fork's. So this standalone form is the one that captures fork-PR failures too. The trade-off is it can't fail the build it's reporting on (it runs after, separately) — which for a failure *log* is exactly right.

9. Confirm it works

Don't wait for a real breakage. Push a branch with one deliberately failing test (or add a `- run: exit 1` step before the logging step), let CI go red once, then check the note:

```bash
curl -fsS https://freshjots.com/api/v1/notes/by-filename/ci-failures-my-app \
  -H "Authorization: Bearer $FRESHJOTS_TOKEN"
```

You'll get the note JSON back with your one `FAIL …` line in `plain_body`. Open the note in the Fresh Jots web app and — bonus you didn't ask for — every future failure **streams in live over the open tab**, no refresh. A wall-mounted "what broke today" board, for the price of one curl per red build. Then revert the deliberate failure.

10. Patterns that work

A. Failure-only (the default above)

`if: ${{ failure() }}`. The note contains *only* breakages. Quiet when things are healthy; a dense, greppable record of every red build when they're not. This is what most people want.

B. Every run, pass or fail (a heartbeat)

Swap `failure()` for `always()` and put the status in the line. The Action also takes `append-deadline-hours` — set it once on every run and Fresh Jots will email you when the next pulse fails to arrive in time:

```yaml
        if: ${{ always() }}
        with:
          note: ci-runs-${{ github.event.repository.name }}
          text: "${{ job.status }} ${{ github.workflow }} #${{ github.run_number }} ${{ github.ref_name }}"
          append-deadline-hours: 26   # nightly CI + 2h grace
```

`job.status` is `success`, `failure`, or `cancelled`. Now you can answer "what's our pass rate this week" by skimming one note. The `append-deadline-hours` line wires up the **dead-man's switch**: as long as runs keep heartbeating in, nothing happens; the moment they stop — the build server died, the cron got disabled, the runner pool was drained — the note's deadline passes unsent and you get an email. Idempotent: setting the same value on every run is a no-op server-side, so it's safe to leave in. See [The dead-man's switch](/blog/dead-mans-switch) for the longer treatment.

This is the failure mode no green/red dashboard can show you, because a dashboard that isn't being written to looks the same as one that's fine.

C. Gated jobs (deploys): when `always()` doesn't reach

Logging a *deploy* is the variant of this pattern people reach for next. A separate workflow runs on `workflow_run` after CI, deploys when CI was green, and appends a line to a `deploys-<repo>` note so there's a permanent record of what shipped when. The obvious shape — and the one that bites every first attempt — looks like this:

```yaml
jobs:
  deploy:
    runs-on: ubuntu-latest
    if: ${{ github.event.workflow_run.conclusion == 'success' }}
    steps:
      - name: Deploy via SSH
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.DEPLOY_HOST }}
          key: ${{ secrets.DEPLOY_SSH_KEY }}
          script: /var/www/my-app/bin/deploy

      - name: Log deploy to Fresh Jots
        if: always()
        uses: Goran-Arsov/freshjots-append@v1
        with:
          note: deploys-${{ github.event.repository.name }}
          text: "${{ github.sha }} (${{ job.status }})"
        env:
          FRESHJOTS_TOKEN: ${{ secrets.FRESHJOTS_TOKEN }}
```

Run it for a week and the note fills with `(success)` lines. The runs you most wanted captured — the red-CI builds where deploy was skipped because the job's gate failed — leave no trace; the note reads as though those builds never happened. The cause is one rung up the YAML tree. `if: always()` on a *step* overrides a step-level skip; it cannot override a job-level skip. When CI fails and the job's own `if:` evaluates false, GitHub skips the entire job and every step inside it, `always()` included. The "always" in the function name is step-scoped — it never reaches across the job boundary.

Two ways out, depending on what you want the note to read like.

**Move the gate down to the step.** The job always runs; only the deploy step is conditional; the log step records both CI's conclusion and what the deploy step actually did. Save the file below as `.github/workflows/deploy.yml` — it is a complete, paste-ready workflow, not a snippet to splice in:

```yaml
name: Deploy

on:
  workflow_run:
    workflows: ["CI"]            # the `name:` of your existing test workflow
    types: [completed]
    branches: [master]           # restrict to CI runs on your default branch
  workflow_dispatch:             # optional "Run workflow" button in the Actions UI

concurrency:
  group: deploy-production
  cancel-in-progress: false

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - name: Deploy via SSH
        if: ${{ github.event_name == 'workflow_dispatch'
                || github.event.workflow_run.conclusion == 'success' }}
        id: ssh
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.DEPLOY_HOST }}
          username: deploy
          key: ${{ secrets.DEPLOY_SSH_KEY }}
          script: /var/www/my-app/bin/deploy

      - name: Log deploy to Fresh Jots
        if: always()
        uses: Goran-Arsov/freshjots-append@v1
        with:
          note: deploys-${{ github.event.repository.name }}
          text: >-
            ${{ github.sha }} —
            CI ${{ github.event.workflow_run.conclusion || 'manual' }},
            deploy ${{ steps.ssh.outcome }}
        env:
          FRESHJOTS_TOKEN: ${{ secrets.FRESHJOTS_TOKEN }}
```

The `appleboy/ssh-action` step is a placeholder for however you actually ship — swap it for a Capistrano step, a `kamal-action` step, an `scp-action` step, a `gh` deployment API call, or a one-liner that hits your platform's deploy hook. Leave the three lines around it intact: `id: ssh` so the log step can read its outcome, `if:` *on the step* (not the job), and the log step underneath with `if: always()` referencing `steps.ssh.outcome`. Those three are the pattern; the deploy mechanism is the placeholder. The secrets the workflow expects on the repo are `FRESHJOTS_TOKEN` (set per Step 1 above) plus whatever your deployer needs — `DEPLOY_HOST` and `DEPLOY_SSH_KEY` for the SSH example, added the same way in *Settings → Secrets and variables → Actions*.

Red CI now writes `<sha> — CI failure, deploy skipped`; a clean run writes `<sha> — CI success, deploy success`; a break inside the deploy step writes `<sha> — CI success, deploy failure`. Three states, one note, one record per commit. Read `steps.<id>.outcome` rather than `job.status` in that line — when the deploy step is *skipped*, no step has actually failed, so `job.status` is `success` and you would log `(success)` for a deploy that never ran, which is the bug you started with in slightly different clothes. `outcome` distinguishes `success`, `failure`, and `skipped`; `job.status` collapses skipped-and-clean into one bucket.

**Or split it into two streams.** Keep `deploys-<repo>` strictly about deploys that actually ran, and route red CI to the separate `ci-failures-<repo>` note this post is otherwise about. Same coverage, two notes, no `outcome` plumbing — and the win is conceptual: *what shipped* and *what broke* are questions you reach for at different moments, and one note per question scans faster than a mixed stream.

The shape generalises beyond deploys. Any time `if: always()` lives inside a job that has its own `if:`, you are asking `always()` to do work it cannot — gate the step you actually meant to gate, or put the always-run logic in its own workflow.

D. Matrix builds — don't fan out into noise

A matrix job (`os: [ubuntu, macos, windows]`) runs the logging step once *per leg*, so one bad commit can write three near-identical lines. Either include the leg in the line so they're distinguishable:

```yaml
          text: "FAIL ${{ matrix.os }} ${{ github.workflow }} #${{ github.run_number }} ${{ github.sha }}"
```

…or gate the step to a single leg (`if: ${{ failure() && matrix.os == 'ubuntu-latest' }}`) when you only want one entry per failed run.

E. Monorepo — one note per package

If one repo ships several packages, the repo name is too coarse. Append the package to the note name:

```yaml
          note: ci-failures-${{ github.event.repository.name }}-api
```

Now `…-api`, `…-web`, `…-jobs` are independent searchable streams, each its own failure history.

F. Org-wide, defined once

Don't paste this into forty repos. Put it in a [reusable workflow](https://docs.github.com/actions/using-workflows/reusing-workflows) or a composite action in one internal repo, store `FRESHJOTS_TOKEN` as an **organization** secret (Settings → Secrets and variables → Actions, at the org level) scoped to the repos that should report, and `uses:` it everywhere. One definition, one secret, every repo's failures landing in its own `ci-failures-<repo>` note.

11. When it doesn't work — the short list

- **Step never runs.** You almost certainly omitted `if: ${{ failure() }}`. Without it the step inherits the default "only on success," and a *failed* build skips it — the exact moment you wanted it. The condition isn't optional decoration; it's the mechanism.
- **`401 unauthenticated` in the step log.** Wrong or expired token, or the secret name doesn't match `FRESHJOTS_TOKEN` character-for-character. Re-mint at [/settings/api_tokens](/settings/api_tokens) and re-save the secret.
- **`403 forbidden`.** The token's plan can't use the API — the trial lapsed, or Pro wasn't renewed. The API is a Pro/Team capability; [see billing](/billing).
- **Nothing logged on a fork PR.** Expected and correct — GitHub withholds secrets from fork-triggered runs. Use the `workflow_run` standalone form above if you need fork failures captured.
- **`422 validation_failed: filename must be 1..200 bytes, contain no slashes`.** You used `github.repository` (which is `owner/repo`, with a slash) instead of `github.event.repository.name`. Swap the expression and the next failed build will log cleanly.
- **`if: always()` step is silently skipping some runs.** The step lives inside a job that has its own `if:` — a deploy job gated on CI success is the canonical case. When the job's condition fails, GitHub skips the whole job and every step inside it; `always()` is step-scoped, not workflow-scoped. Move the gate to the step you actually meant to gate, or split the gated work into its own `workflow_run` listener. The "Gated jobs (deploys)" pattern above is the worked-out version.

12. The honest constraints

- **Tier.** The API needs **Pro ($149/yr)** or **Team**, or an active **14-day trial**. CI failure logging alone probably doesn't justify Pro — but it rarely travels alone; it's one of a handful of automation hooks (crons, deploys, dead-man alerts) that together do.
- **Rate limit.** **300 appends per minute** on Pro. CI failures are not a high-frequency event; you will never come near this. (If you somehow do, your problem isn't the rate limit.)
- **Body size.** Keep the line short — a status, the run number, the SHA, a link. The append endpoint rejects oversized bodies, and a wall of build log isn't what makes this useful. The line points *at* the full GitHub log; it doesn't replace it.
- **"Searchable" means across notes and by filename — not full-text within one note, today.** You can pull `ci-failures-my-app` instantly and grep it locally; you can list and filter your notes via the API. In-note full-text search isn't the model this leans on. Plan around streams-as-files, not a log query engine.

13. Try it free for 14 days. No card.

Sign up at freshjots.com, pick **"Plain notes"** at onboarding, and a 14-day trial token lands in your inbox — identical to paid Pro, API and dead-man alerts included. Add the six-line step to one repo, push a branch that fails on purpose, and watch the line appear in the note in real time. After 14 days, Pro is **$149/yr**. The full endpoint reference is at [/docs](/docs); the GitHub Action source at github.com/Goran-Arsov/freshjots-append is one short `action.yml` and worth a minute's read before you trust it.

One step, one note per repo, one less reason to pay for log retention you'll never query.

Take a look at Everything You Can Do Here.

Share this post

Ready to start taking better notes? Sign up free