Reference Workflows
The complete Tier 1 and Tier 2 workflow pair, and the trusted-ref evaluation, that the ecosystem guides build on.
30 minute read
Status: DRAFT - for community and maintainer review. This section defines proposed standards and boundaries. It is available for adoption as a draft (see Adoption and ratification), but nothing here has been run in a real Project yet. The workflow snippets are reference drafts: they have been checked for syntax, but not yet run end to end in GitHub Actions.
The use of must and should indicate whether it is a requirement or a strong guideline.
In this section:
Our Projects run two kinds of automation that need credentials:
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.
These principles drive every decision below. When a Project’s situation is not covered, apply the principles.
| Tier | Purpose | Runs for | Secrets | Contributor wait |
|---|---|---|---|---|
| 1 | Build, lint, unit tests, tests against unprotected resources, and preparing the Lifecycle scan input | Every PR, automatically | None | None |
| 2 | Sonatype Lifecycle evaluation of the prepared input | Every PR, automatically | Lifecycle credentials, held only by trusted code | None |
| 3 | Integration tests that need a protected resource | PRs a maintainer has approved; trusted refs | Protected-resource access, held only by trusted code | Maintainer 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 --> T3The 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.
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:
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:
This benefits contributors beyond CI: they can run everything that applies to them on their own machine.
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.
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:
pull_request_target and checking out the PR head is the classic “pwn request” vulnerability.The work is split in two so that the privileged side never executes anything from the Pull Request:
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.
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 SHApull_request. Its last job uploads the scan input with actions/upload-artifact.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.actions/download-artifact, passing the triggering run-id and a token with actions: read, because the artifact belongs to a different run.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:
| Gotcha | Consequence | Required 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:. |
Lifecycle policy is applied per stage, so each kind of run uses its own:
| Run | Stage | Why |
|---|---|---|
| Pull Request, or any other branch | develop | Early feedback for the people changing the code |
CI / build on main | build | The merged code, built as the Project’s pipeline builds it |
| A release (tag push, GitHub Release or manual publish) | release | The 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.
The evaluation workflow must:
source anything from the artifact. It is data.actions/checkout of the Pull Request’s head.develop stage for Pull Requests and other branches, against the Project’s own Lifecycle application (see Which Lifecycle stage to use).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:
build stage for CI on main, and at the release stage for a release. Those are the evaluations that enforce policy.CODEOWNERS guidance below is for.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.
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.
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.
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.
Projects must gate Tier 3 behind an explicit maintainer action, and the gate must meet all of these conditions:
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.CODEOWNERS so that a maintainer is always prompted to review them. See Repository and organization settings.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.
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.
| Mechanism | How it works | Strength | Weakness |
|---|---|---|---|
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.
Even after review, mistakes happen. The strongest protection is that the code under test never holds the real credential. The preferred design (target state):
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:
“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.
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.
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 style | Why it is trusted | Required controls |
|---|---|---|
Merge to main | Branch protection requires reviewed PRs | Branch ruleset on main |
| Tag pushed manually | Only maintainers may create tags | Tag 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_dispatch | Only users with write access can trigger | Environment with required reviewers for publishing |
In all cases:
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.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.
push events, which would need changes to support the Tier 2 / Tier 3 split.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.push workflows run the version on the pushed branch.If a Project uses intermediate branches, it must:
pr/<number>, and a branch ruleset so that only maintainers (or a tightly scoped automation) can create or update pr/* branches.pr/* unless the Project has deliberately decided so.pr/* branches from triggering release or publishing workflows..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).Intermediate branches are an optional, break-glass mechanism, not the normal route. The approval gate described in Part A is the standard way to run Tier 3 for a Pull Request and needs fewer moving parts. Use an intermediate branch only when a maintainer has a specific reason, for example a Project whose existing trusted push workflows cannot yet be split.
Each Project must set, under Settings -> Actions -> General:
Following CODEOWNERS, Projects should list the files that are most useful to an attacker and so deserve close review, including:
.github/ (workflows and actions)Makefile, pom.xml, build.gradle, package.json, go.mod)All Projects should follow these practices in every workflow, as they make the tiers above safer:
permissions: {} at the top of the workflow and grant extra permissions per job.run: scripts. Pass them as environment variables.persist-credentials: false on actions/checkout.pull_request_target unless the job does not check out or run contributor code.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.
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.CODEOWNERS.This standard moves through two states.
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.
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.
These were considered during drafting. They are recorded so reviewers can see why, and challenge them with evidence.
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.
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.
See What a PR-time result means.
Pull Request evaluations go to the Project’s own Lifecycle application (named after the repository), at the develop stage.
develop. It is the stage for early feedback on Pull Requests and branches. CI on main uses build, and a release uses release.build stage for every run, will need updating to match when the standard is ratified.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.
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:
CODEOWNERS and required reviews.The reference workflows then become the pattern for the callers. The repository’s name and home are still to be decided.
We want feedback from maintainers, contributors and Sonatype on these before the standard is ratified.
| Term | Meaning |
|---|---|
| Fork PR | A Pull Request from a contributor’s own copy of the repository. |
| Untrusted code | Any code a person outside the maintainers can change, including code in a PR. |
| Trusted code | Code on a protected default branch or protected tag. |
| Protected resource | Something the Project cannot let arbitrary code use freely, such as a licence, a paid service, or powerful credentials. |
| Scan input | The prepared files, produced by Tier 1, that the Tier 2 Lifecycle evaluation reads. |
pull_request | Runs the PR’s code with a read-only token and no secrets when it comes from a fork. |
pull_request_target | Runs the default branch’s workflow with secrets. Safe only if it never runs the PR’s code. |
workflow_run | Runs a workflow (from the default branch) after another workflow completes. Used to pass data from the unprivileged to the privileged side. |
| Check run | A status attached to a commit, shown on the Pull Request. How Tier 2 reports back. |
| Environment | A GitHub setting that holds secrets together with rules for who and what may use them. |
| Ephemeral instance | A temporary installation of a protected resource created for one test run and destroyed afterwards. |
| Intermediate branch | A branch in the main repository holding a reviewed copy of a PR commit. |
The complete Tier 1 and Tier 2 workflow pair, and the trusted-ref evaluation, that the ecosystem guides build on.
For each language, what to prepare for the Lifecycle scan, and the Tier 1 job that prepares it.
Was this page helpful?
Glad to hear it! Please tell us how we can improve.
Sorry to hear that. Please tell us how we can improve.