Release signing ceremony
CI builds and publishes an unsigned draft. It never receives either private key. The maintainer signs locally; only verified, already-published bytes become an official release.
Ordinary main CI also retains GoReleaser archives for 14 days as an unsigned
development artifact named for the exact source commit. Those snapshots carry
checksums and an explicit warning, but no release manifest or signature. They
are evaluation output only: they never create a GitHub Release and cannot enter
this ceremony as a release candidate.
Interactive macOS assistant
scripts/release/ceremony.sh guides the same locked ceremony as five resumable
phases: bootstrap, candidate/PR, immutable tag and draft verification, recovery
binding plus primary signing, and publication plus external verification.
Run a mutation-free preview first:
./scripts/release/ceremony.sh --dry-run
Then run the selected real phase without the flag. state.json lives under
${XDG_STATE_HOME:-~/Library/Application Support}/hikyo/release-ceremony/ and
contains only version, tag, phase, and repository identity; public draft and
signed assets live beside it. Rerunning a phase loads that checkpoint, verifies
already-created tags, PRs, downloads, OCI signatures, and release assets, then
continues from the first incomplete boundary. The assistant never stores a
private key or passphrase on workstation storage. It refuses an online route during offline
phases, uses an unindexed macOS RAM disk, disables core dumps, requires exact
typed confirmation before GitHub mutations, and delegates cryptographic policy
to the release scripts documented below. It refuses to mount signing storage
unless macOS reports FileVault is on, which keeps workstation swap encrypted.
Cross-release rollback memory remains separately at
${XDG_STATE_HOME:-~/Library/Application Support}/hikyo/release-trust.json;
never delete or version-scope that file.
The operator still controls network disconnection, removable-media insertion
and ejection for normal releases, both passphrase entries used by each key,
GHCR login, and PR review. Store four distinct secrets in Vaultwarden: primary
Cosign, recovery Cosign, primary age, and recovery age passphrases. Never
attach the .key.age files to Vaultwarden items. The assistant accepts those files only
from external/removable volumes and refuses networking while key media remains
mounted.
Dry-run output is guidance only; it does not create progress state.
Pinned tools
- GoReleaser
v2.17.1 - cosign
v3.1.3 - age
v1.3.1 - Syft
v1.50.0 - Helm
v4.2.3 - Go
1.27.0fromgo.mod - Docker client
29.7.2with Buildxv0.36.1-desktop.1for live GHCR image and chart digest resolution
Verify downloaded tool archives against the checksum asset attached to that exact upstream release. GitHub Actions are full-SHA pinned in each workflow; the repository setting rejects tag-form action references.
One-time trust bootstrap
- Disconnect networking. Run
ulimit -c 0. Mount a memory-backed working directory; do not use a normal disk, synced folder, indexed folder, or a location covered by workstation backups. - Run
cosign generate-key-pair --output-key-prefix primary-1and separatelycosign generate-key-pair --output-key-prefix recovery-1, using different password-manager passphrases. While still on memory-backed storage, wrap each Cosign-encrypted.keyagain withage --passphrase; copy only the resulting.key.agefile to two distinct external USB devices in separate locations. Recovery media must be separate from primary media. Store the distinctagepassphrases in the password manager too. - Commit only both
.pubfiles,release/trust/root.json, and the initialmetadata.json.root.jsonpins each public-key filename and SHA-256. Sequence 1 metadata pins the bootstrap primary from the root and records the first version aspending_release; it does not claim that draft is current. - Sign
metadata.jsonwith the recovery root usingcosign sign-blob --new-bundle-format=false --tlog-upload=false --use-signing-config=false --bundle metadata.sigstore.json. The legacy-output switches are required by pinned cosign v3.1.3: they keep the operation offline and allow the OCI ceremony to emit a raw signature. Commit the bundle. Eject media, wipe the tmpfs, then reconnect networking. - Before the first tag, verify the recovery signature, pinned bootstrap key,
and every public-key hash with
scripts/release/verify-bundle.sh --root release/trust/root.json --metadata release/trust/metadata.json --metadata-signature release/trust/metadata.sigstore.json --state PATH --trust-only. This writes trust sequence 1 with null latest-release fields. The later bound metadata must advance to sequence 2 before it can replace that state. Separately runscripts/release/test-fixtures.shfor the complete signed-bundle rehearsal. A real release tag remains blocked until all production trust files exist.
The recovery key signs trust-metadata changes only. A primary signature can never replace the recovery root. Current v1 verification deliberately requires recovery authorization for rotation and revocation; this is stricter than allowing a routine old-primary rotation and keeps the recovery direction one-way.
Cosign performs every cryptographic operation and defines the OCI signature payload; the shell code only enforces the project-specific release-range and recovery policy locked by the ADR. TUF was not substituted for that policy: doing so would replace the mandated long-lived cosign root, recovery-only authority, and release-range revocation semantics. Changing those semantics is an ADR amendment, not an implementation refactor.
Per-release ceremony
- For every release after the bootstrap release, before tagging, increment the
trust-metadata
sequence, setevent.typetorelease-candidate, and addpending_releasewith the exact version, monotonic release sequence, and 64 zeroes as itsmanifest_sha256. Do not changereleases,highest_release, orhighest_release_sequence: the currently published installer must remain current while the new release is only a draft. Recovery-sign this candidate metadata offline, commit it, and merge it tomain. For the first release, sequence 1 bootstrap metadata is already the candidate; skip this step and bind it directly to sequence 2 after CI builds the draft. - Create
vX.Y.Zat that merged commit. CI verifies reachability and prior non-use, proves a changed-SHA update to the permanent non-releasev-ruleset-probetag is rejected, builds GoReleaser archives for Linux/macOS/Windows on amd64/arm64, copies the same GoReleaser Linux binaries into the distroless amd64/arm64 image, produces Debian, RPM, APK, and Arch Linux packages for amd64/arm64, pushes that image and the digest-pinned Helm chart, emits binary provenance binding both package inputs to the candidate commit and hashes, source and image SPDX SBOMs, renders an installer containing the exact trust root and verifier hashes, and opens a draft GitHub release. CI also makes the cosign OCI payloads while it has registry access; it never signs them. - With networking on and no decrypted key present, download every draft asset.
Recompute
checksums.txt, compare the GHCR index digest withimage-index.digestandchart-index.digest. Confirm both prepared OCI payloads name those exact published subjects. Confirm the manifest’s version, sequence, commit, and signing key match the canonicalrelease-candidate.jsonartifact; its hash is part of the manifest. - Disconnect networking, mount tmpfs, decrypt only the recovery key, and run
scripts/release/bind-manifest.sh release-manifest.json metadata.json metadata.bound.json. Recovery-signmetadata.bound.json, then re-encrypt and eject recovery media. Reconnect only after plaintext is gone; commit the bound file asrelease/trust/metadata.jsonplus its signature tomain.bind-manifest.shincrements the trust sequence again, converts the pending row into a finalized release row, and only then advanceshighest_release. The release remains a draft. This makes rebuilding different bytes under an already-used version fail verification even if a primary key signs them. - Disconnect networking again, set
ulimit -c 0, mount tmpfs, decrypt only the primary key there, disable core dumps, and runscripts/release/sign-bundle.sh. It creates a cosign bundle for every asset and raw signatures for both prepared OCI payloads. Remove plaintext, unmount tmpfs, and eject the key media before networking returns. - With networking restored and no private key mounted, run
scripts/release/publish-oci-signatures.sh BUNDLE ROOT METADATA METADATA_SIGNATURE. It re-verifies the candidate-bound bundle, derives the primary public key from that candidate, attaches the offline signatures to the exact image and chart digests, then requirescosign verifyto succeed for both published subjects. Upload the manifest and every*.sigstore.jsonbundle to the draft; raw OCI signatures are transport scratch and are not release assets. - Redownload the complete draft and verify it through
verify-bundle.sh --published --state "$XDG_STATE_HOME/hikyo/release-trust.json"; then publish the draft. The installer fetches current recovery-signed metadata frommain, then runs the same published-subject check before extracting a binary. The pinned root and verifier code still come from the immutable release tag. GitHub immutable releases locks its assets at publication. Preserve that state file; deleting it discards locally remembered rollback protection. - After the now-public release is downloaded and verified again, the ceremony
renders
Casks/hikyo.rbfrom the signed macOS archive records and opens or refreshesHikyo-Org/homebrew-tap’s protected release PR. Drafts never reach this step, prereleases do not update the stable cask, and the ceremony never merges the tap PR. Review itsci-requiredresult and merge separately. Homebrew authorizes the tap/cask and checks the rendered SHA-256, but does not independently verify Hikyo’s pinned signing root; this is a convenience channel, not an official fail-closed installer. Users requiring that trust guarantee must use the complete signed-bundle verification path.
checksums.txt remains GoReleaser’s exact six-archive checksum list. Native
packages are not added to that legacy list; each package byte stream is instead
hashed in the release manifest and receives its own offline Cosign bundle. The
package payload is only /usr/bin/hikyo plus the MPL-2.0 license: it creates no
configuration and never installs or starts a service. Before the draft is
created, the release build extracts all eight packages and compares their
binary and license bytes with the canonical architecture archives and source
license.
binary-provenance.json records the GoReleaser configuration hash and proves
that each Linux archive input and OCI image input has the same binary hash. The
signed release manifest is authoritative for the canonical release candidate,
binaries, native packages, binary provenance,
SBOMs, installer, chart, digest files, and OCI payloads.
Hash agreement proves asset consistency, not an honest CI build. Reproducible build comparison is the named future control for compromised-CI risk.
Automated nightly publication identity
Nightly builds do not use either offline signing key. An organization-owned
GitHub App named Hikyo Nightly Release, installed only on this repository,
owns nightly tag and prerelease publication. It has only repository
Contents: read and write, no webhook, and no event subscriptions.
Each nightly publishes the six platform archives and all eight native Linux
packages produced by the same verified GoReleaser snapshot. They remain
explicitly unsigned development artifacts, not pinned-root releases.
The workflow stores the app client ID in the
NIGHTLY_RELEASE_APP_CLIENT_ID repository variable and its private key in the
NIGHTLY_RELEASE_APP_PRIVATE_KEY Actions secret. Each run mints a short-lived,
current-repository-only token and requests only contents: write. The built-in
workflow token remains read-only. configure-repository.sh applies the
dedicated app as the sole bypass actor for creation of v*-nightly.*; stable
tag creation remains admin-only and every v* tag remains immutable.
Rotation, revocation, and loss
- Rotation: recovery-sign metadata that closes the old primary at a named release sequence and activates the new primary at the next. No overlap.
- Primary compromise or workstation compromise: recovery-sign a distinct
revocation event.
verify-bundle.shrefuses that primary for old releases too. - Lost primary: conservatively recovery-revoke it, then rotate; this prevents a later recovery of the old private key from fabricating a historical bundle. Lost recovery: out-of-band recovery-root rebootstrap; a primary-signed replacement is invalid. Both lost: full out-of-band trust bootstrap.
- Run restore/decrypt/sign/verify from each USB copy yearly and before first use after any storage change.