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
130 changes: 129 additions & 1 deletion .github/workflows/changeset-presence.yml
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,20 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 5

# ⚠️ A JOB-LEVEL block, so `pull-requests: write` reaches THIS job and not
# the declaration gate above it, which needs nothing but a checkout. A
# job-level block REPLACES the workflow-level one rather than extending it,
# so `contents: read` has to be named again here or checkout loses it.
#
# ⛔ Not a new permission (objectui#9140's ruling required this to be
# checked): `pull-requests: write` on the run's `GITHUB_TOKEN` is what
# `performance-budget.yml` already uses to post the Console Performance
# Budget report, and four other workflows here hold it too. There is no new
# secret, no personal token, no app, and no `pull_request_target`.
permissions:
contents: read
pull-requests: write

steps:
- name: Checkout code
uses: actions/checkout@v7
Expand All @@ -165,4 +179,118 @@ jobs:
# batched blob read — measured at ~1.1s against the 1,384 changesets
# pending when it was written — so it needs no install and no build.
- name: Report pending changesets this change may have falsified
run: node scripts/check-changeset-claims.mjs
# `--json` is the hand-off to the delivery steps below, written from the
# same run that prints the log — ⛔ never a second measurement, which
# could disagree with the log it claims to report.
run: node scripts/check-changeset-claims.mjs --json claims.json

# ── Delivery (objectui#9140, director ruling, maintainer 「同意」) ──────
#
# The finding is a REQUEST TO READ, and objectui#9140 measured that request
# at zero answers out of four because it was addressed to a job log nobody
# opens on a green check. So it is carried to the pull request, where the
# seat that can answer it already is.
#
# ⛔ Report-only is UNCHANGED and must stay that way: the step above still
# exits 0 on findings, this job is still not a required context, and the
# rendered comment says both in its own opening lines. Delivery moves where
# the finding is READ, never what it does.
#
# Rendering lives in a script for the reason the budget report's does:
# logic embedded in YAML cannot be unit tested, and the failure this
# rendering can carry — an empty finding set rendered as a request to
# re-read — is the same class of bug as objectui#3152.
- name: Render the claim re-read comment
if: ${{ github.event_name == 'pull_request' }}
env:
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: node scripts/render-changeset-claims-comment.mjs --from claims.json > claims-comment.md

# One comment per pull request, updated in place on re-runs rather than
# stacked. The marker it finds itself by is the FIRST LINE of the body it
# is about to post — plain text, never an HTML comment, because this
# repository has measured that GitHub deletes tag-shaped fragments from a
# stored body and that a first-line HTML comment is eaten with them
# (AGENTS.md). A marker that vanishes does not degrade gracefully: every
# re-run would fail to find its predecessor and post again, which is the
# stacking this is required to avoid.
#
# ⛔ The marker is not spelled here. It is read off the rendered body, so
# there is only ever one copy of it.
- name: Deliver the finding to the pull request
if: ${{ github.event_name == 'pull_request' }}
# A fork's pull request gets a read-only token, so the API call fails for
# a reason that says nothing about changesets. `continue-on-error` plus
# the try/catch below keep that from painting this job red — the same
# pairing, for the same reason, as the budget report's comment step.
continue-on-error: true
uses: actions/github-script@v9
with:
# GitHub's API intermittently rejects valid run tokens with 401
# (seen on PR #1627), so 401/403 must stay retryable here.
retries: 3
retry-exempt-status-codes: 400,404,422
script: |
const fs = require('fs');

if (!fs.existsSync('claims-comment.md')) {
core.warning('claims-comment.md was not rendered; skipping the pull request comment.');
return;
}
const body = fs.readFileSync('claims-comment.md', 'utf8');
const marker = body.split('\n', 1)[0].trim();
if (!/^[a-z][a-z0-9-]{4,}$/.test(marker)) {
core.warning(
`Rendered body does not open with a marker line (read: ${JSON.stringify(marker)}). ` +
'Posting without in-place update — this comment may stack.',
);
}

let findings = 0;
try {
findings = JSON.parse(fs.readFileSync('claims.json', 'utf8')).findings.length;
} catch (error) {
core.warning(`Could not read the finding count: ${error.message}`);
}

const target = {
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
};

try {
const all = await github.paginate(github.rest.issues.listComments, {
...target,
per_page: 100,
});
const mine = all
.filter((comment) => (comment.body ?? '').split('\n', 1)[0].trim() === marker)
.sort((a, b) => a.id - b.id);

if (mine.length === 0) {
// ⛔ Never CREATE a comment for an empty finding set. Most pull
// requests here have nothing to re-read, and a reassuring "all
// clear" on every one of them is how a channel gets muted — the
// channel this ruling exists to un-mute.
if (findings === 0) {
core.info('Nothing to re-read, and no earlier request on this pull request.');
return;
}
const created = await github.rest.issues.createComment({ ...target, body });
core.info(`Posted the request to re-read: ${created.data.html_url}`);
return;
}

await github.rest.issues.updateComment({ ...target, comment_id: mine[0].id, body });
core.info(`Updated the request to re-read in place: ${mine[0].html_url}`);
if (mine.length > 1) {
core.warning(
`${mine.length} comments carry this marker; updated the earliest and left the ` +
'rest alone. ⛔ Not this job\'s to delete — somebody else may own them.',
);
}
} catch (error) {
core.warning(`Could not deliver the finding to the pull request: ${error.message}`);
await core.summary.addRaw(body).write();
}
20 changes: 20 additions & 0 deletions content/docs/guide/ci-cd-pipeline.md
Original file line number Diff line number Diff line change
Expand Up @@ -2425,6 +2425,26 @@ filter, and this gate's subject — an ordinary source change that falsifies a p
under no obligation to touch `.changeset/**` at all. Run it locally with
`pnpm check:changeset-claims`.

**That finding is delivered onto the pull request, not left in the job log.**
[#9140](https://github.com/objectstack-ai/objectui/issues/9140) re-ran the gate over five live
instances of the shape it exists for: it named four of them, and **none of the four was acted on**.
The finding calls itself a *request to read*, and it was addressed to a job log nobody opens on a
green check. So the job now runs `scripts/check-changeset-claims.mjs --json claims.json`, renders
that hand-off with `scripts/render-changeset-claims-comment.mjs`, and posts it as a comment on the
pull request through `actions/github-script` — the same channel the Console Performance Budget
report uses, on the same existing `pull-requests: write` grant, declared on this job only.

- **One comment per pull request, updated in place.** The job finds its previous comment by the
first line of the body, which is a plain-text marker rather than the usual hidden HTML comment:
GitHub deletes tag-shaped fragments from a stored body — an HTML comment alone on the first line
included — and a marker that vanishes makes every re-run post a *new* comment. See AGENTS.md.
- **A revision that resolves the finding updates the same comment** to say so, rather than leaving
a stale request at the top of the thread. A pull request with nothing to re-read and no earlier
comment gets no comment at all.
- **Nothing about enforcement moved.** The step still exits 0 on findings, the job is still not a
required context, and the comment says both in its own opening lines. Failing a build over prose
being adjacent is the shape triage fenced, and it stays fenced.

**Why this is separate from `changeset-guard.yml`, which also polices changesets:** that workflow's
trigger is `paths: ['.changeset/**']`, and the inversion is deliberate — on a PR that adds *only* a
changeset, every gate inside `ci.yml` and `lint.yml` short-circuits, so nothing in either of them
Expand Down
139 changes: 125 additions & 14 deletions scripts/__tests__/check-changeset-claims.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { afterAll, describe, expect, it } from 'vitest';
import { execFileSync } from 'node:child_process';
import { execFileSync, spawnSync } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
Expand Down Expand Up @@ -148,20 +148,23 @@ interface Run {
output: string;
}

/** Runs the real gate against a fixture, capturing status and both streams. */
/**
* Runs the real gate against a fixture, capturing status and both streams.
*
* BOTH streams on the success path too, not just on failure: this gate warns on
* stderr while still exiting 0 — losing the DELIVERY hand-off is a warning, not
* a verdict — and a harness that reads only stdout on a green run cannot see
* that half of the output. In the job log the two streams are interleaved
* anyway, so this is what a reader actually gets.
*/
function runGate(root: string, args: string[] = [], env: Record<string, string> = {}): Run {
try {
const stdout = execFileSync('node', [path.join(repoRoot, GATE), '--root', root, ...args], {
cwd: repoRoot,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
env: { ...process.env, ...env },
});
return { status: 0, output: stdout };
} catch (error) {
const failure = error as { status?: number; stdout?: string; stderr?: string };
return { status: failure.status ?? -1, output: `${failure.stdout ?? ''}${failure.stderr ?? ''}` };
}
const run = spawnSync('node', [path.join(repoRoot, GATE), '--root', root, ...args], {
cwd: repoRoot,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
env: { ...process.env, ...env },
});
return { status: run.status ?? -1, output: `${run.stdout ?? ''}${run.stderr ?? ''}` };
}

/** base..head for a fixture's last commit. */
Expand Down Expand Up @@ -196,6 +199,63 @@ describe('changeset-presence.yml — where this gate has to live', () => {
});
});

// ── 1b. the finding is DELIVERED, not archived (objectui#9140) ───────────────

describe('delivery — the ruling that the finding reaches the pull request', () => {
// objectui#9140 measured the "REQUEST TO READ" at zero answers out of four
// live instances, because it was addressed to a job log nobody opens on a
// green check. The director's ruling (maintainer 「同意」) moves WHERE the
// finding is read and ⛔ nothing else: exit 0, not a required context,
// nothing blocks.
it('hands this run\'s finding set to the renderer instead of re-measuring it', () => {
// One measurement, two consumers. A report that re-derives its own subject
// can disagree with the log it claims to report, and the disagreement is
// invisible to both sides.
expect(workflowYaml).toMatch(/node\s+scripts\/check-changeset-claims\.mjs\s+--json\s+claims\.json/);
expect(workflowYaml).toMatch(
/node\s+scripts\/render-changeset-claims-comment\.mjs\s+--from\s+claims\.json/,
);
});

it('posts through the channel the Console Performance Budget report uses', () => {
expect(workflowYaml).toMatch(/uses:\s*actions\/github-script@/);
const budget = withoutComments(
fs.readFileSync(path.join(repoRoot, '.github/workflows/performance-budget.yml'), 'utf8'),
);
expect(budget).toMatch(/uses:\s*actions\/github-script@/);
});

it('holds `pull-requests: write` on the JOB, leaving the declaration gate with a read-only token', () => {
// A job-level block REPLACES the workflow-level one, so `contents: read`
// has to be named again or checkout loses it. The workflow-level block must
// stay read-only: the declaration gate above needs nothing but a checkout,
// and a grant it does not use is a grant nobody is auditing.
expect(workflowYaml).toMatch(/permissions:\n\s+contents: read\n\s+pull-requests: write/);
expect(workflowYaml).toMatch(/^permissions:\n\s+contents: read\n/m);
expect(workflowYaml).not.toMatch(/^permissions:\n\s+contents: read\n\s+pull-requests/m);
});

it('⛔ never subscribes `pull_request_target`, which would run this on a fork\'s branch with a write token', () => {
expect(workflowYaml).not.toMatch(/pull_request_target/);
});

it('comments only on a pull request', () => {
// A `merge_group` build has no pull request to comment on, and the gate
// still has to REPORT there — so the delivery steps are conditioned and the
// measuring step is not.
const conditions = workflowYaml.match(/if: \$\{\{ github\.event_name == 'pull_request' \}\}/g) ?? [];
expect(conditions.length).toBe(2);
});

it('cannot paint this job red by failing to post', () => {
// A fork's pull request gets a read-only token and the API call fails for a
// reason that says nothing about changesets. Report-only means the gate
// declines to fail on its FINDINGS; it must not start failing on its
// PLUMBING either.
expect(workflowYaml).toMatch(/continue-on-error: true/);
});
});

// ── 2. both directions of the verdict ────────────────────────────────────────

describe('a change that edits a file a pending changeset NAMES', () => {
Expand Down Expand Up @@ -512,3 +572,54 @@ describe('objectui#9140 — bodies this gate can never reach', () => {
);
});
});

// ── 6. the delivery hand-off is a real artefact of the real run ──────────────

describe('--json, the hand-off the pull request comment is rendered from', () => {
const fixture = fixtureRepo('json-handoff');
fixture.write('packages/alpha/src/reconciliation.test.ts', 'export const pinned = false;\n');
fixture.commit('fix(alpha): flip the reconciliation pin');
const handOff = path.join(fixture.root, 'claims.json');
const run = runGate(fixture.root, [...lastCommitRange(fixture), '--json', handOff]);

it('writes the finding set this run measured', () => {
const written = JSON.parse(fs.readFileSync(handOff, 'utf8'));
expect(written.findings).toHaveLength(1);
expect(written.findings[0].changeset).toBe('.changeset/6794-declared-default.md');
expect(written.findings[0].file).toBe('packages/alpha/src/reconciliation.test.ts');
expect(written.findings[0].paragraph).toContain('keeps the two sides pinned');
});

it('carries the population the log reports, so the comment cannot contradict it', () => {
const written = JSON.parse(fs.readFileSync(handOff, 'utf8'));
expect(run.output).toContain(`read against ${written.considered} pending declaration(s)`);
expect(run.output).toContain(`(${written.pending} pending in total)`);
});

it('still exits 0 — delivery moved where the finding is read, never what the gate does', () => {
expect(run.status).toBe(0);
});

it('writes an EMPTY finding list rather than no file, so the renderer can resolve a stale request', () => {
const quiet = fixtureRepo('json-handoff-quiet');
quiet.write('packages/alpha/src/untouched.ts', 'export const untouched = false;\n');
quiet.commit('fix(alpha): change a file nothing pending names');
const quietHandOff = path.join(quiet.root, 'claims.json');
const quietRun = runGate(quiet.root, [...lastCommitRange(quiet), '--json', quietHandOff]);
expect(quietRun.status).toBe(0);
expect(JSON.parse(fs.readFileSync(quietHandOff, 'utf8')).findings).toEqual([]);
});

it('⛔ never fails the gate over its own plumbing', () => {
// An unwritable hand-off loses the DELIVERY. The verdict is the log and it
// is already printed, so exit 1 here would be this gate failing a build
// over a broken pipe — the one thing report-only forbids.
const broken = runGate(fixture.root, [
...lastCommitRange(fixture),
'--json',
path.join(fixture.root, 'no-such-directory', 'claims.json'),
]);
expect(broken.status).toBe(0);
expect(broken.output).toContain('Could not write');
});
});
Loading
Loading