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 fnmatch
import re import re
import subprocess
import unittest import unittest
from pathlib import Path from pathlib import Path
@@ -10,8 +11,19 @@ from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[2] REPO_ROOT = Path(__file__).resolve().parents[2]
WORKFLOW_PATH = REPO_ROOT / ".github" / "workflows" / "release.yml" WORKFLOW_PATH = REPO_ROOT / ".github" / "workflows" / "release.yml"
DIAGNOSTICS_PATH = REPO_ROOT / ".github" / "scripts" / "collect-macos-desktop-diagnostics.sh" 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() WORKFLOW = WORKFLOW_PATH.read_text()
DIAGNOSTICS = DIAGNOSTICS_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: def step_block(name: str) -> str:
@@ -35,6 +47,152 @@ def job_block(name: str) -> str:
class ReleaseWorkflowContractTest(unittest.TestCase): 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: def test_backfill_tag_input_uses_existing_tag_without_recreating_it(self) -> None:
self.assertIn("backfill_tag:", WORKFLOW) self.assertIn("backfill_tag:", WORKFLOW)
self.assertIn("BACKFILL_TAG: ${{ inputs.backfill_tag }}", WORKFLOW) self.assertIn("BACKFILL_TAG: ${{ inputs.backfill_tag }}", WORKFLOW)
@@ -59,7 +217,10 @@ class ReleaseWorkflowContractTest(unittest.TestCase):
for name in ( for name in (
"Bump version + generate CHANGELOG (in working tree)", "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)) self.assertIn("inputs.backfill_tag == ''", step_block(name))
@@ -9,6 +9,8 @@ on:
- ".github/scripts/lint-action-pinning_test.py" - ".github/scripts/lint-action-pinning_test.py"
- ".github/scripts/collect-macos-desktop-diagnostics.sh" - ".github/scripts/collect-macos-desktop-diagnostics.sh"
- ".github/scripts/release-workflow-contract_test.py" - ".github/scripts/release-workflow-contract_test.py"
- ".github/release-signing-key.asc"
- "docs/public/release-process.md"
pull_request: pull_request:
branches: [main] branches: [main]
paths: paths:
@@ -17,6 +19,8 @@ on:
- ".github/scripts/lint-action-pinning_test.py" - ".github/scripts/lint-action-pinning_test.py"
- ".github/scripts/collect-macos-desktop-diagnostics.sh" - ".github/scripts/collect-macos-desktop-diagnostics.sh"
- ".github/scripts/release-workflow-contract_test.py" - ".github/scripts/release-workflow-contract_test.py"
- ".github/release-signing-key.asc"
- "docs/public/release-process.md"
concurrency: concurrency:
group: ${{ github.workflow }}-${{ github.ref }} 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 # Each package (kandev + 5 @kdlbs/runtime-* packages) must have this workflow
# configured as its trusted publisher on npmjs.com (Settings → Publishing access). # configured as its trusted publisher on npmjs.com (Settings → Publishing access).
# #
# Required secrets for public releases: # Required repository secret for public releases:
# HOMEBREW_TAP_DEPLOY_KEY — SSH private key for kdlbs/homebrew-kandev deploy key # 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 # Optional secrets for signed desktop artifacts. If these are incomplete, the
# workflow still builds unsigned desktop artifacts and warns in release notes. # workflow still builds unsigned desktop artifacts and warns in release notes.
# APPLE_CERTIFICATE / APPLE_CERTIFICATE_PASSWORD / KEYCHAIN_PASSWORD — macOS Developer ID signing # APPLE_CERTIFICATE / APPLE_CERTIFICATE_PASSWORD / KEYCHAIN_PASSWORD — macOS Developer ID signing
@@ -68,6 +73,7 @@ jobs:
prepare: prepare:
name: Prepare release (bump version, PR, tag) name: Prepare release (bump version, PR, tag)
runs-on: ubuntu-latest runs-on: ubuntu-latest
environment: ${{ (!inputs.dry_run && !inputs.desktop_validation_only && inputs.backfill_tag == '') && 'release' || 'release-validation' }}
permissions: permissions:
contents: write # push release branch + tag contents: write # push release branch + tag
pull-requests: write # create + merge release PR (required by main ruleset) pull-requests: write # create + merge release PR (required by main ruleset)
@@ -81,6 +87,17 @@ jobs:
fetch-depth: 0 fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }} 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 - uses: pnpm/action-setup@a8198c4bff370c8506180b035930dea56dbd5288 # v5
with: with:
version: "9.15.9" version: "9.15.9"
@@ -314,7 +331,7 @@ jobs:
pnpm -C apps install --no-frozen-lockfile pnpm -C apps install --no-frozen-lockfile
git-cliff --tag "$TAG" -o CHANGELOG.md 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 == '' }} if: ${{ !inputs.dry_run && !inputs.desktop_validation_only && inputs.backfill_tag == '' }}
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -349,23 +366,125 @@ jobs:
# polling for merge completion before the tag steps below. # polling for merge completion before the tag steps below.
gh pr merge "$BRANCH" --squash --delete-branch --subject "$MSG" --body "" 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 fetch origin main
git checkout main git checkout main
git reset --hard origin/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 # Tag push: retry once for transient network blips. If both attempts
# fail, main already has the bumped commit but no tag exists. # 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" || { git push origin "$TAG" || {
echo "Tag push failed; retrying in 5s..." >&2 echo "Tag push failed; retrying in 5s..." >&2
sleep 5 sleep 5
git push origin "$TAG" || { git push origin "$TAG" || {
echo "ERROR: Tag push failed twice after PR merge succeeded." >&2 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 " Release merge commit: $MERGE_COMMIT" >&2
echo " Recover manually: git push origin $TAG" >&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 exit 1
} }
} }
+1
View File
@@ -65,6 +65,7 @@ temp/
# Keeps dev backend state (db, tasks, worktrees, repos) out of the user's # Keeps dev backend state (db, tasks, worktrees, repos) out of the user's
# production ~/.kandev when developing kandev using kandev. # production ~/.kandev when developing kandev using kandev.
/.kandev-dev/ /.kandev-dev/
/.kandev/
# Runtime scratch workspaces created for repo-less tasks. The lifecycle # Runtime scratch workspaces created for repo-less tasks. The lifecycle
# manager places them at <homeDir>/tasks/<workspaceID>/<taskID>; in dev mode # 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.
+1
View File
@@ -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-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-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-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-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-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 | | 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 |
+46 -3
View File
@@ -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. 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. 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. 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. 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: For release automation changes, run:
```bash ```bash
@@ -44,7 +52,7 @@ Use dry run to validate version/changelog preparation. Use desktop validation to
Normal mode performs these stages: 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`. 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. 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. 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. 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 ## 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. 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. 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 ## Repair a partial release
First identify exactly which immutable and mutable channels succeeded. Preserve workflow logs, checksums, package versions, image digests, and signing output. 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. 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. 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.