Implement independent format gates for release workflow (issue #86)

- Add XBPS build and sign steps to CI workflow
- Add XBPS publication as independent gate (requires manual trigger)
- Track format availability in release notes
- Update release-footer.md with XBPS install instructions
- Add --available/--withheld arguments to extract_changelog.py
- Attach XBPS artifacts to Gitea release
- Clean up XBPS signing key material after use

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
This commit is contained in:
xavierk
2026-09-15 07:47:34 +05:30
co-authored by CommandCodeBot
parent d93238b0f3
commit 2d4cb16a00
4 changed files with 227 additions and 6 deletions
+107 -6
View File
@@ -8,6 +8,12 @@ on:
tags: tags:
- 'v*' - 'v*'
workflow_dispatch: 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. # Built-in Gitea token needs write access for release assets and package registry.
permissions: permissions:
@@ -62,6 +68,9 @@ jobs:
- name: Build packages - name: Build packages
run: make package run: make package
- name: Build XBPS package
run: make package-xbps
- name: Import packaging key - name: Import packaging key
env: env:
GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }} GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }}
@@ -87,6 +96,26 @@ jobs:
- name: Sign RPM payload - name: Sign RPM payload
run: make sign-rpm 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 - name: Generate and clearsign SHA256SUMS
run: | run: |
set -euo pipefail set -euo pipefail
@@ -116,6 +145,7 @@ jobs:
gpg --batch --yes --delete-secret-keys "${FINGERPRINT}" gpg --batch --yes --delete-secret-keys "${FINGERPRINT}"
gpg --batch --yes --delete-keys "${FINGERPRINT}" gpg --batch --yes --delete-keys "${FINGERPRINT}"
fi fi
rm -f ~/.ssh/id_xbps
- name: Determine version - name: Determine version
id: version id: version
@@ -159,6 +189,54 @@ jobs:
*) echo "::error::RPM upload failed with HTTP ${STATUS}"; exit 1 ;; *) echo "::error::RPM upload failed with HTTP ${STATUS}"; exit 1 ;;
esac esac
- name: Publish XBPS to distribution repository
if: 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
- 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=$([ "${{ github.event.inputs.publish_xbps }}" = "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 - name: Create Gitea release
env: env:
GITEA_PUBLISH_TOKEN: ${{ secrets.GITEAPACKAGETOKEN }} GITEA_PUBLISH_TOKEN: ${{ secrets.GITEAPACKAGETOKEN }}
@@ -174,6 +252,18 @@ jobs:
echo "::error::validated release body is missing or empty" echo "::error::validated release body is missing or empty"
exit 1 exit 1
fi 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_RELEASE="${RUNNER_TEMP}/existing-release.json"
EXISTING=$(curl --silent --show-error -o "${EXISTING_RELEASE}" -w '%{http_code}' \ EXISTING=$(curl --silent --show-error -o "${EXISTING_RELEASE}" -w '%{http_code}' \
-H "Authorization: token ${GITEA_PUBLISH_TOKEN}" \ -H "Authorization: token ${GITEA_PUBLISH_TOKEN}" \
@@ -182,11 +272,11 @@ jobs:
200) 200)
echo "Release v${VERSION} exists; resynchronizing its notes" echo "Release v${VERSION} exists; resynchronizing its notes"
REQUEST="$(python3 scripts/release_request.py --version "${VERSION}" \ REQUEST="$(python3 scripts/release_request.py --version "${VERSION}" \
--body-file "${RELEASE_BODY}" --existing-release "${EXISTING_RELEASE}")" --body-file "${RELEASE_BODY_WITH_FORMATS}" --existing-release "${EXISTING_RELEASE}")"
;; ;;
404) 404)
REQUEST="$(python3 scripts/release_request.py --version "${VERSION}" \ REQUEST="$(python3 scripts/release_request.py --version "${VERSION}" \
--body-file "${RELEASE_BODY}")" --body-file "${RELEASE_BODY_WITH_FORMATS}")"
;; ;;
*) *)
echo "::error::release lookup failed with HTTP ${EXISTING}" echo "::error::release lookup failed with HTTP ${EXISTING}"
@@ -214,10 +304,21 @@ jobs:
"https://git.bongbetic.com/api/v1/repos/xavierk/Fenris/releases/tags/v${VERSION}") "https://git.bongbetic.com/api/v1/repos/xavierk/Fenris/releases/tags/v${VERSION}")
RELEASE_ID=$(printf '%s' "${RELEASE_JSON}" \ RELEASE_ID=$(printf '%s' "${RELEASE_JSON}" \
| python3 -c "import sys,json; print(json.load(sys.stdin)['id'])") | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
# Attach deb, rpm, and clearsigned checksums once. # Attach deb, rpm, clearsigned checksums, and XBPS artifacts once.
for FILE in "dist/fenris_${VERSION}_amd64.deb" \ ARTIFACTS=(
"dist/fenris-${VERSION}-1.x86_64.rpm" \ "dist/fenris_${VERSION}_amd64.deb"
"dist/SHA256SUMS.asc"; do "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
ARTIFACTS+=("${XBPS_FILE}")
if [ -f "${XBPS_FILE}.sig2" ]; then
ARTIFACTS+=("${XBPS_FILE}.sig2")
fi
fi
for FILE in "${ARTIFACTS[@]}"; do
ASSET_NAME="${FILE##*/}" 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 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" echo "${ASSET_NAME}: already attached"
+7
View File
@@ -6,6 +6,13 @@ Install Fenris from its package channel after following the [package setup instr
sudo apt update && sudo apt install fenris # Debian / Ubuntu sudo apt update && sudo apt install fenris # Debian / Ubuntu
sudo dnf install fenris # Fedora sudo dnf install fenris # Fedora
sudo zypper install fenris # openSUSE Tumbleweed sudo zypper install fenris # openSUSE Tumbleweed
sudo xbps-install fenris # Void Linux
```
For Void Linux, configure the XBPS repository first:
```bash
sudo xbps-install -S https://git.bongbetic.com/xavierk/Fenris-xbps/raw/branch/stable/x86_64
``` ```
## Verify downloads ## Verify downloads
+30
View File
@@ -68,6 +68,20 @@ def assemble_release_body(section: str, footer: str) -> str:
return f"{section}{separator}{footer}" return f"{section}{separator}{footer}"
def format_availability_section(
available: list[str], withheld: list[str]
) -> str:
"""Generate a package formats section for release notes."""
if not available and not withheld:
return ""
lines = ["\n## Package formats\n"]
if available:
lines.append(f"Available: {', '.join(available)}")
if withheld:
lines.append(f"Withheld: {', '.join(withheld)}")
return "\n".join(lines)
def main(argv: list[str] | None = None) -> int: def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__) parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("changelog", type=Path) parser.add_argument("changelog", type=Path)
@@ -77,6 +91,18 @@ def main(argv: list[str] | None = None) -> int:
type=Path, type=Path,
help="append this standing release guidance after the extracted section", help="append this standing release guidance after the extracted section",
) )
parser.add_argument(
"--available",
action="append",
default=[],
help="format available for this release (can be repeated)",
)
parser.add_argument(
"--withheld",
action="append",
default=[],
help="format withheld from this release (can be repeated)",
)
args = parser.parse_args(argv) args = parser.parse_args(argv)
try: try:
section = extract_changelog(args.changelog, args.version) section = extract_changelog(args.changelog, args.version)
@@ -88,6 +114,10 @@ def main(argv: list[str] | None = None) -> int:
f"cannot read release footer {args.footer}: {error.strerror}" f"cannot read release footer {args.footer}: {error.strerror}"
) from error ) from error
section = assemble_release_body(section, footer) section = assemble_release_body(section, footer)
if args.available or args.withheld:
formats = format_availability_section(args.available, args.withheld)
if formats:
section = f"{section}\n{formats}"
sys.stdout.write(section) sys.stdout.write(section)
except ChangelogError as error: except ChangelogError as error:
print(f"::error::{error}", file=sys.stderr) print(f"::error::{error}", file=sys.stderr)
+83
View File
@@ -209,3 +209,86 @@ def test_release_request_command_reports_create_or_patch_decisions(tmp_path):
"path": "/releases/17", "path": "/releases/17",
"payload": {"body": "## [1.4.0] - 2026-09-10\n"}, "payload": {"body": "## [1.4.0] - 2026-09-10\n"},
} }
def test_format_availability_section_with_both():
extractor = _extractor_module()
result = extractor.format_availability_section(
available=["Debian/Ubuntu (deb)", "Fedora/openSUSE (rpm)"],
withheld=["Void Linux (xbps) — pending host acceptance"],
)
assert "Available: Debian/Ubuntu (deb), Fedora/openSUSE (rpm)" in result
assert "Withheld: Void Linux (xbps) — pending host acceptance" in result
assert "## Package formats" in result
def test_format_availability_section_available_only():
extractor = _extractor_module()
result = extractor.format_availability_section(
available=["Debian/Ubuntu (deb)", "Fedora/openSUSE (rpm)", "Void Linux (xbps)"],
withheld=[],
)
assert "Available: Debian/Ubuntu (deb), Fedora/openSUSE (rpm), Void Linux (xbps)" in result
assert "Withheld" not in result
def test_format_availability_section_empty():
extractor = _extractor_module()
result = extractor.format_availability_section(available=[], withheld=[])
assert result == ""
def test_command_includes_format_availability(tmp_path):
changelog = tmp_path / "CHANGELOG.md"
changelog.write_text(
"# Changelog\n\n## [Unreleased]\n\n## [1.4.0] - 2026-09-10\n\n"
"### Added\n\n- Show a release summary.\n",
encoding="utf-8",
)
result = subprocess.run(
[
sys.executable,
str(EXTRACTOR_PATH),
str(changelog),
"1.4.0",
"--available",
"Debian/Ubuntu (deb)",
"--available",
"Fedora/openSUSE (rpm)",
"--withheld",
"Void Linux (xbps)",
],
capture_output=True,
text=True,
check=False,
)
assert result.returncode == 0
assert "## Package formats" in result.stdout
assert "Available: Debian/Ubuntu (deb), Fedora/openSUSE (rpm)" in result.stdout
assert "Withheld: Void Linux (xbps)" in result.stdout
def test_command_without_format_args_has_no_formats_section(tmp_path):
changelog = tmp_path / "CHANGELOG.md"
changelog.write_text(
"# Changelog\n\n## [Unreleased]\n\n## [1.4.0] - 2026-09-10\n\n"
"### Added\n\n- Show a release summary.\n",
encoding="utf-8",
)
result = subprocess.run(
[
sys.executable,
str(EXTRACTOR_PATH),
str(changelog),
"1.4.0",
],
capture_output=True,
text=True,
check=False,
)
assert result.returncode == 0
assert "## Package formats" not in result.stdout