fix(release): preflight signing configuration before merge (#1948)
This commit is contained in:
@@ -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))
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user