fix(release): sign and verify release tags (#1902)

This commit is contained in:
Carlos Florêncio
2026-07-24 09:54:00 +01:00
committed by GitHub
parent cd332ef26b
commit ae6e2f3daa
8 changed files with 459 additions and 13 deletions
+53
View File
@@ -0,0 +1,53 @@
-----BEGIN PGP PUBLIC KEY BLOCK-----
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
yBDmsQ68l2LaHc0B/SBq7yAdGJO4zPtjEAE=
=R1I9
-----END PGP PUBLIC KEY BLOCK-----
@@ -3,6 +3,7 @@
import fnmatch
import re
import subprocess
import unittest
from pathlib import Path
@@ -10,8 +11,19 @@ from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[2]
WORKFLOW_PATH = REPO_ROOT / ".github" / "workflows" / "release.yml"
DIAGNOSTICS_PATH = REPO_ROOT / ".github" / "scripts" / "collect-macos-desktop-diagnostics.sh"
PUBLISH_NPM_PATH = REPO_ROOT / "scripts" / "release" / "publish-npm.sh"
PUBLIC_KEY_PATH = REPO_ROOT / ".github" / "release-signing-key.asc"
RELEASE_PROCESS_PATH = REPO_ROOT / "docs" / "public" / "release-process.md"
LINT_WORKFLOW_PATH = REPO_ROOT / ".github" / "workflows" / "lint-action-pinning.yml"
WORKFLOW = WORKFLOW_PATH.read_text()
DIAGNOSTICS = DIAGNOSTICS_PATH.read_text()
PUBLISH_NPM = PUBLISH_NPM_PATH.read_text()
RELEASE_PROCESS = RELEASE_PROCESS_PATH.read_text()
LINT_WORKFLOW = LINT_WORKFLOW_PATH.read_text()
NORMAL_RELEASE_IF = (
"if: ${{ !inputs.dry_run && !inputs.desktop_validation_only "
"&& inputs.backfill_tag == '' }}"
)
def step_block(name: str) -> str:
@@ -35,6 +47,152 @@ def job_block(name: str) -> str:
class ReleaseWorkflowContractTest(unittest.TestCase):
def test_normal_release_uses_release_environment_and_requires_main(self) -> None:
prepare = job_block("prepare")
self.assertIn(
"environment: ${{ (!inputs.dry_run && !inputs.desktop_validation_only "
"&& inputs.backfill_tag == '') && 'release' || 'release-validation' }}",
prepare,
)
guard = step_block("Require main for normal release")
self.assertIn(NORMAL_RELEASE_IF, guard)
self.assertIn("CURRENT_REF: ${{ github.ref }}", guard)
self.assertIn('if [ "$CURRENT_REF" != "refs/heads/main" ]', guard)
def test_normal_release_validates_signing_identity_after_merge(self) -> None:
prepare = job_block("prepare")
merge = step_block("Create release PR + squash-merge")
public_key = step_block("Validate committed release signing public key")
signing = step_block("Import release tag signing key")
self.assertIn(NORMAL_RELEASE_IF, public_key)
self.assertIn("gpg --batch --with-colons --show-keys .github/release-signing-key.asc", public_key)
self.assertIn("PUBLIC_FINGERPRINT", public_key)
self.assertIn('echo "fingerprint=$PUBLIC_FINGERPRINT" >> "$GITHUB_OUTPUT"', public_key)
self.assertIn(NORMAL_RELEASE_IF, signing)
self.assertIn("id: import_release_gpg", signing)
self.assertIn(
"uses: crazy-max/ghaction-import-gpg@2dc316deee8e90f13e1a351ab510b4d5bc0c82cd # v7.0.0",
signing,
)
self.assertIn("gpg_private_key: ${{ secrets.RELEASE_GPG_PRIVATE_KEY }}", signing)
self.assertIn("passphrase: ${{ secrets.RELEASE_GPG_PASSPHRASE }}", signing)
self.assertIn("git_user_signingkey: true", signing)
self.assertIn("git_tag_gpgsign: true", signing)
validate = step_block("Validate release tag signing identity")
self.assertIn(NORMAL_RELEASE_IF, validate)
self.assertIn("EXPECTED_FINGERPRINT: ${{ vars.RELEASE_GPG_FINGERPRINT }}", validate)
self.assertIn(
"COMMITTED_PUBLIC_FINGERPRINT: ${{ steps.committed_release_gpg.outputs.fingerprint }}",
validate,
)
self.assertIn(
"IMPORTED_FINGERPRINT: ${{ steps.import_release_gpg.outputs.fingerprint }}",
validate,
)
self.assertIn('if [ -z "$EXPECTED_FINGERPRINT" ]', validate)
self.assertIn('if [ "$COMMITTED_PUBLIC_FINGERPRINT" != "$EXPECTED_FINGERPRINT" ]', validate)
self.assertIn('if [ "$IMPORTED_FINGERPRINT" != "$COMMITTED_PUBLIC_FINGERPRINT" ]', validate)
self.assertIn('if [ "$IMPORTED_FINGERPRINT" != "$EXPECTED_FINGERPRINT" ]', validate)
self.assertIn("TAGGER_NAME: ${{ steps.import_release_gpg.outputs.name }}", validate)
self.assertIn("TAGGER_EMAIL: ${{ steps.import_release_gpg.outputs.email }}", validate)
self.assertIn('git config user.name "$TAGGER_NAME"', validate)
self.assertIn('git config user.email "$TAGGER_EMAIL"', validate)
self.assertIn('git config user.signingkey "$IMPORTED_FINGERPRINT"', validate)
tag = step_block("Create and push signed release tag")
self.assertIn(NORMAL_RELEASE_IF, tag)
self.assertIn('git tag -s "$TAG" -m "release: $NEXT"', tag)
self.assertIn('git tag -v "$TAG"', tag)
self.assertLess(tag.index('git tag -s "$TAG"'), tag.index('git tag -v "$TAG"'))
self.assertLess(tag.index('git tag -v "$TAG"'), tag.index('git push origin "$TAG"'))
self.assertNotIn('git tag -a "$TAG"', tag)
self.assertLess(prepare.index(merge), prepare.index(public_key))
self.assertLess(prepare.index(public_key), prepare.index(signing))
self.assertLess(prepare.index(signing), prepare.index(validate))
self.assertLess(prepare.index(validate), prepare.index(tag))
def test_committed_release_key_is_one_public_primary_key_without_secret_material(self) -> None:
result = subprocess.run(
["gpg", "--batch", "--with-colons", "--show-keys", str(PUBLIC_KEY_PATH)],
check=True,
capture_output=True,
text=True,
)
records = [line.split(":") for line in result.stdout.splitlines() if line]
primary_keys = [record for record in records if record[0] == "pub"]
secret_keys = [record for record in records if record[0] in {"sec", "ssb"}]
primary_fingerprints = [
records[index + 1][9]
for index, record in enumerate(records[:-1])
if record[0] == "pub" and records[index + 1][0] == "fpr"
]
self.assertEqual([], secret_keys)
self.assertEqual(1, len(primary_keys))
self.assertEqual(1, len(primary_fingerprints))
self.assertRegex(primary_fingerprints[0], r"^[A-F0-9]{40}$")
def test_release_documentation_declares_the_committed_public_key_fingerprint(self) -> None:
result = subprocess.run(
["gpg", "--batch", "--with-colons", "--show-keys", str(PUBLIC_KEY_PATH)],
check=True,
capture_output=True,
text=True,
)
records = [line.split(":") for line in result.stdout.splitlines() if line]
primary_fingerprints = [
records[index + 1][9]
for index, record in enumerate(records[:-1])
if record[0] == "pub" and records[index + 1][0] == "fpr"
]
documented_fingerprint = re.search(
r"The current key is committed at `\.github/release-signing-key\.asc`; "
r"its full fingerprint is `([A-F0-9]{40})`\.",
RELEASE_PROCESS,
)
self.assertIsNotNone(documented_fingerprint)
self.assertEqual(primary_fingerprints[0], documented_fingerprint.group(1))
def test_release_contract_ci_runs_when_key_or_release_documentation_changes(self) -> None:
for trigger in ("push", "pull_request"):
trigger_block = re.search(
rf" {trigger}:\n.*?(?=\n [a-z_]+:|\nconcurrency:)",
LINT_WORKFLOW,
re.DOTALL,
)
self.assertIsNotNone(trigger_block)
self.assertIn('".github/release-signing-key.asc"', trigger_block.group(0))
self.assertIn('"docs/public/release-process.md"', trigger_block.group(0))
def test_tag_push_recovery_recreates_tag_at_logged_merge_commit(self) -> None:
tag = step_block("Create and push signed release tag")
self.assertIn('MERGE_COMMIT="$(git rev-parse HEAD)"', tag)
self.assertIn('echo "Release merge commit: $MERGE_COMMIT"', tag)
self.assertIn('git checkout --detach $MERGE_COMMIT', tag)
self.assertIn("git tag -s $TAG -m 'release: $NEXT'", tag)
self.assertIn('git tag -v $TAG', tag)
self.assertIn("matches RELEASE_GPG_FINGERPRINT", tag)
self.assertIn('git push origin $TAG', tag)
self.assertNotIn('Recover manually: git push origin $TAG', tag)
def test_npm_publish_preserves_oidc_provenance_for_all_packages(self) -> None:
publish_job = job_block("publish-npm")
self.assertIn("id-token: write", publish_job)
self.assertEqual(PUBLISH_NPM.count("npm publish --access public --provenance"), 2)
for package in (
"@kdlbs/runtime-linux-x64",
"@kdlbs/runtime-linux-arm64",
"@kdlbs/runtime-darwin-x64",
"@kdlbs/runtime-darwin-arm64",
"@kdlbs/runtime-win32-x64",
):
self.assertIn(f'"{package}"', PUBLISH_NPM)
def test_backfill_tag_input_uses_existing_tag_without_recreating_it(self) -> None:
self.assertIn("backfill_tag:", WORKFLOW)
self.assertIn("BACKFILL_TAG: ${{ inputs.backfill_tag }}", WORKFLOW)
@@ -59,7 +217,10 @@ class ReleaseWorkflowContractTest(unittest.TestCase):
for name in (
"Bump version + generate CHANGELOG (in working tree)",
"Create release PR + squash-merge + tag",
"Create release PR + squash-merge",
"Import release tag signing key",
"Validate release tag signing identity",
"Create and push signed release tag",
):
self.assertIn("inputs.backfill_tag == ''", step_block(name))
@@ -9,6 +9,8 @@ on:
- ".github/scripts/lint-action-pinning_test.py"
- ".github/scripts/collect-macos-desktop-diagnostics.sh"
- ".github/scripts/release-workflow-contract_test.py"
- ".github/release-signing-key.asc"
- "docs/public/release-process.md"
pull_request:
branches: [main]
paths:
@@ -17,6 +19,8 @@ on:
- ".github/scripts/lint-action-pinning_test.py"
- ".github/scripts/collect-macos-desktop-diagnostics.sh"
- ".github/scripts/release-workflow-contract_test.py"
- ".github/release-signing-key.asc"
- "docs/public/release-process.md"
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
+128 -9
View File
@@ -24,8 +24,13 @@ name: release
# Each package (kandev + 5 @kdlbs/runtime-* packages) must have this workflow
# configured as its trusted publisher on npmjs.com (Settings → Publishing access).
#
# Required secrets for public releases:
# HOMEBREW_TAP_DEPLOY_KEY — SSH private key for kdlbs/homebrew-kandev deploy key
# Required repository secret for public releases:
# HOMEBREW_TAP_DEPLOY_KEY — SSH private key for kdlbs/homebrew-kandev deploy key
# Required configuration in the `release` environment:
# RELEASE_GPG_PRIVATE_KEY — secret containing the ASCII-armored release tag signing key
# RELEASE_GPG_FINGERPRINT — variable containing the key's full fingerprint
# Optional configuration in the `release` environment:
# RELEASE_GPG_PASSPHRASE — secret containing the private key passphrase
# Optional secrets for signed desktop artifacts. If these are incomplete, the
# workflow still builds unsigned desktop artifacts and warns in release notes.
# APPLE_CERTIFICATE / APPLE_CERTIFICATE_PASSWORD / KEYCHAIN_PASSWORD — macOS Developer ID signing
@@ -68,6 +73,7 @@ jobs:
prepare:
name: Prepare release (bump version, PR, tag)
runs-on: ubuntu-latest
environment: ${{ (!inputs.dry_run && !inputs.desktop_validation_only && inputs.backfill_tag == '') && 'release' || 'release-validation' }}
permissions:
contents: write # push release branch + tag
pull-requests: write # create + merge release PR (required by main ruleset)
@@ -81,6 +87,17 @@ jobs:
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
- name: Require main for normal release
if: ${{ !inputs.dry_run && !inputs.desktop_validation_only && inputs.backfill_tag == '' }}
env:
CURRENT_REF: ${{ github.ref }}
run: |
set -euo pipefail
if [ "$CURRENT_REF" != "refs/heads/main" ]; then
echo "Normal releases must be dispatched from refs/heads/main (got $CURRENT_REF)." >&2
exit 1
fi
- uses: pnpm/action-setup@a8198c4bff370c8506180b035930dea56dbd5288 # v5
with:
version: "9.15.9"
@@ -314,7 +331,7 @@ jobs:
pnpm -C apps install --no-frozen-lockfile
git-cliff --tag "$TAG" -o CHANGELOG.md
- name: Create release PR + squash-merge + tag
- name: Create release PR + squash-merge
if: ${{ !inputs.dry_run && !inputs.desktop_validation_only && inputs.backfill_tag == '' }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -349,23 +366,125 @@ jobs:
# polling for merge completion before the tag steps below.
gh pr merge "$BRANCH" --squash --delete-branch --subject "$MSG" --body ""
# Pull the squashed commit into local main, then tag from it.
# Pull the squashed commit into local main. The signing key is not
# imported until all repository-controlled release work is complete.
git fetch origin main
git checkout main
git reset --hard origin/main
git tag -a "$TAG" -m "release: $NEXT"
- name: Validate committed release signing public key
id: committed_release_gpg
if: ${{ !inputs.dry_run && !inputs.desktop_validation_only && inputs.backfill_tag == '' }}
run: |
set -euo pipefail
KEY_LISTING="$(gpg --batch --with-colons --show-keys .github/release-signing-key.asc)"
SECRET_KEY_RECORDS="$(printf '%s\n' "$KEY_LISTING" | awk -F: '$1 == "sec" || $1 == "ssb" { count++ } END { print count + 0 }')"
PUBLIC_KEY_RECORDS="$(printf '%s\n' "$KEY_LISTING" | awk -F: '$1 == "pub" { count++ } END { print count + 0 }')"
PRIMARY_FINGERPRINTS="$(printf '%s\n' "$KEY_LISTING" | awk -F: '
$1 == "pub" {
if (getline && $1 == "fpr") print $10
}
')"
PRIMARY_FINGERPRINT_COUNT="$(printf '%s\n' "$PRIMARY_FINGERPRINTS" | awk 'NF { count++ } END { print count + 0 }')"
if [ "$SECRET_KEY_RECORDS" -ne 0 ]; then
echo ".github/release-signing-key.asc must not contain secret-key material." >&2
exit 1
fi
if [ "$PUBLIC_KEY_RECORDS" -eq 0 ]; then
echo ".github/release-signing-key.asc must contain one primary public key; none found." >&2
exit 1
fi
if [ "$PUBLIC_KEY_RECORDS" -ne 1 ] || [ "$PRIMARY_FINGERPRINT_COUNT" -ne 1 ]; then
echo ".github/release-signing-key.asc must contain exactly one primary public key and fingerprint." >&2
exit 1
fi
PUBLIC_FINGERPRINT="$PRIMARY_FINGERPRINTS"
if ! [[ "$PUBLIC_FINGERPRINT" =~ ^[A-F0-9]{40}$ ]]; then
echo ".github/release-signing-key.asc contains an invalid primary public-key fingerprint." >&2
exit 1
fi
echo "fingerprint=$PUBLIC_FINGERPRINT" >> "$GITHUB_OUTPUT"
- name: Import release tag signing key
id: import_release_gpg
if: ${{ !inputs.dry_run && !inputs.desktop_validation_only && inputs.backfill_tag == '' }}
uses: crazy-max/ghaction-import-gpg@2dc316deee8e90f13e1a351ab510b4d5bc0c82cd # v7.0.0
with:
gpg_private_key: ${{ secrets.RELEASE_GPG_PRIVATE_KEY }}
passphrase: ${{ secrets.RELEASE_GPG_PASSPHRASE }}
git_user_signingkey: true
git_tag_gpgsign: true
- name: Validate release tag signing identity
if: ${{ !inputs.dry_run && !inputs.desktop_validation_only && inputs.backfill_tag == '' }}
env:
EXPECTED_FINGERPRINT: ${{ vars.RELEASE_GPG_FINGERPRINT }}
COMMITTED_PUBLIC_FINGERPRINT: ${{ steps.committed_release_gpg.outputs.fingerprint }}
IMPORTED_FINGERPRINT: ${{ steps.import_release_gpg.outputs.fingerprint }}
TAGGER_NAME: ${{ steps.import_release_gpg.outputs.name }}
TAGGER_EMAIL: ${{ steps.import_release_gpg.outputs.email }}
run: |
set -euo pipefail
if [ -z "$EXPECTED_FINGERPRINT" ]; then
echo "RELEASE_GPG_FINGERPRINT is required in the release environment." >&2
exit 1
fi
if [ -z "$COMMITTED_PUBLIC_FINGERPRINT" ]; then
echo "The committed release public key did not report a fingerprint." >&2
exit 1
fi
if [ "$COMMITTED_PUBLIC_FINGERPRINT" != "$EXPECTED_FINGERPRINT" ]; then
echo "The committed release public key does not match RELEASE_GPG_FINGERPRINT." >&2
exit 1
fi
if [ -z "$IMPORTED_FINGERPRINT" ]; then
echo "The imported release signing key did not report a fingerprint." >&2
exit 1
fi
if [ "$IMPORTED_FINGERPRINT" != "$COMMITTED_PUBLIC_FINGERPRINT" ]; then
echo "The imported release signing key does not match the committed release public key." >&2
exit 1
fi
if [ "$IMPORTED_FINGERPRINT" != "$EXPECTED_FINGERPRINT" ]; then
echo "The imported release signing key does not match RELEASE_GPG_FINGERPRINT." >&2
exit 1
fi
if [ -z "$TAGGER_NAME" ] || [ -z "$TAGGER_EMAIL" ]; then
echo "The validated release signing key must contain a tagger name and email." >&2
exit 1
fi
git config user.name "$TAGGER_NAME"
git config user.email "$TAGGER_EMAIL"
git config user.signingkey "$IMPORTED_FINGERPRINT"
- name: Create and push signed release tag
if: ${{ !inputs.dry_run && !inputs.desktop_validation_only && inputs.backfill_tag == '' }}
env:
NEXT: ${{ steps.compute.outputs.version }}
TAG: ${{ steps.compute.outputs.tag }}
run: |
set -euo pipefail
MERGE_COMMIT="$(git rev-parse HEAD)"
echo "Release merge commit: $MERGE_COMMIT"
git tag -s "$TAG" -m "release: $NEXT"
git tag -v "$TAG"
# Tag push: retry once for transient network blips. If both attempts
# fail, main already has the bumped commit but no tag exists.
# Recover by manually pushing the tag from any clone pointing at the
# squashed merge commit: git push origin "$TAG"
git push origin "$TAG" || {
echo "Tag push failed; retrying in 5s..." >&2
sleep 5
git push origin "$TAG" || {
echo "ERROR: Tag push failed twice after PR merge succeeded." >&2
echo " main is at $NEXT but the tag was not created on origin." >&2
echo " Recover manually: git push origin $TAG" >&2
echo " Release merge commit: $MERGE_COMMIT" >&2
echo " Recover only with the configured release signing identity:" >&2
echo " git checkout --detach $MERGE_COMMIT" >&2
echo " git tag -s $TAG -m 'release: $NEXT'" >&2
echo " git tag -v $TAG" >&2
echo " Confirm the verified full fingerprint matches RELEASE_GPG_FINGERPRINT, then:" >&2
echo " git push origin $TAG" >&2
exit 1
}
}