Compare commits

..
6 Commits
Author SHA1 Message Date
xavierk 3c07f37c78 fix(release): refresh published XBPS assets 2026-09-29 04:38:58 +05:30
xavierk b098132595 fix(ci): authenticate XBPS repository publish
Set the existing Fenris commit identity and pass the Gitea publish token to Git without storing credentials on the runner.
2026-09-29 04:32:54 +05:30
xavierk 1dbe372714 fix(ci): honor XBPS dispatch input
Handle Gitea boolean inputs in the publish condition. Mark Void available only after the repository publish step succeeds.
2026-09-29 04:27:57 +05:30
xavierk 1063fa4dae test(signing): align key storage contract with Gitea
Release / release (push) Successful in 2m3s
2026-09-29 04:19:23 +05:30
xavierk 3f2dd6a5a1 fix(release): retain XBPS key through publication
The optional XBPS publisher signs repository metadata after package signing. Remove the runner key only after publication and release asset upload.
2026-09-29 04:06:45 +05:30
xavierk a5b84f7566 docs: align signing ceremony with Gitea workflow 2026-09-29 03:57:24 +05:30
4 changed files with 84 additions and 57 deletions
+36 -11
View File
@@ -159,7 +159,6 @@ jobs:
gpg --batch --yes --delete-secret-keys "${FINGERPRINT}"
gpg --batch --yes --delete-keys "${FINGERPRINT}"
fi
rm -f ~/.ssh/id_xbps
- name: Determine version
id: version
@@ -204,9 +203,21 @@ jobs:
esac
- name: Publish XBPS to distribution repository
if: github.event.inputs.publish_xbps == 'true'
id: publish-xbps
if: ${{ github.event.inputs.publish_xbps == true || github.event.inputs.publish_xbps == 'true' }}
env:
GITEA_PUBLISH_TOKEN: ${{ secrets.GITEAPACKAGETOKEN }}
run: |
set -euo pipefail
if [ -z "${GITEA_PUBLISH_TOKEN}" ]; then
echo "::error::GITEAPACKAGETOKEN repository secret is not configured"
exit 1
fi
git config user.name "xavierk"
git config user.email "xavierk@bongbetic.com"
export GIT_CONFIG_COUNT=1
export GIT_CONFIG_KEY_0='http.https://git.bongbetic.com/.extraheader'
export GIT_CONFIG_VALUE_0="Authorization: token ${GITEA_PUBLISH_TOKEN}"
VERSION=${{ steps.version.outputs.version }}
XBPS_FILE="fenris-${VERSION}_1.x86_64.xbps"
if [ ! -f "${XBPS_FILE}" ]; then
@@ -218,6 +229,7 @@ jobs:
exit 1
fi
bash scripts/xbps-publish.sh --publish
echo "xbps_published=true" >> "$GITHUB_OUTPUT"
- name: Track format availability
id: formats
@@ -227,7 +239,7 @@ jobs:
DEB_EXISTS=$([ -f "dist/fenris_${VERSION}_amd64.deb" ] && echo "true" || echo "false")
RPM_EXISTS=$([ -f "dist/fenris-${VERSION}-1.x86_64.rpm" ] && echo "true" || echo "false")
XBPS_EXISTS=$([ -f "fenris-${VERSION}_1.x86_64.xbps" ] && echo "true" || echo "false")
XBPS_PUBLISHED=$([ "${{ github.event.inputs.publish_xbps }}" = "true" ] && echo "true" || echo "false")
XBPS_PUBLISHED=$([ "${{ steps.publish-xbps.outputs.xbps_published }}" = "true" ] && echo "true" || echo "false")
echo "deb_available=${DEB_EXISTS}" >> "$GITHUB_OUTPUT"
echo "rpm_available=${RPM_EXISTS}" >> "$GITHUB_OUTPUT"
echo "xbps_available=${XBPS_EXISTS}" >> "$GITHUB_OUTPUT"
@@ -318,7 +330,8 @@ jobs:
"https://git.bongbetic.com/api/v1/repos/xavierk/Fenris/releases/tags/v${VERSION}")
RELEASE_ID=$(printf '%s' "${RELEASE_JSON}" \
| python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
# Attach deb, rpm, clearsigned checksums, and XBPS artifacts once.
PUBLISH_XBPS="${{ steps.formats.outputs.xbps_published }}"
# Attach package artifacts; refresh XBPS assets after publication.
ARTIFACTS=(
"dist/fenris_${VERSION}_amd64.deb"
"dist/fenris-${VERSION}-1.x86_64.rpm"
@@ -336,12 +349,24 @@ jobs:
fi
for FILE in "${ARTIFACTS[@]}"; do
ASSET_NAME="${FILE##*/}"
if python3 -c 'import json,sys; name=sys.argv[1]; sys.exit(0 if any(a.get("name") == name for a in json.load(sys.stdin).get("assets", [])) else 1)' "${ASSET_NAME}" <<<"${RELEASE_JSON}"; then
echo "${ASSET_NAME}: already attached"
else
curl --fail --silent --show-error -X POST \
-H "Authorization: token ${GITEA_PUBLISH_TOKEN}" \
-F "attachment=@${FILE}" \
"https://git.bongbetic.com/api/v1/repos/xavierk/Fenris/releases/${RELEASE_ID}/assets"
EXISTING_ASSET_ID=$(python3 -c 'import json,sys; name=sys.argv[1]; print(next((a["id"] for a in json.load(sys.stdin).get("assets", []) if a.get("name") == name), ""))' \
"${ASSET_NAME}" <<<"${RELEASE_JSON}")
if [ -n "${EXISTING_ASSET_ID}" ]; then
if [ "${PUBLISH_XBPS}" = "true" ] && [[ "${ASSET_NAME}" = "${XBPS_FILE}" || "${ASSET_NAME}" = "${XBPS_FILE}.sig2" ]]; then
curl --fail --silent --show-error -X DELETE \
-H "Authorization: token ${GITEA_PUBLISH_TOKEN}" \
"https://git.bongbetic.com/api/v1/repos/xavierk/Fenris/releases/${RELEASE_ID}/assets/${EXISTING_ASSET_ID}"
else
echo "${ASSET_NAME}: already attached"
continue
fi
fi
curl --fail --silent --show-error -X POST \
-H "Authorization: token ${GITEA_PUBLISH_TOKEN}" \
-F "attachment=@${FILE}" \
"https://git.bongbetic.com/api/v1/repos/xavierk/Fenris/releases/${RELEASE_ID}/assets"
done
- name: Remove XBPS signing key
if: always()
run: rm -f ~/.ssh/id_xbps
+43 -42
View File
@@ -12,7 +12,7 @@ and destruction.
| UID | `Fenris Packaging <packaging@bongbetic.com>` |
| Expiry | 2 years from creation |
| Hierarchy | Single key — no master/subkey split (single maintainer, manual builds) |
| Private key storage | Password manager only |
| Private key storage | Gitea repository Actions secret `GPG_PRIVATE_KEY` |
| Public key storage | `packaging/keys/fenris-packaging.asc` in-repo, release notes, docs |
| Keyservers | Never — TOFU-over-TLS via raw URL |
@@ -37,71 +37,72 @@ gpg --armor --export packaging@bongbetic.com > packaging/keys/fenris-packaging.a
gpg --fingerprint packaging@bongbetic.com
```
Save the **private key** to the password manager immediately:
Provision the **private key** as the repository Actions secret `GPG_PRIVATE_KEY`.
Run the export on the trusted key-generation machine, then enter its output in
the Gitea repository's Actions secret settings. Do not save it in the checkout,
logs, or a runner directory. The release workflow checks its fingerprint
against the committed public key before signing.
```bash
gpg --armor --export-secret-keys packaging@bongbetic.com
```
Then **delete the private key from the local keyring** — it must never persist
on any build host:
After provisioning the secret, delete the private key from the key-generation
keyring:
```bash
gpg --delete-secret-keys packaging@bongbetic.com
gpg --delete-keys packaging@bongbetic.com
gpg --batch --yes --delete-secret-keys packaging@bongbetic.com
gpg --batch --yes --delete-keys packaging@bongbetic.com
```
The committed `fenris-packaging.asc` must contain the real public key (replace
the placeholder comments).
## XBPS signing key
XBPS uses a separate RSA 3072 key. Its private half is stored as the Gitea
repository Actions secret `XBPS_SIGNING_KEY`. The corresponding public key is
published at
`https://git.bongbetic.com/xavierk/Fenris-xbps/raw/branch/stable/keys/fenris-xbps-signing.pub`,
with fingerprint `SHA256:AvPMRlKMikPg75u0iKr8AUkxlfU/Ad4k/S4o2M9W4/w`.
The secret must match that public key.
The release workflow writes the key to `~/.ssh/id_xbps` to sign the XBPS
package. A requested XBPS publication also uses the key to sign repository
metadata. A final `always()` cleanup removes the runner copy after publication
and release asset upload, including when an earlier step fails.
## Per-release signing flow
Each release performs: **import → sign → delete**. The private key is never
stored on disk longer than the release takes.
Each tagged release performs: **import → verify → sign → delete** on the
repository-scoped Gitea Actions runner. The Gitea secret remains configured;
the runner's keyring copy is removed after the job.
### Step 1: Import the private key
### Step 1: Push the release tag
Retrieve the private key from the password manager and import it:
After updating the version and dated changelog section, push the matching tag:
```bash
gpg --import /tmp/packaging-key-private.asc
rm /f /tmp/packaging-key-private.asc # Shred if possible
git push origin v<version>
```
### Step 2: Build and sign packages
### Step 2: Build, verify, and sign packages
The Makefile target `make release` handles signing automatically when the
key is in the keyring:
The release workflow imports `GPG_PRIVATE_KEY`, checks it against
`packaging/keys/fenris-packaging.asc`, builds packages, signs the RPM and
clearsigned checksum manifest, validates both, and publishes the release. The
workflow imports `XBPS_SIGNING_KEY` separately and signs the XBPS package.
XBPS publication is optional and also signs repository metadata; it requires
host acceptance and explicit selection during workflow dispatch.
```bash
make release # builds, signs RPM, clearsigns SHA256SUMS, prints upload steps
```
### Step 3: Verify runner cleanup
Under the hood:
The workflow's `always()` cleanup removes the GPG key from the runner's keyring
and deletes `~/.ssh/id_xbps`, including after a failed job. Confirm no signing
key remains on the runner after the release job.
1. `rpmsign --addsign` signs the RPM payload with the packaging key
(invoked by `make sign-rpm`).
2. `sha256sum` generates the checksum manifest.
3. `gpg --clearsign` produces `SHA256SUMS.asc` with the packaging key.
### Step 3: Delete the private key
Immediately after signing:
```bash
gpg --delete-secret-keys packaging@bongbetic.com
gpg --delete-keys packaging@bongbetic.com
```
Verify the key is gone:
```bash
gpg --list-keys packaging@bongbetic.com
# Should produce: gpg: keyblock resource ...: No such file or directory
```
The entire import → sign → delete cycle should take minutes. The private key
must never be left in any keyring between releases.
The Gitea Actions secret remains the approved signing source. Do not copy it to
the runner or repository outside the workflow.
## Key rotation (outline)
+3 -2
View File
@@ -44,7 +44,8 @@
- **RPM payload: signed.** rpmsign with the dedicated packaging key, invoked by `make sign-rpm` after the package is built. This is required, not optional: it is the only working dnf-native verification path.
- **deb: unsigned.** apt never verifies payload signatures; trust = instance-signed `InRelease` (signed-by keyring) + TLS + Acquire-By-Hash. Manual-download integrity is covered by SHA256SUMS.
- **SHA256SUMS: clearsigned** with the packaging key — the trust anchor for manually downloaded release assets, independent of TLS.
- **Packaging key:** single dedicated key, RSA 3072, UID `Fenris Packaging <packaging@bongbetic.com>`, 2-year expiry, no master/subkey hierarchy (single maintainer, manual builds). Private key lives in the password manager only; each release does import → sign → delete — nothing permanent on any build host. The full ceremony is documented in `docs/install/signing-key-ceremony.md`.
- **Packaging key:** single dedicated key, RSA 3072, UID `Fenris Packaging <packaging@bongbetic.com>`, 2-year expiry, no master/subkey hierarchy. The private key is stored as the repository Actions secret `GPG_PRIVATE_KEY`. The release workflow imports it on the self-hosted runner, verifies it against the in-repo public key, signs the RPM and SHA256SUMS, then deletes the runner's keyring copy in an `always()` cleanup step. The full ceremony is documented in `docs/install/signing-key-ceremony.md`.
- **XBPS key:** separate RSA 3072 key stored as the repository Actions secret `XBPS_SIGNING_KEY`; its public key and fingerprint are published in `Fenris-xbps`. The release workflow uses it for the XBPS package and, when publication is explicitly requested, the repository index. A final `always()` cleanup deletes its runner copy after publication and release asset upload.
- **Public key publication:** in-repo `packaging/keys/fenris-packaging.asc` (raw URL doubles as the `.repo` gpgkey target), release notes, docs page. No keyservers — TOFU-over-TLS.
- **Rotation (outline):** new key published alongside old; rpm signed with the new key; `fenris.repo` gpgkey lists both URLs (dnf accepts multiple); old key dropped after one release cycle. Procedure details stay in map fog.
@@ -53,7 +54,7 @@
- **A Release is:** a version tag, its packages in the channel, a Gitea release entry with notes, and a clearsigned SHA256SUMS — all together. **Bare tags are forbidden** (tag without packages + release entry is not a Release).
- **Cadence: on-demand.** Tag when user-visible changes or fixes accumulate; no calendar, no empty releases, no frequency SLA, no RC ceremony — fixes ship as a revision bump of the current version.
- **Versioning: plain semver.** Major = breaking CLI/config/unit change; store schema changes ride the natural bump (the forward-only refusal handles old-reader/new-store).
- **Promotion flow:** bump `pyproject.toml` and the matching dated `CHANGELOG.md` section, then push tag `v<version>`. The repository-scoped Gitea Actions workflow builds and validates the deb, rpm, checksums, and release entry. XBPS publication remains a manual dispatch option after host acceptance. `make release` remains the local package/sign/checksum fallback. The ceremony is documented in `docs/install/signing-key-ceremony.md`.
- **Promotion flow:** bump `pyproject.toml` and the matching dated `CHANGELOG.md` section, then push tag `v<version>`. The repository-scoped Gitea Actions workflow builds and validates the deb, rpm, checksums, and release entry. XBPS publication remains a manual dispatch option after host acceptance. `make release` is for local artifact preparation and does not replace the tag workflow as the supported publication path. The ceremony is documented in `docs/install/signing-key-ceremony.md`.
- **Rollback:** installing an older package over a newer store is **unsupported** — the store's forward-only version refusal fails it by design. Documented rollback = restore the observation-store snapshot, then install the old Release. No automatic downgrade machinery exists or will be built.
- **CI:** a repository-scoped self-hosted runner is registered and online (checked 2026-09-29). `.gitea/workflows/release.yml` is the tag-triggered release path; maintainers must confirm runner availability and required Gitea secrets before tagging. The workflow publishes deb/rpm packages and release assets; Void publication is withheld unless the signed XBPS host-release step is explicitly requested.
+2 -2
View File
@@ -259,8 +259,8 @@ class TestKeyCeremonyDoc:
def test_documents_private_key_storage(self):
content = self._doc_content()
assert "password manager" in content.lower(), \
"Must document that private key lives in password manager"
assert "gitea repository actions secret `gpg_private_key`" in content.lower(), \
"Must document that Gitea Actions stores the private signing key"
# ---------------------------------------------------------------------------