Skip to content

Harden release CI #28

@ricochet

Description

@ricochet

Why now

A static-analysis pass with zizmor flagged 37 high-severity findings across .github/workflows/, including six template-injection sites in the release workflow itself. Independent of that, the workflow has no tests gating the publish step, so a regression on main ships to Central. To fix, its largely mechanical but they're coupled enough that doing them as one design pass is cleaner than one-off PRs.

Problems in the current release pipeline

Working through .github/workflows/release.yaml and the release profile in pom.xml:

  1. No tests gate the release. Both mvn -Dquickly and mvn deploy ... -DskipTests=true skip the test suite. The dispatch button publishes whatever is on main.
  2. No protected environment around the deploy. workflow_dispatch can be invoked by anyone with workflow write access; the release secrets (GPG_PRIVATE_KEY, SONATYPE_*, GH_TOKEN) are exposed at the repo level.
  3. branch: input is dead. The workflow takes a branch: input but actions/checkout never references it. Dispatching from a non-default branch silently releases main.
  4. Attestations would land AFTER mvn deploy succeeds — if attestation issuance fails, jars are already on Central, unattested. Want: verify → attest the bytes → deploy.
  5. PAT (GH_TOKEN) used to push to main. mvn versions:set + git push bypasses branch protection. The PAT is long-lived and exfiltratable.
  6. GPG key imported twice; once via insecure shell expansion. setup-java already imports the GPG key; the second cat <(echo -e "${{ secrets.GPG_PRIVATE_KEY }}") | gpg --batch --import step expands the secret into a rendered shell command.
  7. mvn -X in the deploy step. Full debug output; some Maven plugins log credentials and signing-key paths at DEBUG.
  8. No SHA pinning on any action. actions/checkout@v6, actions/setup-java@v5, etc. across every workflow. Mutable tags = compromised-publisher / tag-mover risk.
  9. Over-scoped permissions: at the workflow level — unused deployments: write and packages: write.
  10. softprops/action-gh-release creates an empty GH release — no jars, no SBOM, no checksums attached.
  11. Redundant Compile step runs before deploy and is then thrown away by the subsequent clean deploy.
  12. SONATYPE_USERNAME / SONATYPE_PASSWORD — need to confirm these are Central Portal user tokens, not legacy OSSRH credentials.
  13. Two commits to main per release (Release version update X and Snapshot version update). Tag points at a commit that's no longer on main.
  14. No reproducible-build flag. Re-running the workflow produces a different jar SHA each time; attestations only prove "built once at this time," not "this exact bytes."
  15. central-publishing-maven-plugin 0.10.0 — currently the latest, but worth flagging for periodic bumps.
  16. No concurrency group — two simultaneous dispatches will conflict on git pushes.

Proposed changes

1. Test-gated release (P0)

Replace the deploy invocation with a three-phase flow:

  • mvn verify -Drelease — compile, run full test suite, sign (GPG), generate aggregate CycloneDX SBOM. A failure here stops the release before anything is published.
  • actions/attest-build-provenance and actions/attest-sbom against the bytes produced.
  • mvn deploy -Drelease -DskipTests=true -Dgpg.skip=true — reuses the artifacts and signatures from verify.

2. Defer all git pushes until after Central deploy succeeds (P0)

Edit pom.xml versions in the working tree, build, attest, deploy. Only then create the version commit, tag, and push. If anything fails before deploy, the remote is untouched and the dispatch is safe to re-run.

3. maven-central GitHub Environment (P0)

Add environment: maven-central to the release job. Repo admin will need to:

  • Create the environment in repo settings.
  • Add required reviewers.
  • Set a deployment-branch policy restricting refs.
  • Move the release secrets (GPG_PRIVATE_KEY, GPG_PASSPHRASE, SONATYPE_USERNAME, SONATYPE_PASSWORD, GH_TOKEN) onto the environment so they cannot be read by unrelated workflows.

4. SLSA build provenance and SBOM attestations

  • Add actions/attest-build-provenance@<sha> # v3 against **/target/*.{jar,pom,jar.asc,pom.asc}.
  • Add a cyclonedx-maven-plugin execution in the release profile (root-only via <inherited>false</inherited>, makeAggregateBom bound to package).
  • Add actions/attest-sbom@<sha> # v3 against **/target/*.jar with the aggregate target/bom.json.

Configure the SBOM plugin with <includeBomSerialNumber>false</includeBomSerialNumber> so it's deterministic across rebuilds at the same SHA (otherwise the SBOM hash changes between the verify we attest and the deploy re-package). Also <skipNotDeployed>false</skipNotDeployed> because central-publishing sets maven.deploy.skip=true on the aggregator pom.

5. Reproducible builds

Set <project.build.outputTimestamp>1980-02-01T00:00:00Z</project.build.outputTimestamp> in the root pom. Strips timestamps from JAR entries so re-runs at the same SHA produce byte-identical artifacts. Required for SLSA-3 verification to mean what people think it means.

Note: the maven-shade-plugin's intermediate original-*.jar files (never deployed) will not be reproducible. Released artifacts will be.

6. SHA-pin every action

Replace every major-tag reference with a commit SHA + trailing version comment. The actions currently in use, with versions resolved live from each upstream's GitHub repo at the time this issue was drafted:

Action Tag Commit SHA
actions/checkout v6.0.2 de0fac2e4500dabe0009e67214ff5f5447ce83dd
actions/setup-java v5.2.0 be666c2fcd27ec809703dec50e508c2fdc7f6654
actions/setup-node v6.4.0 48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e
actions/upload-artifact v7.0.1 043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
actions/upload-pages-artifact v5.0.0 fc324d3547104276b827a68afc52ff2a11cc49c9
actions/deploy-pages v5.0.0 cd2ce8fcbc39b97be8ca5fce6e763baed58fa128
gradle/actions/setup-gradle v6.1.0 50e97c2cd7a37755bbfafc9c5b7cafaece252f6e
actions/attest-build-provenance v3 977bb373ede98d70efdf65b84cb5f73e068dcc2a
actions/attest-sbom v3 4651f806c01d8637787e274ac3bdf724ef169f34

Versions should be re-resolved at PR time in case there are newer point releases.

7. Dependabot grouping for github-actions

.github/dependabot.yml already has package-ecosystem: github-actions on a daily schedule. With ~9 SHA-pinned actions across the repo, each Dependabot run will fan out into one PR per action. Add a groups: block batching all action bumps into one weekly PR. Dependabot preserves the # vX.Y.Z trailing comment when bumping a SHA reference, so the audit trail stays readable.

8. zizmor findings to fix in release.yaml

Running uvx zizmor .github/workflows/release.yaml:

  • 6 × template-injection (High)${{ github.event.inputs.* }} expanded directly into shell commands. Route inputs through env: vars referenced as "$VAR" so the shell, not the YAML preprocessor, expands them.
  • 1 × superfluous-actions (Info)softprops/action-gh-release is redundant when gh release create is available via the GitHub-supplied gh CLI. Drop the third-party action dependency.

In --persona=auditor mode, additional fixes:

  • 3 × excessive-permissions — scope contents/id-token/attestations: write to the job level. Set permissions: {} at workflow scope as default-deny.
  • undocumented-permissions — add inline trailing comments justifying each permission line.

9. Sigstore keyless signing (getting into only nice-to-have territory)

Add dev.sigstore:sigstore-maven-plugin 1.3.0 to the release profile, bound to verify. Produces <artifact>.sigstore.json bundles OIDC-bound to the workflow identity. These ride with the artifact to Maven Central so consumers can verify without contacting GitHub. In the future, you could phase out maven-gpg although I don't think that's practical in the near-term.

10. Other repo-wide hygiene

  • Remove the redundant Compile step.
  • Remove mvn -X from production.
  • Remove the duplicate gpg --batch --import step (setup-java already handles it).
  • persist-credentials: false on actions/checkout; explicit git remote set-url to inject GH_TOKEN only at the push step.
  • Drop unused deployments: write and packages: write permissions.
  • Attach the deployed jars + sbom-cyclonedx.json + SHA256SUMS to the GitHub release.
  • Add a concurrency: { group: release, cancel-in-progress: false } block.

11. Other workflows still have zizmor findings

Not in scope for the release pipeline rebuild, but worth tracking:

  • 2 High + 2 Medium excessive-permissions in ci.yaml, perf.yaml, deploy-docs.yml (workflow-level perms that should be job-scoped).
  • 14 Low artipacked warnings — every actions/checkout without persist-credentials: false. zizmor --fix=safe handles these mechanically.
  • 7 Info template-injection in perf.yaml${{ needs.<job>.outputs.result-link }} in shell. Low practical risk (input source is a JMH result URL from an upstream job) but the env-var fix is mechanical.

Out of scope (deferred)

R5 / R13 — eliminating the PAT-push-to-main pattern entirely. The proper fix is ${revision} placeholders + flatten-maven-plugin so no version-edit commits are needed, or a release-please-style PR flow. Both touch every module pom and deserve a dedicated design discussion.

Action items requiring repo-admin or human follow-up

  • Create maven-central GitHub Environment with required reviewers + deployment-branch policy. Move the five release secrets onto it.
  • Confirm SONATYPE_USERNAME / SONATYPE_PASSWORD are Central Portal user tokens (not legacy OSSRH credentials).
  • After first release with both GPG and Sigstore signatures, decide on Sigstore phase 2 (drop GPG, retire long-lived key).
  • Decide on R5 / R13 direction (${revision} vs release-please vs status quo).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions