fix(release): preflight signing configuration before merge (#1948)

This commit is contained in:
Carlos Florêncio
2026-07-25 18:06:49 +01:00
committed by GitHub
parent cbc3b97b29
commit 2d5fedd5d0
4 changed files with 140 additions and 33 deletions
@@ -60,15 +60,55 @@ class ReleaseWorkflowContractTest(unittest.TestCase):
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:
def test_normal_release_preflights_and_revalidates_signing_key_around_merge(
self,
) -> None:
prepare = job_block("prepare")
bump = step_block("Bump version + generate CHANGELOG (in working tree)")
merge = step_block("Create release PR + squash-merge")
public_key = step_block("Validate committed release signing public key")
preflight_public_key = step_block("Validate committed release signing public key")
preflight = step_block("Preflight release tag signing fingerprint")
merged_public_key = step_block("Validate merged 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, preflight_public_key)
self.assertIn(
"gpg --batch --with-colons --show-keys .github/release-signing-key.asc",
preflight_public_key,
)
self.assertIn("SECRET_KEY_RECORDS", preflight_public_key)
self.assertIn("PUBLIC_KEY_RECORDS", preflight_public_key)
self.assertIn("PRIMARY_FINGERPRINT_COUNT", preflight_public_key)
self.assertIn('if [ "$SECRET_KEY_RECORDS" -ne 0 ]', preflight_public_key)
self.assertIn('if [ "$PUBLIC_KEY_RECORDS" -eq 0 ]', preflight_public_key)
self.assertIn('if [ "$PUBLIC_KEY_RECORDS" -ne 1 ]', preflight_public_key)
self.assertIn('[[ "$PUBLIC_FINGERPRINT" =~ ^[A-F0-9]{40}$ ]]', preflight_public_key)
self.assertIn("PUBLIC_FINGERPRINT", preflight_public_key)
self.assertIn(
'echo "fingerprint=$PUBLIC_FINGERPRINT" >> "$GITHUB_OUTPUT"',
preflight_public_key,
)
self.assertIn(NORMAL_RELEASE_IF, preflight)
self.assertIn("EXPECTED_FINGERPRINT: ${{ vars.RELEASE_GPG_FINGERPRINT }}", preflight)
self.assertIn(
"COMMITTED_PUBLIC_FINGERPRINT: ${{ steps.committed_release_gpg.outputs.fingerprint }}",
preflight,
)
self.assertIn('if [ -z "$EXPECTED_FINGERPRINT" ]', preflight)
self.assertIn('if [ -z "$COMMITTED_PUBLIC_FINGERPRINT" ]', preflight)
self.assertIn(
'if [ "$COMMITTED_PUBLIC_FINGERPRINT" != "$EXPECTED_FINGERPRINT" ]',
preflight,
)
self.assertIn(NORMAL_RELEASE_IF, merged_public_key)
self.assertIn("id: merged_release_gpg", merged_public_key)
self.assertIn(
"gpg --batch --with-colons --show-keys .github/release-signing-key.asc",
merged_public_key,
)
self.assertIn('echo "fingerprint=$PUBLIC_FINGERPRINT" >> "$GITHUB_OUTPUT"', merged_public_key)
self.assertIn(NORMAL_RELEASE_IF, signing)
self.assertIn("id: import_release_gpg", signing)
self.assertIn(
@@ -84,7 +124,7 @@ class ReleaseWorkflowContractTest(unittest.TestCase):
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 }}",
"MERGED_PUBLIC_FINGERPRINT: ${{ steps.merged_release_gpg.outputs.fingerprint }}",
validate,
)
self.assertIn(
@@ -92,8 +132,8 @@ class ReleaseWorkflowContractTest(unittest.TestCase):
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 [ "$MERGED_PUBLIC_FINGERPRINT" != "$EXPECTED_FINGERPRINT" ]', validate)
self.assertIn('if [ "$IMPORTED_FINGERPRINT" != "$MERGED_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)
@@ -109,8 +149,12 @@ class ReleaseWorkflowContractTest(unittest.TestCase):
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(preflight_public_key), prepare.index(preflight))
self.assertLess(prepare.index(preflight), prepare.index(bump))
self.assertLess(prepare.index(bump), prepare.index(merge))
self.assertLess(prepare.index(merge), prepare.index(merged_public_key))
self.assertLess(prepare.index(merged_public_key), prepare.index(signing))
self.assertLess(prepare.index(merge), prepare.index(signing))
self.assertLess(prepare.index(signing), prepare.index(validate))
self.assertLess(prepare.index(validate), prepare.index(tag))
+64 -9
View File
@@ -293,6 +293,61 @@ jobs:
exit 1
fi
- 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: Preflight release tag signing fingerprint
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 }}
run: |
set -euo pipefail
if [ -z "$EXPECTED_FINGERPRINT" ]; then
echo "RELEASE_GPG_FINGERPRINT is required as a variable 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
- name: Bump version + generate CHANGELOG (in working tree)
if: ${{ !inputs.dry_run && !inputs.desktop_validation_only && inputs.backfill_tag == '' }}
env:
@@ -372,8 +427,8 @@ jobs:
git checkout main
git reset --hard origin/main
- name: Validate committed release signing public key
id: committed_release_gpg
- name: Validate merged release signing public key
id: merged_release_gpg
if: ${{ !inputs.dry_run && !inputs.desktop_validation_only && inputs.backfill_tag == '' }}
run: |
set -euo pipefail
@@ -421,7 +476,7 @@ jobs:
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 }}
MERGED_PUBLIC_FINGERPRINT: ${{ steps.merged_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 }}
@@ -431,20 +486,20 @@ jobs:
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
if [ -z "$MERGED_PUBLIC_FINGERPRINT" ]; then
echo "The merged 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
if [ "$MERGED_PUBLIC_FINGERPRINT" != "$EXPECTED_FINGERPRINT" ]; then
echo "The merged 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
if [ "$IMPORTED_FINGERPRINT" != "$MERGED_PUBLIC_FINGERPRINT" ]; then
echo "The imported release signing key does not match the merged release public key." >&2
exit 1
fi
if [ "$IMPORTED_FINGERPRINT" != "$EXPECTED_FINGERPRINT" ]; then
@@ -19,11 +19,16 @@ in the release runner before pushing the tag. The signing key and optional
passphrase are supplied through a GitHub Environment restricted to `main`.
The environment has no required reviewer, so any maintainer authorized to
dispatch the workflow and access the environment may start a normal release.
The workflow imports the key only
after merging the release PR, requires its full fingerprint to match the
environment's expected fingerprint, and adopts the validated key identity for
the tagger. Missing, invalid, or mismatched signing material fails the release
before tag publication. Historical and backfill tags remain unchanged.
Before changing version files or creating the release PR, the workflow validates
that the committed public key is well-formed and that its fingerprint matches
the environment's expected fingerprint. It imports the private key only after
merging the release PR, after revalidating the public key from the merged
revision. The imported key must match the merged public key and the expected
fingerprint before its identity is adopted for the tagger. Missing, invalid, or
mismatched public configuration therefore fails before `main` is mutated;
invalid private signing material or a key rotation during the release PR still
fails before tag publication.
Historical and backfill tags remain unchanged.
The repository tag ruleset targets `v*`, enables **Restrict updates** and
**Restrict deletions**, leaves **Restrict creations** off, and has an empty
@@ -47,11 +52,14 @@ invariant rather than a user-invocable product capability.
Future release tags can be tied to a maintained signing identity and checked
against the public key stored in the repository. Normal releases depend on the
`release` environment, the configured expected fingerprint, and valid GPG
signing secrets, while dry runs, desktop validation, and backfills use the empty
`release-validation` environment and do not import the private key. Key
generation, rotation, revocation, GitHub identity association, and environment
configuration remain maintainer operations; public-key distribution is owned
by the repository.
signing secrets. Public-key and fingerprint configuration errors are caught
before the workflow creates a release commit, while a fresh check after merge
binds the signer to the public key in the tag's revision. Private-key import
remains deliberately deferred until after that commit merges. Dry runs, desktop
validation, and backfills use the empty `release-validation` environment and do
not import the private key. Key generation, rotation, revocation, GitHub
identity association, and environment configuration remain maintainer
operations; public-key distribution is owned by the repository.
## Alternatives Considered
+3 -3
View File
@@ -29,7 +29,7 @@ The workflow has four mutually exclusive operating modes:
5. Check GitHub/GHCR access, npm trusted publishing, the release-tag GPG key, the Homebrew deploy key, and any configured desktop signing/notarization secrets.
6. Update public docs for behavior that is about to ship.
Normal releases require a dedicated release-tag signing identity and a GitHub Environment named `release`. Restrict that environment to deployments from `main`, but do not configure required reviewers: any maintainer authorized to dispatch the workflow and access the environment may start a normal release. Store the ASCII-armored private key as the environment secret `RELEASE_GPG_PRIVATE_KEY`, its optional passphrase as the environment secret `RELEASE_GPG_PASSPHRASE`, and the exact full fingerprint as the required environment variable `RELEASE_GPG_FINGERPRINT`. Before importing the private key, the workflow rejects a missing, multi-key, or secret-material public-key attachment and requires its sole primary fingerprint to match `RELEASE_GPG_FINGERPRINT`; it then requires the imported private key to match both values. Do not define this signing material as repository-wide configuration.
Normal releases require a dedicated release-tag signing identity and a GitHub Environment named `release`. Restrict that environment to deployments from `main`, but do not configure required reviewers: any maintainer authorized to dispatch the workflow and access the environment may start a normal release. Store the ASCII-armored private key as the environment secret `RELEASE_GPG_PRIVATE_KEY`, its optional passphrase as the environment secret `RELEASE_GPG_PASSPHRASE`, and the exact full fingerprint as the required environment variable `RELEASE_GPG_FINGERPRINT`. Before changing version files or creating the release PR, the workflow rejects a missing, multi-key, or secret-material public-key attachment and requires its sole primary fingerprint to match `RELEASE_GPG_FINGERPRINT`. After the release PR merges, it revalidates the public key from the merged revision, imports the private key, and requires that key to match both the merged key and `RELEASE_GPG_FINGERPRINT`. Do not define this signing material as repository-wide configuration.
The `prepare` job selects its environment by mode: normal releases use `release`, while dry runs, desktop validation, and backfills use `release-validation`. Leave `release-validation` as an empty default environment with no secrets, variables, deployment restrictions, or protection rules so validation and repair modes remain usable without access to the signing identity.
@@ -51,8 +51,8 @@ Use dry run to validate version/changelog preparation. Use desktop validation to
Normal mode performs these stages:
1. **Prepare version.** Compute the next version from packages and tags. Update the CLI package/lock, desktop package and Tauri/Cargo manifests, and `CHANGELOG.md`.
2. **Merge and tag.** Open a release branch and PR, squash-merge it, then import the protected signing key. The workflow requires the imported key's full fingerprint to exactly match `RELEASE_GPG_FINGERPRINT`, adopts that key's name and email as the tagger identity, and locally verifies the GPG-signed `vX.Y.Z` tag before pushing it.
1. **Preflight and prepare version.** Compute the next version from packages and tags, then verify that the committed public key matches the `release` environment fingerprint before changing release files. Update the CLI package/lock, desktop package and Tauri/Cargo manifests, and `CHANGELOG.md`.
2. **Merge and tag.** Open a release branch and PR, squash-merge it, then revalidate the public key from the merged revision before importing the protected signing key. The workflow requires the imported key's full fingerprint to exactly match that merged key and `RELEASE_GPG_FINGERPRINT`, adopts that key's name and email as the tagger identity, and locally verifies the GPG-signed `vX.Y.Z` tag before pushing it.
3. **Build web and runtimes.** Build the SPA and five runtime targets: Linux x64/arm64, macOS x64/arm64, and Windows x64. Each archive contains `kandev`, the host `agentctl`, and required remote agentctl helpers; the workflow produces an adjacent checksum for each archive.
4. **Build desktop.** Embed the matching runtime and package the same five platform/architecture targets into macOS, Linux, and Windows installer formats.
5. **Build containers.** Publish amd64/arm64 base manifests, enforce the universal-image size gate, then publish multi-architecture universal images.