Releases: somework/lockrot
Release list
v0.13.0
lockrot 0.13.0 writes several reports from one run, keeps self-update within its major version, and writes the decisions behind each verdict, priority and exit code into the report as typed fields. Read Before you upgrade if you: run lockrot in CI, set COMPOSER or LOCKROT_*, publish reports, validate them with Ajv strict or a vendored schema, run self-update in CI, or use lockrot's PHP classes.
No verdict or priority rule changed. Documents written by earlier releases still validate.
Before you upgrade
| If you… | What changes | What to do |
|---|---|---|
allow exit 1 in CI (e.g. GitLab allow_failure: exit_codes: [1]) |
A command line lockrot cannot read (unknown option, missing value) exits 2, not 1. An unknown command name is still 1 |
Fix the command line (exit codes) |
set LOCKROT_FAIL_ON or LOCKROT_TARGET_PHP |
An invalid value is a configuration error (exit 2), even when the matching option overrides it |
Fix or unset the variable |
set COMPOSER=alt.json |
lockrot reads extra.lockrot from alt.json and analyses alt.lock, as Composer does, so verdicts can differ; annotations name alt.lock |
Keep extra.lockrot and the baseline with that manifest (environment overrides) |
| publish reports | json and html carry run.root_package, composer.json's name, whatever extra.lockrot.project says |
Remove the name before publishing if it must stay private |
| published reports from earlier releases | Earlier releases could print logins, URL tokens and machine paths. 0.13.0 quotes none (SARIF's %SRCROOT% aside) |
Check old published reports; rotate any token you find (what a report reveals) |
| validate reports with Ajv strict, or keep a copy of the schema | Sets that grow (signal ids, reasons, note codes, formats…) are open strings with x-known-values. The schema URLs serve the newest release's files, so this applies even if you stay on 0.12 |
Declare x-known-values or set strict: false; refresh a vendored copy (open sets) |
run self-update in CI |
A 0.13 archive installs only within its major (--allow-major moves one), skips releases needing a newer PHP or a key it lacks, and reads lockrot.phar.meta.json from github.com. --check exits 1 only when an update would install; --force can exit 2 |
Allow github.com where only api.github.com was allowed; let a --force job accept exit 2 (self-update exit codes) |
reviewed lockrot or set an egress allowlist from 0.12.0's SECURITY.md |
That page left out GitLab, Bitbucket, GitHub's release downloads and the GitLab token variables, which earlier releases used too | Recheck against what lockrot does and does not do |
| use lockrot's PHP classes | Everything under src/ is @internal; Lockrot\Extension\ is reserved |
Depend on the CLI and the json report |
cut the JSON out of an html page |
That recipe wrote an empty file when the page did not match | Write both from one run: --output=json:lockrot.json (the JSON beside the page) |
What's new
-
Several reports from one run. Repeat
--output=<format>:<path>;--formatstill decides stdout, and every file shares one analysis:php lockrot.phar --format=github \ --output=sarif:lockrot.sarif --output=html:lockrot.html --output=json:lockrot.json
-
The report states what lockrot decided. Instead of rebuilding it from prose or lockrot's rules, read it from the
jsonreport:gate: whether the run fails and why, and per finding whether it reaches--fail-onor is exempt by the baseline;priority_basisandno_fix_expected: how each priority was reached, and which advisories expect no fix;origin,from_composer_repositoryandreplacement_url: where each lock entry came from, and links lockrot can vouch for;note_details: every run note typed, with a link to its section;libyears_unmeasured,exposure_rulewithunattributed, S6's release facts, and which release branches the project's PHP can take.
All are listed in schema.md. The
htmlpage (lockrot-report 0.13.0) reads them. -
A warning for a mistyped key. An
extra.lockrotkey lockrot does not read gets one line on stderr, with the likely key:lockrot: unknown key extra.lockrot.install-tme ignored (did you mean install-time?). -
A safer
self-update. It stays within its major version, checks the PHP floor and signing key a release declares, and moves to a new key through a transition release. -
A draft of the 1.0 promise. Compatibility says what 1.0 freezes, how verdicts may change between releases, and the deprecation policy.
Fixed
- Text from a package or an error message can no longer restyle the console, open a terminal link or end the run with exit
2. - A key starting with a NUL byte no longer switches
extra.lockrotor baseline validation off. LOCKROT_DISABLE=1skips the run before the configuration is read.- Report and baseline files are written through an exclusive temporary file and never follow a planted symlink.
- Several docs statements that gave wrong results when followed; each is in the changelog.
The full list, with a link from every entry to the page that explains it, is the 0.13.0 changelog.
v0.12.0
v0.11.0
Added
-
S10: what the run could not check.
okmeant two things — every check ran and found nothing,
or a check never ran — and the difference was invisible on the finding. Anonymously the activity
round asks only about packages already stale on release age, so a repository archived a week
after its last release cannot be seen, and the only trace was a note counting how many packages
were skipped without naming one. A package whose newest releases are dated by a commit their tags
share has the same shape: S2 has nothing to measure, and the finding read asok. S10 carries
the missing check on the finding, with the reason and the signals it blocked (repository_activity
→ S3, S4;release_dates→ S2, S8). It is informational, like S7 and S9: it never decides a
verdict, and it is raised only where the missing check could have changed one — never on a package
the repository already marks abandoned, nor on an allowlisted one. Credentials for every host take
away therepository_activityreasons and only those —release_datesasks no forge — and a
branch snapshot, which is on no release branch, is told about S2 alone.
--fail-on=uncheckedfails a run that could not check everything,
which is how a pipeline catches the workflow that never passedGITHUB_TOKENthrough. See
What was not checked. -
One number for how far behind the lock is: libyears. For each package, the years between
the release installed and the package's newest stable release, summed over the analysed lock —
171.3 on the wallabag fixture. Every finding carries itslibyearsin--format=json(null
when not measured: a branch snapshot, no dated stable release, not from a Composer repository,
metadata unavailable), and the document alibyearsblock that is the arithmetic over them —
total,direct_requirements(the same sum over the direct requirements, the nearest number
to php-libyear's, which readscomposer.json),measured,unmeasuredby reason and
furthest_behind.totalanddirect_requirementsare null where nothing could be measured, so
that a reader adding the field up over several projects never counts an unmeasurable lock as a
lock with nothing to fix; a measured lock with nothing behind reports0. The table and markdown footers print one line (libyears: 171.3 behind across 195 of 200 packages · 111.7 from direct requirements · furthest behind smalot/pdfparser v1.1.0 at 4.7); the HTML page shows the total in its ledger, a sortable column, and the counts
by reason on the Run tab;--explainprints the package's own value under its verdict, or the
reason it was not measured. It is laid over the verdicts, not one of them: it counts every drift,
healthy patches included, and enters no priority,--fail-onor baseline. The schemas gain the
fields under the same number, optional so that older documents still validate. No new request
is made: both dates were already in the data. A split package's installed version is dated by
its monorepo parent's tag of the same version, not by the lock: the lock copies the date
Packagist gave the split's tag, the commit its tags share, and illuminate/contracts v8.83.27
would read as 4.65 libyears behind for 3.75.--explainprints that date asinstalled release
and names the monorepo it came from. That holds for an installed tag on a shared commit
under a branch whose newest tag has a commit of its own, as after a change to the split's
directory: the parent is asked for the installed version, not only for the branch.--explain
says whose date it is under
installed_releaseandinstalled_release_dated_by, and so does the package card. Where no
parent dates it, such a tag is not measured at all rather than measured from the commit's date:
the installed tag's own entry in the repository decides that, not what dated the package's
newest release. -
left-behindsuggests a branch the project can actually move to. S8 named the newest
releasing branch and wrote the constraint that follows it, whatever PHP that branch requires:
Matomo supportsphp >=7.2.5, locks monolog 1.27.1, and was toldrequire ^3.12— monolog
3.x needs PHP 8.1. On the weekly watch, 99 of the 138 constraints suggested to projects that
declare arequire.phpcontradicted it. Every release branch now carries the php requirement of
the release it names (read from the same repository data, no new request), and S8 holds the
higher branches to two floors — the project's ownrequire.phpand the target PHP, the version
Composer resolves against. The newest branch still proves the upstream moved on; the branch the
evidence tells the project to follow is the newest releasing one within both floors, and when
that is not the newest the line says what holds the newest back:3.x released 3.12.0 (2026-09-09), needs php >=8.1 above the project's php >=7.2.5; 2.x released 2.11.1 (2026-09-02); require ^2.11 to follow. With no releasing branch within reach it says so and suggests nothing:
the way forward is a PHP upgrade, not acomposer.jsonline.--format=jsongainsnewest_php,
newest_within_reach,floor_php,floor_sourceandreachable_branch/_version/_release
on the signal;--explainshows each branch's php requirement in the branch table and in the
JSONbranchesrows. See Within reach. -
abandonedsays whether there is somewhere to go. Packagist's marker comes with a free-text
replacement, and on the weekly watch 19 of 72 abandoned packages carried one — 17 naming a
package, two namingSymfonyand a sentence. The verdict stays one (it is what--fail-on, the
baseline, the SARIF rule and every count key on); what changes is the reading. Each finding
carriesreplacementin--format=json— the named package when it is a Composer package name,
null otherwise, the free text staying in the evidence as text — the document carries
"abandoned": {"total", "with_replacement"}next tocounts, the summary line reads
abandoned 7 (6 with a replacement)where that is not zero, and the HTML page tags the row with
the replacement and links it on the card. Both fields are optional in the schemas. See
Abandoned, and where to.
Changed
-
One reading of the installed version's date. The lock's
timeis a release date only
sometimes — a branch snapshot carries its commit's, a subtree split's tag the date of a commit
its tags share, a monorepo parent can date the version instead — and each surface re-derived
that for itself, which is how one explanation came to call a date a release four lines above
saying it was not one.InstalledReleaseanswers it once. No output changes. -
old-promisereads the date against the target's major, not its minor. S5 held a release
against the GA of the target PHP minor: anything older than 2024-11-21 with a>=7.xconstraint
was an old promise about PHP 8.4. That caught the wrong thing. A>=7.2cut in 2022 was written
with PHP 8.1 on every CI matrix and differs from a^7.2 || ^8.0of the same day in spelling
alone, yet only the first was flagged — and the evidence blamed the style (has no upper bound),
which is Symfony's own convention. The line is now the GA of the target's major (8.0,
2020-11-26, for any 8.x target): the release predates the major it admits, and its constraint was
written for an older one. On the weekly watch that is 22 of the 178old-promiseverdicts the
minor line produced; the other 156 were releases of the PHP 8 era. The evidence readsreleased 2020-01-11 for PHP 5 (php ">=5.3.2"), before PHP 8 existed (8.0 GA 2020-11-26); admits 8.4 untested. In--format=jsonthe signal'sga_dateis now the major's, next to a new
target_major, andwritten_for_phpnames the constraint's lower major. The verdict, its
priority and--fail-onare unchanged; a project's count ofold-promisewill drop.
Fixed
--explainsays what the lock's date is a date of. Thecomposer.lockblock called that
date a release whenever the entry carried one, so a branch snapshot readreleased 2026-09-21 · branch snapshotand a subtree split readreleased 2023-06-05four lines above the block saying
that same date is a commit its tags share, not a release. It now readsdated … by its commit
for a snapshot anddated … by a commit its tags sharefor a split, andreleased …only where
the repository dated the version by a release. The JSON keeps itslock.releasedfield, whose
schema description now says which of the three it is carrying.- The report schema accepts the empty chain a run can really produce.
chainwas declared with
minItems: 1, and two ordinary runs break that: acomposer.lockwith nocomposer.jsonbeside
it has no direct requirements at all, so nothing reaches any package, and a lock can hold a
package only the skippedrequire-devasks for. Every finding of such a run was valid JSON that
its own published schema rejected. Documents themselves are unchanged. migrate tonames a package, or says nothing. The clause read the abandoned marker as the
repository wrote it, so free text became an instruction —no fix expected; migrate to Symfony,
migrate to EnglishInflector from the String component— and a repository that names the package
itself sent the reader back to what they were leaving, while the new
abandoned.with_replacementcounted it. The clause, the JSONreplacementand that count now
all read the same validated successor: a Composer package name that is not this package's own.
The repository's text is still shown in the evidence, as all free text is.
v0.10.0
Security
-
A repository URL no longer carries its credentials into a report. A private Composer source is routinely configured with a token in the URL —
https://gitlab-ci-token:$CI_JOB_TOKEN@…is how GitLab CI hands a job access to one, and Bitbucket app passwords take the same shape — and Composer keeps it in the lock because it has to fetch with it.--explainprinted that value verbatim: in the text output as thesourceline, and in--format=jsonaslock.repository. A pasted terminal buffer, an uploaded CI artifact or a report attached to a ticket therefore carried a working token to everyone who could read it. Every repository URL lockrot prints now has its userinfo removed and its host kept (Lockrot\Data\Repository\RepositoryUrl).Affects 0.8.0 and 0.9.0, where
--explainwas the only path to it — a run that never asked for an explanation was never affected. If you have published an--explainoutput from either release for a project with a token in a repository URL, treat that token as disclosed and rotate it.
Added
-
--format=html: the whole run as one self-contained page. It carries the report, the release branches behind every finding, the advisories and the baseline comparison inside a single file, so it opens fromfile://, uploads as one CI artifact and attaches to a ticket — no server, no network, no fonts or scripts fetched from anywhere.What a stream cannot show: one line per signal instead of one sentence with four semicolons in it, every release branch on a time axis with the installed one marked, advisories grouped by whether the fix is a patch on your own branch or a move to another, and what is new or worsened since the baseline. Filters and the open package live in the URL hash, the query understands
verdict:,priority:,signal:,severity:,cve:,direct:anddev:, and?opens a glossary of every verdict and signal.A report is usually read by someone who did not run it, so the page closes with the two commands that produce the same page for their own lock. The payload's
reportkey is what--format=jsonwrites, envelope included, so it validates against the published report schema.--allputs every package in the page at roughly 4 KB each; without it a 100-package lock lands around 250 KB. The page carries a description and an Open Graph card so a shared link says what was found, and norobotsdirective: whether a published report may be indexed is the publisher's call, made in their robots.txt, not this file's.See CI and reports.
-
The report says what it was decided against. Every verdict depends on settings the document did not record: the thresholds separate
stalefromsilent, the target PHP decides whether a release predates it. Until now--format=jsonnamed the target in exactly one place — inside the data of an S5 signal — so a run where S5 never fired left no trace of what it aimed at, and the thresholds left none at all. Two people comparing two reports could not tell whether they differ because the locks do or because the settings do.The report now carries a
runblock: the project's own name from composer.json — until now nothing in a report said which project it was about, every lock being calledcomposer.lock— the target PHP, the thresholds, thefail-on, the name of the lock (never its path, which carries the account and often the client's directory) andflagged_verdicts, the verdicts the run counted as findings.extra.lockrot.projectoverrides the name where the manifest has none, or where its name is not the one to publish: a package inside a monorepo names itself after the package, and a private project names itself after the client.Each finding also carries
baseline, where it stands against the baseline file —known,neworworsened, with the verdict the baseline accepted — beside the totals thebaselineblock already gave.Both are optional in the published schema, so documents written by 0.9.0 still validate, and
lockrot.schemastays1.
Verifying this release
sha256sum -c lockrot.phar.sha256
gpg --verify lockrot.phar.asc lockrot.phar # key 39EC C3F6 4AE8 D06A 9A63 FD99 AB6F 7F52 AE51 3141
gh attestation verify lockrot.phar --repo somework/lockrotsha256sum is GNU; on macOS use shasum -a 256 -c. What each check proves, and how to fetch
the key, is on the PHAR page.
See the full changelog.
v0.9.0
Added
-
Published JSON schemas for every document lockrot writes for a machine, and the one it reads. The
--format=jsonreport (report-1.json), the--explaindocument (explain-1.json), the baseline file (baseline-1.json) andextra.lockrot(config-1.json). The report, the explanation and the baseline file now open with a$schemakey naming theirs, so an editor completes a baseline file as you type it and a CI step can validate a report with any draft-04 validator — no copy of lockrot required. The files ship underresources/in the repository and the PHAR. Objects are open: under one number a document only ever gains fields, so a report from a newer lockrot still validates against the copy you vendored earlier, and the number moves only when a field is removed or renamed —lockrot.schemastays1. The test suite validates what the formatters write, and every JSON sample in the docs, against these files, with a strict copy that rejects any undeclared field, so the published schema, the code and the docs cannot drift apart. See JSON schemas. -
A split package's release branches are dated by the monorepo they are cut from. Since 0.8.0 a tag sharing its commit with two others is read as undated, which killed the false
left-behindonilluminate/macroablebut also stopped measuring a branch that really did end. The monorepo's own tag for the same version carries the release date, andreplace: {illuminate/contracts: self.version}says the two are one release — so where a branch of the split package has no date, the branch of the same name in its parent supplies one. A lock onilluminate/contracts v5.8.36reported nothing in 0.8.0 and now reads:left-behind illuminate/contracts v5.8.36 direct branch 5.x last released 2020-08-18 (6.1 years ago, dated by laravel/framework); 12.x released v12.69.2 (2026-09-08); require ^12.69 to followEvery other Laravel component on a branch of its own is measured again rather than skipped. Laravel's late security tags on 6.x, 7.x and 8.x are recent enough that those branches read as current under the default thresholds: being measured is the difference, not the verdict.
The parent is taken from the lock when it is already there; otherwise it is loaded from the configured repositories, one request, and only when
resources/monorepo-parents.jsonlists that monorepo as carrying a package this lock needs dates for —laravel/framework,symfony/symfonyandcakephp/cakephp, with the components each replaces, refreshed bybin/refresh-monorepo-parents. A lock without such a package fetches nothing, and neither does one whose undated packages belong to no listed monorepo, which is the common case:symfony/polyfill-*is cut by a repository no Packagist package replaces. An install-time run that has used up its budget skips the request and the branch stays as it was. What a parent dates is its livereplacelist, never the snapshot.--format=jsoncarries the parent asdated_byon S8 and S2, and--explainmarks the branch rows it supplied.
Verify this release
curl -fsSL -O https://github.com/somework/lockrot/releases/download/v0.9.0/lockrot.phar
curl -fsSL -O https://github.com/somework/lockrot/releases/download/v0.9.0/lockrot.phar.sha256
sha256sum -c lockrot.phar.sha256
gpg --keyserver hkps://keys.openpgp.org --recv-keys 39ECC3F64AE8D06A9A63FD99AB6F7F52AE513141
curl -fsSL -O https://github.com/somework/lockrot/releases/download/v0.9.0/lockrot.phar.asc
gpg --verify lockrot.phar.asc lockrot.phar
gh attestation verify lockrot.phar --repo somework/lockrotOr rebuild it: check out v0.9.0, run build/build-phar.sh with Composer 2.10.3, and compare the sha256 — see the PHAR page.
Full changelog: CHANGELOG.md.
lockrot 0.8.0
Added
--explain=vendor/package: one package, everything it was decided on, one call. The verdict and priority with how the package is reached; every signal with its summary and raw data (the dates the years were computed from, one line per advisory with what fixes it and where); thecomposer.lockentry; the repository metadata with the table S8 reads — every release branch, its highest tag and that tag's date, the installed branch marked, and a line saying so when that branch's highest tag is undated and S8 therefore does not measure it; the repository activity; the thresholds and target PHP; the run's notes. Text, or the same as JSON with--format=json. Exit 0 — it answers a question, it does not gate; a package not in the lock, or inpackages-devwithout--dev, is a configuration error (exit 2). See Explaining one package.left-behindsays what to require. S8 carriessuggested_constraint— the constraint that follows the upstream onto the branch fixes land on, written ascomposer requirewrites it (^8.2from 8.2.0,^0.4.3below 1.0) — and for a package the project requires itself the evidence ends…; 8.x released 8.2.0 (2026-09-06); require ^8.2 to follow. A transitive package's parent owns that line, so there the clause stays off the row and the constraint stays on the signal's data.no fix expectedsays where to go when there is somewhere. On anabandonedpackage whose repository names a replacement, an advisory nothing fixes readsno fix expected; migrate to symfony/mailer— the fix is not coming here, and the package that took over is where it lands.
Fixed
- A false
left-behindon packages split out of a monorepo. A subtree split (illuminate/*,symfony/*) cuts a tag on every release whether or not the directory changed, so tags pile up on one commit and Packagist dates each of them by that commit:illuminate/macroablehas 83 stable tags on the commit behindv10.49.0, all dated 2023-06-05, and a lock on 10.x read asbranch 10.x last released 2023-06-05 (3.3 years ago)while Laravel 10 kept releasing. A tag that shares its commit with two or more other stable tags is now read as undated — the date is the directory's, not the release's — so S8 does not measure the branch and S2 does not measure the package. Two tags on one commit keep their date: a re-tag, or a branch's last two releases cut with nothing changed between them (symfony/*3.4.46 and 3.4.47), where the date is one release interval off at most — so a Symfony 3.4 lock still readsleft-behindon every component. The cost is the other way: a split branch that really did stop and piled up more tags (illuminate/contracts8.x, 31 on one commit) is no longer reportedleft-behind, since its last tag is dated the same way. Dev branches and pre-releases on a tag's commit do not count.
Changed
- The footer's advisory line says why
composer auditcounts more. Without--devit now reads… the report does not flag; see composer audit (it counts packages-dev too, which this run skipped; pass --dev to include them): plaincomposer audittotalspackages-devand a plain lockrot run does not, and the difference should read as the scope it is.--format=jsonrecords the scope asinclude_dev. - The JSON
schemanumber stays1:include_devat the top level andsuggested_constraintin S8's data are additions, nothing removed or renamed.
Verify this release
curl -fsSL -O https://github.com/somework/lockrot/releases/download/v0.8.0/lockrot.phar
curl -fsSL -O https://github.com/somework/lockrot/releases/download/v0.8.0/lockrot.phar.sha256
sha256sum -c lockrot.phar.sha256
gpg --keyserver hkps://keys.openpgp.org --recv-keys 39ECC3F64AE8D06A9A63FD99AB6F7F52AE513141
curl -fsSL -O https://github.com/somework/lockrot/releases/download/v0.8.0/lockrot.phar.asc
gpg --verify lockrot.phar.asc lockrot.phar
gh attestation verify lockrot.phar --repo somework/lockrotOr rebuild it: check out v0.8.0, run build/build-phar.sh with Composer 2.10.3, and compare the sha256 — see the PHAR page.
Full changelog: CHANGELOG.md.
lockrot 0.7.0
Added
left-behind: a verdict for the branch you are on, not the package. Signal S8 takes the newest stable release on the installed version's release branch (1.x;0.3.xbelow 1.0; the patch alone below 0.1 — what a caret constraint stays inside; pre-releases do not count), measures its age againstrelease-warn-years/release-high-years, and fires only when a higher branch has released since and withinrelease-warn-yearsof today — a package dead on every branch stays S2's.composer outdated --major-onlysays a newer major exists; S2 sees the package's newest release and stays quiet; this says the branch installed here gets no fixes. The verdict isleft-behindat either threshold — betweenpinnedandold-promisein severity, base priorityhigh. The evidence readsbranch 1.x last released 2021-08-03 (5.1 years ago); 2.x released v2.12.5 (2026-04-17)— the higher branch whose release is newest, named as the branch fixes land on. A branch whose highest tag the repository leaves undated is not measured: Packagist dates a tag by its commit, and a subtree split (illuminate/*,symfony/*) has undated tags and tags dated years before the release.- Security advisories on the finding, with whether a fix is coming. Signal S9 carries the advisories that affect the installed version — the same ones
composer auditreports, fetched through Composer's own advisory API from the configured repositories, honouring Composer's ignore lists (config.policy.advisorieson 2.10+,config.audit.ignorebefore) — underdata.advisoriesin--format=json. S9 never decides a verdict. Each advisory is held against the highest stable tag on the installed version's branch and the package's highest stable tag, each only when above the installed version; one out of both ranges is already fixed, and the line says by what (fixed by 6.3.0;1 fixed by v3.4.47, 3 fixed by v8.1.7when they differ), withaffected_versions,fixed_byandfixed_on_branchon each advisory. On anabandoned,silentorleft-behindpackage the advisories nothing listed fixes — on a left-behind branch, nothing listed on the branch — earnno fix expected(no fix expected on 3.xnext to a fix in a higher branch) and the priority goes up one step,criticalat most. Advisories on packages the report does not flag stay off the rows and are totalled in the footer:53 security advisories on 17 packages the report does not flag; see composer audit. On Composer 2.2, under--offlineand once the install-time budget is spent the report carries one note instead.
Changed
- A package on a quiet branch of a living upstream is now
left-behindwhere it wasok,staleorold-promise;--fail-on=left-behindsees it. A baseline holding such a package atstaleorold-promisereports itworsened. - A priority threshold can trip on a verdict that did not move: an advisory on an
abandoned,silentorleft-behindpackage liftshightocritical, so--fail-on=criticalnow fails on it.left-behinditself starts athigh, so a run that passed--fail-on=highcan fail on a package that wasokbefore. The baseline, keyed on the verdict, still calls the findingknown. - S2 stays quiet when the package's highest non-dev tag carries no release date: "last release" would otherwise date the newest tag the repository dated and say nothing about the undated ones above it.
--strict-networkcovers the advisory request too: every repository that publishes advisories is asked, ascomposer auditasks them. A private repository that is down fails a strict run where it used to pass unnoticed.- The counts, priority and
pulled in by:lines fold between their·-separated items, never between a label and its number, and never wider than the terminal. The install-time block shows up to three notes, one per source that could not answer. - The evidence line opens with the signal that decided the verdict, then the rest in signal order, then what the package pulls in.
--format=jsonkeeps the signals in signal order. - The counts line gained
left-behind; theverdictenums in the config and baseline schemas, the SARIF rule list and--fail-onaccept it. The JSONschemanumber stays1: an added enum value and two new signal ids, nothing removed or renamed.
Verify this release
curl -fsSL -O https://github.com/somework/lockrot/releases/download/v0.7.0/lockrot.phar
curl -fsSL -O https://github.com/somework/lockrot/releases/download/v0.7.0/lockrot.phar.sha256
sha256sum -c lockrot.phar.sha256
gpg --keyserver hkps://keys.openpgp.org --recv-keys 39ECC3F64AE8D06A9A63FD99AB6F7F52AE513141
curl -fsSL -O https://github.com/somework/lockrot/releases/download/v0.7.0/lockrot.phar.asc
gpg --verify lockrot.phar.asc lockrot.phar
gh attestation verify lockrot.phar --repo somework/lockrotOr rebuild it: check out v0.7.0, run build/build-phar.sh with Composer 2.10.3, and compare the sha256 — see the PHAR page.
Full changelog: CHANGELOG.md.
lockrot 0.6.1
Fixed
phive install somework/lockrotworks again. PHIVE takes any release asset ending in.ascor.sigfor the GPG signature of the PHAR (last one wins), and 0.6.0's self-update signature was published aslockrot.phar.sig— so PHIVE tried to verify the archive with a JSON document and failed. The asset islockrot.phar.sig.jsonfrom this release on, and was renamed on the 0.6.0 release as well.- A 0.6.0 archive cannot
self-update. It looks for the old asset name, which no later release carries: it reportsrelease v0.6.1 has no lockrot.phar.sig assetand leaves itself in place. Download this release by hand or runphive update. Every other build, 0.5.0 included, updates as before — and from 0.6.1 on,self-updateverifies the release signature with the key built into the archive.
Verify this release
curl -fsSL -O https://github.com/somework/lockrot/releases/download/v0.6.1/lockrot.phar
curl -fsSL -O https://github.com/somework/lockrot/releases/download/v0.6.1/lockrot.phar.sha256
sha256sum -c lockrot.phar.sha256
gpg --keyserver hkps://keys.openpgp.org --recv-keys 39ECC3F64AE8D06A9A63FD99AB6F7F52AE513141
curl -fsSL -O https://github.com/somework/lockrot/releases/download/v0.6.1/lockrot.phar.asc
gpg --verify lockrot.phar.asc lockrot.phar
gh attestation verify lockrot.phar --repo somework/lockrotOr rebuild it: check out v0.6.1, run build/build-phar.sh with Composer 2.10.3, and compare the sha256 — see the PHAR page.
Full changelog: CHANGELOG.md.
lockrot 0.6.0
Added
self-updateverifies the release signature. Every release from 0.6.0 on publisheslockrot.phar.signext to the archive — an RSA signature (PKCS#1 v1.5 over SHA-384) by the new lockrot self-update key, in the{"sha384": "<base64>"}file format Composer uses for its own self-update — and the archive checks it withopenssl_verify()against the public key built into itself before anything is written, after the sha256 check it already made. A release signed with a key the archive does not know, a signature over other bytes, or a signature file that is not one is reported and not installed. The key is RSA 4096, separate from the GPG release key (which still signslockrot.phar.ascfor people and PHIVE); its public half islockrot-selfupdate-key.puband SECURITY.md says how it is rotated. The archive running 0.5.0 still checks the checksum only when it updates to 0.6.0; releases before 0.6.0 carry no.sig, so a 0.6.0 archive cannot--forceits way back to one.- The PHAR is built reproducibly.
build/build-phar.shon the tagged commit — with Box 4.7.0, which the script downloads and checks, and the Composer version the release workflow pins at that tag — produces the archive byte for byte, whatever the PHP version, so a release can be verified against its own source without trusting the builder. CI rebuilds every commit on a second machine, on another PHP version, and compares the bytes. The recipe is on the PHAR page.
Changed
- The mutation-testing gate now covers the whole source tree instead of the verdict engine, the signals, the analyzer and the output formats alone. No behaviour changes: the escapes it surfaced were closed with sharper tests, and a handful of statements no test could observe were removed as redundant.
- The PHAR's alias is
lockrot.phar(Box used to generate a random one per build), and itsinstalled.phpnames lockrot asdev-mainwith no commit reference. lockrot reads neither.
Verify this release
curl -fsSL -O https://github.com/somework/lockrot/releases/download/v0.6.0/lockrot.phar
curl -fsSL -O https://github.com/somework/lockrot/releases/download/v0.6.0/lockrot.phar.sha256
sha256sum -c lockrot.phar.sha256
gpg --keyserver hkps://keys.openpgp.org --recv-keys 39ECC3F64AE8D06A9A63FD99AB6F7F52AE513141
curl -fsSL -O https://github.com/somework/lockrot/releases/download/v0.6.0/lockrot.phar.asc
gpg --verify lockrot.phar.asc lockrot.phar
gh attestation verify lockrot.phar --repo somework/lockrotOr rebuild it: check out v0.6.0, run build/build-phar.sh with Composer 2.10.3, and compare the sha256.
Full changelog: CHANGELOG.md.
v0.5.0
Added
- Signed releases. From this release on,
lockrot.phar.ascships next to the PHAR: a detached OpenPGP signature by the lockrot release key (39EC C3F6 4AE8 D06A 9A63 FD99 AB6F 7F52 AE51 3141, public half inlockrot-release-key.ascand onkeys.openpgp.org), together with a GitHub build-provenance attestation. The release workflow verifies its own signature against the committed public key before it publishes anything. Verify withgpg --verify lockrot.phar.asc lockrot.pharorgh attestation verify lockrot.phar --repo somework/lockrot;phive install somework/lockrot --trust-gpg-keys 39ECC3F64AE8D06A9A63FD99AB6F7F52AE513141now works. The sha256 checksum andself-updateare unchanged:self-updatestill verifies the checksum only. See The standalone PHAR.
Fixed
- phpstan/phpstan is no longer
abandoned. A package is no longer flagged because an older release points at an archived repository. The repository asked about activity is the one the highest stable release names — itssource, else itssupport.source— then the lock entry's, and when none of those names one the package is judged without a repository-activity check (Packagist's ownabandonedflag still counts). phpstan/phpstan was reportedabandonedon every project that runs lockrot with a GitHub token: its recent releases carry nosource,support.sourcenames the live phpstan/phpstan-src, and three old releases point at a one-off build repository that has since been archived. Asupport.sourcein the shape Packagist fills in by default,<repository>/tree/<version>(or/src/<ref>on Bitbucket), is reduced to the repository first.
Full changelog: v0.4.0...v0.5.0