Why a valid signature is not a release decision

A package named v2.4.1 can be built from the wrong commit even when the tag has a valid signature. The signature proves that a key signed a tag object; it does not, by itself, prove that the key was authorized or that the tag points to the commit your release was meant to ship.

That distinction matters when a release workflow selects a tag by name and builds whatever it resolves to. Git tag names are references, and references can be moved. A successful signature check is meaningful only when you also establish who signed the tag and which commit it identifies.

Use this release decision rule: accept a tag only if it is annotated, its signature verifies, the signing key matches an independently maintained allowlist, and its peeled commit equals an approved full commit ID. Then build from that commit ID, not from a name that could resolve differently later.

Know what the tag signature covers

An annotated tag is a Git object containing a target, tag name, tagger information, a message, and, when signed, a signature. A lightweight tag is just a reference to an object; it has no tag object to carry a signature. The command git show <tag> can display an annotated tag’s metadata and, depending on the tag and options, its target commit.

The signature authenticates the tag object’s contents. That connects a signing key to the target recorded in that particular tag object, but it does not establish that the key belongs to your release team. Nor does it prove that a package uploaded to a registry was built from that target. Those are separate checks.

QuestionEvidence to check
Is this a signed annotated tag?The tag object exists and Git verifies its signature.
Is the signer authorized?The signing-key fingerprint matches your release policy.
Is this the intended source?The tag resolves to the independently approved full commit ID.
Is this the package you intended?The build and publishing process is tied to that commit and its outputs.

Fetch the tag into a clean workspace

Start with a clean CI checkout or a fresh clone. That avoids trusting a local tag left behind by an earlier job. Set the exact tag name in the job rather than discovering a release tag with a broad pattern such as “latest”; a pattern can select a different tag than the one reviewed.

For a fresh checkout, fetch only the named tag from the expected remote. This example assumes the remote is named origin and the tag is v2.4.1:

git fetch --no-tags origin "refs/tags/v2.4.1:refs/tags/v2.4.1"

Record the repository identity and the tag name in the release job’s logs. The tag name is useful for humans, but it is not a trusted source identifier by itself. If a workflow reuses workspaces, clean or reject an existing tag before fetching; otherwise, a fetch can encounter a local reference that does not represent the object you expected to inspect.

Confirm that the tag is annotated

Check the object type before attempting signature verification. A signed annotated tag resolves first to an object of type tag; a lightweight tag usually resolves directly to a commit. If the first command prints commit, stop the release rather than treating the tag name as signed provenance.

git cat-file -t v2.4.1

For an annotated tag, inspect its metadata and the object it names. This is useful for review and debugging, but reading the tag with git show is not a substitute for cryptographic verification.

git show --no-patch --format=fuller v2.4.1

The expected target type for a normal source release is a commit. Tags can point to other objects, including another tag, so check the peeled commit explicitly in the next steps rather than assuming the displayed name tells the whole story.

Verify the tag’s cryptographic signature

Run Git’s verification command against the exact fetched tag. Git uses the configured signing backend, such as GPG, to check the signature. The command must exit successfully; do not turn verification output into a warning and continue publishing.

git verify-tag v2.4.1

For a quick manual inspection, git tag -v v2.4.1 is another Git command that verifies and reports the tag signature. A successful result means the signature matches the tag object under the verification setup available to that machine. It does not mean the signer is permitted to release this package.

Keep verification in the same job that makes the publish decision. A developer checking the tag on a laptop and a separate, unverified CI job publishing the package leaves a gap: the publishing job has not established that it is acting on the object the developer reviewed.

Authorize the signer, not merely the signature

For a concrete GPG policy, pin the full signing-key fingerprint in protected release configuration. Require the fingerprint reported for the valid signature to match that value exactly. Do not authorize by display name or email address: those are identity labels, not unique cryptographic key identifiers. If your organization permits multiple release keys, keep an explicit list and update it through the same reviewed process as other release permissions.

For automation, git verify-tag --raw passes GPG’s machine-readable status output through to standard error. The following Bash example accepts only one valid signature from the configured fingerprint. Replace the example values with values supplied by protected CI configuration and your approved release record.

set -euo pipefail
tag="v2.4.1"
expected_signer="0123456789ABCDEF0123456789ABCDEF01234567"
status_file="$(mktemp)"
trap 'rm -f "$status_file"' EXIT

if ! git verify-tag --raw "$tag" 2>"$status_file"; then
  cat "$status_file" >&2
  echo "Tag signature verification failed" >&2
  exit 1
fi

actual_signer="$(awk '$1 == "[GNUPG:]" && $2 == "VALIDSIG" { print $3 }' "$status_file")"
if [ "$actual_signer" != "$expected_signer" ]; then
  echo "Tag was not signed by the approved key" >&2; exit 1
fi

This example is specifically for a GPG-backed Git signing setup. If you use SSH signing instead, define and enforce an equivalent allowlist of approved signing keys using your SSH allowed-signers policy; do not assume that a GPG fingerprint check applies to every Git signing format.

Compare the peeled commit with an approved ID

Next, resolve the tag to the commit it ultimately names. The ^{commit} suffix asks Git to peel the tag and require a commit object. The resulting full object ID—not the version string—is what you compare with the commit approved for the release.

git rev-parse "v2.4.1^{commit}"

Get the expected ID from an independent approval point: for example, a reviewed release manifest or a protected CI variable set during release approval. Do not calculate the “expected” ID by resolving the same tag again; that merely repeats the lookup and cannot detect a tag that points somewhere unexpected.

Then make the comparison a hard gate. This example uses an illustrative full commit ID; substitute the ID your release review approved.

tag="v2.4.1"
expected_commit="7d44b6bb8abb96dee33f32610f56441496d77e8a"
actual_commit="$(git rev-parse "${tag}^{commit}")"

if [ "$actual_commit" != "$expected_commit" ]; then
  echo "Tag does not point to the approved commit" >&2; exit 1
fi

Use the full object ID recorded by your repository and release process. A shortened ID is convenient in conversation, but full IDs make the comparison explicit and avoid relying on an abbreviation that could become ambiguous.

Build and publish from the verified commit

After both checks pass, check out the approved commit in detached mode and run the package build from there. This avoids asking later build steps to resolve the tag name again. The release log can record the tag, signer fingerprint, and full commit ID as separate facts.

git checkout --detach "$expected_commit"

Then run the repository’s normal build and publish commands from that checkout. For an npm package, for example, the build and publish commands depend on the project’s scripts and registry configuration; the important control is that they execute only after the checks and from the pinned commit. A signed tag does not prove that a registry artifact matches its source. Reproducible builds or a build attestation are needed if you also need evidence connecting the published archive to the verified source.

This is the practical gap between release naming and provenance. A GitHub release page can associate notes and assets with a tag, and automation can generate those notes, but that association is not a replacement for checking the tag object and the commit your build consumes.

Turn the checks into a release gate this week

Put the checks in the publishing job, fail closed, and make the expected signer and commit inputs visible to reviewers. A compact release record can include the repository, tag, approved signer fingerprint, expected commit, actual peeled commit, and build identifier. That makes a failed or disputed release easier to diagnose than a log line that says only “tag verified.”

Start with one package and make these three changes before its next release:

  1. Require an annotated tag. Reject a tag whose object type is not tag.
  2. Require an authorized signer. Verify the signature and compare its fingerprint against a protected allowlist.
  3. Require the approved source. Compare the peeled commit with a full ID approved independently, then build from that ID.

These checks add a little release configuration and key-maintenance work. That is the tradeoff: key rotations need a deliberate allowlist update, and release approval must capture a commit ID rather than only a version label. In return, the job can distinguish “a validly signed tag exists” from “this authorized release key signed the intended source, and this is the source we are publishing.”