Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 43 additions & 7 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,21 @@ jobs:
[ -n "$fpr" ] || { echo "::error::GPG_SIGNING_KEY did not import — is it truncated?"; exit 1; }
echo "GPG_FPR=$fpr" >> "$GITHUB_ENV"

# The tagger address is READ FROM THE KEY, never hardcoded. It is not a preference: GitHub marks
# a tag Verified only when the tagger email, an email on the registered key's uid, and a verified
# account email all agree, so the only address that can possibly work is the one this key carries.
# Deriving it also keeps the address out of the repository — the project deliberately publishes no
# contact email anywhere (CHANGELOG 5.0.0) — and makes rotating the key sufficient on its own.
email=$(gpg --list-keys --with-colons "$fpr" \
| awk -F: '/^uid:/ {print $10; exit}' | sed -n 's/.*<\(.*\)>.*/\1/p')
if [ -z "$email" ]; then
echo "::error::The CI signing key carries no email in its uid, so any tag signed with it will"
echo "::error::show as UNVERIFIED. This is exactly how v5.0.0 shipped. Regenerate the key with"
echo "::error::scripts/gen-ci-signing-key.sh and re-register it: scripts/bootstrap-ci.sh."
exit 1
fi
echo "GPG_EMAIL=$email" >> "$GITHUB_ENV"

# Signed with the CI key, NOT the maintainer's YubiKey — which cannot sign inside a runner, and whose
# non-exportability is exactly what makes it worth trusting. The chain still terminates in hardware
# because the CI key is certified by it. The claims therefore shift, and SECURITY.md says so: the tag
Expand All @@ -226,10 +241,17 @@ jobs:
> /tmp/gpg-loopback
chmod +x /tmp/gpg-loopback

# A bot identity, not a person: this tag is not a human's assertion and must not look like one.
# The noreply address is required by git and is not anyone's mailbox.
git config user.name 'github-actions[bot]'
git config user.email 'github-actions[bot]@users.noreply.github.com'
# The tagger email is NOT a stylistic choice — it is what decides whether GitHub will ever show
# this tag as Verified. Marking a signature verified requires three things to agree: the tagger
# email, an email in a uid of the registered key, and a verified email on the account. This used
# to tag as `github-actions[bot]@users.noreply.github.com`, an address that can never appear on
# anyone's key, so the signature was valid and the badge was impossible. v5.0.0 shipped that way.
#
# It therefore uses the address carried by the signing key itself, resolved in the import step
# above. The identity is still unmistakable, because the key's uid names itself as the CI key and
# the signature is made by the CI key, not the hardware one — SECURITY.md keeps that distinction.
git config user.name 'Claude Code Native CI'
git config user.email "$GPG_EMAIL"
git config gpg.program /tmp/gpg-loopback
git config user.signingkey "$GPG_FPR"

Expand Down Expand Up @@ -269,9 +291,23 @@ jobs:
GH_TOKEN: ${{ github.token }}
TAG: ${{ needs.guard.outputs.tag }}
run: |
# The newest section of RELEASE_NOTES.md: from the first "## v" heading to the next one. Same
# source build.gradle.kts reads for the Marketplace "What's New" panel, so they cannot drift.
awk '/^## v/{if(seen)exit; seen=1} seen' RELEASE_NOTES.md > /tmp/notes.md
# The newest section of CHANGELOG.md — from the first "## [x.y.z]" heading to the next one.
#
# It used to read RELEASE_NOTES.md, which is the Marketplace copy: emoji-led, second person,
# "one more thing". That register belongs on a storefront page where someone is deciding whether
# to install; it is the wrong document to hand a person who arrived at a GitHub Release because
# something broke and they need to know what changed. Those are different readers, so they now
# get different documents: CHANGELOG.md here, RELEASE_NOTES.md on the Marketplace panel that
# build.gradle.kts still feeds.
#
# Sized before switching: the 5.0.0 section is ~27 KB against GitHub's 125 000-character limit.
awk '/^## \[/{if(seen)exit; seen=1} seen' CHANGELOG.md > /tmp/notes.md
[ -s /tmp/notes.md ] || {
echo "::error::No section found in CHANGELOG.md. The heading format is '## [x.y.z] — date';"
echo "::error::if that changed, this extraction changed with it and the release would go out"
echo "::error::with empty notes rather than fail — which is why this check exists."
exit 1
}
# NB the heredoc body stays indented to this block's level: YAML strips the common indentation,
# so the emitted markdown is flush-left. An unindented line here (a bare `---`, say) would end
# the block scalar and be read as a YAML document separator.
Expand Down
22 changes: 11 additions & 11 deletions docs/ci-signing-key.asc
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
-----BEGIN PGP PUBLIC KEY BLOCK-----

mDMEanNlBRYJKwYBBAHaRw8BAQdAyRg3jhh+IuekRayUcDmgQgTHJNjbtRacv5Fj
STWNNdK0SUNsYXVkZSBDb2RlIE5hdGl2ZSBDSSAocmVsZWFzZSBhcnRpZmFjdHMg
b25seSDigJQgTk9UIHRoZSBtYWludGFpbmVyIGtleSmImQQTFgoAQRYhBIHdxQ/r
WupPJSbioIvP0huNQLU4BQJqc2UFAhsDBQkB4TOABQsJCAcCAiICBhUKCQgLAgQW
AgMBAh4HAheAAAoJEIvP0huNQLU4p9EA/2zqIcTJZZrHyhRrF6voaZo/D/eH37PO
UxEuIc/Kwi3lAP9qQgz0U3wSL9UKknGH9sTSvl8wcuiDlhSXThRBHljrAIiVBBAT
CQAdFiEEbNMGdWEyxv3e6Ip0zQwS2DwEQ1oFAmpzZQcACgkQzQwS2DwEQ1qZVwGA
ksBT/+Lrn0CXd5kDWZHiOvLhXUDKhthi8P/Tfdudk+JF3AZmiZeQwXuI2hYQ+As/
AX9uiO4q5kjN06xLxQShpyLb/+uLO3NHCivxSiSgBlNgsTLLcUDSuCCYTa73zklN
Rk8=
=HS6V
mDMEanQWphYJKwYBBAHaRw8BAQdA6TIfb0hlLWMPUFPN16vEP50s1+akZV1g8Atv
2R3i+qe0ZkNsYXVkZSBDb2RlIE5hdGl2ZSBDSSAocmVsZWFzZSBhcnRpZmFjdHMg
b25seSDigJQgTk9UIHRoZSBtYWludGFpbmVyIGtleSkgPGxhaW4uYWdlbnQ2MDRA
cGFzc21haWwuY29tPoiZBBMWCgBBFiEEtdQO2CTk4PX0gJosPUJ9JHMSuuEFAmp0
FqYCGwMFCQHhM4AFCwkIBwICIgIGFQoJCAsCBBYCAwECHgcCF4AACgkQPUJ9JHMS
uuEexwEA+RC8tUJQff3hBbMz4eG6Ii/z72omr22GEWK+5r18srgBANdYduOQakpx
iGvGtuH1UhGpx47m/G8ohyMUCRXI9fgNiJUEEBMJAB0WIQRs0wZ1YTLG/d7oinTN
DBLYPARDWgUCanQWqAAKCRDNDBLYPARDWokFAYDtpKeEpAwlFO018Lrh5oULk/sw
ZRV4tFvNpusIT5ZCIQdCnj0lD4yEYDdviZDZ98EBf1io9kZexAJOuftZUhMT0lbq
k2NxLs4i8xqI9LoHRe1/VL3GJjkD3paqYS0jeKQ8qA==
=S9+K
-----END PGP PUBLIC KEY BLOCK-----
100 changes: 73 additions & 27 deletions scripts/bootstrap-ci.sh
Original file line number Diff line number Diff line change
Expand Up @@ -72,37 +72,48 @@ echo "environment: $ENVIRONMENT"
# --- 1. environment ------------------------------------------------------------------------------------
say "1/6 Deployment environment"

REVIEWER_ID=$(gh api user -q .id)
REVIEWER_LOGIN=$(gh api user -q .login)
info "required reviewer: $REVIEWER_LOGIN ($REVIEWER_ID)"

# NO required reviewer, deliberately, and this reverses an earlier decision rather than overlooking one.
#
# The approval existed as the human gate on an irreversible publish. On a single-maintainer repository it
# was not buying that: this account is the ONLY collaborator, `main` is protected and accepts nothing but
# pull requests, and the merge of that pull request is already a deliberate human act. The approval added
# a second click by the same person, moments later, over the same decision.
#
# What is genuinely lost is named rather than glossed: an automated publish now follows a merge without
# anyone confirming which VERSION is about to go out. The remaining guards are the reviewed pull request
# into main, the lineage assertion in release.yml's `guard` job, and the fact that `guard` refuses to
# publish a version whose tag already exists.
#
# The body is built with jq and piped in, rather than assembled from -f/-F flags. Two reasons, both
# learned the hard way: gh's `-f` sends STRINGS (so `-f wait_timer=0` is rejected as `"0"` is not an
# integer) while `-F` guesses the type, and the bracket syntax for an array of objects
# (`reviewers[][type]=`) is ambiguous enough that it is not worth relying on. A JSON document has
# exactly one meaning.
#
# prevent_self_review=false is REQUIRED here, not an oversight. You push the tag, so you are the
# deployment creator; with self-review prevented you would be the one person unable to approve it, and
# nothing would ever publish. On a single-maintainer project that setting is a deadlock, not a control.
jq -n --argjson id "$REVIEWER_ID" '{
# integer) while `-F` guesses the type, and the bracket syntax for an array of objects is ambiguous
# enough that it is not worth relying on. A JSON document has exactly one meaning.
jq -n '{
wait_timer: 0,
prevent_self_review: false,
reviewers: [{ type: "User", id: $id }],
reviewers: [],
deployment_branch_policy: { protected_branches: false, custom_branch_policies: true }
}' | gh api --method PUT "repos/$REPO/environments/$ENVIRONMENT" --input - >/dev/null
info "environment created/updated with a required reviewer"

# Restrict it to release tags: a second lock, independent of the workflow's own lineage guard. The guard
# checks the tag descends from main; this checks the environment is only reachable from a version tag.
if ! gh api "repos/$REPO/environments/$ENVIRONMENT/deployment-branch-policies" \
-q '.branch_policies[].name' 2>/dev/null | grep -qx 'v\*\.\*\.\*'; then
gh api --method POST "repos/$REPO/environments/$ENVIRONMENT/deployment-branch-policies" \
-f 'name=v*.*.*' -f type=tag >/dev/null
info "restricted deployments to v*.*.* tags"
else
info "tag policy v*.*.* already present"
fi
info "environment created/updated — publish runs without a manual approval"

# Which refs may deploy. BOTH are needed and they are not interchangeable:
#
# main the primary path. release.yml triggers on a push to main and derives the tag from
# build.gradle.kts, so at deployment time github.ref is refs/heads/main. Without this entry
# the job is rejected outright with "Branch 'main' is not allowed to deploy to marketplace",
# before it even reaches the workflow — which is exactly how the first attempt failed.
# v*.*.* the manual escape hatch: re-cutting a release by pushing an explicit tag.
for policy in "main:branch" "v*.*.*:tag"; do
name=${policy%:*}; type=${policy##*:}
if gh api "repos/$REPO/environments/$ENVIRONMENT/deployment-branch-policies" \
-q '.branch_policies[].name' 2>/dev/null | grep -qxF "$name"; then
info "deployment policy '$name' already present"
else
gh api --method POST "repos/$REPO/environments/$ENVIRONMENT/deployment-branch-policies" \
-f "name=$name" -f "type=$type" >/dev/null
info "allowed deployments from '$name' ($type)"
fi
done

# --- 2. Marketplace token ------------------------------------------------------------------------------
say "2/6 JetBrains Marketplace token"
Expand Down Expand Up @@ -235,6 +246,34 @@ else

info "wrote docs/ci-signing-key.asc (fingerprint $ci_fpr)"
warn "COMMIT docs/ci-signing-key.asc — without the public key nobody can verify a release."

# --- register the CI key on the GitHub ACCOUNT --------------------------------------------------------
# This step did not exist, and its absence is the whole reason v5.0.0 shipped with an unverified tag.
#
# Certifying the key with the YubiKey (above) makes it trustworthy to a human running `gpg --verify`.
# It does nothing for the "Verified" badge, which is a different mechanism entirely: GitHub marks a
# signature verified only when the tagger email, an email in a uid of a key REGISTERED ON THE ACCOUNT,
# and a verified account email all agree. Certification is not registration, and the two were conflated.
if gh gpg-key list >/dev/null 2>&1; then
if gh gpg-key add docs/ci-signing-key.asc >/dev/null 2>&1; then
info "registered the CI public key on the GitHub account"
else
info "GitHub already knows this key (or rejected it) — verifying below"
fi
# The check that matters. A key registered with NO email can never verify a tag, which is precisely
# the state the previous key was in: `emails=` came back empty and nothing said so.
short=${ci_fpr: -16}
if gh api user/gpg_keys -q ".[] | select(.key_id==\"$short\") | .emails[]?.email" 2>/dev/null | grep -q .; then
info "the registered key carries an email — tags signed with it can be verified"
else
warn "the registered key lists NO email address. Tags signed with it will show as UNVERIFIED."
warn "Regenerate it with gen-ci-signing-key.sh (which now sets Name-Email) and re-run this step."
fi
else
warn "gh lacks the GPG scope, so the key was NOT registered on your account."
warn "Without this the release tag will show as unverified. Run:"
warn " gh auth refresh -s write:gpg_key && gh gpg-key add docs/ci-signing-key.asc"
fi
fi

# --- 5. verify -----------------------------------------------------------------------------------------
Expand All @@ -260,8 +299,15 @@ else
info "no repository-level secrets (correct)"
fi

gh api "repos/$REPO/environments/$ENVIRONMENT" -q \
'" reviewers: " + ([.protection_rules[]? | select(.type=="required_reviewers") | .reviewers[].reviewer.login] | join(", ")) + " | self-review prevented: " + (.prevent_self_review|tostring)'
reviewers=$(gh api "repos/$REPO/environments/$ENVIRONMENT" \
-q '[.protection_rules[]? | select(.type=="required_reviewers") | .reviewers[].reviewer.login] | join(", ")')
if [ -z "$reviewers" ]; then
info "no required reviewer — a merge to main publishes without a second confirmation"
else
warn "required reviewer(s) present: $reviewers — publish will WAIT for approval"
fi
info "deployments allowed from: $(gh api "repos/$REPO/environments/$ENVIRONMENT/deployment-branch-policies" \
-q '[.branch_policies[] | "\(.type):\(.name)"] | join(", ")')"

# --- 6. branch protection ------------------------------------------------------------------------------
say "6/6 Branch protection"
Expand Down
50 changes: 48 additions & 2 deletions scripts/gen-ci-signing-key.sh
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,39 @@ fi
NAME="Claude Code Native CI (release artifacts only — NOT the maintainer key)"
EXPIRY="1y"

# The key MUST carry an email, and it must be the maintainer's verified GitHub address.
#
# The first version of this script set only Name-Real, so the key was generated with no email in its uid
# at all. Everything still worked — artifacts were signed, `gpg --verify` passed — except the one thing
# that is visible to everyone: GitHub never showed the release tag as Verified, and could not, because
# marking a signature Verified requires THREE things to agree (docs: "Associating an email with your GPG
# key"): the tagger's email, an email in a uid of the registered key, and a verified email on the account.
# A key with no email fails the second forever. GitHub's own API reported the key as `emails=` — empty.
#
# Using the maintainer's address here does blunt one edge of the separation this file argues for, so state
# what actually keeps the two keys distinguishable now: the uid NAME says out loud that this is a CI key,
# the key EXPIRES after a year, and it is certified by the hardware key rather than merely asserted. What
# it no longer provides is separation by address — and it never could, because GitHub offers no way to
# verify a tag signed by a key bearing an address that is not yours.
#
# READ from the maintainer key, never written here. The project publishes no contact address anywhere (see
# CHANGELOG 5.0.0), and a committed script is published: hardcoding it would put the address into the
# repository, into every clone, and into the search index — undoing that decision to save one lookup.
# It also cannot drift, since the address that must match is by definition the one on the key.
maintainer_fpr="${CI_KEY_FROM:-$(git config --get user.signingkey || true)}"
[ -n "$maintainer_fpr" ] || {
echo "error: git config user.signingkey is unset, so the maintainer key is unknown." >&2
echo " Set it, or pass the address explicitly: CI_KEY_EMAIL=you@example.com $0" >&2
exit 1
}
EMAIL="${CI_KEY_EMAIL:-$(gpg --list-keys --with-colons "$maintainer_fpr" 2>/dev/null \
| awk -F: '/^uid:/ {print $10; exit}' | sed -n 's/.*<\(.*\)>.*/\1/p')}"
[ -n "$EMAIL" ] || {
echo "error: could not read an email from the maintainer key $maintainer_fpr." >&2
echo " A CI key without an email can never produce a verified tag — that is what this fixes." >&2
exit 1
}

tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT
chmod 700 "$tmp"
Expand All @@ -54,6 +87,7 @@ Key-Type: EDDSA
Key-Curve: ed25519
Key-Usage: sign
Name-Real: $NAME
Name-Email: $EMAIL
Expire-Date: $EXPIRY
Passphrase: $passphrase
%commit
Expand Down Expand Up @@ -84,7 +118,7 @@ CI signing key generated.

Fingerprint : $fpr
Expires : in $EXPIRY (renew or replace before then — see SECURITY.md)
Identity : $NAME
Identity : $NAME <$EMAIL>

--------------------------------------------------------------------------------
1) Add TWO secrets to the 'marketplace' environment
Expand All @@ -111,7 +145,19 @@ CI signing key generated.
gpg --armor --export $fpr > docs/ci-signing-key.asc

--------------------------------------------------------------------------------
4) Never import the PRIVATE half into your keyring. It belongs in exactly one
4) Register the PUBLIC key on your GitHub ACCOUNT, or the release tag will never
show as Verified. This step was missing from earlier versions of this script,
which is exactly how a release shipped with an unverified tag.

gh auth refresh -s write:gpg_key # one-off, interactive
gh gpg-key add docs/ci-signing-key.asc
gh api user/gpg_keys --jq '.[]|"\(.key_id) \(([.emails[]?.email]|join(",")))"'

The last command is the check that matters: if the key lists NO emails, the
tag cannot be verified no matter what else is correct.

--------------------------------------------------------------------------------
5) Never import the PRIVATE half into your keyring. It belongs in exactly one
place — the GitHub environment secret. Keeping it out of your keyring is what
stops it quietly becoming a second maintainer identity.
================================================================================
Expand Down