What putnami observe reads
putnami observe is the default subcommand of the observe group. It is
interactive and workspace-optional, so it runs from the user scope in any
directory. Inside a Putnami workspace only the workspace's own extensions
answer, so the workspace must declare @putnami/observe to run it there.
Flags
| Flag | Meaning |
|---|---|
--base <commit or branch> |
The commit the change is measured against. It overrides the CI. A generic CI needs it. |
--head <revision> |
The commit that was built. Default HEAD. |
--junit <paths> |
Comma-separated JUnit XML or TRX paths or glob patterns, resolved against the directory you run in. Replaces JUnit discovery. |
--coverage <paths> |
The same for coverage reports. Replaces coverage discovery. |
--sbom <path> |
The software bill of materials (SBOM) of the commit, a path or glob pattern. Replaces SBOM discovery. |
--base-sbom <path> |
The SBOM of the base. Without it, the base state of each dependency is unknown. |
--moon-graph <path> |
The moon project graph export, from moon project-graph --json. Default moon-graph.json at the repository root when it exists. See the moon detector. |
--pants-graph <path> |
The Pants export pants peek :: wrote, resolved against the directory you run in. Default pants-graph.json at the repository root. See the Pants detector. |
--bazel-graph <path> |
The Bazel query export, from bazel query 'deps(//...)' --output=streamed_jsonproto. Default bazel-graph.json at the repository root. See the Bazel detector. |
--no-fetch |
Read only the local history: fetch no base and deepen no shallow clone. |
--evidence <path> |
Write the change evidence document to this path, resolved against the directory you run in. |
--upload |
Hand the change evidence document to the evidence reporter. See Uploading the evidence. |
--publish |
Post a commit status and one summary note on the pull or merge request. See Publishing on the pull or merge request. |
A glob pattern takes *, ?, [...] and **, which matches any number of
directories.
The CI
| CI | Recognized by | Repository key | Change request |
|---|---|---|---|
| GitHub Actions | GITHUB_ACTIONS=true |
GITHUB_SERVER_URL host, GITHUB_REPOSITORY_ID |
GITHUB_REF refs/pull/<n>/merge, or the event payload |
| GitLab CI | GITLAB_CI=true |
CI_SERVER_HOST, CI_PROJECT_ID |
CI_MERGE_REQUEST_IID |
| Any other | neither | none | none |
The repository path (GITHUB_REPOSITORY, CI_PROJECT_PATH, or the path of
the origin remote) is for display only. A GitHub or GitLab job without a
numeric project id gets the observe.project_id_missing diagnostic.
The commit is the one the job checked out and measured, HEAD or --head. On
a GitHub pull request that is the merge commit refs/pull/<n>/merge, not the
pull request's head, so the changed files are the pull request's own changes.
GitHub Actions exposes no numeric job id, so the run's job is the job key
GITHUB_JOB, which every leg of a matrix shares.
The base
The base is the first of:
--base.- The merge request's diff base,
CI_MERGE_REQUEST_DIFF_BASE_SHA. - The pull request's base commit from the event payload, else its target
branch,
GITHUB_BASE_REF, read onorigin. - The previous commit of a push: the event payload's
beforeon GitHub,CI_COMMIT_BEFORE_SHAon GitLab, unless it is all zeros.
A base missing from the local history is fetched from origin. On a shallow
clone (fetch-depth: 1 on GitHub, GIT_DEPTH on GitLab), the history is then
deepened by 64 commits, then 128, and so on for ten steps, until the merge base
of the base and the head is reachable. The changed files are the diff from that
merge base to the head, each added, modified, deleted or renamed.
When no base is named, none can be fetched, or the histories share no commit,
the changed files are empty and a diagnostic says why:
observe.base_missing, observe.base_unresolved or
observe.merge_base_unreachable.
What it writes
In the directory you run it in, putnami observe writes only the Git history
it fetches to reach the merge base (objects, the origin branch of the base
and the shallow boundary) and the file --evidence names. When the server
refuses to deepen by commit id, the history is deepened through the remote's
configured refspecs, which also updates their remote-tracking branches. The
working tree, the index and HEAD stay as they were. Its fetches start no
automatic Git maintenance, so nothing repacks the history after it exits.
--no-fetch turns the fetch off. Everything else is printed.
The fetch and the deepening need Git 2.29 or later. With an older Git, each fails as a diagnostic.
Reports
Without --junit or --coverage, putnami observe walks the repository,
skipping .git, node_modules, vendor, the Gradle and Maven build
outputs that hold no report (see Gradle and Maven), and
all of a Cargo target directory but its nextest store, its nested target
directories and the files it holds directly (see
the Cargo detector), and reads the start of each file
whose name can hold a report (.xml, .trx, .info, .lcov, .out, .cov,
.coverprofile, .gocov, and .txt files whose name contains cover). It
keeps a file by its content:
| Format | Recognized by |
|---|---|
| JUnit XML | a testsuites or testsuite root (Jest, Vitest, PHPUnit --log-junit, gotestsum, pytest --junitxml, bazel test, Gradle, Maven Surefire and Failsafe, cargo-nextest) |
| Cobertura | a coverage root with line-rate, lines-valid or branch-rate, or a sources or packages child (pytest-cov --cov-report=xml, Coverlet --collect:"XPlat Code Coverage", cargo-llvm-cov --cobertura) |
| Clover | a coverage root with generated or clover, or a project child (PHPUnit --coverage-clover) |
| JaCoCo | a report root whose first child is sessioninfo, group, package or counter (the JaCoCo Maven plugin, the Gradle jacoco plugin, Kover) |
| LCOV | a first TN: or SF: record (bazel coverage --combined_report=lcov, cargo-llvm-cov --lcov) |
| Go cover profile | a first mode: set, mode: count or mode: atomic line |
| TRX | a TestRun root (dotnet test --logger trx), read as test cases |
The walk does not follow links. It also reads, through the links Bazel leaves
at the root, the test.xml of each test target, shard and run under
bazel-testlogs and the combined LCOV
report bazel-out/_coverage/_coverage_report.dat; see
the Bazel detector.
Without --sbom, it also reads the .json files whose name contains bom,
cdx, cyclonedx or spdx, and keeps a CycloneDX document (a
"bomFormat": "CycloneDX" member) or an SPDX one (a "spdxVersion": "SPDX-…"
member) as the commit's SBOM. A missing SBOM is no diagnostic. The base SBOM
is never discovered: name it with --base-sbom, and discovery then leaves
that file out. When a side has several SBOMs, none is read, with
observe.sbom_ambiguous: a repository that commits SBOM documents, such as
test fixtures, names its own with --sbom. See
licences and vulnerabilities.
Discovery stops after 200 000 entries or 10 000 candidate files, and keeps at
most 1 000 reports, with the observe.discovery_bounded diagnostic. A named
glob pattern has the same bounds and the same diagnostic. A kind
with no report gets observe.report_missing. A named path that matches
nothing gets observe.report_missing, an unreadable file
observe.report_unreadable, and a file of another kind or none
observe.report_unrecognized.
Output
The summary names the CI, the repository, the commit, the base, the changed
files (the first 50), the changed and dependent components, the tests and
coverage of each component, the coverage of the changed lines, the dependency
changes with their licences and vulnerabilities, and the reports, then each
diagnostic. See
the change analyses. putnami observe
exits 0 whatever it observes, 2 on a usage error, and 1 when it cannot read its
job context or write its output. --output=json
prints one document with subject, ci, producer, base,
deepenedCommits, changedFiles, reports, analysis and diagnostics.
The evidence document
--evidence <path> writes a
protocol/evidence v1 document: the
subject, the CI run, the producer, the changed files with their component,
the components with their impact and edges, the tests and coverage per
component, the changed-line coverage, the dependency changes with their
before and after states, the sbom they come from, and the diagnostics.
The document is validated before it is written, and replaces the file in one
rename. A document that does not validate, such as one without a commit or a
GitHub or GitLab job without its numeric project id, is written nowhere and
gets observe.evidence_invalid; a path that cannot be written gets
observe.evidence_unwritable. Diagnostics past the document's 500 are
summarized by observe.too_many_diagnostics. A run page that is not an https
URL without credentials is left out with observe.run_url_dropped, and the
rest of the document is written.
Uploading the evidence
--upload hands the evidence document to the evidence reporter. Without
--evidence, the document goes to a temporary file that is removed after the
upload.
The reporter is an installed extension. PUTNAMI_EVIDENCE_REPORTER names it,
@putnami/cloud when unset. Observe runs
putnami <name> evidence-report --file <path>, where <name> is the
extension name without its scope, through the CLI that started it, with
PUTNAMI_NO_AUTO_INSTALL=1. The reporter's output goes to standard error. A
third party writes its own reporter as one workspace-optional subcommand
evidence-report that takes --file.
The upload never fails the job. A reporter that cannot start, exits non-zero
or runs past 2 minutes, a selector that is not an extension name, and a
document that was not written each give observe.upload_failed, and observe
still exits 0.
For @putnami/cloud, the job sets PUTNAMI_CLOUD_TOKEN to a workspace token
of scope delivery.ingest. See
the Cloud extension.
Publishing on the pull or merge request
--publish posts the summary where a reviewer reads it, with a token the CI
already has. On a GitHub pull request or a GitLab merge request it:
- Sets the commit status
putnami/observeto success. On GitHub it goes on the head commit of the pull request, since the job built a merge commit. On GitLab it goes on the commit the pipeline ran for,CI_COMMIT_SHA, and joins that pipeline, so a merged results pipeline starts no other one. The status means the analysis ran, not that the tests passed. Its description counts the changed and dependent components, the coverage of the changed lines and the dependency changes, for example1 changed, 2 dependent components; changed lines 80.0% covered; 1 dependency change, or says that the base was not resolved. It links the CI run. - Writes one Markdown note on the request: the components with their tests
and coverage, the failing tests, the least covered changed files, the
dependency changes and the diagnostic codes. The note starts with the
hidden line
<!-- putnami-observe:summary -->. The next run finds the first note that starts with that line and updates it, so the request keeps one note.
| Code host | Token | API |
|---|---|---|
| GitHub | GITHUB_TOKEN, with statuses: write and pull-requests: write |
GITHUB_API_URL, https://api.github.com when unset |
| GitLab | PUTNAMI_GITLAB_TOKEN: a project access token with the api scope and the Developer role |
CI_API_V4_URL, else CI_SERVER_URL followed by /api/v4 |
The GitLab job token cannot write notes, so GitLab needs a project access
token stored as a masked CI/CD variable. A self-managed GitLab answers on its
own URL, which GitLab CI sets in CI_API_V4_URL.
The token goes only to an https API URL without credentials, and observe
follows no redirect. Run --publish in one job of the pipeline: two jobs that
publish on the same request overwrite each other's note.
Publishing never fails the job:
| Case | Diagnostic |
|---|---|
| A generic CI, or a job that runs for no pull or merge request, such as a branch push | observe.publish_skipped (info) |
| The code host's token is unset | observe.publish_token_missing (warning) |
| A refused or failed call, no GitLab API URL, an API URL that is not https, no commit to set the status on, or a run past 1 minute | observe.publish_failed (warning) |
A pull request from a fork gets a read-only GITHUB_TOKEN, so GitHub refuses
both calls with observe.publish_failed. A failed status call does not stop
the note.
The note is the first one that starts with the hidden line, whoever wrote it. A comment that starts with that line is overwritten by the summary. When the token changes owner, for example a new GitLab project access token, the old note may refuse the update: delete it, and the next run creates a new one.