This is the multi-page printable view of this section. Click here to print.

Return to the regular view of this page.

Pull Request CI & Secrets

How Community Projects run automated checks on Pull Requests from forks without exposing secrets.

Contents

In this section:

  • Reference workflows: the complete Tier 1 to Tier 2 workflow pair that every ecosystem guide builds on.
  • Ecosystem guides: what to prepare, and the Tier 1 snippet to prepare it, for each language.

The problem

Our Projects run two kinds of automation that need credentials:

  1. Sonatype Lifecycle evaluations, which need credentials for Sonatype Lifecycle.
  2. Integration tests that need a protected resource: something that is licensed, paid for, rate limited, or whose credentials would be damaging to lose. Many of our Projects test against live, licensed installations of Sonatype’s commercial products. Others have no such tests at all.

Contributors outside the Project’s maintainers work from forks. GitHub deliberately withholds secrets from workflows triggered by fork Pull Requests (see Secrets Management), so these checks fail, or are skipped, for exactly the people we most want to encourage.

The obvious workarounds are dangerous. A Pull Request is untrusted code: its author can change the application, its tests, its build scripts, its dependency manifests and even the workflow files. If untrusted code runs anywhere a secret is available, the secret should be considered leaked. Hiding the value in logs does not help, because code can send a secret elsewhere in any encoding it likes.

This standard exists to give contributors fast, useful feedback while keeping untrusted code and secrets apart.

Principles

These principles drive every decision below. When a Project’s situation is not covered, apply the principles.

  1. Untrusted code never runs where secrets exist. Any job that executes code from a fork has no secrets, and any job that has secrets does not execute code from a fork.
  2. Only data crosses the boundary. What passes from the untrusted side to the trusted side is a file that is checked and read, never run.
  3. Privilege is earned by review, not assumed. Access to secrets follows a maintainer’s explicit decision about a specific commit, never an author’s identity or history.
  4. Approval is bound to a commit. A review of one commit is not a review of the next one pushed.
  5. Give as much feedback as possible without secrets. The fewer checks that need a secret, the fewer contributors wait on a maintainer.
  6. Limit the damage if a secret does leak. Secrets must be narrowly scoped, short lived and easy to rotate.
  7. Let Lifecycle do the dependency resolution. We hand Sonatype Lifecycle the files its own ecosystem analysis is built to read, rather than generating our own inventory of a Project’s dependencies.
  8. One standard, many release styles. The standard does not dictate how a Project cuts a release.

Overview of the tiers

TierPurposeRuns forSecretsContributor wait
1Build, lint, unit tests, tests against unprotected resources, and preparing the Lifecycle scan inputEvery PR, automaticallyNoneNone
2Sonatype Lifecycle evaluation of the prepared inputEvery PR, automaticallyLifecycle credentials, held only by trusted codeNone
3Integration tests that need a protected resourcePRs a maintainer has approved; trusted refsProtected-resource access, held only by trusted codeMaintainer review

A contributor’s Pull Request should show a result for Tier 1 and Tier 2 within minutes of opening. Tier 3 shows as waiting for maintainer approval until a maintainer has reviewed the change. A Project with no protected-resource tests has no Tier 3.

flowchart LR
    PR([Fork PR opened or updated])

    subgraph U["UNTRUSTED: runs PR code, has NO secrets"]
        T1["Tier 1<br/>build, lint, tests"]
        PREP["Prepare scan input<br/>(files Lifecycle reads natively)"]
    end

    subgraph T["TRUSTED: runs default-branch code, has secrets"]
        T2["Tier 2<br/>Lifecycle evaluation"]
        T3["Tier 3<br/>protected-resource tests"]
    end

    M{{"Maintainer approves<br/>a specific commit"}}

    PR --> T1
    T1 --> PREP
    PREP -. "artifact: DATA only,<br/>never executed" .-> T2
    PR --> M
    M --> T3

The dotted line is the only thing that crosses from the untrusted side to the trusted side. It carries data that is checked but never run.

Tier 1: Open checks (no secrets)

What

Every Project must run the following on every Pull Request, including those from forks, using the pull_request trigger and with no access to secrets:

  • Build / compile.
  • Linting, formatting and static analysis that does not need credentials.
  • Unit tests.
  • Integration tests that can run against unprotected resources: software that needs no licence or credentials, such as Nexus Repository Community Edition running in a container, or mocked and recorded responses.
  • Preparing the Lifecycle scan input (see Tier 2) and uploading it as an artifact.

Why

  • It is the safest tier: a hostile Pull Request can only harm a short-lived, credential-free runner.
  • It gives contributors the same feedback as maintainers get, immediately.
  • It sets the boundary for Tier 3. Everything that can be tested without a protected resource must be tested here, which keeps the protected tier small.
  • It is the right place to run the project’s own build tooling (package managers, build plugins, install scripts), because that tooling is itself untrusted code.

The protected / unprotected test split

Separating tests that need a protected resource is a core part of this standard. Examples of protected resources are a commercial licence (such as a licensed Sonatype installation), a paid cloud account, a private registry, or a signing key. Projects must:

  • Identify which tests need a protected resource and which do not.
  • Make the split explicit in the test code, using the ecosystem’s native mechanism (build tags, test categories or markers, separate test directories, or an environment flag), not only in CI configuration.
  • Ensure that a contributor can run the unprotected set locally with a single documented command and no credentials.
  • Ensure the protected set skips cleanly with a clear message when its credentials are absent, instead of failing.

This benefits contributors beyond CI: they can run everything that applies to them on their own machine.

Tier 2: Sonatype Lifecycle evaluation

What

Every Project must evaluate its dependencies with Sonatype Lifecycle on every Pull Request and at release (see Dependency Management). For fork Pull Requests this standard requires the evaluation to be performed by trusted code in a separate workflow from the one that builds the Pull Request.

Why this is harder than it looks

Lifecycle needs credentials. The straightforward approach is to run the scan in the same job that checks out the Pull Request, and that places untrusted code next to the credentials. Two things make a naive fix wrong:

  • Switching to pull_request_target and checking out the PR head is the classic “pwn request” vulnerability.
  • Some ecosystems resolve dependencies by running the project’s own build tooling (for example, build plugins or install scripts). Pointing a privileged scan at an untrusted checkout can therefore execute untrusted code, even though nobody intended to “run” the PR.

Approach: prepare unprivileged, evaluate privileged

The work is split in two so that the privileged side never executes anything from the Pull Request:

  1. Prepare (untrusted, no secrets). In a Tier 1 job, produce the scan input: the files Lifecycle’s own analysis reads for that ecosystem. Depending on the ecosystem this is a lockfile, a generated dependency list, the dependencies themselves, or a built artifact. Upload it as a single workflow artifact.
  2. Evaluate (trusted, has secrets). A separate workflow, triggered by workflow_run, downloads the artifact as data, checks it, and runs the standard Sonatype Lifecycle evaluation over it. It then reports the result back to the Pull Request as a check run.

The ecosystem guides say what the scan input is for each language, and the reference workflows show the complete pair.

How the hand-off works on GitHub

The automation depends on how GitHub’s events behave, which is not obvious. These are the mechanics, and the things that go wrong:

sequenceDiagram
    autonumber
    participant C as Contributor
    participant U as Tier 1 workflow<br/>(pull_request, no secrets)
    participant A as Artifact store
    participant P as Tier 2 workflow<br/>(workflow_run, default branch, secrets)
    participant L as Sonatype Lifecycle

    C->>U: Open or update PR
    U->>U: Build and prepare scan input
    U->>A: Upload artifact "lifecycle-scan-input"
    U-->>P: workflow_run completed (run id, head SHA)
    P->>A: Download by run id, as data
    P->>P: Validate size, file count, no symlinks
    P->>L: Evaluate (stage: develop)
    L-->>P: Policy result and report URL
    P-->>C: Check run on the PR's head SHA
  1. The Tier 1 workflow runs on pull_request. Its last job uploads the scan input with actions/upload-artifact.
  2. The Tier 2 workflow declares on: workflow_run for the Tier 1 workflow, with types: [completed]. GitHub runs the default branch’s copy of this workflow, with access to secrets, even when the triggering run came from a fork.
  3. Tier 2 downloads the artifact with actions/download-artifact, passing the triggering run-id and a token with actions: read, because the artifact belongs to a different run.
  4. Tier 2 reports the result with a check run on the triggering head_sha. A workflow_run workflow’s own status is attached to the default branch, not to the Pull Request, so without a check run the contributor never sees the result. Creating the check run needs checks: write.

Things that must be handled, because each is a known way for this pattern to fail or be abused:

GotchaConsequenceRequired handling
The PR number is not in the event for fork PRs. In runs we observed, workflow_run.pull_requests is empty when the PR comes from a fork. GitHub documents the same behaviour for check runs and check suites, but not explicitly for workflow_run.Workflows that rely on it silently do nothing.Attach the result to head_sha. If the PR number is needed, look it up from the SHA through the API. Never read it from the artifact, which the contributor controls.
workflow_run only fires for workflow files on the default branch.The first Tier 2 PR cannot test itself, and changes to the Tier 2 workflow are not exercised by the PR that makes them.Merge Tier 2 first, then change it with care: it runs with secrets, so changes to it need maintainer review.
Tier 1 might have failed or been cancelled.There may be no artifact, or an incomplete one.Check the triggering run’s conclusion and the run’s event before downloading.
The artifact belongs to another run.download-artifact cannot find it with defaults.Pass run-id and github-token explicitly.
Branch names, titles and other event fields are attacker-controlled.Template injection into scripts.Use only head_sha and numeric ids from the event, and pass anything else through environment variables, never inline in run:.

Which Lifecycle stage to use

Lifecycle policy is applied per stage, so each kind of run uses its own:

RunStageWhy
Pull Request, or any other branchdevelopEarly feedback for the people changing the code
CI / build on mainbuildThe merged code, built as the Project’s pipeline builds it
A release (tag push, GitHub Release or manual publish)releaseThe code that is being shipped

The develop evaluation is advisory. The build and release evaluations run on trusted refs, against reviewed code, and are the ones that enforce policy.

Rules for the privileged phase

The evaluation workflow must:

  • Never execute, build, install or source anything from the artifact. It is data.
  • Take the commit SHA from the triggering event, never from files in the artifact.
  • Choose the Lifecycle application ID itself (the repository name), and never take it from the artifact.
  • Check the artifact before use: a size limit, a file-count limit, and no symbolic links.
  • Have no actions/checkout of the Pull Request’s head.
  • Use credentials for a dedicated Lifecycle account with the least privilege that can perform an evaluation, and with no ability to read or change other applications or policies.
  • Evaluate at the develop stage for Pull Requests and other branches, against the Project’s own Lifecycle application (see Which Lifecycle stage to use).

What a PR-time result means

The contributor controls what goes into the artifact. A hostile Pull Request could therefore leave a vulnerable dependency out of the scan input and pass. This is true of any scheme where untrusted code prepares the input. It follows that:

  • The PR-time result is advisory. It gives contributors early, accurate feedback on honest changes, and it catches most mistakes.
  • The authoritative evaluations run on trusted refs (see Trusted refs), against the real checkout of reviewed, merged code: at the build stage for CI on main, and at the release stage for a release. Those are the evaluations that enforce policy.
  • Maintainers reviewing a PR still look at changes to dependency manifests. That is what the CODEOWNERS guidance below is for.

The remaining exposure

The trusted job runs the Sonatype CLI over files that a contributor produced, while Lifecycle credentials are present. This standard relies on the Sonatype CLI not running build tools, package managers or project code. It does have to read and process the files it is given, so a flaw in that processing, or a deliberately awkward input, is a theoretical route to the credentials. This is a much narrower exposure than running contributor code, and it is reduced by: a dedicated job with no checkout, the size and file-count limits above, a throwaway runner, and an evaluate-only account for a single application. It is not eliminated.

Reporting

The result should appear on the Pull Request as a check run, with a link to the Lifecycle report where the contributor can see it. Contributors should not need a Sonatype account to understand what to fix.

Tier 3: Protected-resource integration tests

What

Tests that need a protected resource run only when a maintainer has approved the specific commit under test, or when the code is already trusted (see Trusted refs).

There can be more than one Tier 3 workflow. A Project with a clustered or high-availability configuration that needs a protected resource, for example, runs that as another Tier 3 workflow, behind the same approval. All of them are required checks (see Required status checks).

A protected resource is anything the Project cannot let arbitrary code use freely: a commercial licence, a paid or quota-limited service, a private registry or environment, or credentials with a wide blast radius. For many of our Projects the protected resource is a licensed Sonatype installation, such as a licensed Nexus Repository or Lifecycle instance. That is an example, not a definition. A Project with no such tests does not have a Tier 3, and does not need a gate.

Why

These are the most sensitive credentials we hold, and the tests are the code a hostile contributor would most want to modify. A human review is the one control that reliably addresses that.

Part A: The approval gate

Projects must gate Tier 3 behind an explicit maintainer action, and the gate must meet all of these conditions:

  • It is explicit. A maintainer approves with a label or a comment command, after reading the diff (see Who can approve and Approval mechanisms).
  • It has no way round. Tier 3 workflows must not offer a manual start (workflow_dispatch) that takes a commit or branch, because that would let anyone with Maintain run unreviewed code with the protected resource, bypassing the check on the commit. The only ways in are an approval of one exact commit, or a push to a protected ref.
  • It is bound to a commit. The workflow records the approved commit SHA and runs exactly that commit. If the Pull Request is updated, approval is revoked automatically and a maintainer must approve again.
  • It does not trust history. Having a previous merged Pull Request is not grounds for automatic approval. Accounts get compromised.
  • It may auto-approve maintainers. Pull Requests from members of the Project’s maintainer team may skip the gate, as their branches live in the main repository.
  • Reviewers know what to look for. Changes to CI configuration, build files, dependency manifests (including module replacement or override directives), test helpers and scripts are the most common ways to extract secrets. Projects should list these in CODEOWNERS so that a maintainer is always prompted to review them. See Repository and organization settings.

Who can approve

Only a person with Maintain permission or higher on the repository may approve Tier 3 for a Pull Request. This is the Project’s maintainers team, @sonatype-nexus-community/[PROJECT], which is also its CODEOWNERS (see Project Maintainer and Standard Files). Each Project must have that team, with the Maintain role on the repository, and must list it in CODEOWNERS. Membership of the team, with no individual grant, is enough for the check below to see the Maintain role.

The approval workflow must check this itself. GitHub lets anyone with Triage permission apply a label, so a label is not proof of authority. For the person who applied the label or wrote the comment, look up their role with the repository collaborator permission API and read role_name. Do not use the permission field, which reports Maintain as write and so cannot tell Maintain from Write. An approval from anyone below Maintain is ignored, and a label they applied is removed.

Approval mechanisms

A Project may use either of these, and both must meet Who can approve and the commit-binding rule above. The approval belongs to a commit, not to a Pull Request: it is recorded as a commit status on that exact commit, so a Pull Request that is updated has no approval for its new commit until a maintainer gives one.

MechanismHow it worksStrengthWeakness
Label (for example approved-to-test)A maintainer applies it. The workflow records the head commit at that moment, and removes the label whenever new commits are pushed.Visible on the Pull Request. No typing.The recorded commit is the one at the moment of labelling, which can differ from the one the maintainer read if the contributor pushed in between.
Comment command (for example /ok-to-test <short-sha>)A maintainer comments with the commit they reviewed. The workflow refuses if the head commit is different.The approval names exactly what was read. Easy to audit.The workflow has to parse the comment.

Where a Project can choose, the comment command is preferred, because the maintainer states which commit they reviewed.

A Project may additionally keep the protected resource’s secrets in a GitHub Environment with required reviewers. GitHub then withholds the secrets from a run until a reviewer approves it, whatever our workflow logic does. That makes it a backstop for a mistake in the approval workflow. It costs a click on every run, including re-runs, so it is optional rather than part of the standard.

Part B: Don’t give the protected resource to the test code

Even after review, mistakes happen. The strongest protection is that the code under test never holds the real credential. The preferred design (target state):

  1. A trusted job, running only default-branch code, provisions an ephemeral instance of the protected resource (for example a container, VM or cloud sandbox) and injects the licence or credential there.
  2. It passes the test job only the instance’s address and credentials generated for that run.
  3. The test job, which runs the contributor’s code, has no organizational secrets. If it leaks what it was given, the leak is a throwaway instance that is destroyed when the run ends.

Projects that cannot yet do this may run protected tests with real secrets after the approval gate, as an interim step. When they do, they must:

  • Use secrets scoped to the repository or to a protected Environment, never organization-wide secrets available to all repositories.
  • Use credentials and licences that exist only for CI, are time-limited and are rotated on a schedule.
  • Keep the protected job’s permissions minimal.

How this could be realised

“Don’t give the protected resource to the test code” can be built in stages. A Project can stop at any stage, and should say which one it has reached.

  1. Gated, with scoped secrets. The approval gate above, with the secrets held at repository or Environment scope. This is the interim approach and needs no new infrastructure. The test code can still reach the secrets once it runs, which is why it is only acceptable behind the gate.
  2. Hardening inside the job. Keep the licence file out of the workspace, give it to the service rather than the tests, and remove it once the service has started. This reduces accidents. It is not separation: on a single runner the tests and the service run on the same machine, and the test process can normally reach the service’s files and environment. Treat it as hardening.
  3. A separate machine for the protected resource. A trusted job creates the instance of the protected resource somewhere other than the runner that executes the tests. For example, a short-lived container or virtual machine in a cloud account the community controls. It generates credentials for that run only, and passes the test job just the address and those credentials. Whatever the tests do, the licence never reaches them. A step that always runs at the end destroys the instance, and a scheduled clean-up removes any that leak.

Stage 3 needs decisions that are not technical: where the instances run, whose account and budget pays for them, and whether the licence terms allow short-lived instances. A Project that tests against many product versions needs one instance for each version in each run, so the cost grows with the size of the test matrix.

Trusted refs, releases and tags

Projects release in different ways: by merging to main, by pushing a version tag, by publishing a GitHub Release, or through a manual dispatch. This standard does not dictate which. It defines what makes a trigger trusted.

A ref is trusted when only maintainers can create or change it. On a trusted ref, the code has been reviewed and merged, so the authoritative Tier 2 evaluations (build stage for CI on main, release stage for a release, against the real checkout) and Tier 3 may run directly with full access to their secrets, exactly as described in Dependency Management.

Release styleWhy it is trustedRequired controls
Merge to mainBranch protection requires reviewed PRsBranch ruleset on main
Tag pushed manuallyOnly maintainers may create tagsTag ruleset restricting creation of release tags (for example v*) to maintainers; many Projects forget this and any writer can create tags by default
GitHub Release / workflow_dispatchOnly users with write access can triggerEnvironment with required reviewers for publishing

In all cases:

  • Publishing credentials (package registries, signing keys) must be Environment secrets with deployment rules restricted to the trusted refs. A feature branch, or an intermediate branch, must not be able to read them.
  • Release workflows must not be triggered by a Pull Request event.
  • Whatever the trigger, the release workflow should perform the release-stage Lifecycle evaluation and the full test set, including Tier 3 where the Project has one. CI on main performs the build-stage evaluation.

Intermediate branches

An intermediate branch is a branch in the main repository that holds a copy of a fork Pull Request’s commit, so that it can be tested by the same push-triggered workflows used for trusted code.

Why consider them

  • Some Projects already have a Lifecycle and protected-test workflow for push events, which would need changes to support the Tier 2 / Tier 3 split.
  • They give maintainers a simple “break-glass” way to run Tier 3 for a PR they have reviewed.
  • It lets you apply the trusted-ref controls (protected Environments with branch rules) to a PR.

Why handle them carefully

  • A push to any branch that matches a workflow trigger will run that workflow with the secrets that trigger can see. If anything other than a human maintainer can create these branches, you have rebuilt the vulnerability.
  • The branch’s content still came from a contributor. The workflow file on that branch is contributor-controlled, and push workflows run the version on the pushed branch.

Conditions for use

If a Project uses intermediate branches, it must:

  • Use a reserved naming scheme, such as pr/<number>, and a branch ruleset so that only maintainers (or a tightly scoped automation) can create or update pr/* branches.
  • Publish only a commit a maintainer has reviewed, recording the reviewed SHA. Mirroring is a result of approval, not a substitute for it.
  • Ensure secrets are held in an Environment whose deployment branches are limited to an explicit pattern, and not exposed to pr/* unless the Project has deliberately decided so.
  • Prevent the pr/* branches from triggering release or publishing workflows.
  • Delete the branch when the Pull Request is closed or merged.
  • Make changes to .github/ in a mirrored commit a blocking reason to refuse the mirror, or make the workflow definition come from the default branch instead (for example by calling a reusable workflow at a pinned reference).

Repository and organization settings

Actions settings

Each Project must set, under Settings -> Actions -> General:

  • Fork pull request workflows from outside collaborators: require approval (at minimum for first-time contributors).
  • Send write tokens / secrets to workflows from fork pull requests: disabled.
  • Workflow permissions: default to read-only.

Secrets

  • Secrets used by Tier 2 and Tier 3 should be repository- or Environment-scoped, not organization-wide. An organization secret is available to every repository allowed to use it, so a mistake in one repository exposes the secret for all of them.
  • Environments holding the most sensitive secrets should use deployment branch/tag rules and, where useful, required reviewers.
  • Credentials must be dedicated to CI, narrowly permissioned and rotated on a schedule, and the process for emergency revocation must be documented for the Project’s maintainers.

CODEOWNERS

Following CODEOWNERS, Projects should list the files that are most useful to an attacker and so deserve close review, including:

  • .github/ (workflows and actions)
  • Build and dependency definitions (for example Makefile, pom.xml, build.gradle, package.json, go.mod)
  • Test helpers, fixtures and scripts that Tier 3 runs

Workflow hygiene

All Projects should follow these practices in every workflow, as they make the tiers above safer:

  • Set permissions: {} at the top of the workflow and grant extra permissions per job.
  • Pin third-party actions to a full commit SHA, with the version in a trailing comment.
  • Never insert untrusted values (PR titles, branch names, commit messages) directly into run: scripts. Pass them as environment variables.
  • Use persist-credentials: false on actions/checkout.
  • Avoid pull_request_target unless the job does not check out or run contributor code.
  • Do not use self-hosted runners for Pull Request workflows from forks.
  • Run a workflow linter and a security scanner such as actionlint and zizmor in CI. They detect the specific mistakes this page warns about.

Contributor experience

The aim is that a contributor is never confused about why a check isn’t running, and never blocked by something they could have fixed themselves.

  • Tier 1 and 2 results arrive on their own. No approval is needed.
  • Tier 3 explains itself. A bot comment or check summary should say that protected-resource tests need a maintainer to approve the change, why this is the case, and roughly how long it takes.
  • Contributors can test locally. The CONTRIBUTING guide of each Project should explain how to run the unprotected tests locally, for example with a Compose file that starts Nexus Repository Community Edition.
  • Contributors can bring their own access. A contributor with their own trial licence or account can run the protected tests in their own fork with their own secrets and link the run to the Pull Request. This is optional and useful evidence for maintainers, but never a requirement.
  • Maintainers have a response expectation. Projects should aim to review approval requests promptly and say so in their contributor docs.

What Projects must, should and must not do

Must

  • Have a maintainers team for the Project, with the Maintain role, named in CODEOWNERS.
  • Require every tier the Project has as a status check before merging.
  • Run Tier 1 on every Pull Request without secrets.
  • Separate tests that need a protected resource from those that do not, in the test code itself.
  • Perform Lifecycle evaluation for fork PRs only through trusted code that treats the prepared scan input as data.
  • Run the authoritative Lifecycle evaluation on trusted refs.
  • Gate protected-resource tests behind a commit-bound maintainer approval.
  • Restrict who can create release tags and publishing refs.
  • Disable sending secrets to fork PR workflows.

Should

  • Scope secrets to repository or Environment rather than the whole organization.
  • Use ephemeral, per-run credentials for protected resources.
  • Pin actions, restrict token permissions, lint workflows.
  • Provide a local way to run unprotected tests.

Must not

  • Check out and execute a fork’s code in a job that can read secrets.
  • Rely on log masking as a protection.
  • Auto-approve a contributor because of their earlier contributions.
  • Allow Pull Request events to trigger release or publish workflows.
  • Treat the PR-time Lifecycle result as proof that a Pull Request is safe to merge.

Adoption and ratification

This standard moves through two states.

  • Draft (now). Once this page is published, the standard is available for adoption. Projects may adopt it early, and early adopters are how we find what does not work. A Project that adopts it should follow it in full, and tell us what it learned (see Your tool or ecosystem is not covered).
  • Ratified. Once the standard is ratified it is no longer optional. Every Project must adopt the latest ratified version the next time it is updated, developed or released. A Project does not have to stop and retrofit on the day of ratification, but it cannot ship its next change without bringing its Pull Request CI into line.

The first real-world trial is terraform-provider-sonatyperepo. What we learn there, including anything in these workflows that does not work as written, feeds back into this draft before it is ratified.

Your tool or ecosystem is not covered

The ecosystem guides cover the ecosystems our Projects use today. If yours is not there, or your tooling does something these guides do not allow for, engage with us rather than working around the standard:

Please say which Project and which ecosystem or tool it concerns. We add guides as Projects need them. Sonatype Lifecycle supports more ecosystems than we have guides for, so a missing guide usually means nobody has asked yet, not that it cannot be done.

While a guide is missing, the principles still apply: prepare the files Lifecycle reads for that ecosystem in a job with no secrets, and let a separate trusted job evaluate them as data.

Decisions recorded

These were considered during drafting. They are recorded so reviewers can see why, and challenge them with evidence.

Scan input is native files, not a generated SBOM

A Software Bill of Materials was the first candidate for the data that crosses the boundary, because it is a standard, inert format. We chose against it as the default.

  • What an SBOM would add. A uniform format. It would not add any extra separation, because the boundary is held by “only data crosses”, which is equally true of a lockfile or a built archive.
  • What it would cost. Someone has to generate it accurately. Lifecycle resolves dependencies as part of its own analysis, and that is where its accuracy comes from. A third-party generator reproduces that resolution with varying fidelity per ecosystem. Sonatype’s own documentation also lists limits on SBOM ingestion, including that nested components are ignored and that a repeated component keeps only its first entry.
  • Generating it with Sonatype’s own tooling does not remove the problem. The Sonatype CLI can produce an accurate SBOM, but only by running with Lifecycle credentials against a server, using a temporary application. Run on the untrusted side, that puts credentials next to contributor code. Run on the trusted side, the SBOM adds nothing, because the same step could have been the evaluation. It also needs an account permitted to create and delete applications, which is more privilege than an evaluation needs.
  • What we do instead. The untrusted side prepares the files Lifecycle already reads for that ecosystem, and the trusted side lets Lifecycle do the resolving.

An SBOM remains acceptable for a Project whose ecosystem Lifecycle cannot analyse from prepared files. The reference SBOM that Lifecycle can export from a trusted-ref evaluation is also useful, to measure the difference between any alternative generator and Lifecycle’s own result. We will revisit this decision if the native approach hits a blocking limit.

PR-time evaluation is advisory

See What a PR-time result means.

Pull Requests are evaluated at the develop stage, against the real application

Pull Request evaluations go to the Project’s own Lifecycle application (named after the repository), at the develop stage.

  • One application, not a separate one for Pull Requests. We considered a separate application to keep unreviewed Pull Requests out of a Project’s results, and chose against it. Contributors then see the policy the Project actually uses, and the history stays in one place.
  • Why develop. It is the stage for early feedback on Pull Requests and branches. CI on main uses build, and a release uses release.
  • Consequences. The Tier 2 account needs permission to evaluate that one application. The existing Dependency Management page, whose Continuous Integration example uses the build stage for every run, will need updating to match when the standard is ratified.

The Sonatype CLI does not run build tools

The design relies on the Sonatype CLI only reading the files it is given: it does not run build tools, package managers or project code. That is why the Tier 2 job can run it over a contributor’s files. See the remaining exposure.

Reusable workflows live in a central Community repository

Where it is sane and safe to do so, the trusted workflow logic (the Tier 2 evaluation, the Tier 3 approval gate, and shared preparation steps) will be provided as reusable workflows in a new central Community repository. It is then hardened and audited once, and each Project calls it with a few lines.

“Sane and safe” means:

  • Callers pin the reusable workflow to a commit SHA.
  • The central repository has tight access control, CODEOWNERS and required reviews.
  • Each Project’s own workflow still defines its own triggers. A reusable workflow does not decide when it runs.

The reference workflows then become the pattern for the callers. The repository’s name and home are still to be decided.

Open questions for review

We want feedback from maintainers, contributors and Sonatype on these before the standard is ratified.

  1. Protected resources created for each test run. Part B of Tier 3 describes creating a throwaway copy of the protected resource for every test run, so that the contributor’s test code never sees a real credential. It is the safest design, but no Project has built it yet. What would create and destroy the copies, where they would run, what licence they would use, and who would pay are all undecided. How this could be realised sets out the stages. Until it exists, Projects use the interim approach.
  2. Approval mechanism. The standard allows a label or a comment command, restricted to the people named in Who can approve, and is considering whether to also use a protected Environment. See Approval mechanisms.

Glossary

TermMeaning
Fork PRA Pull Request from a contributor’s own copy of the repository.
Untrusted codeAny code a person outside the maintainers can change, including code in a PR.
Trusted codeCode on a protected default branch or protected tag.
Protected resourceSomething the Project cannot let arbitrary code use freely, such as a licence, a paid service, or powerful credentials.
Scan inputThe prepared files, produced by Tier 1, that the Tier 2 Lifecycle evaluation reads.
pull_requestRuns the PR’s code with a read-only token and no secrets when it comes from a fork.
pull_request_targetRuns the default branch’s workflow with secrets. Safe only if it never runs the PR’s code.
workflow_runRuns a workflow (from the default branch) after another workflow completes. Used to pass data from the unprivileged to the privileged side.
Check runA status attached to a commit, shown on the Pull Request. How Tier 2 reports back.
EnvironmentA GitHub setting that holds secrets together with rules for who and what may use them.
Ephemeral instanceA temporary installation of a protected resource created for one test run and destroyed afterwards.
Intermediate branchA branch in the main repository holding a reviewed copy of a PR commit.

1 - Reference Workflows

The complete Tier 1 and Tier 2 workflow pair, and the trusted-ref evaluation, that the ecosystem guides build on.

Contents

How the pieces fit

FileTriggerHas secrets?Runs PR code?Job
.github/workflows/ci.yamlpull_request, push to mainNoYesBuild, test, and produce the scan input
.github/workflows/lifecycle-evaluate.yamlworkflow_run of CIYesNoEvaluate the scan input and report
.github/workflows/lifecycle-release.yamlpush to main or release tagsYesOnly reviewed, merged codeAuthoritative evaluation

The name of the first workflow (CI) is referenced by the second, so the two must agree.

Pick your language from the ecosystem guides and put its prepare-scan-input job in ci.yaml. Nothing else in these workflows changes between languages, except where a guide says to set a repository variable.

Tier 1: the CI workflow

This is the skeleton. The build-and-test job stands for your Project’s own build and tests. The prepare-scan-input job comes from the ecosystem guide; the Go version is shown as a worked example.

# .github/workflows/ci.yaml
name: CI

on:
  pull_request:
  push:
    branches: [main]

# No permissions by default. Each job asks only for what it needs.
permissions: {}

jobs:
  build-and-test:
    name: Build and test
    runs-on: ubuntu-latest
    permissions:
      contents: read
    steps:
      - name: Checkout
        uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          persist-credentials: false
      # ... your build, lint and unprotected tests here ...

  prepare-scan-input:
    name: Prepare Lifecycle scan input
    runs-on: ubuntu-latest
    permissions:
      contents: read
    steps:
      - name: Checkout
        uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          persist-credentials: false

      # --- Ecosystem-specific: replace this block with your guide's steps ---
      - name: Set up Go
        uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
        with:
          go-version-file: go.mod
      - name: Prepare scan input
        run: |
          mkdir scan-input
          go list -f '{{define "M"}}{{.Path}} {{.Version}}{{end}}{{with .Module}}
          {{if not .Main}}{{if .Replace}}{{template "M" .Replace}}
          {{else}}{{template "M" .}}{{end}}{{end}}{{end}}' -compiled -deps ./... | sort -u > scan-input/go.list
          cp go.mod scan-input/
      # --- End ecosystem-specific ---

      - name: Upload scan input
        uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
        with:
          name: lifecycle-scan-input
          path: scan-input/
          if-no-files-found: error
          retention-days: 3

Requirements for the prepare-scan-input job, whatever the language:

  • It must have no secrets and no write permissions.
  • It must put everything it wants evaluated in a directory named scan-input, and upload it as an artifact named lifecycle-scan-input.
  • It must not put anything in the artifact that is not a file Lifecycle reads for that ecosystem.
  • It must fail if there is nothing to upload (if-no-files-found: error), so a broken preparation step is visible.

Tier 2: the Lifecycle evaluation workflow

This workflow runs with secrets, so it is the sensitive part of the design. It never checks out the Pull Request, and it never runs anything it downloads.

# .github/workflows/lifecycle-evaluate.yaml
name: Lifecycle Evaluate

on:
  workflow_run:
    workflows: [CI]
    types: [completed]

permissions: {}

jobs:
  evaluate:
    name: Evaluate scan input
    # Only for pull requests, and only when Tier 1 succeeded. Trusted refs are
    # handled by lifecycle-release.yaml instead.
    if: >-
      github.event.workflow_run.event == 'pull_request' &&
      github.event.workflow_run.conclusion == 'success'
    runs-on: ubuntu-latest
    timeout-minutes: 20
    permissions:
      actions: read # download the artifact from another run
      checks: write # report the result on the pull request
    env:
      # Limits on the downloaded data. Override with repository variables.
      MAX_BYTES: ${{ vars.LIFECYCLE_SCAN_MAX_BYTES || 536870912 }}
      MAX_FILES: ${{ vars.LIFECYCLE_SCAN_MAX_FILES || 50000 }}
    steps:
      - name: Download scan input (as data)
        uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
        with:
          name: lifecycle-scan-input
          path: scan-input
          run-id: ${{ github.event.workflow_run.id }}
          github-token: ${{ github.token }}

      - name: Validate scan input
        run: |
          set -euo pipefail
          bytes=$(du -sb scan-input | cut -f1)
          files=$(find scan-input -type f | wc -l)
          if [ "$bytes" -gt "$MAX_BYTES" ]; then
            echo "::error::Scan input is $bytes bytes; the limit is $MAX_BYTES"
            exit 1
          fi
          if [ "$files" -gt "$MAX_FILES" ]; then
            echo "::error::Scan input has $files files; the limit is $MAX_FILES"
            exit 1
          fi
          if [ -n "$(find scan-input ! -type f ! -type d -print -quit)" ]; then
            echo "::error::Scan input contains something other than regular files and directories"
            exit 1
          fi

      - name: Sonatype Lifecycle Evaluation
        id: evaluate
        # Do not fail here, so that the result is always reported below.
        continue-on-error: true
        uses: sonatype/actions/evaluate@b1c823a70e98d3c67478cd72c0128712623455b9 # v1.14.1
        with:
          iq-server-url: ${{ vars.SONATYPE_PLATFORM_URL }}
          username: ${{ secrets.SONATYPE_LIFECYCLE_USERNAME }}
          password: ${{ secrets.SONATYPE_LIFECYCLE_PASSWORD }}
          # Chosen here, from trusted values. Never read from the artifact.
          application-id: ${{ github.event.repository.name }}
          scan-targets: ${{ vars.LIFECYCLE_SCAN_TARGETS || 'scan-input' }}
          stage: develop # Pull Requests use the develop stage, see the standard

      - name: Report result on the pull request
        if: ${{ !cancelled() }}
        uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
        env:
          HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
          EVALUATE_OUTCOME: ${{ steps.evaluate.outcome }}
          REPORT_URL: ${{ steps.evaluate.outputs.report-url }}
        with:
          script: |
            const outcome = process.env.EVALUATE_OUTCOME;
            const passed = outcome === 'success';
            const summaries = {
              success: 'The evaluation passed.',
              skipped: 'The scan input was missing or was rejected by validation, so nothing was evaluated.',
            };
            await github.rest.checks.create({
              owner: context.repo.owner,
              repo: context.repo.repo,
              name: 'Sonatype Lifecycle (pull request, advisory)',
              head_sha: process.env.HEAD_SHA,
              status: 'completed',
              conclusion: passed ? 'success' : 'failure',
              details_url: process.env.REPORT_URL || undefined,
              output: {
                title: passed ? 'Passed' : 'Needs attention',
                summary: summaries[outcome] ??
                  'The evaluation reported policy violations or did not complete. Open the report for details.',
              },
            });

What Tier 2 deliberately does not do

  • It has no actions/checkout. The only thing from the Pull Request on the runner is the downloaded artifact.
  • It does not read the Pull Request number, branch name, title or any file contents to decide what to do. It uses head_sha, which GitHub sets, and the repository name, which is the Project’s own.
  • Tier 1 must have succeeded before Tier 2 runs. A contributor whose tests are failing fixes them first. A Project that wants a Lifecycle result even when tests fail can run the prepare-scan-input job in a separate workflow, and point workflows: at that one.

Trusted refs: the authoritative evaluation

On a trusted ref (see Trusted refs, releases and tags) the code is reviewed and merged, so the Project can scan the real checkout directly, with secrets, as described in Dependency Management. This evaluation, and not the advisory Pull Request one, is what enforces policy.

# .github/workflows/lifecycle-release.yaml
name: Lifecycle Release Evaluation

on:
  push:
    branches: [main]
    tags: ['v*'] # adjust to your release convention

permissions: {}

jobs:
  evaluate:
    name: Evaluate (build on main, release on a tag)
    runs-on: ubuntu-latest
    permissions:
      contents: read
    steps:
      - name: Checkout
        uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          persist-credentials: false

      # ... build the project here if your ecosystem is scanned from build output ...

      - name: Sonatype Lifecycle Evaluation
        uses: sonatype/actions/evaluate@b1c823a70e98d3c67478cd72c0128712623455b9 # v1.14.1
        with:
          iq-server-url: ${{ vars.SONATYPE_PLATFORM_URL }}
          username: ${{ secrets.SONATYPE_LIFECYCLE_USERNAME }}
          password: ${{ secrets.SONATYPE_LIFECYCLE_PASSWORD }}
          application-id: ${{ github.event.repository.name }}
          scan-targets: '.' # or the build output, per the ecosystem guide
          # Tags are releases; a push to main is CI. See "Which Lifecycle stage to use".
          stage: ${{ startsWith(github.ref, 'refs/tags/') && 'release' || 'build' }}

This workflow is only safe if the refs that trigger it are protected. A branch ruleset on main and a tag ruleset on the release tags are required, as described in the standard. If a Project releases some other way (a manual dispatch, a published GitHub Release), change the on: block to match and keep the protection requirement.

Repository configuration

For these workflows to work as designed, the repository needs:

SettingValueWhy
Variable SONATYPE_PLATFORM_URLThe Lifecycle URLNot secret, but only Tier 2 and the release workflow read it
Secrets SONATYPE_LIFECYCLE_USERNAME and SONATYPE_LIFECYCLE_PASSWORDA dedicated, evaluate-only accountLeast privilege; see the standard
Where those secrets liveRepository or Environment scope, not organizationA mistake then affects one repository
Actions setting: send secrets to fork PR workflowsDisabledTier 1 must not receive them
Actions setting: approval for fork PR workflowsRequired for first-time contributorsLimits abuse of runner time
Optional variable LIFECYCLE_SCAN_TARGETSA path under scan-inputFor ecosystems whose target is a single file (for example Docker)
Optional variables LIFECYCLE_SCAN_MAX_BYTES, LIFECYCLE_SCAN_MAX_FILESLarger limitsFor ecosystems with big inputs (for example Docker, node_modules)

The lifecycle-evaluate.yaml workflow must be on the default branch before it takes effect, and changes to it, to ci.yaml’s prepare-scan-input job, and to anything under .github/ should be listed in CODEOWNERS.

Why each part is the way it is

ChoiceReason
Two workflows, not oneThe first runs contributor code and has no secrets; the second has secrets and runs none. This is the whole design.
workflow_run, not pull_request_targetworkflow_run runs the default branch’s copy of the workflow and never checks out the Pull Request by default, so there is no code from the PR to run.
permissions: {} at the topNothing gets a permission it did not ask for.
Actions pinned to a commit SHAA tag can be moved; a commit cannot. The version is kept in a trailing comment so updates are easy to review.
persist-credentials: falseCheckout does not leave the token in .git where later steps could read it.
Limits and the file-type check in Tier 2The downloaded data is untrusted. A very large or oddly-shaped input is rejected before anything parses it.
continue-on-error then a check runA failed evaluation is still reported on the PR. Without the check run, a workflow_run result is attached to the default branch and the contributor never sees it.
application-id from the repository nameIt comes from a value the contributor cannot change, and it matches the existing Dependency Management convention.
Trusted-ref workflow separate from Tier 2The advisory and authoritative results are different things and should not share a name, a trigger or a failure mode.

2 - Ecosystem Guides

For each language, what to prepare for the Lifecycle scan, and the Tier 1 job that prepares it.

What these guides are

Under the Pull Request CI & Secrets standard, a Tier 1 job (no secrets) prepares a scan input, and a separate Tier 2 job (with secrets) hands it to Sonatype Lifecycle. Which files Lifecycle can accurately evaluate depends on the language. These guides record, for each one:

  • What Lifecycle reads, and so what the scan input must contain.
  • A prepare-scan-input job to paste into your ci.yaml (see Reference Workflows).
  • What accuracy you give up compared with scanning a full build, so that nobody is surprised.

The Tier 2 workflow is the same for every language. Only the preparation differs.

Two kinds of ecosystem

KindExamplesWhat the scan input isAccuracy
ManifestGo, Rust, PHP, Ruby, Python, .NET, RA lockfile or a dependency list, produced by the language’s own toolingGood, if the lockfile or list pins exact versions. Transitive dependencies depend on what the file contains.
BinaryJava, npm with node_modules, DockerThe built artifact, or the installed dependenciesBest. Lifecycle fingerprints the files themselves, which also finds modified or repackaged dependencies.

Binary inputs are large. Allow for that in the artifact limits (see Reference Workflows).

The guides

EcosystemUsed byGuide
GoNancy, Cheque, Terraform providers, API clientsGo
Java (Maven, Gradle)Sherlock Trunks, Nexus CasC Plugin, API clientsJava
JavaScript (npm, Yarn)AuditJS, API clientsJavaScript
PythonJake, API clientsPython
.NET (NuGet)No current Project.NET
Rust (Cargo)Cargo PantsRust
Ruby (Bundler)ChelseaRuby
PHP (Composer)BachPHP
R (CRAN)OysterRR
C/C++ (Conan)No current ProjectC/C++
Docker imagesContainer image ProjectsDocker

Terraform providers are written in Go, so their Lifecycle scan is the Go guide.

Not yet covered: Dart and Flutter, Swift, Objective-C, Conda, RPM, and Hugging Face. Sonatype supports these. If your Project needs one of them, or uses a tool or ecosystem not listed at all, see Your tool or ecosystem is not covered.

Adding a guide

Each guide follows the same shape so they can be compared: what Lifecycle reads, the prepare-scan-input job, what you give up, and a link to the Sonatype page it came from. Quote Sonatype’s documentation, and say “not documented” where it is silent.

2.1 - .NET

Preparing the Lifecycle scan input for NuGet projects.

What Lifecycle reads

Sonatype’s documentation says Lifecycle scans .nupkg files and PE binaries (.dll, .exe and others), as a binary scan, and project files (.csproj, packages.config) as a manifest scan. In manifest mode “only components with an exact version specified are evaluated”, floating versions such as 3.6.* are not supported, and transitive dependencies are not included.

Running dotnet restore is not required, but it is recommended for a more complete analysis. Restoring into a directory (dotnet restore --packages packages) gives a folder of packages to scan.

Tier 1 job

  prepare-scan-input:
    name: Prepare Lifecycle scan input
    runs-on: ubuntu-latest
    permissions:
      contents: read
    steps:
      - name: Checkout
        uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          persist-credentials: false

      - name: Set up .NET
        uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
        with:
          global-json-file: global.json

      - name: Restore packages
        run: |
          mkdir scan-input
          dotnet restore --packages scan-input/packages
          find . -name '*.csproj' -not -path './scan-input/*' -exec cp --parents {} scan-input/ \;

      - name: Upload scan input
        uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
        with:
          name: lifecycle-scan-input
          path: scan-input/
          if-no-files-found: error
          retention-days: 3

dotnet restore can run MSBuild targets from the project, which is code from the Pull Request. That is acceptable in Tier 1 and is why it must not run in Tier 2.

Tier 2

Unchanged from the reference workflow.

Trusted-ref evaluation

Restore and scan the same way against the real checkout, at stage: build on main and stage: release for a release. Scanning the published output (dotnet publish) is the closest equivalent of the Java “deployed artifact” advice, but Sonatype’s page does not state this for .NET.

What you give up

  • Manifest mode skips transitive dependencies. Restoring into a package folder avoids this.
  • Floating versions are not evaluated. Pin exact versions.
  • .msi files are not supported.
  • Advisory only at PR time, as for every ecosystem.

2.2 - C/C++

Preparing the Lifecycle scan input for Conan-based C and C++ projects.

What Lifecycle reads

Sonatype’s documentation says it analyses files named conanfile.txt, conanfile.py or conaninfo.txt, taking dependencies “with an exact version” from the requires and build_requires sections. It describes a scan of “a directory or subdirectories containing” those files. It does not describe a lockfile or a binary scan, and does not say that conan install is needed first.

Note that the Conan support is for the dependencies declared in those files. C and C++ code that is vendored into a repository, or fetched some other way, is not covered.

Tier 1 job

  prepare-scan-input:
    name: Prepare Lifecycle scan input
    runs-on: ubuntu-latest
    permissions:
      contents: read
    steps:
      - name: Checkout
        uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          persist-credentials: false

      - name: Gather scan input
        run: |
          mkdir scan-input
          for f in conanfile.txt conanfile.py conaninfo.txt; do
            [ -f "$f" ] && cp "$f" scan-input/
          done
          test -n "$(ls -A scan-input)"

      - name: Upload scan input
        uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
        with:
          name: lifecycle-scan-input
          path: scan-input/
          if-no-files-found: error
          retention-days: 3

This job runs nothing from the Pull Request. The final test makes the job fail if none of the files exist, because an empty directory would upload nothing.

Tier 2

Unchanged from the reference workflow.

Trusted-ref evaluation

Scan the real Conan files at stage: build on main and stage: release for a release.

What you give up

  • conanfile.py is code. Lifecycle reads it as text and does not run it, but be aware that it is a Python file and that reviewers should treat changes to it as code changes.
  • Exact versions only. Version ranges are not evaluated.
  • No binary scan is documented.
  • Advisory only at PR time, as for every ecosystem.

2.3 - Docker

Preparing the Lifecycle scan input for container images.

What Lifecycle reads

Docker is a binary ecosystem. Sonatype’s documentation describes the scan target as an image saved to a tar archive with docker save, which the CLI then accepts directly: for example docker save webgoat/webgoat-7.1:latest > webgoat-7.1.tar. It says the Sonatype CLI “relies on the Docker daemon to package the image as an archive file”.

That is helpful for this standard. If the Tier 1 job builds the image and saves the tar, the Tier 2 job has no need for a Docker daemon, because the CLI is given a file. It also means the Tier 2 job never pulls an image from a registry, so it never needs registry credentials.

Tier 1 job

  prepare-scan-input:
    name: Prepare Lifecycle scan input
    runs-on: ubuntu-latest
    permissions:
      contents: read
    steps:
      - name: Checkout
        uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          persist-credentials: false

      - name: Build image
        run: docker build --tag scan-candidate:pr .

      - name: Save image
        run: |
          mkdir scan-input
          docker save scan-candidate:pr --output scan-input/image.tar

      - name: Upload scan input
        uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
        with:
          name: lifecycle-scan-input
          path: scan-input/
          if-no-files-found: error
          retention-days: 3

A docker build runs the Dockerfile’s instructions, which is code from the Pull Request. That is acceptable in Tier 1.

Tier 2

Two changes from the reference workflow:

  • Set the repository variable LIFECYCLE_SCAN_TARGETS to scan-input/image.tar, so that the scan target is the single archive.
  • Raise LIFECYCLE_SCAN_MAX_BYTES. Images are routinely hundreds of megabytes to gigabytes, and the default of 512 MB will reject many.

An image archive is large and contains whole filesystems, so also see the remaining exposure.

Trusted-ref evaluation

Build the image from the real checkout, save it, and scan the archive at stage: build on main and stage: release for a release. A Project that publishes the image should scan the same image it publishes.

What you give up

  • Time and storage. An image artifact is expensive to upload and download. Keep retention-days short, and consider building a smaller image for the scan if your Dockerfile has a multi-stage build with a test stage.
  • Base image drift. The result reflects the base image as pulled at build time, which can change between runs.
  • Platforms. docker save of a multi-platform build saves one platform. Choose the one you ship, or scan each.
  • Advisory only at PR time, as for every ecosystem. In particular, a Dockerfile change that adds a layer is exactly what a hostile Pull Request might try, and the image contents are what get scanned, which is a mitigating factor.

2.4 - Go

Preparing the Lifecycle scan input for Go modules, including Terraform providers.

What Lifecycle reads

Go is a manifest ecosystem. Lifecycle matches Go components by module path and version. Sonatype’s documentation says:

  • A valid go.mod is required for Go module analysis.
  • The recommended scan target is a go.list file generated with go list.
  • go.sum is also supported, but “may include dependencies that are not used by the application”, so it can report more than your build actually uses.
  • The Go toolchain does not need to be present when Lifecycle scans the file.

Why go.list and not go.sum

go.sum records every module version your module graph has ever needed, including ones the build does not use. go.list is produced from what the compiler would actually link, so it matches your real dependencies. The tradeoff is that producing it needs the Go toolchain and module downloads, which is why it happens in Tier 1.

Tier 1 job

  prepare-scan-input:
    name: Prepare Lifecycle scan input
    runs-on: ubuntu-latest
    permissions:
      contents: read
    steps:
      - name: Checkout
        uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          persist-credentials: false

      - name: Set up Go
        uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
        with:
          go-version-file: go.mod

      - name: Generate go.list
        run: |
          mkdir scan-input
          go list -f '{{define "M"}}{{.Path}} {{.Version}}{{end}}{{with .Module}}
          {{if not .Main}}{{if .Replace}}{{template "M" .Replace}}
          {{else}}{{template "M" .}}{{end}}{{end}}{{end}}' -compiled -deps ./... | sort -u > scan-input/go.list
          cp go.mod scan-input/go.mod

      - name: Upload scan input
        uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
        with:
          name: lifecycle-scan-input
          path: scan-input/
          if-no-files-found: error
          retention-days: 3

The go list template is Sonatype’s, verbatim. Two things are ours: the ./... package pattern, which the Sonatype page does not show (its command ends at -deps), and copying go.mod next to the list, because the page says a valid go.mod is required.

Tier 2

No change from the reference workflow. The default scan-targets of scan-input finds go.list and go.mod.

Trusted-ref evaluation

Scan the real checkout. Run the same go list step, and use scan-targets: 'go.list go.mod' or '.'.

What you give up

  • Replace directives and local modules. The template substitutes a replace target’s path and version, which is what you want for a module replacement. A replace pointing at a local directory has no version, so it cannot be matched. Reviewers should treat new replace directives in a Pull Request as a signal to look closely.
  • Versions Lifecycle cannot match. Sonatype’s documentation lists these: +incompatible versions “may not be identified and can appear as Unknown”; pseudo-versions have “partial support”; arbitrary pre-release versions “are not supported”.
  • Build tags. -compiled -deps follows the build configuration the job runs with. Code behind other build tags or other operating systems is not listed. If you ship for several platforms, run go list for each (GOOS, GOARCH) and combine the results with sort -u.
  • Advisory only at PR time. As for every ecosystem, the contributor produces this file, so the Pull Request result is advisory. See What a PR-time result means.

Terraform providers

terraform-provider-sonatyperepo and terraform-provider-sonatypeiq are Go modules, so this guide applies unchanged. The provider’s docs/ and example Terraform files are not dependencies and are not scanned.

2.5 - Java

Preparing the Lifecycle scan input for Maven and Gradle projects.

What Lifecycle reads

Java is a binary ecosystem. Sonatype’s documentation says Lifecycle scans packaged archives (.ear, .war, .jar, .zip, .tar.gz) and post-build artifacts, using Advanced Binary Fingerprinting, and that “for the best results” the post-build artifact you plan to deploy should be the scan target.

It can also read pom.xml and build.gradle, but that is a manifest scan with a real limit: “transitive dependencies are not automatically resolved and, therefore not included”. It needs the group, artifact and exact version of each direct dependency.

So for an accurate Pull Request scan, the scan input should be the built output. That means the build runs in Tier 1, where running a build is safe.

Tier 1 job: Maven

  prepare-scan-input:
    name: Prepare Lifecycle scan input
    runs-on: ubuntu-latest
    permissions:
      contents: read
    steps:
      - name: Checkout
        uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          persist-credentials: false

      - name: Set up Java
        uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1
        with:
          distribution: temurin
          java-version-file: .java-version # or set java-version: explicitly
          cache: maven

      - name: Build
        run: ./mvnw --batch-mode -DskipTests package

      - name: Gather scan input
        run: |
          mkdir scan-input
          cp -r target/*.jar scan-input/ 2>/dev/null || true
          cp -r target/*.war scan-input/ 2>/dev/null || true
          # A thin jar does not contain its dependencies. Copy them alongside.
          ./mvnw --batch-mode dependency:copy-dependencies -DoutputDirectory=scan-input/dependencies

      - name: Upload scan input
        uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
        with:
          name: lifecycle-scan-input
          path: scan-input/
          if-no-files-found: error
          retention-days: 3

Notes:

  • mvn dependency:copy-dependencies is the command Sonatype’s documentation gives for thin jars.
  • An uber or shaded jar already contains its dependencies, so the dependency:copy-dependencies step is not needed. Sonatype’s documentation says to “call the index goal of the Maven plugin at the time of building the uber/shaded jar”. Add that to the build step if you produce one.
  • -DskipTests is for speed here. Your tests run in the build-and-test job.
  • Use the project’s own wrapper (./mvnw), so the Maven version is the one the Project uses. A Maven wrapper downloads Maven, which is acceptable in Tier 1 and is why this must not run in Tier 2.

Tier 1 job: Gradle

      - name: Build
        run: ./gradlew --no-daemon assemble

      - name: Gather scan input
        run: |
          mkdir scan-input
          cp -r build/libs/. scan-input/

Tier 2

Mostly unchanged from the reference workflow. Two adjustments:

  • Binary inputs are larger than lockfiles. Raise LIFECYCLE_SCAN_MAX_BYTES if the default of 512 MB is too small, and keep it as low as is workable.

Trusted-ref evaluation

Build the project, then scan the same output (scan-targets: 'target' or 'build/libs') at stage: build on main and stage: release for a release. Do not scan ., because the source tree is a weaker target than the deployed artifact.

What you give up

  • Test and provided scope. dependency:copy-dependencies copies test-scope dependencies by default. To scan only what ships, add -DincludeScope=runtime. Whether to include test dependencies is a policy decision for the Project.
  • A build that fails. If the Pull Request does not compile, there is no artifact, so the Pull Request gets no Lifecycle result until it builds. This is consistent with Tier 2 only running after Tier 1 succeeds.
  • Advisory only at PR time. A Pull Request could build an artifact that omits a dependency. Maintainers reviewing pom.xml and build.gradle changes is the control for that, and the trusted-ref evaluation is the backstop.

Nexus plugin Projects

Nexus Repository plugins (for example the CasC plugin) are built as bundles that run inside Nexus Repository. The bundle is the deployed artifact, so scan it. The Nexus platform itself is provided by the host at runtime and is not in the bundle. Whether it should appear in the scan is a policy decision for the Project.

2.6 - JavaScript

Preparing the Lifecycle scan input for npm and Yarn projects.

What Lifecycle reads

JavaScript can be scanned two ways, and the choice sets the accuracy:

Scan inputModeAccuracy
package.json plus a lockfile (package-lock.json, npm-shrinkwrap.json, yarn.lock, pnpm-lock.yaml)Manifest matchingGood. Evaluates dependencies from metadata only.
package.json, the lockfile and node_modulesBinary (ABF)Best. Sonatype recommends this for “the most precise results”.

Sonatype’s documentation says that when JavaScript files are included, ABF is used and the JavaScript files are the primary identification source, and that “manifest entries without corresponding JavaScript files are ignored”. package.json must come with a package-lock.json.

The binary mode needs node_modules from “a clean production flag build”.

Which should a Project choose

  • Manifest is small, fast and cheap to move between jobs. Choose it as the default for Pull Requests.
  • Binary matters when you ship or bundle node_modules, or need to detect modified copies of packages. It is large. Choose it for the trusted-ref evaluation and, if your Project wants it, for Pull Requests too.

Tier 1 job: manifest

  prepare-scan-input:
    name: Prepare Lifecycle scan input
    runs-on: ubuntu-latest
    permissions:
      contents: read
    steps:
      - name: Checkout
        uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          persist-credentials: false

      - name: Gather scan input
        run: |
          mkdir scan-input
          cp package.json scan-input/
          # Use whichever lockfile your Project has.
          cp package-lock.json scan-input/

      - name: Upload scan input
        uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
        with:
          name: lifecycle-scan-input
          path: scan-input/
          if-no-files-found: error
          retention-days: 3

This job does not run npm, so it runs nothing from the Pull Request at all. It is the simplest and safest way to prepare a scan input.

For a monorepo or workspace, copy each package’s package.json into a matching subdirectory of scan-input, so that each lockfile stays next to its manifest.

Tier 1 job: binary

      - name: Set up Node
        uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
        with:
          node-version-file: .nvmrc

      - name: Install production dependencies
        run: npm install --production
        # For Yarn: yarn install --prod

      - name: Gather scan input
        run: |
          mkdir scan-input
          cp package.json package-lock.json scan-input/
          cp -r node_modules scan-input/node_modules

npm install --production is the command Sonatype’s preparation page gives, as is yarn install --prod. Use these exactly and not npm ci, until Sonatype says the others are equivalent.

Tier 2

Unchanged from the reference workflow for the manifest mode. For the binary mode, node_modules is large and has many files. Raise LIFECYCLE_SCAN_MAX_BYTES and LIFECYCLE_SCAN_MAX_FILES for this Project, and note that the artifact upload and download will be slow.

Trusted-ref evaluation

Run npm install --production on the real checkout, then scan package.json, the lockfile and node_modules together, at stage: build on main and stage: release for a release.

What you give up

  • Manifest mode cannot detect a package whose files were altered, and relies on the lockfile being honest. A lockfile that a Pull Request changed deserves a reviewer’s attention.
  • Development dependencies. The production flag means they are excluded in binary mode. If your policy covers build-time dependencies, scan them separately.
  • Name matching sunset. Sonatype’s documentation notes that matching unmatched JavaScript files by name ended on 2025-09-10. Only files that match known hashes are counted.
  • Advisory only at PR time, as for every ecosystem.

2.7 - PHP

Preparing the Lifecycle scan input for Composer projects.

What Lifecycle reads

PHP is a manifest ecosystem and the documentation describes one file only: “files named composer.lock (a JSON file generated by PHP composer) will be analyzed”. It does not scan composer.json or the vendor/ directory. Sonatype recommends generating and committing composer.lock before scanning, so the Project must commit composer.lock.

Tier 1 job

  prepare-scan-input:
    name: Prepare Lifecycle scan input
    runs-on: ubuntu-latest
    permissions:
      contents: read
    steps:
      - name: Checkout
        uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          persist-credentials: false

      - name: Gather scan input
        run: |
          mkdir scan-input
          cp composer.lock scan-input/

      - name: Upload scan input
        uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
        with:
          name: lifecycle-scan-input
          path: scan-input/
          if-no-files-found: error
          retention-days: 3

This job runs nothing from the Pull Request, and does not need composer install.

Tier 2

Unchanged from the reference workflow.

Trusted-ref evaluation

Scan the real composer.lock at stage: build on main and stage: release for a release.

What you give up

  • Only top-level packages. Sonatype’s documentation says “only top-level dependencies (specified under the ‘packages’ group) are evaluated”. Development dependencies, which Composer records under packages-dev, are not.
  • No binary scan is documented for PHP.
  • Advisory only at PR time, as for every ecosystem. A Pull Request that edits composer.lock is changing the thing being scanned.

2.8 - Python

Preparing the Lifecycle scan input for Python projects using pip or Poetry.

What Lifecycle reads

Python can be scanned from manifests or from binaries:

Scan inputNotes
requirements.txtMust use == with a version and no wildcards. Variable ranges and environment ranges are ignored.
poetry.lockParsed automatically. For Poetry 1.5.1 and later, development dependencies are excluded when pyproject.toml is present and discoverable.
Pipfile.lockNeeds IQ CLI 2.0 or later. Only dependencies under the default key are parsed.
.whl and .tar.gz filesBinary scan of the actual packages.

Sonatype’s recommendation for the best results is to first create a virtual environment, resolve dependencies with pip install -r requirements.txt, and use pip download or pip wheel to gather the packages as the scan target.

Tier 1 job: Poetry lockfile (manifest)

This is the simplest option and fits a Project that already commits poetry.lock, such as Jake.

  prepare-scan-input:
    name: Prepare Lifecycle scan input
    runs-on: ubuntu-latest
    permissions:
      contents: read
    steps:
      - name: Checkout
        uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          persist-credentials: false

      - name: Gather scan input
        run: |
          mkdir scan-input
          cp poetry.lock pyproject.toml scan-input/

      - name: Upload scan input
        uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
        with:
          name: lifecycle-scan-input
          path: scan-input/
          if-no-files-found: error
          retention-days: 3

Include pyproject.toml so that Lifecycle can exclude development dependencies. This job runs nothing from the Pull Request.

Tier 1 job: pip (binary)

      - name: Set up Python
        uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
        with:
          python-version-file: .python-version # or set python-version: explicitly

      - name: Download packages
        run: |
          python -m venv "$RUNNER_TEMP/venv"
          . "$RUNNER_TEMP/venv/bin/activate"
          mkdir scan-input
          pip download -r requirements.txt -d scan-input

This follows Sonatype’s guidance. Use pip wheel -r requirements.txt -w scan-input instead if you want wheels built from source distributions.

Tier 2

Unchanged from the reference workflow. Binary inputs are larger, so check the limits.

Trusted-ref evaluation

Scan the real poetry.lock and pyproject.toml, or the downloaded packages, at stage: build on main and stage: release for a release.

What you give up

  • Unpinned requirements. A requirements.txt with ranges is skipped for those lines. Pin versions (for example with pip freeze, or a tool such as pip-compile) before scanning.
  • A single requirements file. Sonatype’s documentation says other variants of requirements files are ignored. If you split them (requirements-dev.txt), only the one named exactly is read.
  • Advisory only at PR time, as for every ecosystem. A Pull Request that edits poetry.lock is changing the thing being scanned, and a reviewer should look at it.

Not documented

Sonatype’s page does not state what site-packages scans look like, and does not describe a poetry export flow. If a Project needs either, ask Sonatype before relying on it.

2.9 - R

Preparing the Lifecycle scan input for R (CRAN) packages.

What Lifecycle reads

The only input Sonatype’s documentation describes is a file named cran-installed.packages, “exported via installed.packages() command in CRAN console”. It does not describe scanning DESCRIPTION, renv.lock or package tarballs. Matching is by package name and version.

That means the scan input has to be generated from an R installation that has the package’s dependencies installed. The Project’s own repository does not contain it.

Tier 1 job

  prepare-scan-input:
    name: Prepare Lifecycle scan input
    runs-on: ubuntu-latest
    permissions:
      contents: read
    steps:
      - name: Checkout
        uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          persist-credentials: false

      - name: Set up R
        uses: r-lib/actions/setup-r@f9a764fea8d5c63df6ef9a5c7795bf7deb5d7e05 # v2.14.0
        with:
          use-public-rspm: true

      - name: Install dependencies and export package list
        run: |
          mkdir scan-input
          Rscript -e 'install.packages("remotes"); remotes::install_deps(dependencies = TRUE)'
          Rscript -e 'write.table(installed.packages()[, c("Package", "Version")], file = "scan-input/cran-installed.packages", sep = "\t", quote = FALSE, row.names = FALSE)'

      - name: Upload scan input
        uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
        with:
          name: lifecycle-scan-input
          path: scan-input/
          if-no-files-found: error
          retention-days: 3

Tier 2

Unchanged from the reference workflow.

Trusted-ref evaluation

Generate the same file from the real checkout and scan it at stage: build on main and stage: release for a release.

What you give up

  • Only what is installed. The scan covers what the Tier 1 job installed, which depends on the repositories and versions available at that moment, so two runs can differ.
  • Name and version only. There is no binary fingerprinting for R in Sonatype’s documentation.
  • Installing packages runs their code (compilation, .onLoad hooks in some cases). That is acceptable in Tier 1 and is why this must not run in Tier 2.
  • Advisory only at PR time, as for every ecosystem.

2.10 - Ruby

Preparing the Lifecycle scan input for Bundler projects.

What Lifecycle reads

Ruby is a manifest ecosystem, with a binary option. Sonatype’s documentation says it scans Gemfile.lock, .gem files and vendor/bundle. For the lockfile, “components from sections GIT, GEM and PATH and with an exact version will be analyzed”. For the best results it recommends scanning “after either installing the packages from a clean environment or storing the gems in a local cache”.

The Project must commit Gemfile.lock. Gem projects (libraries) often do not, and then there is nothing to scan.

Tier 1 job: lockfile

  prepare-scan-input:
    name: Prepare Lifecycle scan input
    runs-on: ubuntu-latest
    permissions:
      contents: read
    steps:
      - name: Checkout
        uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          persist-credentials: false

      - name: Gather scan input
        run: |
          mkdir scan-input
          cp Gemfile.lock scan-input/

      - name: Upload scan input
        uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
        with:
          name: lifecycle-scan-input
          path: scan-input/
          if-no-files-found: error
          retention-days: 3

This job runs nothing from the Pull Request.

Tier 1 job: installed gems (binary)

Add this to scan the gems themselves. It runs bundle, which may execute native extension builds, so it belongs in Tier 1.

      - name: Install gems into a local path
        run: |
          bundle config set --local path vendor/bundle
          bundle install
          mkdir scan-input
          cp Gemfile.lock scan-input/
          cp -r vendor/bundle scan-input/vendor-bundle

Tier 2

Unchanged from the reference workflow.

Trusted-ref evaluation

Scan the real Gemfile.lock, or the installed gems, at stage: build on main and stage: release for a release.

What you give up

  • Only exact versions are analysed, so a lockfile entry without one is skipped.
  • The Gemfile is not read. The lockfile is the truth, which means a Pull Request that edits it is changing the thing being scanned, and a reviewer should look at it.
  • Advisory only at PR time, as for every ecosystem.

2.11 - Rust

Preparing the Lifecycle scan input for Cargo projects.

What Lifecycle reads

Rust is a manifest ecosystem, and the only file Sonatype’s documentation describes is Cargo.lock: “the Cargo coordinate-based matching feature provides the ability to scan and evaluate Rust dependencies found in the Cargo.lock file”. The name and version of each package entry are evaluated. The documentation does not mention Cargo.toml or built binaries.

Because the lockfile is the input, the Project must commit Cargo.lock. Library crates often do not. A Project that does not commit it has nothing to scan, and should either start committing it or generate it in Tier 1 with cargo generate-lockfile (which resolves to the newest versions, not the ones the Project tested against, and is therefore a weaker signal).

Tier 1 job

  prepare-scan-input:
    name: Prepare Lifecycle scan input
    runs-on: ubuntu-latest
    permissions:
      contents: read
    steps:
      - name: Checkout
        uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          persist-credentials: false

      - name: Gather scan input
        run: |
          mkdir scan-input
          cp Cargo.lock scan-input/

      - name: Upload scan input
        uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
        with:
          name: lifecycle-scan-input
          path: scan-input/
          if-no-files-found: error
          retention-days: 3

This job runs nothing from the Pull Request. For a workspace with several Cargo.lock files, copy each into its own subdirectory.

Tier 2

Unchanged from the reference workflow.

Trusted-ref evaluation

Scan the real Cargo.lock at stage: build on main and stage: release for a release.

What you give up

  • No binary scan is documented. The result is only as honest as Cargo.lock. A Pull Request that edits the lockfile is changing the thing being scanned, and a reviewer should look at it.
  • Path and git dependencies may not match published crates. Sonatype’s page does not say how they are handled.
  • Advisory only at PR time, as for every ecosystem.