# Fenris release workflow — release path on Coolify-hosted Gitea runner. # The runner is repository-scoped and executes package build, signing, validation, # registry publication, and release attachment. Spec: §5, issue #52 name: Release on: push: tags: - 'v*' workflow_dispatch: inputs: publish_xbps: description: 'Publish XBPS package to distribution repository (requires host acceptance)' required: false default: false type: boolean # Built-in Gitea token needs write access for release assets and package registry. permissions: contents: read releases: write packages: write jobs: release: runs-on: [self-hosted] steps: - uses: actions/checkout@v4 - name: Validate release tag and notes run: | set -euo pipefail VERSION="$(sed -n 's/^version = "\(.*\)"/\1/p' pyproject.toml)" if [ -z "${VERSION}" ]; then echo "::error::could not determine the project version" exit 1 fi if [ "${GITHUB_EVENT_NAME}" != "workflow_dispatch" ]; then EXPECTED_TAG="v${VERSION}" ACTUAL_TAG="${GITHUB_REF#refs/tags/}" if [ "${ACTUAL_TAG}" != "${EXPECTED_TAG}" ]; then echo "::error::tag ${ACTUAL_TAG} does not match ${EXPECTED_TAG}" exit 1 fi fi python3 scripts/extract_changelog.py CHANGELOG.md "${VERSION}" \ --footer packaging/release-footer.md > "${RUNNER_TEMP}/release-body.md" - name: Set up Python uses: actions/setup-python@v5 with: python-version: '3.12' - name: Install build dependencies run: | sudo apt-get update sudo apt-get install -y gnupg2 rpm python3-venv python3 -m venv /tmp/fenris-ci /tmp/fenris-ci/bin/pip install --quiet build echo "/tmp/fenris-ci/bin" >> "$GITHUB_PATH" NFPM_VERSION=2.47.0 curl --fail --silent --show-error --location \ "https://github.com/goreleaser/nfpm/releases/download/v${NFPM_VERSION}/nfpm_${NFPM_VERSION}_Linux_x86_64.tar.gz" \ -o /tmp/nfpm.tar.gz sudo tar -xzf /tmp/nfpm.tar.gz -C /usr/local/bin nfpm nfpm --version # Ubuntu does not package the XBPS build tools. Use Void's static # toolchain, pinned and checksum-verified before it reaches PATH. XBPS_STATIC_VERSION=0.60.4_1 XBPS_STATIC_ARCHIVE="xbps-static-static-${XBPS_STATIC_VERSION}.x86_64-musl.tar.xz" XBPS_STATIC_SHA256=603b3c55e9cabd5af79b461b929b14e1556a443c97b5714d188681c2172d9e28 curl --fail --silent --show-error --location \ "https://repo-default.voidlinux.org/static/${XBPS_STATIC_ARCHIVE}" \ -o "/tmp/${XBPS_STATIC_ARCHIVE}" echo "${XBPS_STATIC_SHA256} /tmp/${XBPS_STATIC_ARCHIVE}" | sha256sum --check --strict mkdir -p /tmp/xbps-static tar -xJf "/tmp/${XBPS_STATIC_ARCHIVE}" -C /tmp/xbps-static export PATH="/tmp/xbps-static/usr/bin:${PATH}" echo "/tmp/xbps-static/usr/bin" >> "$GITHUB_PATH" xbps-create --version - name: Build packages run: make package - name: Build XBPS package run: make package-xbps - name: Import packaging key env: GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }} run: | set -euo pipefail if [ -z "${GPG_PRIVATE_KEY}" ]; then echo "::error::GPG_PRIVATE_KEY repository secret is not configured" exit 1 fi printf '%s\n' "${GPG_PRIVATE_KEY}" | gpg --batch --import SECRET_FINGERPRINT="$(gpg --batch --list-secret-keys --with-colons 'packaging@bongbetic.com' | awk -F: '$1 == "fpr" { print $10; exit }')" PUBLIC_FINGERPRINT="$(gpg --batch --show-keys --with-colons packaging/keys/fenris-packaging.asc | awk -F: '$1 == "fpr" { print $10; exit }')" if [ -z "${PUBLIC_FINGERPRINT}" ]; then echo "::error::packaging/keys/fenris-packaging.asc has no OpenPGP key" exit 1 fi if [ "${SECRET_FINGERPRINT}" != "${PUBLIC_FINGERPRINT}" ]; then echo "::error::packaging public key does not match imported private key" exit 1 fi echo "Packaging key fingerprint verified: ${PUBLIC_FINGERPRINT}" - name: Sign RPM payload run: make sign-rpm - name: Import XBPS signing key id: import-xbps-key env: XBPS_SIGNING_KEY: ${{ secrets.XBPS_SIGNING_KEY }} run: | set -euo pipefail if [ -z "${XBPS_SIGNING_KEY}" ]; then echo "::warning::XBPS_SIGNING_KEY secret not configured; XBPS signing skipped" echo "xbps_signed=false" >> "$GITHUB_OUTPUT" exit 0 fi mkdir -p ~/.ssh printf '%s\n' "${XBPS_SIGNING_KEY}" > ~/.ssh/id_xbps chmod 600 ~/.ssh/id_xbps echo "xbps_signed=true" >> "$GITHUB_OUTPUT" - name: Sign XBPS package if: steps.import-xbps-key.outputs.xbps_signed == 'true' run: make sign-xbps - name: Generate and clearsign SHA256SUMS run: | set -euo pipefail VERSION="$(sed -n 's/^version = "\(.*\)"/\1/p' pyproject.toml)" cd dist sha256sum "fenris_${VERSION}_amd64.deb" \ "fenris-${VERSION}-1.x86_64.rpm" > SHA256SUMS gpg --batch --yes --clearsign --local-user packaging@bongbetic.com SHA256SUMS - name: Validate signatures and checksums run: | set -euo pipefail VERSION="$(sed -n 's/^version = "\(.*\)"/\1/p' pyproject.toml)" RPM="fenris-${VERSION}-1.x86_64.rpm" RPM_VERIFY="$(rpm -Kv "dist/${RPM}" 2>&1)" printf '%s\n' "${RPM_VERIFY}" printf '%s\n' "${RPM_VERIFY}" | grep -Eiq 'signature.*: *ok' gpg --batch --verify dist/SHA256SUMS.asc (cd dist && sha256sum -c SHA256SUMS) - name: Remove packaging key material if: always() run: | set +e FINGERPRINT="$(gpg --batch --list-secret-keys --with-colons 'packaging@bongbetic.com' 2>/dev/null | awk -F: '$1 == "fpr" { print $10; exit }')" if [ -n "${FINGERPRINT}" ]; then gpg --batch --yes --delete-secret-keys "${FINGERPRINT}" gpg --batch --yes --delete-keys "${FINGERPRINT}" fi - name: Determine version id: version run: echo "version=$(sed -n 's/^version = "\(.*\)"/\1/p' pyproject.toml)" >> "$GITHUB_OUTPUT" - name: Upload deb packages to registry 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 VERSION=${{ steps.version.outputs.version }} DEB="fenris_${VERSION}_amd64.deb" for CODENAME in bookworm jammy noble; do STATUS=$(curl --silent --show-error --user "xavierk:${GITEA_PUBLISH_TOKEN}" -X PUT \ -T "dist/${DEB}" -o /dev/null -w '%{http_code}' \ "https://git.bongbetic.com/api/packages/xavierk/debian/pool/${CODENAME}/main/upload" || true) case "${STATUS}" in 200|201|204) echo "Debian ${CODENAME}: uploaded" ;; 409) echo "Debian ${CODENAME}: already exists, kept existing package" ;; *) echo "::error::Debian ${CODENAME} upload failed with HTTP ${STATUS}"; exit 1 ;; esac done - name: Upload RPM to registry env: GITEA_PUBLISH_TOKEN: ${{ secrets.GITEAPACKAGETOKEN }} run: | set -euo pipefail VERSION=${{ steps.version.outputs.version }} RPM="fenris-${VERSION}-1.x86_64.rpm" STATUS=$(curl --silent --show-error --user "xavierk:${GITEA_PUBLISH_TOKEN}" -X PUT \ -T "dist/${RPM}" -o /dev/null -w '%{http_code}' \ "https://git.bongbetic.com/api/packages/xavierk/rpm/fenris/upload" || true) case "${STATUS}" in 200|201|204) echo "RPM: uploaded" ;; 409) echo "RPM: already exists, kept existing package" ;; *) echo "::error::RPM upload failed with HTTP ${STATUS}"; exit 1 ;; esac - name: Publish XBPS to distribution repository id: publish-xbps if: ${{ github.event.inputs.publish_xbps == true || github.event.inputs.publish_xbps == 'true' }} run: | set -euo pipefail VERSION=${{ steps.version.outputs.version }} XBPS_FILE="fenris-${VERSION}_1.x86_64.xbps" if [ ! -f "${XBPS_FILE}" ]; then echo "::error::XBPS package not found: ${XBPS_FILE}" exit 1 fi if [ ! -f "${XBPS_FILE}.sig2" ]; then echo "::error::XBPS signature not found: ${XBPS_FILE}.sig2" exit 1 fi bash scripts/xbps-publish.sh --publish echo "xbps_published=true" >> "$GITHUB_OUTPUT" - name: Track format availability id: formats run: | set -euo pipefail VERSION=${{ steps.version.outputs.version }} 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=$([ "${{ 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" echo "xbps_published=${XBPS_PUBLISHED}" >> "$GITHUB_OUTPUT" # Build format availability summary for release notes AVAILABLE_FORMATS="" WITHHELD_FORMATS="" if [ "${DEB_EXISTS}" = "true" ]; then AVAILABLE_FORMATS="${AVAILABLE_FORMATS}Debian/Ubuntu (deb), " fi if [ "${RPM_EXISTS}" = "true" ]; then AVAILABLE_FORMATS="${AVAILABLE_FORMATS}Fedora/openSUSE (rpm), " fi if [ "${XBPS_EXISTS}" = "true" ] && [ "${XBPS_PUBLISHED}" = "true" ]; then AVAILABLE_FORMATS="${AVAILABLE_FORMATS}Void Linux (xbps)" elif [ "${XBPS_EXISTS}" = "true" ]; then WITHHELD_FORMATS="Void Linux (xbps) — pending host acceptance" fi # Remove trailing comma and space AVAILABLE_FORMATS=$(echo "${AVAILABLE_FORMATS}" | sed 's/, $//') echo "available_formats=${AVAILABLE_FORMATS}" >> "$GITHUB_OUTPUT" echo "withheld_formats=${WITHHELD_FORMATS}" >> "$GITHUB_OUTPUT" - name: Create Gitea release 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 VERSION=${{ steps.version.outputs.version }} RELEASE_BODY="${RUNNER_TEMP}/release-body.md" if [ ! -s "${RELEASE_BODY}" ]; then echo "::error::validated release body is missing or empty" exit 1 fi # Append format availability to release notes AVAILABLE_FORMATS="${{ steps.formats.outputs.available_formats }}" WITHHELD_FORMATS="${{ steps.formats.outputs.withheld_formats }}" RELEASE_BODY_WITH_FORMATS="${RUNNER_TEMP}/release-body-formats.md" cp "${RELEASE_BODY}" "${RELEASE_BODY_WITH_FORMATS}" echo "" >> "${RELEASE_BODY_WITH_FORMATS}" echo "## Package formats" >> "${RELEASE_BODY_WITH_FORMATS}" echo "" >> "${RELEASE_BODY_WITH_FORMATS}" echo "Available: ${AVAILABLE_FORMATS}" >> "${RELEASE_BODY_WITH_FORMATS}" if [ -n "${WITHHELD_FORMATS}" ]; then echo "Withheld: ${WITHHELD_FORMATS}" >> "${RELEASE_BODY_WITH_FORMATS}" fi EXISTING_RELEASE="${RUNNER_TEMP}/existing-release.json" EXISTING=$(curl --silent --show-error -o "${EXISTING_RELEASE}" -w '%{http_code}' \ -H "Authorization: token ${GITEA_PUBLISH_TOKEN}" \ "https://git.bongbetic.com/api/v1/repos/xavierk/Fenris/releases/tags/v${VERSION}" || true) case "${EXISTING}" in 200) echo "Release v${VERSION} exists; resynchronizing its notes" REQUEST="$(python3 scripts/release_request.py --version "${VERSION}" \ --body-file "${RELEASE_BODY_WITH_FORMATS}" --existing-release "${EXISTING_RELEASE}")" ;; 404) REQUEST="$(python3 scripts/release_request.py --version "${VERSION}" \ --body-file "${RELEASE_BODY_WITH_FORMATS}")" ;; *) echo "::error::release lookup failed with HTTP ${EXISTING}" exit 1 ;; esac METHOD="$(printf '%s' "${REQUEST}" | python3 -c "import json,sys; print(json.load(sys.stdin)['method'])")" RELEASE_PATH="$(printf '%s' "${REQUEST}" | python3 -c "import json,sys; print(json.load(sys.stdin)['path'])")" PAYLOAD="$(printf '%s' "${REQUEST}" | python3 -c "import json,sys; print(json.dumps(json.load(sys.stdin)['payload']))")" curl --fail --silent --show-error -X "${METHOD}" \ -H "Authorization: token ${GITEA_PUBLISH_TOKEN}" \ -H "Content-Type: application/json" \ -d "${PAYLOAD}" \ "https://git.bongbetic.com/api/v1/repos/xavierk/Fenris${RELEASE_PATH}" - name: Attach artifacts to release env: GITEA_PUBLISH_TOKEN: ${{ secrets.GITEAPACKAGETOKEN }} run: | set -euo pipefail VERSION=${{ steps.version.outputs.version }} # Get release ID for this tag RELEASE_JSON=$(curl --fail --silent --show-error \ -H "Authorization: token ${GITEA_PUBLISH_TOKEN}" \ "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. ARTIFACTS=( "dist/fenris_${VERSION}_amd64.deb" "dist/fenris-${VERSION}-1.x86_64.rpm" "dist/SHA256SUMS.asc" ) # Add XBPS artifacts if they exist XBPS_FILE="fenris-${VERSION}_1.x86_64.xbps" if [ -f "${XBPS_FILE}" ]; then if [ -f "${XBPS_FILE}.sig2" ]; then ARTIFACTS+=("${XBPS_FILE}") ARTIFACTS+=("${XBPS_FILE}.sig2") else echo "::warning::Unsigned XBPS artifact omitted from release assets" fi 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" fi done - name: Remove XBPS signing key if: always() run: rm -f ~/.ssh/id_xbps