ADR 0017 — Sign release tags with keyless Sigstore¶
Context¶
Release Please currently creates the Git tag and GitHub Release through the GitHub API. The resulting lightweight tag is not signed, so a consumer cannot authenticate the source ref independently of GitHub. Issue 59 originally named python-semantic-release, but ADR 0008 has since replaced it with Release Please; the requirement is still to make new release tags signed and verifiable.
Tag provenance is distinct from the existing release records. The CycloneDX SBOM inventories components, GitHub’s build-provenance attestation authenticates the wheel and sdist built by the workflow, and PyPI supplies publication attestations. None authenticates the Git tag object that selects the source revision.
GPG would make GitHub display a Verified badge, but automation would need a
long-lived private key and passphrase in repository secrets, plus key ownership,
rotation, revocation, and recovery procedures. The release workflow already has
a short-lived GitHub Actions OIDC identity for trusted publishing and artifact
attestation, so adding a second long-lived signing identity would move against
the repository’s existing supply-chain model.
Release Please constraints (why this workflow is custom)¶
Release Please has no native tag signing and no clean handoff for external tagging, so a signed tag cannot be produced without taking tag creation away from it:
It has no signing option of any kind (open, unaddressed feature request release-please-action#1171), and it creates the tag through the GitHub REST API — so a local signer such as
gitsign(which hooksgit tag -s) cannot intercept it. A signed tag therefore requires creating the tag locally, outside Release Please.skip-github-release: trueis the only lever that stops Release Please from creating the unsigned tag; it suppresses both the tag and the GitHub Release (there is no tag-only mode — release-please-action#1034), and it leaves the release-PR outputs (release_created,tag_name) unset, so the downstream job cannot rely on them.Under
skip-github-release, Release Please does not complete its ownautorelease: pending→autorelease: taggedlabel transition, and it aborts a later run with “untagged, merged release PRs outstanding” (release-please#1561, still current — closed only by a documentation change). There is no documented handoff for external tagging.
The design below therefore takes over only tag creation — Release Please keeps
owning the version, changelog, and release PR — authorizes on the merged PR’s
autorelease: pending label instead of the unset outputs, and completes Release
Please’s own label transition so its state machine stays consistent. This is
unsupported territory in Release Please; the end-to-end path is validated on the
first real release.
Decision¶
Use keyless Sigstore signing with gitsign for release tags.
Release Please remains the only version, changelog, and release-PR authority.
It runs in PR-only mode (skip-github-release: true, declared once in the
schema-backed release-please-config.json) so it never creates the unsigned tag
or GitHub Release. The canonical
.github/workflows/release-please.yml owns the remaining release boundary; no
parallel release workflow or second version calculation is introduced.
Publication authorization is bound to an authenticated Release Please signal,
not to a raw version change. Only Release Please applies the autorelease: pending
label to its own release PRs, so the workflow resolves the pull request whose
merge produced the push and treats that label’s presence as the release
authorization. A collaborator who can merge ordinary PRs but not the admin-gated
release act cannot forge the label, so a feature PR that merely edits
[project].version cannot trigger a signed tag or a PyPI publish. Because
skip-github-release leaves Release Please’s own state machine expecting the
release to be tagged, the workflow — after creating the signed tag — flips the
merged PR from autorelease: pending to autorelease: tagged, or Release Please
aborts its next run with “untagged, merged release PRs outstanding”
(release-please#1561).
With authorization established, the version/manifest parsing below is a consistency check, never the authorization by itself. Before treating the push as releasable, the workflow must also validate all of the following:
the previous and current revisions are the exact revisions from the trusted
pushevent, and the current revision isgithub.shaonmain;[project].versioninpyproject.tomlis a strict stable SemVer and changed across that push;.release-please-manifest.jsonhas exactly the root package entry (.) and its version equals[project].version; andthe expected tag is exactly
v<version>, preserving Release Please’s existing tag contract.
TOML and JSON are parsed with their real parsers, never sourced or scraped with
shell text matching. Event revisions and derived values cross into shell only
through environment bindings after shape validation; no value is evaluated as
shell code. Release runs are serialized with cancel-in-progress: false, so two
workflow runs cannot race to create or publish the same tag.
The release job creates one signed, annotated tag at the exact current commit with gitsign’s GitHub Actions token provider. It immediately verifies both the signature and the signer policy before any GitHub Release creation, PyPI publication, or artifact upload. The accepted identity is:
certificate identity: https://github.com/RAESystem/env-packs/.github/workflows/release-please.yml@refs/heads/main
OIDC issuer: https://token.actions.githubusercontent.com
The identity is bound to the repository’s path at signing time. A repository
transfer therefore changes it: the OIDC token’s workflow ref is issued for the
repository’s current location, and GitHub’s redirect for a moved repository does
not extend to certificate-identity matching. When this repository moved to
RAESystem/env-packs, a stale value would have failed gitsign verify-tag
closed and blocked every subsequent release (#142).
Tags signed before that move remain valid under the previous identity:
certificate identity: https://github.com/Brad-Edwards/aces-scenario-packs/.github/workflows/release-please.yml@refs/heads/main
Both are legitimate for their own release ranges, and neither is a fallback for
the other – verification asserts one exact identity, so the verifier picks the
one matching the tag rather than trying both until one passes. Any future
transfer must update the workflow, this ADR, SECURITY.md, and
tests/test_release_workflow_tag_signing.py together.
An existing tag during a rerun is acceptable only when it dereferences to the same release commit and passes that exact identity-and-issuer policy. Any other existing tag fails closed. The workflow never deletes, force-updates, or temporarily replaces a release tag. Previously published unsigned tags are historical and are not rewritten; enforcement starts with the first release after this decision.
The GitHub Release must consume the pre-existing remote tag (gh release create --verify-tag) rather than implicitly creating one. Release notes continue to
come from the matching Release Please-owned CHANGELOG.md section; the workflow
must not regenerate a competing changelog or recompute the version from commits.
Notes are passed to gh through a file or standard input, never interpolated
into a command line.
Use least-privilege job boundaries:
the Release Please PR job has
contents: writeandpull-requests: write, but no OIDC or attestation permission;the read-only detection job has
contents: readandpull-requests: readto inspect the merged PR’s labels and the version transition; andthe protected
pypirelease job has thecontents: write,id-token: write, andattestations: writepermissions needed to sign, attest, publish, and attach the release artifacts, pluspull-requests: writesolely to relabel the release PRautorelease: tagged.
No signing key, certificate, passphrase, or new long-lived token is stored.
Git checkout must not persist credentials; GitHub authentication is exposed to
the minimum push/release commands through the existing masked GH_TOKEN
environment binding, never embedded in a remote URL or process argument. The
OIDC token is obtained by gitsign’s native github-actions provider and is never
captured, printed, or passed through workflow outputs.
The gitsign installer/action is SHA-pinned like all third-party actions, and the
gitsign release is explicitly pinned to a reviewed, non-vulnerable version. In
particular, versions before 0.15.0 are forbidden because of
CVE-2026-44310; the workflow
also enforces that floor at runtime and fails closed below it. Verification uses
gitsign verify-tag (the tag-scoped command; gitsign verify targets a commit)
with both expected policy arguments; git verify-tag alone is insufficient
because it does not enforce the expected certificate identity.
The repository’s existing unittest + PyYAML workflow-contract tests guard the
static invariants: PR-only Release Please mode, pinned signer installation,
least-privilege permissions, version/manifest validation, exact tag target,
sign-before-publish ordering, identity-bound verification, and refusal to delete
or force-update tags. This is a workflow contract, not a package schema or a new
runtime validation framework. Final acceptance still requires one real release
whose tag passes the documented command.
Verification contract¶
Consumers fetch the tag and verify the Sigstore signature against the exact workflow identity, not merely against any certificate accepted by Sigstore. Install a supported gitsign version (0.15.0 or newer) using the upstream instructions, then run:
git fetch --tags origin
gitsign verify-tag \
--certificate-identity=https://github.com/RAESystem/env-packs/.github/workflows/release-please.yml@refs/heads/main \
--certificate-oidc-issuer=https://token.actions.githubusercontent.com \
vX.Y.Z
For a tag cut before the repository moved, substitute the previous identity recorded above.
A successful result must report a valid Git signature, Rekor entry, and certificate claims. The release workflow performs the same policy check before publication.
Consequences¶
New release tags authenticate the exact workflow, repository, branch, and target commit without a stored signing key.
Signing and verification depend on GitHub OIDC and the public Sigstore Fulcio and Rekor services; an outage blocks a release rather than producing an unsigned one.
The public transparency record includes public repository/workflow/ref identity. This repository is public, so that disclosure adds no private metadata.
GitHub does not currently render gitsign signatures with its
Verifiedbadge. The documented identity-bound gitsign check, not the web badge, is the acceptance signal.Tag signing, build provenance, PyPI publication attestations, the SBOM, and scenario-pack provenance remain separate concepts with separate verification commands and trust boundaries.