Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 5 additions & 12 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -289,26 +289,19 @@ jobs:
- name: Compute image tags
id: prep
run: |
image="${REGISTRY}/${IMAGE_NAME}"
image="$(printf '%s' "${REGISTRY}/${IMAGE_NAME}" | tr '[:upper:]' '[:lower:]')"
sha_tag="${image}:sha-${GITHUB_SHA}"
aio_version="aio-v1"
upstream_version="$(sed -n 's/^ARG UPSTREAM_VERSION=//p' Dockerfile | head -n1)"
upstream_no_v="${upstream_version#v}"
raw_version="$(sed -n 's/^ARG UPSTREAM_VERSION=//p' Dockerfile | head -n1)"
upstream_version="${raw_version%%@*}"
aio_track="aio-v1"

{
echo "upstream_version=${upstream_version}"
echo "tags<<EOF"
echo "${image}:latest"
if [[ -n "${upstream_version}" ]]; then
IFS='.' read -r major minor patch <<< "${upstream_no_v}"
echo "${image}:${upstream_version}"
echo "${image}:${upstream_version}-${aio_version}"
if [[ -n "${major:-}" ]]; then
echo "${image}:v${major}"
fi
if [[ -n "${major:-}" && -n "${minor:-}" ]]; then
echo "${image}:v${major}.${minor}"
fi
echo "${image}:${upstream_version}-${aio_track}"
fi
echo "${sha_tag}"
echo "EOF"
Expand Down
134 changes: 134 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
name: Release / Sure-AIO

on:
workflow_dispatch:
pull_request_target:
types: [closed]

jobs:
prepare-release:
if: ${{ github.ref == 'refs/heads/main' }}
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
outputs:
release_version: ${{ steps.version.outputs.release_version }}
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0

- name: Install git-cliff
env:
GIT_CLIFF_VERSION: 2.12.0
run: |
archive="git-cliff-${GIT_CLIFF_VERSION}-x86_64-unknown-linux-gnu.tar.gz"
curl -fsSL -o "/tmp/${archive}" "https://github.com/orhun/git-cliff/releases/download/v${GIT_CLIFF_VERSION}/${archive}"
tar -xzf "/tmp/${archive}" -C /tmp
install "/tmp/git-cliff-${GIT_CLIFF_VERSION}/git-cliff" /usr/local/bin/git-cliff
git-cliff --version

- name: Compute release version
id: version
run: |
release_version="$(python3 scripts/release.py next-version)"
echo "release_version=${release_version}" >> "${GITHUB_OUTPUT}"

- name: Generate changelog
env:
GITHUB_REPO: ${{ github.repository }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_VERSION: ${{ steps.version.outputs.release_version }}
run: |
git-cliff --config cliff.toml --tag "${RELEASE_VERSION}" --output CHANGELOG.md
parsed_version="$(python3 scripts/release.py latest-changelog-version)"
if [[ "${parsed_version}" != "${RELEASE_VERSION}" ]]; then
echo "Generated changelog top section ${parsed_version} does not match ${RELEASE_VERSION}" >&2
exit 1
fi

- name: Create release PR
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
with:
commit-message: "chore(release): ${{ steps.version.outputs.release_version }}"
title: "chore(release): ${{ steps.version.outputs.release_version }}"
body: |
This PR prepares `${{ steps.version.outputs.release_version }}`.

- updates `CHANGELOG.md` with `git-cliff`
- is intended to be merged to `main`
- will trigger GitHub Release publishing after merge
branch: "release/${{ steps.version.outputs.release_version }}"
delete-branch: true

publish-release-on-merge:
if: "${{ github.event_name == 'pull_request_target' && github.event.pull_request.merged == true && github.event.pull_request.base.ref == 'main' && startsWith(github.event.pull_request.title, 'chore(release): ') }}"
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout merge commit
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.event.pull_request.merge_commit_sha }}
fetch-depth: 0

- name: Determine release version
id: version
env:
PR_TITLE: ${{ github.event.pull_request.title }}
run: |
release_version="${PR_TITLE#chore(release): }"
echo "release_version=${release_version}" >> "${GITHUB_OUTPUT}"
changelog_version="$(python3 scripts/release.py latest-changelog-version)"
if [[ "${changelog_version}" != "${release_version}" ]]; then
echo "CHANGELOG top entry ${changelog_version} does not match ${release_version}" >&2
exit 1
fi

- name: Extract release notes
id: notes
env:
RELEASE_VERSION: ${{ steps.version.outputs.release_version }}
run: |
{
echo "release_notes<<EOF"
python3 scripts/release.py extract-release-notes "${RELEASE_VERSION}"
echo "EOF"
} >> "${GITHUB_OUTPUT}"

- name: Create Git tag if missing
env:
RELEASE_VERSION: ${{ steps.version.outputs.release_version }}
MERGE_SHA: ${{ github.event.pull_request.merge_commit_sha }}
run: |
if git rev-parse "${RELEASE_VERSION}" >/dev/null 2>&1; then
echo "Tag ${RELEASE_VERSION} already exists; skipping."
exit 0
fi

git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git tag "${RELEASE_VERSION}" "${MERGE_SHA}"
git push origin "${RELEASE_VERSION}"

- name: Publish GitHub release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_VERSION: ${{ steps.version.outputs.release_version }}
RELEASE_NOTES: ${{ steps.notes.outputs.release_notes }}
MERGE_SHA: ${{ github.event.pull_request.merge_commit_sha }}
run: |
if gh release view "${RELEASE_VERSION}" >/dev/null 2>&1; then
echo "GitHub release ${RELEASE_VERSION} already exists; skipping."
exit 0
fi

notes_file="$(mktemp)"
printf '%s\n' "${RELEASE_NOTES}" > "${notes_file}"
gh release create "${RELEASE_VERSION}" \
--title "${RELEASE_VERSION}" \
--notes-file "${notes_file}" \
--target "${MERGE_SHA}"
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,9 @@ Just make sure `/mnt/user/appdata/sure-aio` is covered by your standard Unraid C

- `Sure-AIO` now pins a specific upstream Sure version instead of following the floating `stable` tag.
- The repo monitors stable upstream Sure tags and opens a PR when a newer stable version is released.
- Image publishing supports `latest`, `sha-<commit>`, and release tags when you cut versioned releases.
- Every `main` package publish now ships the exact upstream version tag, an explicit AIO packaging line tag, `latest`, and `sha-<commit>`.
- Formal wrapper releases follow the upstream version plus an AIO revision, such as `v0.6.8-aio.1`.
- See the release workflow details in [docs/releases.md](docs/releases.md).

## License & Acknowledgements
- The underlying application code is maintained by the incredible [community at we-promise/sure](https://github.com/we-promise/sure).
Expand Down
46 changes: 46 additions & 0 deletions cliff.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
[changelog]
header = """
# Changelog

All notable changes to this project will be documented in this file.
"""
body = """
{% if version %}## {{ version }} - {{ timestamp | date(format="%Y-%m-%d") }}{% else %}## Unreleased{% endif %}
{% for group, commits in commits | group_by(attribute="group") %}
### {{ group }}
{% for commit in commits %}
- {{ commit.message | split(pat="\n") | first | trim | upper_first }}
{% endfor %}

{% endfor %}
"""
trim = true
footer = "<!-- generated by git-cliff -->"

[git]
conventional_commits = true
filter_unconventional = false
require_conventional = false
split_commits = false
protect_breaking_commits = true
tag_pattern = '^v?[0-9].*-aio\\.[0-9]+$'
sort_commits = "oldest"
commit_preprocessors = [
{ pattern = " \\(#\\d+\\)$", replace = "" },
]
commit_parsers = [
{ message = "^Merge pull request", skip = true },
{ message = "^chore\\(release\\):", skip = true },
{ message = "^feat", group = "Features" },
{ message = "^fix", group = "Fixes" },
{ message = "^perf", group = "Performance" },
{ message = "^refactor", group = "Refactors" },
{ message = "^docs?", group = "Documentation" },
{ message = "^ci", group = "CI" },
{ message = "^test", group = "Tests" },
{ message = "^build", group = "Build" },
{ message = "^chore\\(deps", group = "Dependency Updates" },
{ message = "^chore", group = "Maintenance" },
{ message = "^revert", group = "Reverts" },
{ message = "^[A-Z].*", group = "Other Changes" },
]
37 changes: 37 additions & 0 deletions docs/releases.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# Releases

`sure-aio` uses GitHub container packages and GitHub releases together, but they mean different things:

- container packages are the images published to GHCR on `main`
- GitHub releases are intentional, versioned milestones for the AIO wrapper itself

## Version format

`sure-aio` follows the pinned upstream Sure version and adds an AIO wrapper revision:

- first wrapper release for upstream `v0.6.8`: `v0.6.8-aio.1`
- second wrapper-only release on the same upstream: `v0.6.8-aio.2`
- first wrapper release after upgrading upstream to `v0.6.9`: `v0.6.9-aio.1`

This keeps the repo honest about what changed:

- the upstream application version
- the JSONbored AIO packaging revision

## Published image tags

Every `main` build publishes:

- `latest`
- the exact pinned upstream version such as `v0.6.8`
- the current packaging line tag such as `v0.6.8-aio-v1`
- `sha-<commit>`

## Release flow

1. Trigger the **Release / Sure-AIO** workflow from `main`.
2. The workflow computes the next version and opens a PR titled `chore(release): <version>`.
3. Merge that PR into `main`.
4. After merge, the release workflow creates the Git tag and GitHub Release automatically from the merged changelog.

This design avoids direct pushes from Actions into a protected `main` branch while still keeping release bookkeeping automated.
113 changes: 113 additions & 0 deletions scripts/release.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
#!/usr/bin/env python3
from __future__ import annotations

import argparse
import pathlib
import re
import subprocess
import sys


ROOT = pathlib.Path(__file__).resolve().parents[1]
DEFAULT_CHANGELOG = ROOT / "CHANGELOG.md"
DEFAULT_DOCKERFILE = ROOT / "Dockerfile"


def read_upstream_version(dockerfile: pathlib.Path) -> str:
pattern = re.compile(r"^ARG UPSTREAM_VERSION=(.+)$")
for line in dockerfile.read_text().splitlines():
match = pattern.match(line.strip())
if match:
return match.group(1).split("@", 1)[0]
raise SystemExit(f"Unable to find ARG UPSTREAM_VERSION in {dockerfile}")


def git_tags() -> list[str]:
output = subprocess.check_output(["git", "tag", "--list"], cwd=ROOT, text=True)
return [line.strip() for line in output.splitlines() if line.strip()]


def next_release_version(dockerfile: pathlib.Path) -> str:
upstream_version = read_upstream_version(dockerfile)
pattern = re.compile(rf"^{re.escape(upstream_version)}-aio\.(\d+)$")
revisions = []
for tag in git_tags():
match = pattern.match(tag)
if match:
revisions.append(int(match.group(1)))
next_revision = max(revisions, default=0) + 1
return f"{upstream_version}-aio.{next_revision}"


def latest_changelog_version(changelog: pathlib.Path) -> str:
pattern = re.compile(r"^##\s+([^\s]+)")
for line in changelog.read_text().splitlines():
match = pattern.match(line.strip())
if match and match.group(1) != "Unreleased":
return match.group(1)
raise SystemExit(f"Unable to find a released version heading in {changelog}")


def extract_release_notes(version: str, changelog: pathlib.Path) -> str:
heading = re.compile(rf"^##\s+{re.escape(version)}(?:\s+-\s+.+)?$")
next_heading = re.compile(r"^##\s+")

lines = changelog.read_text().splitlines()
start = None
for index, line in enumerate(lines):
if heading.match(line.strip()):
start = index + 1
break

if start is None:
raise SystemExit(f"Unable to find release section for {version} in {changelog}")

end = len(lines)
for index in range(start, len(lines)):
if next_heading.match(lines[index].strip()):
end = index
break

notes = "\n".join(lines[start:end]).strip()
if not notes:
raise SystemExit(f"Release section for {version} in {changelog} is empty")
return notes


def main() -> None:
parser = argparse.ArgumentParser(description="Release helpers for sure-aio.")
subparsers = parser.add_subparsers(dest="command", required=True)

upstream_parser = subparsers.add_parser("upstream-version")
upstream_parser.add_argument("--dockerfile", type=pathlib.Path, default=DEFAULT_DOCKERFILE)

next_parser = subparsers.add_parser("next-version")
next_parser.add_argument("--dockerfile", type=pathlib.Path, default=DEFAULT_DOCKERFILE)

latest_parser = subparsers.add_parser("latest-changelog-version")
latest_parser.add_argument("--changelog", type=pathlib.Path, default=DEFAULT_CHANGELOG)

notes_parser = subparsers.add_parser("extract-release-notes")
notes_parser.add_argument("version")
notes_parser.add_argument("--changelog", type=pathlib.Path, default=DEFAULT_CHANGELOG)

args = parser.parse_args()

if args.command == "upstream-version":
print(read_upstream_version(args.dockerfile))
return
if args.command == "next-version":
print(next_release_version(args.dockerfile))
return
if args.command == "latest-changelog-version":
print(latest_changelog_version(args.changelog))
return
if args.command == "extract-release-notes":
print(extract_release_notes(args.version, args.changelog))
return

raise SystemExit(f"Unknown command: {args.command}")


if __name__ == "__main__":
main()
Loading