From eb9a7b89342d5a5367fb8a300db8435746e67949 Mon Sep 17 00:00:00 2001 From: Moritz Mazetti Date: Thu, 30 Jul 2026 15:24:05 +0200 Subject: [PATCH] fix(release): verify tag annotation and signature via the API The first v0.1.0 release run rejected a perfectly good tag: annotated, signed, and reported by GitHub as verification "valid". The workflow was wrong, not the tag. `actions/checkout` materialises a tag ref as a LIGHTWEIGHT local tag pointing straight at the commit, so `git cat-file -t v0.1.0` returns `commit` inside a runner even when the real object is an annotated tag. It returns `tag` on a developer machine and the API agrees, which is why this passed every local check and only failed in CI. Both facts now come from the API, which sees the real object: - annotated-ness from .object.type on the ref - signature from .verification.verified on the tag object That also removes a latent second bug. The old code took the tag SHA from `git rev-parse`, which resolves through an annotated tag on some paths, so it could have queried the commit rather than the tag object and found no verification data at all. Nothing was published by the failed run; it stopped in the first job. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01CEdTd43qLEEE5qCsL1A7gW --- .github/workflows/release.yml | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index dd6d67a..156366b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -86,23 +86,30 @@ jobs: echo "Releasing ${version} to dist-tag '${channel}' (prerelease: ${prerelease})" - # An unsigned tag is an unauthenticated instruction to publish. GitHub's own verification - # status is used rather than importing keys here, so the check reflects the same trust GitHub - # shows in the UI. + # An unsigned tag is an unauthenticated instruction to publish. + # + # Both facts come from the API rather than the local repository, because `actions/checkout` + # materialises a tag ref as a LIGHTWEIGHT local tag pointing straight at the commit. So + # `git cat-file -t ` reports `commit` inside a runner even for a properly annotated, + # verified tag — which is exactly what rejected a good v0.1.0 on the first attempt. The API sees + # the real object, and using it also means the signature check reflects the same trust GitHub + # shows in its UI. - name: Tag is annotated and signed by a verified key env: GH_TOKEN: ${{ github.token }} run: | - object_type="$(git cat-file -t "${GITHUB_REF_NAME}")" + ref="$(gh api "repos/${GITHUB_REPOSITORY}/git/ref/tags/${GITHUB_REF_NAME}")" + object_type="$(printf '%s' "${ref}" | jq -r '.object.type')" + object_sha="$(printf '%s' "${ref}" | jq -r '.object.sha')" if [ "${object_type}" != 'tag' ]; then - echo "::error::${GITHUB_REF_NAME} is a lightweight tag; release tags must be annotated and signed" + echo "::error::${GITHUB_REF_NAME} is a lightweight tag (ref points at a ${object_type}); release tags must be annotated and signed" exit 1 fi - tag_sha="$(git rev-parse "${GITHUB_REF_NAME}")" - verified="$(gh api "repos/${GITHUB_REPOSITORY}/git/tags/${tag_sha}" --jq '.verification.verified')" - reason="$(gh api "repos/${GITHUB_REPOSITORY}/git/tags/${tag_sha}" --jq '.verification.reason')" + verification="$(gh api "repos/${GITHUB_REPOSITORY}/git/tags/${object_sha}" --jq '.verification')" + verified="$(printf '%s' "${verification}" | jq -r '.verified')" + reason="$(printf '%s' "${verification}" | jq -r '.reason')" if [ "${verified}" != 'true' ]; then echo "::error::tag signature is not verified (${reason})"