In CI¶
Add one step and pick the threshold that fails it. The step exits 0 when no finding reaches the
threshold, 1 when one does, and 2 when lockrot could not run (Exit codes).
- name: lockrot
run: composer lockrot --fail-on=high --target-php=8.4
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
--target-php is the PHP version the project runs on
(configuration.md). GITHUB_TOKEN lifts the anonymous cap
on repository-activity checks
(Repository hosts and credentials).
Without the plugin installed, run the PHAR instead (phar.md).
--fail-on takes one value, and every threshold is inclusive:
| Fail the build on | Value | Kind |
|---|---|---|
What was observed, wherever the package sits: silent fails on silent and abandoned |
a verdict, e.g. --fail-on=silent |
verdicts |
How much it applies to this project: high fails on an abandoned direct production requirement, not on the same verdict on a transitive development package |
a priority, e.g. --fail-on=high |
priority |
| A finding whose check did not run: no token passed through, an exhausted rate limit | --fail-on=unchecked |
What was not checked; unflagged ones that fail are listed only with --all, or in json and html (below) |
| Nothing: report only | --fail-on=none (the default) |
-
Where to start. Use
--fail-on=high: a priority gates on what applies to this project. If the project does not start clean, add a baseline: the same step then fails only on findings that are new or have got worse, and reads the default baseline file without a flag. -
One threshold per run.
uncheckedcannot be added to a verdict or priority threshold. To gate on both, run it as a second step.
Every accepted value is listed at fail-on values.
Exit codes¶
| Code | Meaning | stdout |
|---|---|---|
0 |
No finding reached --fail-on, apart from ones the baseline accepts (under --fail-on=none none can), and no --strict-network trip |
The report; nothing under --generate-baseline |
1 |
A finding reached --fail-on and the baseline does not already accept it, or --strict-network is on and a run note counts as a network failure (below) |
The report; nothing under --generate-baseline |
2 |
lockrot could not run or could not finish: an input, configuration or usage error, or a file it could not write | Nothing, except when an --output file fails (outside --generate-baseline): the report is already on stdout |
The exit code is the same for every output format. On stderr, a configuration or usage error starts
with lockrot:, any other failure with lockrot failed:; -v adds the stack trace to the second.
Exit 2 comes from:
- A file lockrot cannot write: an
--outputfile or the baseline.2wins over1: a run whose gate has failed still exits2when a write then fails. - A
composer.jsonorcomposer.locklockrot cannot parse, a missingcomposer.lock, or a lock entry Composer's loader rejects (nonameorversion, a version it cannot normalise). - A value the configuration rejects: in
extra.lockrot, in an environment variable, or on the command line (configuration.md). - A command line lockrot cannot read: an unknown option, an option without its value, a value given to a flag, an argument too many.
- A baseline lockrot cannot read (baseline.md).
- An
--outputlockrot refuses before the analysis, such as an unknown format orcomposer.lock(configuration.md). - An
--explainlockrot cannot answer: a package the run did not analyse, or--explaincombined with--outputor with a format other thantableandjson(configuration.md).
Network failures do not change the exit code on their own. An unreachable Composer repository or
repository host becomes a run note, and the checks it blocked count as absent evidence
(S10). With --strict-network, a run note whose
sets_network_failures is true exits 1; that includes an --offline run where a locked package
has no cached metadata. To fail on the missing checks themselves, use --fail-on=unchecked.
A security advisory alone never exits 1. When no fix is expected for it, it raises the
priority of an abandoned, silent or left-behind finding by one step
(Security advisories), and --fail-on decides as usual.
Other exit paths:
--generate-baselineexits0whatever--fail-onsays, and1only under--strict-network(baseline.md).--explainexits0(configuration.md).LOCKROT_DISABLE=1skips the run and exits0(configuration.md).- The install-time summary sets no exit code unless
install-time-strictis on. lockrot.phar self-updatehas its own codes (phar.md).
When exit 1 does not come from lockrot¶
Composer or Symfony can stop before lockrot runs, with their own exit 1:
| Symptom | Cause | Fix |
|---|---|---|
Exit 1 with a report, or under --generate-baseline with a lockrot: baseline written line |
lockrot's own exit 1: a finding reached --fail-on, or --strict-network |
Read the report; this is the gate working |
Exit 1, Composer's error box or Symfony's message, no lockrot: line |
An unknown command: composer lokrot, php lockrot.phar nope |
Fix the command name |
Exit 1, There are no commands defined in the "json" namespace |
php lockrot.phar --output json:r.json: before the command name, a value after a space is read as the command |
Join the value with =: --output=json:r.json |
Exit 1, Composer's error box about composer.json, in plugin mode |
Composer cannot parse or validate composer.json |
Fix the file; the PHAR reads it itself and exits 2 for it |
GitHub Action¶
somework/lockrot-action v1 runs lockrot as one step; its README lists the inputs.
For other CI systems, run the PHAR as in PHAR in CI, or the container image.
Choosing a format¶
| Format | For | Section |
|---|---|---|
table (default) |
A person reading the job log | example-run.md |
github |
Annotations on composer.lock in the pull request |
below |
sarif |
GitHub code scanning (Security tab) | below |
gitlab |
GitLab Code Quality | below |
markdown |
A pull-request comment or job summary | below |
html |
One self-contained page to read, attach or publish | below |
json |
Scripts and dashboards: every field | below |
How each format marks a finding¶
The formats with a severity give each finding one mark by the same rule, so the mark matches the exit code. The first row that matches decides:
| # | The finding | github |
sarif level |
gitlab severity |
markdown verdict |
|---|---|---|---|---|---|
| 1 | Accepted by the baseline (known) |
notice |
note |
info |
plain |
| 2 | Reaches --fail-on (for --fail-on=unchecked: carries S10, whatever its verdict) |
error |
error |
major |
bold |
| 3 | Any other flagged finding | warning |
warning |
minor |
bold |
| 4 | Anything else (listed only with --all) |
notice |
note |
info |
plain |
- SARIF
rankis the priority as a number:critical100.0,high75.0,medium50.0,low25.0,none0.0. - A SARIF rule's default level is
warningfor a flagged verdict andnoteotherwise. - A finding is marked
errorexactly where its jsongate.reaches_fail_onis true andgate.exempt_byis notbaseline. In a--generate-baselinerun,gate.fail_on_appliedis false, so such a finding fails nothing. --strict-networkmarks no finding: its cause is in the report's notes, whichgitlablacks.
Where annotations point¶
Annotations or Code Quality issues that do not land on the lock come from a project in a
subdirectory of the checkout. github, sarif and gitlab name the analysed lock by its path
relative to the directory lockrot runs in (composer.lock, alt.lock under COMPOSER=alt.json,
app/alt.lock under COMPOSER=app/alt.json), or by its file name alone when it lies outside it.
For github and gitlab, GitHub and GitLab resolve that path against the checkout root, so
-d app still names it composer.lock. sarif also carries the directory the path is relative to,
as %SRCROOT% (below).
For a project in app/, run the PHAR from the checkout root with
COMPOSER=app/composer.json, which names the lock app/composer.lock. The
action's README has a recipe for a project in a
subdirectory.
--fail-on=unchecked in the formats¶
The findings that fail such a run are often ok ones carrying S10, which every format but json
and html lists only with --all. With --all, github, sarif, gitlab and markdown mark
them by row 2 of How each format marks a finding.
Pass --all, or read gate in the json report, to see which packages failed the step.
Several reports from one run¶
--format decides what goes to stdout. Each --output=<format>:<path> writes one more format to a
file from the same analysis, so every file carries the same findings, notes and timestamp. Paths,
refusals and failures: Writing reports to files.
permissions:
contents: read
security-events: write
steps:
- uses: actions/checkout@v7
- name: lockrot
run: >-
composer lockrot --format=github --fail-on=high --target-php=8.4
--output=sarif:lockrot.sarif --output=html:lockrot-report.html --output=json:lockrot.json
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upload SARIF
if: always()
uses: github/codeql-action/upload-sarif@v4
with:
sarif_file: lockrot.sarif
- name: Upload the page and the JSON
if: always()
uses: actions/upload-artifact@v7
with:
name: lockrot-report
path: |
lockrot-report.html
lockrot.json
if: always() keeps the uploads running after --fail-on has failed the lockrot step.
--format=github¶
One workflow command
per flagged finding (every finding with --all), so each package becomes an annotation on its line
of composer.lock in the pull request's Files changed view.
- name: lockrot
run: composer lockrot --format=github --fail-on=high --target-php=8.4
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
The shape of one annotation:
::<level> file=composer.lock,line=<line>,title=lockrot%3A <verdict> (<priority>)::<package> <version>: <evidence>
- The title is
lockrot: <verdict> (<priority>); the level follows the rule in How each format marks a finding. - A transitive finding's message ends with the direct requirements that reach it:
(via a > b, also via c, d)(transitive exposure). - The report's notes, the stale-baseline note included, follow as
::notice title=lockrot::lines. - The output ends with plain log lines, not annotations:
pulled in by:, a count of the advisories on packages the report does not flag, pointing tocomposer audit, and the summary line with the full counts. - GitHub displays a limited number of annotations per step. The step log holds every line, and
--format=sarifuploads the complete set.
--format=sarif¶
A SARIF 2.1.0 document for GitHub code scanning,
with one result per flagged finding (every finding with --all). Code scanning keeps them in the
Security tab and tracks them across runs. The upload needs security-events: write:
permissions:
contents: read
security-events: write
steps:
- uses: actions/checkout@v7
- name: lockrot
run: composer lockrot --format=sarif --fail-on=high --target-php=8.4 > lockrot.sarif
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upload SARIF
if: always()
uses: github/codeql-action/upload-sarif@v4
with:
sarif_file: lockrot.sarif
| SARIF field | Holds |
|---|---|
ruleId |
lockrot/<verdict>; one rule per verdict among the results |
level |
The mark from How each format marks a finding |
rank |
The priority as a number (ranks) |
locations |
The package's line in the analysed lock |
partialFingerprints |
lockrot/package: the package name, so a finding keeps its identity when its line moves |
properties |
package, version, verdict, priority, direct, dev, signals, chain, direct_dependents, data_date, and on a flagged finding baseline (known, new or worsened) when the run read one |
invocations[].toolExecutionNotifications |
The report's notes |
originalUriBaseIds.%SRCROOT% is the directory the lock's path is relative to (the directory
lockrot runs in, or the lock's own when it lies outside it), as an absolute file:// URL on the
machine that ran lockrot. Code scanning resolves the lock's path against it. To publish the file
anywhere else, strip it first (what a report reveals):
--format=gitlab¶
A GitLab Code Quality
report: a JSON array with one issue per flagged finding (every finding with --all). Publish it as
a codequality artifact:
lockrot:
script:
- composer lockrot --format=gitlab --fail-on=high --target-php=8.4 > lockrot-codequality.json
artifacts:
when: always
reports:
codequality: lockrot-codequality.json
when: always uploads the report when --fail-on fails the job. A merge request shows only issues
new or fixed against the target branch
(features per tier).
-
descriptionopens with the package, the version and<verdict> (<priority>), and for a transitive package ends with(via a > b, also via c, d): -
severityfollows the rule in How each format marks a finding. fingerprintis a hash of the package name and the verdict. A version bump, a moved line, a change of priority or anotherCOMPOSERmanifest keeps the same issue; a change of verdict opens a new one.locationis the package's line in the analysed lock.- The format has no field for a document-level note, so the report's notes are not in it; use
--format=jsonfor them.
--format=markdown¶
A report shaped for a pull-request comment or a job summary. The steps after lockrot run with
if: always(), so the comment is posted when the gate fails too:
permissions:
contents: read
pull-requests: write
steps:
- uses: actions/checkout@v7
- name: lockrot
run: composer lockrot --format=markdown --fail-on=high --target-php=8.4 > comment.md
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Comment on the pull request
if: always() && github.event_name == 'pull_request'
run: gh pr comment "${{ github.event.pull_request.number }}" --body-file comment.md
env:
GH_TOKEN: ${{ github.token }}
- name: Job summary
if: always()
run: cat comment.md >> "$GITHUB_STEP_SUMMARY"
Top to bottom, it holds:
- A heading:
### lockrot: dependency rot in <flagged> of <checked> packages, or on a clean run### lockrot: no dependency rot found in <checked> packagesand no table unless--allis given, which lists every package under it. - With a baseline, its
known/new/worsened/stalecounts. - A table of the flagged findings (every finding with
--all) in report order, led by priority. ItsViacolumn holds the shortest chain from a direct requirement and the other direct requirements that reach the package, shortened withand N more;?when nothing reaches it. - The libyears line, the
pulled in by:line, and a count of the advisories on packages the report does not flag, pointing tocomposer audit. - The report's notes as a bullet list, and a
<sub>footer with the summary counts.
The shape of the table:
| Priority | Package | Version | Verdict | Evidence | Via |
|---|---|---|---|---|---|
| <priority> | `<package>` | <version> | **<verdict>** | <evidence> | direct, or <a> › <b>, also via <c> |
Every value from the project or from package metadata is rendered as plain text, with Markdown and
HTML punctuation backslash-escaped. A composer.lock under someone else's control cannot put an
image, a tag or link markup into the comment; a bare URL still autolinks.
--format=html¶
The whole run as one self-contained page: it fetches nothing, so it opens from file://, uploads as
one CI artifact and attaches to a ticket.
- name: lockrot
run: composer lockrot --format=html --fail-on=high --target-php=8.4 > lockrot-report.html
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- uses: actions/upload-artifact@v7
if: always()
with:
name: lockrot-report
path: lockrot-report.html
The page is lockrot-report, vendored at the
version the changelog names; what it shows and how to navigate it are in that
repository. It carries the run as JSON in <script id="lockrot-data" type="application/json">,
and that document's report key is the report-1 document, the part of the page that
is contract.
Without --all the page carries release detail for flagged packages and for unflagged ones with an
advisory. --all adds that detail for every package, so the file grows with the lock.
Content-Security-Policy. The page carries its own policy (lockrot-report describes it). A host
that adds its own policy header gets the intersection of the two; script-src 'unsafe-inline' and
style-src 'unsafe-inline' in that header are enough.
The JSON beside the page¶
To keep the report as a JSON file next to the page, ask for both in one run:
--format=json¶
The complete report, and the only format that carries every field. It holds every finding, with or
without --all, and opens with $schema, the report schema URL.
It carries the evidence and data behind each signal, each finding's standing against the baseline
and the gate, and the run's thresholds and typed notes (notes.md). gate records the
exit decision: gate.fails, its causes in gate.tripped_by, and each finding's own gate.
schema.md types every field and has a CI validation step;
example-run.md has a worked excerpt.
Related¶
- baseline.md — adopt lockrot on a project that does not start clean
- configuration.md — every option, key and environment variable, and
--outputin full - verdicts.md — what each verdict, priority and signal means
- schema.md — the JSON schemas and what a report reveals
- notes.md — every run note and what it means for the exit code
- phar.md — running the standalone PHAR in CI