Skip to content
Closed
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
23 changes: 22 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,9 @@ jobs:
- name: Run coverage
env:
OUTPUT_ECONOMY_BASE: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.event.before }}
# The slow-test reporter persists this run's over-budget tests, which become one
# quality-delta row instead of a line only visible in this job's stderr.
AGENT_DEVICE_SLOW_TEST_REPORT: .tmp/slow-tests.json
run: pnpm test:coverage

# Reuses the lcov the coverage step just wrote (never runs coverage twice)
Expand All @@ -265,7 +268,25 @@ jobs:
if: always() && github.event_name == 'pull_request'
env:
AGENT_DEVICE_COVERAGE_WAIVER: ${{ contains(github.event.pull_request.labels.*.name, 'coverage-waiver') }}
run: pnpm check:coverage-changed --base "${{ github.event.pull_request.base.sha }}"
run: pnpm check:coverage-changed --base "${{ github.event.pull_request.base.sha }}" --json .tmp/changed-coverage.json

# Inputs for the quality-delta comment (#1424), which is rendered by
# .github/workflows/quality-delta.yml AFTER both this workflow and Size finish — rendering
# here would race the concurrently produced size report and permanently omit its rows.
# Publishing them costs one upload; nothing about the comment can fail this job.
- name: Upload quality-delta inputs
if: always() && github.event_name == 'pull_request'
continue-on-error: true
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: quality-delta-inputs
path: |
.tmp/changed-coverage.json
.tmp/slow-tests.json
# .tmp is a dotted directory, which upload-artifact skips by default.
include-hidden-files: true
retention-days: 7
if-no-files-found: warn

typecheck:
name: Typecheck
Expand Down
136 changes: 136 additions & 0 deletions .github/workflows/quality-delta.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
name: Quality Delta

# The single sticky PR comment (#1424). It renders from two workflows that run CONCURRENTLY on the
# same head sha — CI (changed-line coverage, slow-test breaches) and Size (the size report) — so it
# runs on their completion instead of inside either one: whichever finishes LAST triggers the render,
# and earlier firings exit at the readiness gate below. Rendering inside CI would lose the race
# whenever Size finished second, permanently omitting size rows from that head's comment.
#
# Fork PRs are skipped deliberately, twice over: `workflow_run` runs with a write token in the BASE
# repo, so checking out and installing fork code here would be a pwn-request. Fork PRs therefore get
# the producers' own job summaries, which is the documented degradation (never a failure).

on:
workflow_run:
workflows:
- CI
- Size
types:
- completed

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

# Serialized by PULL REQUEST, so a renderer for an older head is cancelled the moment the newer
# head's renderer starts — a sha-keyed group could not do that, and the loser could still be inside
# its comment write. `pull_requests` is empty for fork triggers (skipped below anyway), hence the
# sha fallback. The `--expect-head` check in the write path stays as defense in depth for the
# cancellation window itself.
concurrency:
group: quality-delta-${{ github.event.workflow_run.pull_requests[0].number || github.event.workflow_run.head_sha }}
cancel-in-progress: true

jobs:
comment:
name: Quality Delta
if: github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.head_repository.full_name == github.repository
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
# The base repo's tree at the producers' head sha. Safe because the fork guard above means this
# sha is always a branch in this repository.
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.event.workflow_run.head_sha }}
fetch-depth: 0

# The race policy, as a decision the unit tests own: scripts/quality-delta/producers.ts. It
# answers both halves of "may this run post" — every producer finished, AND this sha is still
# the open PR's head, so a superseded head renders nothing instead of overwriting the comment.
- name: Check that every producer finished and this head is current
id: producers
env:
GH_TOKEN: ${{ github.token }}
HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
run: |
set -euo pipefail
mkdir -p .tmp
gh api "repos/${GITHUB_REPOSITORY}/actions/runs?head_sha=${HEAD_SHA}&per_page=100" > .tmp/runs.json
gh api "repos/${GITHUB_REPOSITORY}/commits/${HEAD_SHA}/pulls" > .tmp/pulls.json
node --experimental-strip-types scripts/quality-delta/producers-run.ts \
--runs .tmp/runs.json --pulls .tmp/pulls.json --head-sha "${HEAD_SHA}"

- name: Setup toolchain
if: steps.producers.outputs.ready == 'true'
uses: ./.github/actions/setup-node-pnpm

# Both downloads are best effort: a producer that failed before uploading costs the rows it
# owned plus a job-summary note, never the comment.
- name: Download producer artifacts
if: steps.producers.outputs.ready == 'true'
continue-on-error: true
env:
GH_TOKEN: ${{ github.token }}
CI_RUN_ID: ${{ steps.producers.outputs.ci_run_id }}
SIZE_RUN_ID: ${{ steps.producers.outputs.size_run_id }}
run: |
set -uo pipefail
download() {
local run_id="$1" name="$2"
local id
id=$(gh api "repos/${GITHUB_REPOSITORY}/actions/runs/${run_id}/artifacts" \
--jq "[.artifacts[] | select(.name == \"${name}\" and .expired == false) | .id] | first")
if [ -z "$id" ] || [ "$id" = "null" ]; then
echo "run ${run_id} published no ${name}; its rows will be omitted."
return 0
fi
gh api "repos/${GITHUB_REPOSITORY}/actions/artifacts/${id}/zip" > ".tmp/${name}.zip"
unzip -o -q -d .tmp ".tmp/${name}.zip"
}
mkdir -p .tmp
download "${SIZE_RUN_ID}" size-report-json
download "${CI_RUN_ID}" quality-delta-inputs

# Read-only extraction of the history ref the Repo Health History workflow writes on main — no
# checkout, no write, absent until that workflow has run once.
- name: Fetch the main-branch metric history
if: steps.producers.outputs.ready == 'true'
continue-on-error: true
run: |
set -uo pipefail
mkdir -p .tmp/history-ref
if git fetch --quiet --depth 1 origin +refs/heads/metrics/repo-health:refs/remotes/origin/metrics/repo-health; then
git archive origin/metrics/repo-health | tar -x -C .tmp/history-ref
else
echo "No metrics/repo-health ref yet; the comment will report no baseline."
fi

- name: Render quality delta
if: steps.producers.outputs.ready == 'true'
continue-on-error: true
run: |
pnpm repo-health --out .tmp/repo-health.json
pnpm quality-delta \
--head .tmp/repo-health.json \
--history-dir .tmp/history-ref \
--base-sha "${{ steps.producers.outputs.pr_base_sha }}" \
--changed-coverage .tmp/changed-coverage.json \
--slow-test .tmp/slow-tests.json \
--markdown .tmp/quality-delta.md

# ONE comment per PR: the marker machinery in scripts/size-report.mjs updates it in place, and
# no other workflow posts a comment. `--expect-head` re-reads the PR head inside the write path
# and refuses the write if it moved during setup/download/render — the gate above cannot cover
# that window, and sha-scoped concurrency does not cancel this run when a newer head starts.
- name: Comment on the PR
if: steps.producers.outputs.ready == 'true'
continue-on-error: true
env:
GITHUB_TOKEN: ${{ github.token }}
GITHUB_PR_NUMBER: ${{ steps.producers.outputs.pr_number }}
run: |
pnpm size --post-comment .tmp/quality-delta.md \
--expect-head "${{ github.event.workflow_run.head_sha }}"
79 changes: 79 additions & 0 deletions .github/workflows/repo-health-history.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
name: Repo Health History

# Appends one repo-health snapshot (#1423) per push to main to the JSONL metric history the
# quality-delta PR comment (#1424) reads its baseline from. See scripts/quality-delta/README.md for
# why the history lives on a dedicated orphan ref instead of an artifact chain or gh-pages.

on:
push:
branches:
- main

permissions:
# Only to append to the history ref. Nothing here touches the source tree.
contents: write

# First half of the serialization contract: two merges landing seconds apart queue instead of
# racing, and cancel-in-progress is OFF because a cancelled run is a lost history entry. The second
# half is the compare-and-swap push below, which covers anything that slips past this group.
concurrency:
group: repo-health-history
cancel-in-progress: false

jobs:
append-snapshot:
name: Append snapshot to metric history
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

- name: Setup toolchain
uses: ./.github/actions/setup-node-pnpm

# The snapshot READS .tmp/size-report.json rather than measuring size itself, so main's
# entry needs a build first — this is the whole cost of the job (~5 min), and it runs only on
# main, never on the PR gate.
- name: Measure package size
run: |
pnpm build
pnpm size:markdown

- name: Collect snapshot
run: pnpm repo-health --out .tmp/repo-health.json

# Compare-and-swap with one retry: clone the history ref's tip, append, push. A push rejected
# because another run advanced the ref re-runs the whole function against the new tip.
# Appending is keyed by commit sha, so a retry can never double-write an entry.
- name: Append to the history ref
env:
GITHUB_TOKEN: ${{ github.token }}
HISTORY_REF: metrics/repo-health
run: |
set -uo pipefail
remote="https://x-access-token:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git"
append_and_push() {
rm -rf .tmp/history-ref
if git ls-remote --exit-code --heads "$remote" "$HISTORY_REF" >/dev/null 2>&1; then
git clone --quiet --depth 1 --single-branch --branch "$HISTORY_REF" "$remote" .tmp/history-ref || return 1
else
mkdir -p .tmp/history-ref
git -C .tmp/history-ref init --quiet --initial-branch "$HISTORY_REF"
fi
pnpm repo-health:history --snapshot .tmp/repo-health.json --history-dir .tmp/history-ref || return 1
git -C .tmp/history-ref add history || return 1
if git -C .tmp/history-ref diff --cached --quiet; then
echo "History already contains ${GITHUB_SHA}; nothing to push."
return 0
fi
git -C .tmp/history-ref \
-c user.name='github-actions[bot]' \
-c user.email='41898282+github-actions[bot]@users.noreply.github.com' \
commit --quiet -m "repo-health: ${GITHUB_SHA}" || return 1
git -C .tmp/history-ref push --quiet "$remote" "HEAD:refs/heads/$HISTORY_REF"
}
append_and_push || {
echo "History ref moved under us; retrying once against the new tip."
append_and_push
}
19 changes: 13 additions & 6 deletions .github/workflows/size.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ on:

permissions:
contents: read
pull-requests: write

concurrency:
group: size-${{ github.workflow }}-${{ github.ref }}
Expand Down Expand Up @@ -80,8 +79,16 @@ jobs:
- name: Add job summary
run: cat .tmp/size-report.md >> "$GITHUB_STEP_SUMMARY"

- name: Comment on PR
env:
GITHUB_TOKEN: ${{ github.token }}
GITHUB_PR_NUMBER: ${{ github.event.pull_request.number }}
run: pnpm size --post-comment .tmp/size-report.md
# The size table itself is job-summary output now: the sticky PR comment it used to post is
# the quality-delta comment (#1424), rendered by quality-delta.yml under the SAME marker once
# this workflow and CI have both finished, so a PR still carries exactly one comment. Size rows
# reach it through this artifact rather than a second base/head measurement.
- name: Upload size report JSON
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: size-report-json
path: .tmp/size-report.json
# .tmp is a dotted directory, which upload-artifact skips by default.
include-hidden-files: true
retention-days: 7
if-no-files-found: error
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,9 @@
"depgraph": "node --experimental-strip-types scripts/depgraph/build.ts",
"depgraph:test": "node --experimental-strip-types --test scripts/depgraph/model.test.ts scripts/depgraph/affected.test.ts",
"repo-health": "node --experimental-strip-types scripts/repo-health/run.ts",
"repo-health:history": "node --experimental-strip-types scripts/quality-delta/append-history.ts",
"quality-delta": "node --experimental-strip-types scripts/quality-delta/run.ts",
"quality-delta:producers": "node --experimental-strip-types scripts/quality-delta/producers-run.ts",
"check:production-exports": "fallow dead-code --config fallow-production-exports.json --production --unused-exports --fail-on-issues",
"check:bundle-owner-files": "node --experimental-strip-types scripts/check-bundle-owner-files.ts",
"check:command-docs": "vitest run --project unit-core src/__tests__/command-doc-coverage.test.ts",
Expand Down
10 changes: 9 additions & 1 deletion scripts/coverage-changed/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import {
type ChangedCoverageResult,
} from './model.ts';

const USAGE = 'Usage: pnpm check:coverage-changed [--base <ref>]\n';
const USAGE = 'Usage: pnpm check:coverage-changed [--base <ref>] [--json <path>]\n';
const LCOV_PATH = 'coverage/lcov.info';

function fmtPct(pct: number | null): string {
Expand Down Expand Up @@ -58,6 +58,8 @@ function render(result: ChangedCoverageResult): string {
export function run(argv: readonly string[], cwd?: string): number {
const values = parseScriptArgs(argv, USAGE, {
base: { type: 'string', default: 'origin/main' },
// The quality-delta comment (#1424) reads these numbers rather than scraping the markdown.
json: { type: 'string' },
});
const base = values.base ?? 'origin/main';
// The `coverage-waiver` PR label sets this env var in CI (and locally).
Expand All @@ -84,6 +86,12 @@ export function run(argv: readonly string[], cwd?: string): number {
},
});

if (typeof values.json === 'string') {
const jsonPath = path.resolve(values.json);
fs.mkdirSync(path.dirname(jsonPath), { recursive: true });
fs.writeFileSync(jsonPath, `${JSON.stringify(result, null, 2)}\n`);
}

const report = render(result);
process.stdout.write(report);
const summaryPath = process.env.GITHUB_STEP_SUMMARY;
Expand Down
Loading
Loading