Repository navigation
v0.5.0
The fixes of the 2026-11-01 review. Test integrity in block fails closed when it cannot see the change, TI03 blocks
the ways a skip was written to slip past it, unreadable agent settings and MCP files are errors, and the upload to
pharn.dev is bounded in time and never sent from a pull_request_target run. The Action input fail-on-threshold is
renamed fail-on-verdict; the old name still works, with a warning. Some warnings become errors on purpose: see
Changed and Security. No schema field or message code was added, removed or renamed.
Added
- The Action input
fail-on-verdict, the new name offail-on-threshold(review 2026-11-01, finding 27): it gates on
the verdict, whatever made it fail, not only on the coverage threshold.fail-on-thresholdstill works when
fail-on-verdictis not set, with a deprecation warning, and is ignored (with a warning) when both are; both stay
declared inaction.yml, which no longer gives either a default (the default,true, is applied in code, so a set
old name is never shadowed).
Changed
-
The Action applies two things the pharn.dev app contract already named (review finding 15 f and g). No schema change,
no new input, no new message code and no new host.- The app's
settings.messageMaxBytesnow lowers the upload's per-message cap: the cap is the smaller of 2048 bytes
and that setting, taken only from a policy response that validated whole in the same run (never under
policy: neveror afile:policy). The app's other settings (uploadJunit,redactMessages) and its
quarantinelist are validated and still not applied. meta.mergeBaseis recorded for a run with a local diff: the merge base of the resolved base andmeta.headSha
(the pull request's head, so on apull_requestrun the fork point, not the base tip), one extra local
git merge-baseper run at most. Every other run, and agit merge-basethat fails or prints no object id (a
shallow checkout), recordsnulland changes nothing else. Matrix legs of one job remain a documented limit
(docs/LIMITS.md).
- The app's
-
The Action's
packages/action/dist/index.jsshrinks from about 11.7 MB to about 1.7 MB: the TypeScript parser no
longer sits inside it.scripts/bundle.mjscopiestypescript@5.9.3'slib/typescript.jsbyte for byte beside the
bundle aspackages/action/dist/typescript.js(9.1 MB, the dist's second committed file), and the Action loads it
only when a pull request makes it parse a test file or test config, after checking its sha256 against a pin in
packages/action/src/vendored-typescript-pin.ts. A file that does not match the pin fails the step with an error
naming the file and both hashes, and nothing is reported. A run that parses no test file never reads it. The CLI
still inlines the parser. No schema change and no new message code. Seedocs/THREAT-MODEL.md, G1. -
One source for three text rules in
@pharn-ci/core(text.ts): the code-unit comparatorbyCodeUnit(about 50
copies, in core, node-io and the tests, are gone), the surrogate-safe cutcutCodeUnits(the…inside the limit,
an option for a cut without it, never inside a surrogate pair) andutf8ByteLength. No limit number, message code
or schema changes. Two byte-level effects: a TI09 value (base/head) is now cut to at most 80 code units,…
included (it was 81), and a key text to at most 100 (it was 101); and the three cuts that could leave a lone
surrogate (TI01 and TI06 details, the 500-unit test name) no longer do. -
The Action's
run.tsitems of the 2026-11-01 review. No schema change and no new message code.- A PR comment or job summary cut as a last resort (the messages alone over the limit) now closes every collapsed
<details>section it cuts into before the truncation note, so the note is no longer hidden inside it (37). - Fewer GitHub API calls (47): a pull request is read once per run (in
pr-numbermode it was read up to three
times: the target, the agent PR detection and the accept label), and the comments and the token's login are
listed once for the upload's comment link, the pointer and the sticky comment (they were listed twice when an
upload was tried, including when the app answered404). A failed listing is still asked again by the next
reader. Theconfigandbase-refinputs are read once, and the coverage file is read from the inputs after the
org policy, as the CLI reads it. - The base is decided once for the diff and for
report.json(45):meta.baseShais now the base the diff was taken
against, recorded only when it is a full commit SHA. With abase-refbranch name, apull_requestrun used to
record the PR base and apushrun the previous commit; both now recordnull. A 64-character (SHA-256) commit
SHA asbase-refis no longer also tried asorigin/<sha>. - The warning for a truncated PR-head file list in
pr-numbermode names what may miss files: the agent setup scan,
the secret scan and the org policy's forbidden-file rule (P08) (48).docs/THREAT-MODEL.mdresidual 36 records
why a bot's marker comment still counts as pharn-ci's own when the token's login is unknown: no API names an
installation token's own login. - An unexpected exception in the upload is still
failedwith the reasoninternaland never changes the check,
and its log line now names the error class (never its message); with debug logging (RUNNER_DEBUG=1) it then
fails the step with the message, so it can be found (28). A413from the app is still read without its body:
the app's API defines none, somaxBytesstaysnull(29,docs/LIMITS.md). - Not changed: finding 39 (the two test-sources tests
vi.mock("@pharn-ci/core")) is dropped. The mock is a pinned
spy-through to the realcheck(); replacing it would delete the test that pins it, which TI02 correctly blocks.
- A PR comment or job summary cut as a last resort (the messages alone over the limit) now closes every collapsed
-
Docs and metadata (second review, 2026-11-01): README says nothing phones home only without
id-token: write, the
quick precedence sentence no longer claims.pharn/ci.yml"can only tighten" (only the test-integrity mode is
merged; an input replaces the config's threshold, file, root, format andsetup.require), the token-footprint
bullet names the accept-label reads, and the AI-agnostic and "fully stateless" sentences name P11 and the opt-in
upload.SECURITY.mdbest practices name whatid-token: writeturns on.docs/THREAT-MODEL.md§1 names the
pharn.dev app, its responses,app-urland the OIDC token, and §3 gains item 37 (the threshold comes from the PR).
docs/LIMITS.md§1 points at TI02, TI05 and TI06 instead of "not detected yet", gains "What P01 misses", and says
fail-on-threshold: falseun-gates every cause of afailverdict;action.ymlsays so too, and its
github-tokentext names the commits read and its fail-closed agent-PR detection.AGENTS.mddescribes Report v2,
the schemas, the Python index subprocess and the opt-in pharn.dev calls.docs/MARKETPLACE.mdno longer says the
listing is refreshed on each release: the Action is not listed yet.CONTRIBUTING.mdsays why secret-pattern
tests build tokens at test time. -
Release verifyrefuses a run whoseHEADis not thechore: release X.Y.Z (#N)merge commit, so a tag can no
longer land on a later PR (v0.2.0 was tagged four PRs after its release PR). -
The skylos-nine fixture pins now cover
fixtures/skylos-nine-pytest/andfixtures/skylos-nine-sources/too, and
name the commits and reasons of the twochange.diffedits made before the pins existed (docs/PRECISION.md§3). -
Test-only, recorded late: 0.4.0 (#111) moved the growth tests to CPU time and raised four vitest timeouts to 300 s
(ac-ti0205-growth,ac-ti06-ti12-linear,ac-py-index-growth,limits); the growth ceilings themselves did not
change. -
An org policy that requires a deny rule or a hook (
setup.required_permissions_denyorsetup.required_hooks,
with P03 or P06 at block) now fails the check when the agent settings files could not be read, instead of warning
(second review, HIGH 4). The files are unread whenever the diff came from the compare API (a shallow checkout, the
actions/checkoutdefault;pr-numbermode; a failed local diff), so before this a default checkout turned the
policy's block into a warning and the step passed with the requirement unchecked.POLICY_SETTINGS_INVALIDis now
errorthere, as it already was underpolicy.required; usefetch-depth: 0. A rule the policy sets towarnor
offkeeps the warning. -
TI10 reports a test script replaced by a lone
trueor:("test": "true", ortrue || vitest run) as forcing a
successful exit, likeexit 0(second review, HIGH 5).docs/LIMITS.mdlists what TI10 still misses:
node -e "process.exit(0)", a positional path filter,--dirand--project. -
.pharn/ci.ymlrefuses a__proto__key, bare, quoted or nested: it set the prototype of the parsed map, so a
coverageblock under it escaped the unknown-key check (second review, finding 26). -
The PR comment shows the accepted line once:
TEST_INTEGRITY_ACCEPTEDis no longer repeated under Messages when the
Test integrity section shows the accepted line, and its hint moves under that line (finding 32). -
TEST_INTEGRITY_MODE_IGNOREDnames the config in use when theconfiginput points elsewhere than
.pharn/ci.yml; the upload bundle redacts secrets in object keys too;action.ymlstates thepython3
requirement; the comment renderer uses the shared unsafe-character set, and the Action reads logins with core's
LOGIN_PATTERN(findings 45, 46 and 48, cleanups). No schema change. -
Tests (findings 12, 35, 41): the uploaded run's report equals the offline one apart from
meta.upload; every
declared input and output must be in the never-removed list (test-integrityandaccept-labelwere missing); the
policy request treats 409, 413 and 422 as unexpected statuses. -
The PR comment no longer hides a test-integrity finding id whose findings all fall past the 20 it lists: that
id'sTEST_INTEGRITY_<id>message now stays under Messages (review finding 10). No schema change. -
No hint tells the reader to loosen the gate (review finding 11): the
POLICY_UNAVAILABLEhint (also under a P10
violation) no longer offerspolicy: never, and theAGENT_SETUP_EXPECTED_MISSINGhint (also under a P01
violation) asks whoever ownssetup.expectto change it instead of suggesting removing the tool from it. -
A
file:policy's loosertest_integrity.modeis now named inPOLICY_OVERRIDE_IGNOREDwhen neither the
test-integrityinput nor.pharn/ci.ymlsets a mode (review finding 14). The mode in force is unchanged. -
Docs (review findings 17 and 18): README and
SECURITY.mdname the pull request commits read made on every
pull_requestrun for agent-PR detection (with an empty or under-scoped token every pull request counts as an
agent PR), the accept-label reads and every git subcommand; the quick start'sid-token: writecomment and
the token-footprint bullet say the permission also turns the upload to pharn.dev on. -
Docs (review findings 28 and 30):
docs/LIMITS.mdgains "What P08 misses";docs/RELEASING.mdsays the
release PR is the last change merged before the dispatch and thatschemaVersionis the one v1 field whose
type changed in Report v2;CONTRIBUTING.mdnames the fixture naming rule and its two historical exceptions. -
TI09 no longer lets a test-runner config made unreadable at HEAD turn a block into a warning (review finding 22):
a modified or renamed config whose settings were read at the merge base and that is not found at HEAD (an IIFE or
other opaque wrapper) or cannot be read there (padded past the size cap, nested too deep, a syntax error, dropped)
is now one(config)finding at the mode's level (blockintest-integrity: block; it was awarn, or no
finding for a refused head). An added config and a config already unreadable at base are unchanged. No schema
change and no new message code. -
report.jsonand the upload now neutralize the same invisible and bidirectional characters the PR comment does
(U+200B to U+200F, U+202A to U+202E, U+2066 to U+2069 and U+FEFF), besides the control characters: in message
text, signal details, test names, test-config values and policy violation details each becomes a space or U+FFFD,
so hidden or reordered text from a pull request cannot reach a consumer of the report (review finding 23). The
DIFF_UNAVAILABLEdetail, the coverage file path inCOVERAGE_FILE_MISSING,COVERAGE_FILE_EMPTYand
COVERAGE_FORMAT_UNSUPPORTED, and the reason inPOLICY_SETTINGS_INVALIDare now cleaned too (control characters
included); text without such characters is unchanged. Theaccept-labelinput (and a policy's accept label) is
now also refused when it holds a line or paragraph separator (U+2028, U+2029). No schema change and no new message
code. -
A
pull_request_targetrun is never uploaded to pharn.dev: it asks for no upload token and sends nothing, and
meta.upload.statusisskipped(pharn-ci refuses to measure its base-branch checkout, so the app would record a
run that measured nothing). The report, comment, summary, verdict and policy request are unchanged. No schema change. -
The install hint (
Install the Pharn GitHub App …) now also ends the PR comment and the job summary when the org
policy request is answered404, for example withupload: never; a run whose policy request and upload both get
a404shows it once, and a204(no policy yet) shows none. No schema change. -
The OIDC token request (for the org policy and the upload, only with
id-token: write) now gives up after
30 s, and the run goes on as if the runner offered no token;@actions/corealone retried it up to ten times
with a 3-minute socket timeout each (review LOW 21). No schema change and no new message code. -
meta.scopeinreport.jsonand in the upload is cut to 200 code points with control characters removed, like
the other run metadata a pull request can influence: the workflowname:in it comes from the pull request's
workflow file (review LOW). No schema change. -
Test integrity in
blocknow fails closed when it cannot see the change (review HIGH 4):DIFF_UNAVAILABLEand a
TEST_INTEGRITY_INDEX_UNAVAILABLEfor which TI02 and TI05, or TI09, did not run at all are now errors in
test-integrity: block(they were warnings; the per-file form stays a warning). The Action now fails the step when
a localgit diffagainst a base that resolves fails, naming the base andfetch-depth: 0, instead of falling back
to the GitHub compare API. Inpr-numbermode,blockfails when a test file or a test-runner config is modified
(usewarnthere). No schema change and no new message code. -
TI03 now blocks an unconditional skip added to a test that exists at base however the change writes it (review
B1): a test renamed toit.skip(…)orit.skipIf(true)(…)with the same body, adescribe.skipwrapped around
existing tests, an existing test moved into a skippeddescribe, andpytestmark = pytest.mark.skip(…)added at
module level or in a test class. When the test index compared the files, TI03 now also reads the base-to-head test
pairs TI05 reads and thedescribe, test-class andpytestmarkmodifiers around each test, giving one finding per
line. The Python index and the line reader readpytestmark. Deliberate precision change: an unguarded in-body
pytest.skip(),pytest.xfail()orself.skipTest()added to an existing test now blocks when the Python index ran
(it warned). The line reader's sentence for a skip in a test the diff shows as new now says only what the diff
shows: "A skip was added to a test that no removed line of this file opens, so the diff shows it as new." No schema
change and no new message code. -
Docs: the report.v2 schema now says
DIFF_UNAVAILABLEis an error with a coverage threshold or in
test-integrity: block, andTEST_INTEGRITY_INDEX_UNAVAILABLEis an error inblockwhen TI02 and TI05, or TI09,
did not run at all (the levels #126 shipped).docs/THREAT-MODEL.md§3 now renders residual numbers 8 to 35 as
written, the numbers the docs and tests cite. No code change. -
*.test.mts,*.spec.mts,*.test.ctsand*.spec.ctsfiles are test files, so TI01, TI03, TI04 and the other
test-file signals read them, and TI13 no longer fires in them (review 2026-11-01, finding 13). The eight JS/TS
extensions are one exported list,JS_TS_EXTENSIONSin@pharn-ci/core, that every site derives from. -
After a successful upload to pharn.dev, the PR comment an earlier run of the same workflow and job left is updated
to one line pointing to the run on pharn.dev, instead of keeping its old verdict beside the Pharn GitHub App's
comment (review finding 7). Nothing is created or deleted; a failed update is a warning. -
The upload to pharn.dev now ends within 90 s of its start (review finding 8): each request and the
PUTis cut off
at the time left, the retry happens only when enough time is left, and running out is a failed upload (reason
…:deadline) that never fails the check. A slow pharn.dev could take about 6 minutes, past the recommended
timeout-minutes: 5. No schema change. -
A failed
git ls-files(or, in PR-number mode, a failed listing of the PR head's tree) now fails the Action step,
as it already failed the CLI. It used to warn and run the agent setup, secret and policy checks on an empty file
list, which passed them and skipped the coverage path check (review finding 9). -
A base that resolves to HEAD or a descendant of it (
base-ref: HEAD,base-ref: ${{ github.sha }}, or the CLI's
--base HEAD) is nowDIFF_UNAVAILABLE, an error with a threshold or intest-integrity: block, instead of a
NO_COVERABLE_CHANGESpass on an empty diff (review finding 13). Running the CLI with a threshold on a branch
that equals its base now exits 1. -
The PR comment and the job summary show a 20-cell bar of the diff-coverage percentage (
▰covered,▱not)
above the "N% of changed lines covered" line. No schema change. -
The TI03 hint leads with the fix, as the other block hints do: "Remove the skip so the test runs again, or say in
the PR why it must stay", then the accept label (review 2026-11-01, A4-F5). -
Test-only (review SPEC finding 29): the markdown render tests compare with committed files byte for byte
(fixtures/markdown/,fixtures/coverage-paths/package-relative/) instead of snapshots thatpnpm test -ucould
silently rewrite. No output changes.
Security
-
A pull request can no longer lower the coverage threshold past TI14 by putting a Unicode space before the comment
(threshold: 50<U+00A0># lowered): the config reader opened a comment after any\scharacter and set the gate
to 50, while TI14 read the line by YAML's rule (space or tab only), saw no number and stayed silent. Both now share
one comment rule (findComment), and the config refuses a#that follows any other space, with an error naming
the character. TI14 also fails closed when a removed threshold is replaced by a value it cannot read as a plain
number, such as one padded past the 400-character kept-line cap. A config that relied on such a space before#
now fails with that error; replace the character with a space. -
The dependency audit in CI and in the release job runs at
--audit-level=moderateinstead ofhigh(second
review, HIGH 3): the Action bundles its production dependencies intopackages/action/dist, and two of the five
undici 6.29.0 advisories were moderate. A known moderate advisory in a production dependency now fails the build. -
The undici bundled into the Action moves from 6.29.0 to 6.29.1 (review HIGH 6). It carries every GitHub API request
and the OIDC token request, and 6.29.0 has three high and two moderate advisories (GHSA-xjj6-f33q-929c,
GHSA-2m5v-pch6-74hx, GHSA-qrxm-fqhp-jwvm, GHSA-4qv8-2p9j-2685, GHSA-9fm3-97px-rjf6). It is a lockfile-only change
with a rebuiltpackages/action/dist: no source, input or output change. CI and the release job now run
pnpm audit --prod --audit-level=highafter install, so a high or critical advisory in a production dependency fails
the build before it can ship. -
Unparseable or binary agent settings and MCP files are now errors whatever the policy lists (review A3-F2). A
settings file that is too deep, not JSON, not an object or binary used to be only a warning unless the policy set a
required deny list or hook list, and such an MCP file was always only a warning, so a pull request could pad
.claude/settings.jsonor.mcp.jsonpast the parser's depth limit, keep it valid for Claude Code and loosen the
policy without a block. NowPOLICY_SETTINGS_INVALIDis anerrorand the verdict fails, and P09 blocks an edit to
such a file. This is a deliberate tightening: a repository with an already broken settings or MCP file under a policy
that reads it now fails where it warned (P02 is in force under every policy, so this applies to any policy
document). No schema change and no new message code. -
SECURITY.mdand the Marketplace listing text no longer say agent setup files are never read (review A7-F1). The
Data handling table now says check A7 reads the machine-readable agent config files atHEADfor committed secrets
and policy mode reads the settings, MCP and instruction files, and a new row says where that text goes: it stays in
the job and never reaches the comment, the summary, the log,report.jsonor the upload. Documentation only. -
SECURITY.md's Data handling table now names the run upload to pharn.dev (review HIGH 5). It said the org policy
request was the only thing sent to Pharn and that nothing else was; a new row lists the run upload's twoPOST
requests to theapp-urlorigin and their body fields, thePUTofreport.jsonandmeta.jsonto the signed
storage URL, everymeta.jsonfield (includingcommentUrl), howreport.jsonis redacted and capped, the one
retry, that the first request goes out before pharn-ci knows whether the Pharn GitHub App is installed, and how to
turn it off. The README's security section now says that table lists what goes to pharn.dev too. Documentation
only. -
A bug in pharn-ci's own code no longer reads as bad input (review finding 28). The test-file index, the JS and JSON
test-config readers and the Python output mapper caught every exception and reported it asparser-threw,
too-deep,syntax-error,invalid-jsonorbad-output, and the policy validator andindexPythonTestSources
turned it into an invalid policy orpython-failed; a fault in them now escapes and fails the run (Action step
failed, CLI exit 2), while a file the parser genuinely throws on is refused as before. Only the third-party call
(createSourceFile,JSON.parse) stays inside atry. The unusedcouldNotValidatemessage is removed.