Skip to content
Merged
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
51 changes: 30 additions & 21 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,26 @@ permissions:
pull-requests: write

jobs:
build:
name: Build and Commit Artifact
runs-on: ubuntu-latest
release-please:
name: Release Please
if: github.event_name == 'push'
runs-on: ubuntu-latest
outputs:
release_created: ${{ steps.release.outputs.release_created }}
tag_name: ${{ steps.release.outputs.tag_name }}
steps:
- uses: googleapis/release-please-action@v4
id: release
with:
token: ${{ secrets.RELEASE_PLEASE_TOKEN }}
config-file: .github/.release-config.json
manifest-file: .github/.release-manifest.json

build-release:
name: Build Release Artifact
needs: release-please
if: needs.release-please.outputs.release_created == 'true'
runs-on: ubuntu-latest
permissions:
contents: write
steps:
Expand All @@ -44,7 +60,14 @@ jobs:
- run: bun install --frozen-lockfile
- run: bun run build

- name: Commit built artifact
# GitHub Actions consume the checked-in bundle from action.yml (`lib/index.js`).
# We only want that generated artifact to change for published releases, not for
# every commit on master. This job runs only after release-please creates a new
# release tag, then commits the freshly built bundle and re-points the tag so the
# published tag includes the exact artifact that users get via `uses: ...@vX.Y.Z`.
- name: Commit artifact and update release tag
env:
TAG: ${{ needs.release-please.outputs.tag_name }}
run: |
if git diff --quiet lib/index.js; then
echo "No changes to lib/index.js"
Expand All @@ -54,21 +77,7 @@ jobs:
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add lib/index.js
git commit -m "build: update lib/index.js"
git commit -m "build: update lib/index.js for ${TAG}"
git push origin master

release-please:
name: Release Please
needs: build
if: github.event_name == 'push'
runs-on: ubuntu-latest
outputs:
release_created: ${{ steps.release.outputs.release_created }}
tag_name: ${{ steps.release.outputs.tag_name }}
steps:
- uses: googleapis/release-please-action@v4
id: release
with:
token: ${{ secrets.RELEASE_PLEASE_TOKEN }}
config-file: .github/.release-config.json
manifest-file: .github/.release-manifest.json
git tag -fa "${TAG}" -m "Release ${TAG}"
git push origin "${TAG}" --force
104 changes: 80 additions & 24 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,11 @@ multiple languages, and shows meaningful per-file diffs without external depende
- Supports any LCOV-producing tool (Bun, Node.js, Jest, c8, nyc, Istanbul, PHPUnit, …) and Go coverage
- Shows per-file coverage deltas against base branch
- Single sticky PR comment (updates existing, no spam)
- Multi-workflow merging — separate workflows contribute to the same comment automatically
- Uses `@actions/cache` for cross-run comparison
- Supports explicit PR number overrides and optional commit links in the comment header
- Optional thresholds and fail-on-decrease
- Omits the top-level comparison block when a full baseline is not available for every tool
- No external services or tokens required

## Output example
Expand All @@ -35,15 +38,15 @@ multiple languages, and shows meaningful per-file diffs without external depende
## Usage

```yaml
- uses: xseman/coverage@v0.2.0
- uses: xseman/coverage@v0.3.0
with:
coverage-artifact-paths: bun:coverage/lcov.info
```

With multiple tools and thresholds:

```yaml
- uses: xseman/coverage@v0.2.0
- uses: xseman/coverage@v0.3.0
with:
coverage-artifact-paths: |
bun:coverage/lcov.info
Expand All @@ -67,11 +70,37 @@ jobs:
- run: bun install
- run: bun test --coverage --coverage-reporter=lcov

- uses: xseman/coverage@v0.2.0
- uses: xseman/coverage@v0.3.0
with:
coverage-artifact-paths: bun:coverage/lcov.info
```

### Multi-workflow setup

When TypeScript and Go (or any other combination) tests run in separate workflows,
use the **same `update-comment-marker`** value in both. The second workflow to finish
will find the first comment, read its embedded tool data, merge the results, and
update the comment in place — producing one combined report.

```yaml
# typescript-quality.yml
- uses: xseman/coverage@v0.3.0
with:
update-comment-marker: "<!-- coverage-reporter-sticky -->"
coverage-artifact-paths: bun:typescript/coverage/lcov.info

# go-quality.yml
- uses: xseman/coverage@v0.3.0
with:
update-comment-marker: "<!-- coverage-reporter-sticky -->"
coverage-artifact-paths: go:go/coverage.out
```

If both workflows run at the same time and there is no existing comment yet, both
may create their own comment. On the next commit push they will converge to one.
Use workflow dependencies (`needs:`) or `concurrency` groups if immediate
convergence on the first push is required.

## How it works

```mermaid
Expand All @@ -82,34 +111,70 @@ config:
fontFamily: monospace
fontSize: "10px"
---
sequenceDiagram
participant F as Filesystem
participant A as Action
participant C as Cache
participant G as GitHub

F->>A: read coverage file
A->>C: restore base coverage
A->>A: compute deltas
A->>G: post PR comment
A->>C: save new coverage

flowchart LR
A[Read coverage artifacts] --> B[Parse reports by tool]
B --> C[Restore cached base snapshot]
C --> D[Compute file deltas and summaries]
D --> E[Post or update one sticky PR comment]
E --> F[Save current snapshot for later comparisons]
```

Each `<tool>:<path>` entry goes through this pipeline independently. Results
are combined into one PR comment. The action caches parsed coverage as JSON
via `@actions/cache` using key `{prefix}-{tool}-{branch}-{sha}`, restoring
by prefix match to find the latest base-branch snapshot.

When the same `update-comment-marker` is used across multiple workflows, each
run reads the previously embedded tool reports from the existing comment, merges
its own results in (current tool takes priority), and rewrites the comment with
the combined data.

If every tool has a comparable base snapshot, the comment also includes an
overall base vs head summary. If some tools do not have cached base data yet,
the action still shows the per-tool sections and any available file deltas,
but skips the top-level comparison block so partial baselines do not distort
the summary. A note in the comment identifies which tools are missing a
baseline.

### Bootstrapping the cache

The diff table compares head coverage against a cached snapshot from the target
branch. On the first run (or when introducing a new tool) there is nothing to
compare against, so deltas are omitted. The cache is seeded automatically when
the workflow runs on a push to the base branch.

To get diffs working immediately:

1. Make sure the workflow triggers on **push** to the base branch (not just
`pull_request`), so coverage is cached after each merge.
2. For a cold start, trigger the workflow manually on the base branch with
`workflow_dispatch`:

```yaml
on:
push:
branches: [master]
pull_request:
branches: [master]
workflow_dispatch: {}
```

Then run the workflow from the Actions tab on the base branch. The next PR
will find the cached snapshot and show full deltas.

## Inputs

| Input | Default | Description |
| ------------------------- | ----------------------------------- | -------------------------------------------------- |
| `coverage-artifact-paths` | _(required)_ | Newline or comma-separated `<tool>:<path>` entries |
| `pull-request-number` | auto-detected | Explicit PR number override for comment updates |
| `show-commit-link` | `on` | Include commit link(s) at the top of the comment |
| `base-branch` | PR base ref | Branch for delta comparison |
| `cache-key` | `coverage-reporter` | Cache key prefix |
| `update-comment-marker` | `<!-- coverage-reporter-sticky -->` | HTML marker for sticky comment |
| `colorize` | `on` | `[+]`/`[-]` delta markers (`on`/`off`) |
| `fail-on-decrease` | `false` | Fail if coverage decreases |
| `fail-on-decrease` | `false` | Fail if any file coverage decreases |
| `coverage-threshold` | `0` | Minimum overall coverage % (0 = disabled) |
| `github-token` | `${{ github.token }}` | Token for PR comments |

Expand Down Expand Up @@ -147,15 +212,6 @@ node --test \
go test -coverprofile=coverage.out ./...
```

## Development

```bash
bun install # install dependencies
bun test # run tests
bun run lint # typecheck + format check
bun run build # bundle to lib/index.mjs
```

## Related

- [@actions/cache](https://github.com/actions/cache)
Expand Down
36 changes: 25 additions & 11 deletions src/comment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,22 +5,22 @@ export interface CommentResult {
created: boolean;
}

export interface ExistingComment {
id: number;
body: string;
}

/**
* Find an existing PR comment containing the given marker string,
* then create or update accordingly.
* Find an existing PR comment containing the given marker string.
*/
export async function upsertComment(
export async function findComment(
token: string,
marker: string,
body: string,
prNumber: number,
): Promise<CommentResult> {
): Promise<ExistingComment | null> {
const octokit = github.getOctokit(token);
const { owner, repo } = github.context.repo;

// Paginate through existing comments to find the one with our marker
let existingCommentId: number | null = null;

for await (
const response of octokit.paginate.iterator(
octokit.rest.issues.listComments,
Expand All @@ -29,13 +29,27 @@ export async function upsertComment(
) {
for (const comment of response.data) {
if (comment.body && comment.body.includes(marker)) {
existingCommentId = comment.id;
break;
return { id: comment.id, body: comment.body };
}
}
if (existingCommentId) break;
}

return null;
}

/**
* Find an existing PR comment containing the given marker string,
* then create or update accordingly.
*/
export async function upsertComment(
token: string,
body: string,
prNumber: number,
existingCommentId?: number,
): Promise<CommentResult> {
const octokit = github.getOctokit(token);
const { owner, repo } = github.context.repo;

if (existingCommentId) {
await octokit.rest.issues.updateComment({
owner,
Expand Down
43 changes: 39 additions & 4 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,10 @@ import {
restoreBaseArtifact,
saveArtifact,
} from "./cache.js";
import { upsertComment } from "./comment.js";
import {
findComment,
upsertComment,
} from "./comment.js";
import {
resolveBaseBranch,
resolveCurrentBranch,
Expand All @@ -28,7 +31,10 @@ import {
formatPercent,
formatPercentValue,
} from "./percent.js";
import { renderReport } from "./render.js";
import {
extractCoverageData,
renderReport,
} from "./render.js";
import type {
ArtifactInput,
FileCoverage,
Expand Down Expand Up @@ -170,6 +176,36 @@ async function run(): Promise<void> {
}
}

// Resolve PR number early so we can look up the existing comment for merging
const prNumber = await resolvePrNumber(prNumberInput, token);

// Merge with previously stored tool reports from the same sticky comment.
// This allows separate workflows (e.g. TS and Go) to contribute to one comment.
let existingCommentId: number | undefined;
if (prNumber && token) {
try {
const existing = await findComment(token, marker, prNumber);
if (existing) {
existingCommentId = existing.id;
const stored = extractCoverageData(existing.body);
if (stored) {
const currentTools = new Set(toolReports.map((r) => r.tool));
for (const prev of stored.tools) {
if (!currentTools.has(prev.tool)) {
toolReports.push(prev);
}
}
if (!baseSha && stored.baseSha) {
baseSha = stored.baseSha;
}
core.info(`Merged ${stored.tools.length} stored tool report(s) from existing comment`);
}
}
} catch {
core.warning("Could not read existing comment for merging");
}
}

// Build full report
const fullReport = buildFullReport(toolReports);

Expand All @@ -183,11 +219,10 @@ async function run(): Promise<void> {
core.setOutput("coverage-decreased", anyDecrease ? "true" : "false");

// Post / update PR comment
const prNumber = await resolvePrNumber(prNumberInput, token);
if (prNumber && token) {
try {
core.info(`Upserting comment on PR #${prNumber}`);
const result = await upsertComment(token, marker, markdown, prNumber);
const result = await upsertComment(token, markdown, prNumber, existingCommentId);
core.setOutput("comment-id", result.commentId.toString());
core.info(
result.created
Expand Down
Loading