fix(release): sign and verify release tags (#1902)
This commit is contained in:
@@ -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 }}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,6 +65,7 @@ temp/
|
||||
# Keeps dev backend state (db, tasks, worktrees, repos) out of the user's
|
||||
# production ~/.kandev when developing kandev using kandev.
|
||||
/.kandev-dev/
|
||||
/.kandev/
|
||||
|
||||
# Runtime scratch workspaces created for repo-less tasks. The lifecycle
|
||||
# manager places them at <homeDir>/tasks/<workspaceID>/<taskID>; in dev mode
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
# ADR-2026-07-22-gpg-signed-release-tags: GPG-Signed Release Tags
|
||||
|
||||
**Status:** accepted
|
||||
**Date:** 2026-07-22
|
||||
**Area:** infra, workflow
|
||||
|
||||
## Context
|
||||
|
||||
Kandev's release workflow creates annotated `vX.Y.Z` tags after merging the
|
||||
release PR, but those tags do not carry a cryptographic signature. The npm
|
||||
launcher and all five platform runtime packages already publish SLSA provenance
|
||||
through npm trusted publishing, so the remaining source-identity gap is the Git
|
||||
tag used as the release build ref.
|
||||
|
||||
## Decision
|
||||
|
||||
Normal releases must create GPG-signed annotated tags and verify each signature
|
||||
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.
|
||||
|
||||
The repository tag ruleset targets `v*`, enables **Restrict updates** and
|
||||
**Restrict deletions**, leaves **Restrict creations** off, and has an empty
|
||||
bypass list. Backfill reuses the protected tag and must not weaken that rule.
|
||||
The pinned key-import action owns key cleanup through its registered post step
|
||||
on the ephemeral runner.
|
||||
|
||||
GitHub and the repository are the source of truth for the public key. The
|
||||
current key is committed at `.github/release-signing-key.asc`, and its full
|
||||
fingerprint is recorded with the release configuration so operators can verify
|
||||
it before running `git tag -v`. The `release-validation` environment remains
|
||||
an empty default environment, while normal releases use `release`. npm
|
||||
publication continues to use GitHub OIDC trusted publishing, with contract
|
||||
coverage preserving provenance for the launcher and every runtime package.
|
||||
|
||||
No product spec applies because this is a release-process and supply-chain
|
||||
invariant rather than a user-invocable product capability.
|
||||
|
||||
## Consequences
|
||||
|
||||
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.
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
- Sigstore/gitsign keyless signatures were rejected because they require a
|
||||
Sigstore-aware verifier and do not satisfy the established `git tag -v`
|
||||
verification workflow.
|
||||
- Repository access controls alone were rejected because they do not provide a
|
||||
cryptographic trust anchor if a write credential is compromised.
|
||||
- Signing only release commits was rejected because the release tag object can
|
||||
still be independently created or replaced.
|
||||
@@ -60,6 +60,7 @@ Read individual ADRs for full context. Create new ones via `/record decision` or
|
||||
| 2026-07-23-planner-direct-small-work | [Planner Direct Small Work](2026-07-23-planner-direct-small-work.md) | accepted | workflow | 2026-07-23 |
|
||||
| 2026-07-23-post-commit-hook-aware-verification | [Post-Commit Hook-Aware Verification](2026-07-23-post-commit-hook-aware-verification.md) | accepted | workflow | 2026-07-23 |
|
||||
| 2026-07-18-turn-configuration-snapshots | [Attribute runtime configuration to turns](2026-07-18-turn-configuration-snapshots.md) | accepted | backend, frontend | 2026-07-18 |
|
||||
| 2026-07-22-gpg-signed-release-tags | [GPG-signed release tags](2026-07-22-gpg-signed-release-tags.md) | accepted | infra, workflow | 2026-07-22 |
|
||||
| 2026-07-19-reject-mcp-actions-on-raw-websocket | [Reject MCP Actions on the Raw WebSocket](2026-07-19-reject-mcp-actions-on-raw-websocket.md) | accepted | backend, protocol | 2026-07-19 |
|
||||
| 2026-07-19-workspace-symlink-entries | [Treat Nested Workspace Symlinks as Entries](2026-07-19-workspace-symlink-entries.md) | accepted | backend, infra | 2026-07-19 |
|
||||
| 2026-07-20-explicit-local-repository-trust | [Explicit Local Repository Trust](2026-07-20-explicit-local-repository-trust.md) | accepted | backend, frontend | 2026-07-20 |
|
||||
|
||||
@@ -26,9 +26,17 @@ The workflow has four mutually exclusive operating modes:
|
||||
2. Confirm required checks are green on `main` and no release or release PR is active.
|
||||
3. Confirm merged PR titles/commits use the conventional categories consumed by `cliff.toml`. The workflow generates `CHANGELOG.md` and release notes.
|
||||
4. Verify platform-sensitive launcher, agentctl, container, and desktop changes on affected targets.
|
||||
5. Check GitHub/GHCR access, npm trusted publishing, the Homebrew deploy key, and any configured desktop signing/notarization secrets.
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
GitHub and the repository are the source of truth for the release public key. The current key is committed at `.github/release-signing-key.asc`; its full fingerprint is `FFB03BCD68F5BCBBD2D1767A84EAB9CE3B8EF52F`. Before rotating the key, update that repository file and the `release` environment secret and fingerprint variable together. Retain prior public keys and revocation information in repository history so historical signatures remain auditable. Key generation, private-key backup, rotation, and revocation are maintainer responsibilities; never place private key material in the repository.
|
||||
|
||||
Repository administrators must also enforce a GitHub tag ruleset targeting `v*`. Enable **Restrict updates** and **Restrict deletions**, leave **Restrict creations** off, and keep the bypass list empty. Do not relax this ruleset to perform a backfill; backfill must reuse the existing tag. Enable immutable GitHub Releases when the repository supports that setting, but do not treat release immutability as a substitute for tag protection before the release is published.
|
||||
|
||||
For release automation changes, run:
|
||||
|
||||
```bash
|
||||
@@ -44,7 +52,7 @@ 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 create `vX.Y.Z` at the merge commit.
|
||||
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.
|
||||
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.
|
||||
@@ -58,7 +66,9 @@ Base image tags include `X.Y.Z`, `vX.Y.Z`, `sha-*`, and `latest`. Universal tags
|
||||
|
||||
## Signing and updater behavior
|
||||
|
||||
npm uses GitHub OIDC trusted publishers; there is no `NPM_TOKEN` release path. Homebrew requires its repository deploy key.
|
||||
npm uses GitHub OIDC trusted publishers; there is no `NPM_TOKEN` release path. The main `kandev` package and all five `@kdlbs/runtime-*` packages publish provenance attestations. Homebrew requires its repository deploy key.
|
||||
|
||||
Release-tag signing applies to future normal releases only. Backfills reuse the existing tag, and historical unsigned tags are not recreated or moved.
|
||||
|
||||
Desktop OS signing and notarization are conditional on a complete secret set. Without them, the workflow can publish unsigned installers and adds a warning to release notes. Tauri updater signatures are stricter: the workflow publishes updater artifacts and `latest.json` only when the required signed set is complete. Do not claim in-app update availability from the presence of installers alone.
|
||||
|
||||
@@ -79,10 +89,43 @@ After publication, verify:
|
||||
|
||||
Record artifact URLs/digests and the workflow run. Do not treat a successful tag or one working installer as a complete release.
|
||||
|
||||
Obtain the release public key from `.github/release-signing-key.asc` in the GitHub repository and confirm that its fingerprint is `FFB03BCD68F5BCBBD2D1767A84EAB9CE3B8EF52F` before importing it into your GPG keyring and verifying a tag:
|
||||
|
||||
```bash
|
||||
gpg --import .github/release-signing-key.asc
|
||||
git fetch --tags origin
|
||||
git tag -v vX.Y.Z
|
||||
```
|
||||
|
||||
Compare the displayed fingerprint with the repository's release-key fingerprint. To check npm provenance, inspect the registry attestations for the launcher and every runtime package, then verify installed dependency signatures and attestations with npm:
|
||||
|
||||
```bash
|
||||
VERSION=X.Y.Z
|
||||
for package in kandev \
|
||||
@kdlbs/runtime-linux-x64 @kdlbs/runtime-linux-arm64 \
|
||||
@kdlbs/runtime-darwin-x64 @kdlbs/runtime-darwin-arm64 \
|
||||
@kdlbs/runtime-win32-x64; do
|
||||
npm view "$package@$VERSION" dist.attestations
|
||||
done
|
||||
npm audit signatures
|
||||
```
|
||||
|
||||
## Repair a partial release
|
||||
|
||||
First identify exactly which immutable and mutable channels succeeded. Preserve workflow logs, checksums, package versions, image digests, and signing output.
|
||||
|
||||
If both tag push attempts fail after the release PR merges, the workflow logs the exact release merge commit. First confirm that `refs/tags/vX.Y.Z` is absent from the remote. Then, from an audited recovery context authorized by the tag ruleset and using the configured release signing identity, recreate and verify the tag at that exact commit:
|
||||
|
||||
```bash
|
||||
git fetch origin main
|
||||
git checkout --detach <release-merge-commit>
|
||||
git tag -s vX.Y.Z -m "release: X.Y.Z"
|
||||
git tag -v vX.Y.Z
|
||||
git push origin vX.Y.Z
|
||||
```
|
||||
|
||||
Before pushing, confirm that `git tag -v` reports the full `RELEASE_GPG_FINGERPRINT` recorded with `.github/release-signing-key.asc`. An arbitrary clone cannot recover by pushing alone because the locally created tag existed only on the failed ephemeral runner.
|
||||
|
||||
Use `backfill_tag` only for the latest release when shipped source is correct and the failure is missing artifacts or a recoverable publication step. Backfill checks out application source from the tag, validates all version manifests, and uses the current workflow's control-plane helpers to rebuild or reconcile GitHub Release, GHCR, npm, desktop/updater, and Homebrew channels. Existing npm versions are not overwritten.
|
||||
|
||||
Publish a new patch instead when code is defective, an immutable npm package or version-specific image is wrong, manifests disagree, or repair would require changing tagged source. Never delete/reuse a published tag or move an npm version as a routine fix.
|
||||
|
||||
Reference in New Issue
Block a user