Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ on:

permissions:
contents: read
checks: write
pull-requests: write

jobs:
Expand All @@ -27,6 +28,7 @@ jobs:
working-directory: test/fixtures/nuxt-app
min-score: 40
baseline: none
annotations: false
comment: on-failure
- name: Outputs are the fixture's numbers
env:
Expand All @@ -50,6 +52,7 @@ jobs:
test/fixtures/*
limit: 3
baseline: none
annotations: false
comment: false
- name: Every fixture package is scanned, the non-package is not
env:
Expand All @@ -70,6 +73,7 @@ jobs:
working-directory: test/fixtures/nuxt-app
min-score: 100
gate: false
annotations: false
comment: false
baseline: none
- name: The step stays green and says the gate would have failed
Expand All @@ -91,6 +95,7 @@ jobs:
with:
working-directory: test/fixtures/nuxt-app
min-score: 100
annotations: false
comment: false
baseline: none
- name: The step failed, and the outputs were still written
Expand Down
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,15 @@ npm run check # syntax check every module
```
action.yml inputs, outputs, the one composite step
src/main.mjs the run: inputs → packages → baseline → scan → report → comment → outputs → gate
src/lib/ one module per concern (inputs, event, packages, cli, baseline, report, comment, outputs)
src/lib/ one module per concern (inputs, event, packages, cli, baseline, findings, report, checks, comment, github, outputs)
test/*.test.mjs unit tests, one file per module
test/fixtures/ small apps the workflow scans: nuxt-app, express-app, hono-app, and a non-package
.github/workflows/ test.yml runs the action against the fixtures on every push and PR; release.yml moves the major tag
```

## Rules

- **The CLI decides, the action delivers.** No rule, threshold, or scoring lives here. If a verdict is wrong, fix `@evlog/cli` in `evloghq/evlog`. The one exception is `prioritize` in `report.mjs`, which mirrors the CLI's FIX FIRST order for display; keep it identical.
- **The CLI decides, the action delivers.** No rule, threshold, or scoring lives here. If a verdict is wrong, fix `@evlog/cli` in `evloghq/evlog`. The two exceptions mirror the CLI for display and are kept identical to it: `prioritize` in `report.mjs` (the FIX FIRST order) and `findings` in `findings.mjs` (what `--format github` would point at, built from the JSON so one list can feed the Checks API and the workflow-command fallback).
- **No dependencies.** Node 20 is what the runner has; `fetch`, `node:test`, `spawnSync` cover everything. A dependency would need a bundle and a `dist/` commit, which is the maintenance this design avoids.
- **Every input is validated in `inputs.mjs`** and nowhere else. A bad input exits 2 before any scan.
- **Degrade, don't throw, on permissions.** A token that cannot comment is a notice, not a failure; annotations and the summary still stand.
Expand Down
26 changes: 15 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
# evlog action

Scores the observability of your entry points with [`evlog map`](https://evlog.dev/cli/map) and puts the findings where the review happens: on the pull request diff, in the job summary, and in one comment that stays up to date.
Scores the observability of your entry points with [`evlog map`](https://evlog.dev/cli/map) and puts the findings where the review happens: a check run with annotations on the diff, a job summary, and one comment that stays up to date.

```yaml
name: observability
on: pull_request
permissions:
contents: read
checks: write
pull-requests: write
jobs:
map:
Expand All @@ -18,16 +19,17 @@ jobs:
min-score: 80
```

That is the whole setup. On a pull request the action scans the base branch too, so a check that used to pass and no longer does fails the job and is drawn on the line that broke it. Nothing has to be committed, no token has to be created: the comment uses the workflow's own token.
That is the whole setup. On a pull request the action scans the base branch too, so a check that used to pass and no longer does fails the job, is named in the comment with the score it cost, and is drawn on the entry point that lost it. Nothing has to be committed, no token has to be created: the check run and the comment use the workflow's own token.

## What it does

1. Runs `evlog map` on each package with the pinned `@evlog/cli`.
2. On a pull request, checks the base out next to the workspace, scans it, and compares. Regressions become `::error` annotations on the diff; without a base, the report's FIX FIRST list becomes `::warning` annotations, capped at `limit`.
3. Writes the report to the job summary: one table across packages, regressions, what to fix first, what got fixed.
4. Posts that report as a pull request comment and edits it on every run.
5. Sets outputs (`score`, `delta`, `regressions`, `passed`, `results`) for whatever comes next.
6. Fails the step when a package regressed or scored under `min-score`, unless `gate: false`.
2. On a pull request, checks the base out next to the workspace, scans it, and compares.
3. Creates a check run named `evlog map` on the commit, with the report as its summary and one annotation per finding: regressions, as failures, when there is a base; the report's FIX FIRST list, as warnings, when there is not. Without `checks: write` the same findings go out as workflow commands, capped at `limit` per package.
4. Writes the report to the job summary, with every entry point and the result of each check per package.
5. Posts the report as a pull request comment and edits it on every run: the score in the title, what moved in a GitHub alert block, the table only for a monorepo, and a `min-score` to adopt when none is set.
6. Sets outputs (`score`, `delta`, `regressions`, `passed`, `results`) for whatever comes next.
7. Fails the step when a package regressed or scored under `min-score`, unless `gate: false`.

The judgment is the CLI's. The action adds no rule of its own, so `npx evlog map` on your machine and the action on your pull request agree.

Expand All @@ -40,13 +42,13 @@ The judgment is the CLI's. The action adds no rule of its own, so `npx evlog map
| `packages` | | One directory or glob per line (`apps/*`), each scanned as its own package. |
| `baseline` | `auto` | `auto` scans the pull request base and compares against it. A path or `git:<ref>` is passed to the CLI. `none` disables the comparison. |
| `min-score` | | Fail when a package scores below this number. |
| `limit` | `10` | Most annotations per package; GitHub keeps ten per level per step. |
| `limit` | `10` | Most annotations per package when findings fall back to workflow commands; GitHub keeps ten per level per step. A check run has no cap. |
| `gate` | `true` | `false` reports and sets outputs without failing the step. |
| `annotations` | `true` | Draw findings on the diff. |
| `annotations` | `true` | Draw findings on the diff through a check run named `evlog map`. Needs `checks: write`; falls back to workflow commands without it. |
| `summary` | `true` | Write the report to the job summary. |
| `comment` | `true` | `true` posts one comment and keeps it updated; `on-failure` only posts when the gate failed, and updates an existing comment either way; `false` posts nothing. Needs `pull-requests: write`; skipped with a notice when the token cannot write (forks). |
| `comment-key` | `default` | Keeps separate comments when the action runs more than once on a pull request. |
| `token` | `${{ github.token }}` | Token for the comment. |
| `token` | `${{ github.token }}` | Token for the check run and the comment. |

## Outputs

Expand Down Expand Up @@ -129,4 +131,6 @@ Everything `evlog map` scans: Nuxt, Nitro, Next.js App Router, TanStack Start, H

## Permissions

`contents: read` to check out. `pull-requests: write` only for the comment; without it the action says so in a notice and everything else still works. Pull requests from forks get a read-only token, so they get annotations and a summary but no comment.
`contents: read` to check out. `checks: write` for the check run and `pull-requests: write` for the comment; without either the action says so in a notice and everything else still works. Pull requests from forks get a read-only token, so they get annotations as workflow commands and a summary, but no check run and no comment.

GitHub's current pull request diff folds annotations into the alerts counter at the top of the page rather than drawing them on the hunk; the Checks tab and the old diff experience draw them inline. The comment names the entry point either way.
8 changes: 4 additions & 4 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ name: evlog map
description: Score the observability of your entry points with evlog map and put the findings on the pull request.
author: evloghq
branding:
icon: activity
icon: bar-chart-2
color: purple

inputs:
Expand All @@ -29,13 +29,13 @@ inputs:
description: Fail when a package scores below this number (0-100).
default: ''
limit:
description: Most annotations per package. GitHub keeps ten per level per step and drops the rest.
description: Most annotations per package when findings fall back to workflow commands (a token without `checks write`). GitHub keeps ten per level per step and drops the rest.
default: '10'
gate:
description: Fail the step when a package regresses or scores below `min-score`. `false` reports and sets outputs without failing.
default: 'true'
annotations:
description: Draw each finding on the pull request diff.
description: Draw each finding on the pull request diff, through a check run named `evlog map`. Needs `checks write`; falls back to workflow commands when the token cannot write.
default: 'true'
summary:
description: Write the report to the job summary.
Expand All @@ -47,7 +47,7 @@ inputs:
description: Distinguishes comments when the action runs more than once on a pull request.
default: default
token:
description: Token for the pull request comment. Defaults to the workflow token.
description: Token for the check run and the pull request comment. Defaults to the workflow token.
default: ${{ github.token }}

outputs:
Expand Down
13 changes: 4 additions & 9 deletions src/lib/baseline.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -28,18 +28,13 @@ export function checkoutBase({ workspace, ref, log }) {
return { root, dir, cleanup: () => spawnSync('git', ['worktree', 'remove', '--force', dir], { cwd: root }) }
}

/**
* The CLI names a baseline the way it was spelled on the command line. Ours is
* a temp file nobody typed, so the ref it stands for goes in its place.
*/
export function relabelBaseline(text, file, ref) {
return file ? text.split(file).join(ref) : text
}

/**
* Scan one package at the base and write its map to a file the CLI can take
* as `--baseline`. A package that does not exist at the base is new and has
* nothing to regress from.
*
* The map itself comes back too: the report needs the base's score per entry
* point, which the CLI's comparison does not carry.
*/
export function baselineFor({ base, packageDir, version, env }) {
const packageRel = toPosix(relative(base.root, packageDir))
Expand All @@ -50,5 +45,5 @@ export function baselineFor({ base, packageDir, version, env }) {
const { map } = JSON.parse(stdout)
const file = join(base.dir, `.evlog-baseline-${packageRel.replace(/[^\w.-]+/g, '_') || 'root'}.json`)
writeFileSync(file, JSON.stringify(map))
return file
return { file, map }
}
54 changes: 54 additions & 0 deletions src/lib/checks.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { request } from './github.mjs'

/* The Checks API takes at most this many annotations per request; the rest
go in updates to the same run. */
const BATCH = 50

/**
* One check run for the whole action run, on the commit the pull request
* points at.
*
* Workflow commands keep ten annotations per level per step, show the job as
* their author, and GitHub's current diff view folds them into a counter. A
* check run has no cap, is listed on the pull request under its own name with
* the report as its summary, and its annotations are what the diff view and
* the Checks tab draw. The token needs `checks: write`; when it cannot write,
* the caller prints workflow commands instead.
*/
export async function createCheckRun({ event, token, name, headSha, conclusion, title, summary, annotations, fetchFn = fetch }) {
if (!token) return { outcome: 'skipped', reason: 'no token' }
const url = `${event.apiUrl}/repos/${event.repository}/check-runs`
const batches = []
for (let i = 0; i < annotations.length; i += BATCH) batches.push(annotations.slice(i, i + BATCH).map(toApi))
const [first = [], ...rest] = batches

const created = await request(fetchFn, url, token, {
method: 'POST',
body: JSON.stringify({ name, head_sha: headSha, status: 'completed', conclusion, output: { title, summary, annotations: first } }),
})
if (created.status === 401 || created.status === 403) {
return { outcome: 'skipped', reason: 'token cannot write check runs; grant `checks: write` to draw findings on the diff' }
}
if (!created.ok) return { outcome: 'failed', reason: `creating check run: ${created.status}` }
const { id, html_url: htmlUrl } = await created.json()

for (const batch of rest) {
const updated = await request(fetchFn, `${url}/${id}`, token, {
method: 'PATCH',
body: JSON.stringify({ output: { title, summary, annotations: batch } }),
})
if (!updated.ok) return { outcome: 'failed', reason: `adding annotations to check run: ${updated.status}` }
}
return { outcome: 'created', id, url: htmlUrl, annotations: annotations.length }
}

function toApi(finding) {
return {
path: finding.path,
start_line: finding.line,
end_line: finding.line,
annotation_level: finding.level,
title: finding.title,
message: finding.message,
}
}
16 changes: 2 additions & 14 deletions src/lib/comment.mjs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { request } from './github.mjs'

/**
* One comment per pull request, edited in place on every run.
*
Expand All @@ -8,20 +10,6 @@ export function marker(key) {
return `<!-- evlog-action:${key} -->`
}

async function request(fetchFn, url, token, init = {}) {
const response = await fetchFn(url, {
...init,
headers: {
'accept': 'application/vnd.github+json',
'authorization': `Bearer ${token}`,
'content-type': 'application/json',
'x-github-api-version': '2022-11-28',
...init.headers,
},
})
return response
}

/**
* Create or update the comment. Returns what happened so the caller can say
* so; a token that cannot write (a fork, read-only workflow permissions) is
Expand Down
55 changes: 55 additions & 0 deletions src/lib/findings.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { filePath, prioritize } from './report.mjs'

/**
* What to point at on the diff, read off the CLI's JSON.
*
* The selection is the CLI's `--format github` one and is kept identical to
* `formats.ts` in evloghq/evlog: with a baseline, the regressions, as failures,
* since the pull request caused them; without one, the FIX FIRST list, as
* warnings. It is built here from the JSON rather than parsed off the CLI's
* workflow commands so one list can feed the Checks API, which has no
* per-level cap, and the workflow-command fallback alike.
*/
export function findings(result, { baselineRef } = {}) {
if (!result.baseline) {
return prioritize(result.routes).flatMap(route => failures(result, route).map(finding => ({ level: 'warning', ...finding })))
}
const routes = new Map(result.routes.map(route => [route.id, route]))
return result.baseline.regressions.flatMap((regression) => {
const route = routes.get(regression.routeId)
if (!route) return []
const failed = failures(result, route).find(finding => finding.check === regression.check)
const finding = failed ?? {
path: filePath(result, route.file),
line: route.handler?.line ?? 1,
check: regression.check,
message: `${regression.check} passed on ${baselineRef ?? 'the base'} and is now silenced`,
}
return [{ level: 'failure', ...finding }]
}).map(finding => ({ ...finding, title: `evlog map: ${finding.check}` }))
}

/** The failing requirements of one entry point, at the evidence line when the check has one. */
function failures(result, route) {
return Object.entries(route.checks)
.filter(([, check]) => check.status === 'fail')
.map(([check, { evidence, message }]) => ({
path: filePath(result, evidence?.file ?? route.file),
line: evidence?.line ?? route.handler?.line ?? 1,
check,
message: message ?? check,
title: `evlog map: ${check}`,
}))
}

/* GitHub reads workflow commands off stdout and unescapes these three in the
message and these five in the properties; anything else passes through. */
const escapeMessage = text => text.replace(/%/g, '%25').replace(/\r/g, '%0D').replace(/\n/g, '%0A')
const escapeProperty = text => escapeMessage(text).replace(/:/g, '%3A').replace(/,/g, '%2C')

/** The same finding as a workflow command, for a token that cannot write check runs. */
export function workflowCommand({ level, path, line, title, message }) {
const command = level === 'failure' ? 'error' : level
const props = Object.entries({ file: path, line: String(line), title }).map(([key, value]) => `${key}=${escapeProperty(value)}`).join(',')
return `::${command} ${props}::${escapeMessage(message)}`
}
13 changes: 13 additions & 0 deletions src/lib/github.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/** One `fetch` against the GitHub REST API, with the headers every call needs. */
export function request(fetchFn, url, token, init = {}) {
return fetchFn(url, {
...init,
headers: {
'accept': 'application/vnd.github+json',
'authorization': `Bearer ${token}`,
'content-type': 'application/json',
'x-github-api-version': '2022-11-28',
...init.headers,
},
})
}
Loading
Loading