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
This commit is contained in:
@@ -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"
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
## Agent skills
|
## Agent skills
|
||||||
|
|
||||||
|
## Commit messages
|
||||||
|
|
||||||
|
Do not add `Co-authored-by: CommandCodeBot <noreply@commandcode.ai>` or other
|
||||||
|
CommandCodeBot attribution trailers to commits.
|
||||||
|
|
||||||
### Issue tracker
|
### Issue tracker
|
||||||
|
|
||||||
Issues are tracked in Gitea using the authenticated `tea` CLI. See `docs/agents/issue-tracker.md`.
|
Issues are tracked in Gitea using the authenticated `tea` CLI. See `docs/agents/issue-tracker.md`.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user