diff --git a/.github/allowed_signers b/.github/allowed_signers index 2756428..87ce163 100644 --- a/.github/allowed_signers +++ b/.github/allowed_signers @@ -10,5 +10,6 @@ # # you@example.com namespaces="git" ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA... # -# Empty until the release manager adds their key (docs/stores.md, "Signing keys"); until then -# every release tag is refused. + +# Ayla Croft (@HackTuah), release manager: SHA256:HqIaFq/thUm844NjYC3a+4Br40cqD2yJHEq1L2iiw4E +dev@scriptkittyos.com namespaces="git" ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAiWNJRQYwtCxb7uEMB5i1XdR6k9Xd+hF6+kMQa4nQ3j dev@scriptkittyos.com diff --git a/MAINTAINERS.md b/MAINTAINERS.md index 2f1ec4e..e847c4a 100644 --- a/MAINTAINERS.md +++ b/MAINTAINERS.md @@ -13,7 +13,7 @@ step. | Name | GitHub | Roles | Areas | Tag-signing key | Since | |---|---|---|---|---|---| -| Ayla Croft | [@HackTuah](https://github.com/HackTuah) | Lead maintainer, maintainer, release manager, security response lead | Server, extension, docs | Not yet: to be added to `.github/allowed_signers` before the first release tag | 2026-10 | +| Ayla Croft | [@HackTuah](https://github.com/HackTuah) | Lead maintainer, maintainer, release manager, security response lead | Server, extension, docs | SSH ed25519 `SHA256:HqIaFq/thUm844NjYC3a+4Br40cqD2yJHEq1L2iiw4E` (`.github/allowed_signers`) | 2026-10 | | minitru | [@minitru](https://github.com/minitru) | Maintainer | Reviews, triage | None (not a release manager) | 2026-10 | ## Not yet filled diff --git a/docs/stores.md b/docs/stores.md index f7231ce..6752697 100644 --- a/docs/stores.md +++ b/docs/stores.md @@ -73,7 +73,7 @@ Tag it on the release PR's head, whose version is already bumped: ```sh git fetch origin release-please--branches--main git switch --detach origin/release-please--branches--main -scripts/tag-release.sh --rc 1 +scripts/tag-release.sh --rc 1 --ssh-key ~/.ssh/kotiko_release.pub ``` ### Trying the pipeline without releasing @@ -152,12 +152,17 @@ store credentials. Each release manager signs tags with their own key; no signing key exists on GitHub. -- **SSH** (simplest): `git config --global gpg.format ssh` and - `git config --global user.signingkey ~/.ssh/id_ed25519.pub`, then add a line to - `.github/allowed_signers` in a pull request: - `you@example.com namespaces="git" ssh-ed25519 AAAA…`. +- **SSH** (simplest): make a key just for release tags, with the project's address and + not a personal one, since the public key and its comment are published: + `ssh-keygen -t ed25519 -C "dev@scriptkittyos.com" -f ~/.ssh/kotiko_release`. Add a line + to `.github/allowed_signers` in a pull request: + `you@example.com namespaces="git" ssh-ed25519 AAAA…`. Sign with + `scripts/tag-release.sh --ssh-key ~/.ssh/kotiko_release.pub`, which uses the key for that + tag only; how git signs your commits stays as it is. (Setting `gpg.format ssh` globally + would switch commit signing to that key too.) - **GPG**: `git config --global user.signingkey `, then - `gpg --armor --export >> .github/release-keys.asc` in a pull request. + `gpg --armor --export >> .github/release-keys.asc` in a pull request. The + exported key includes every user ID on it, email addresses too, and they become public. List the same key's fingerprint in MAINTAINERS.md. Until a key is added, every release tag is refused. diff --git a/scripts/tag-release.sh b/scripts/tag-release.sh index 632066c..493812d 100755 --- a/scripts/tag-release.sh +++ b/scripts/tag-release.sh @@ -9,6 +9,8 @@ # scripts/tag-release.sh # v0.3.0, from the merged release commit # scripts/tag-release.sh --rc 1 # v0.3.0-rc.1, a release candidate (no store upload) # scripts/tag-release.sh --dry-run # every check, no tag +# scripts/tag-release.sh --ssh-key ~/.ssh/kotiko_release.pub # sign this tag with that SSH +# # key, whatever git signs commits with # # Checks first: a clean tree; HEAD is origin/main (a candidate may instead be the head of # release-please's branch, whose version is already bumped); for a release, HEAD is the @@ -28,12 +30,14 @@ die() { rc="" dry_run=false remote="origin" +ssh_key="" while [ $# -gt 0 ]; do case "$1" in --rc) rc="$2"; shift 2 ;; --dry-run) dry_run=true; shift ;; --remote) remote="$2"; shift 2 ;; - *) die "unknown option $1 (use --rc N, --dry-run, --remote NAME)" ;; + --ssh-key) ssh_key="$2"; shift 2 ;; + *) die "unknown option $1 (use --rc N, --dry-run, --remote NAME, --ssh-key FILE)" ;; esac done [ -z "$rc" ] || [[ "$rc" =~ ^[1-9][0-9]*$ ]] || die "--rc takes a number: --rc 1" @@ -78,10 +82,18 @@ if [ "$dry_run" = true ]; then exit 0 fi -[ -n "$(git config user.signingkey || true)" ] || - die "no signing key: set git config user.signingkey (and gpg.format ssh for an SSH key); see docs/stores.md" +# --ssh-key signs this one tag with an SSH key, leaving how git signs commits (often a GPG +# key) as it is. +sign=(git) +if [ -n "$ssh_key" ]; then + [ -f "$ssh_key" ] || die "--ssh-key: $ssh_key isn't a file" + sign=(git -c gpg.format=ssh -c "user.signingkey=$ssh_key") +else + [ -n "$(git config user.signingkey || true)" ] || + die "no signing key: pass --ssh-key FILE, or set git config user.signingkey (and gpg.format ssh for an SSH key); see docs/stores.md" +fi -git tag -s "$tag" -m "Kotiko $version" +"${sign[@]}" tag -s "$tag" -m "Kotiko $version" if ! bash scripts/verify-tag.sh "$tag" --main "$remote/main" --rc-branch "$remote/$rc_branch"; then git tag -d "$tag" >/dev/null die "the new tag didn't verify (is your key in .github/allowed_signers?); it was deleted, nothing was pushed" diff --git a/test/unit/release-tags.test.mjs b/test/unit/release-tags.test.mjs index 4d18b6c..8135808 100644 --- a/test/unit/release-tags.test.mjs +++ b/test/unit/release-tags.test.mjs @@ -197,6 +197,18 @@ test("tag-release refuses a commit that isn't the release, a dirty tree, leftove assert.equal(run(work, "git", ["rev-parse", "--verify", "--quiet", "refs/tags/v0.3.0"]).status, 1, "the bad tag is deleted"); }); +test("--ssh-key signs the tag with that key, whatever git signs commits with", () => { + const { work } = setup(); + git(work, "config", "--unset", "user.signingkey"); + git(work, "config", "gpg.format", "openpgp"); + assert.match(run(work, "bash", ["scripts/tag-release.sh"]).stderr, /no signing key: pass --ssh-key FILE/); + assert.match(run(work, "bash", ["scripts/tag-release.sh", "--ssh-key", join(base, "missing.pub")]).stderr, /isn't a file/); + const r = run(work, "bash", ["scripts/tag-release.sh", "--ssh-key", join(base, "release.pub")]); + assert.equal(r.status, 0, r.stderr); + assert.match(r.stdout, /pushed v0\.3\.0/); + assert.equal(git(work, "config", "gpg.format"), "openpgp", "the repository's own signing settings are untouched"); +}); + test("a release candidate is tagged on release-please's branch and verifies as a pre-release", () => { const { work } = setup(); git(work, "switch", "-q", "-c", "release-please--branches--main");