diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2a87b17e..757120eb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 @@ -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" @@ -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. diff --git a/docs/ci-signing-key.asc b/docs/ci-signing-key.asc index e2a2d53c..2e300539 100644 --- a/docs/ci-signing-key.asc +++ b/docs/ci-signing-key.asc @@ -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----- diff --git a/scripts/bootstrap-ci.sh b/scripts/bootstrap-ci.sh index 225695c8..55590748 100755 --- a/scripts/bootstrap-ci.sh +++ b/scripts/bootstrap-ci.sh @@ -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" @@ -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 ----------------------------------------------------------------------------------------- @@ -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" diff --git a/scripts/gen-ci-signing-key.sh b/scripts/gen-ci-signing-key.sh index 98c8ccb7..e5910acf 100755 --- a/scripts/gen-ci-signing-key.sh +++ b/scripts/gen-ci-signing-key.sh @@ -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" @@ -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 @@ -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 @@ -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. ================================================================================