Skip to content

Commit d8d384e

Browse files
authored
Standardize tags and add release automation (#17)
1 parent 515fa39 commit d8d384e

6 files changed

Lines changed: 338 additions & 13 deletions

File tree

.github/workflows/build.yml

Lines changed: 5 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -289,26 +289,19 @@ jobs:
289289
- name: Compute image tags
290290
id: prep
291291
run: |
292-
image="${REGISTRY}/${IMAGE_NAME}"
292+
image="$(printf '%s' "${REGISTRY}/${IMAGE_NAME}" | tr '[:upper:]' '[:lower:]')"
293293
sha_tag="${image}:sha-${GITHUB_SHA}"
294-
aio_version="aio-v1"
295-
upstream_version="$(sed -n 's/^ARG UPSTREAM_VERSION=//p' Dockerfile | head -n1)"
296-
upstream_no_v="${upstream_version#v}"
294+
raw_version="$(sed -n 's/^ARG UPSTREAM_VERSION=//p' Dockerfile | head -n1)"
295+
upstream_version="${raw_version%%@*}"
296+
aio_track="aio-v1"
297297
298298
{
299299
echo "upstream_version=${upstream_version}"
300300
echo "tags<<EOF"
301301
echo "${image}:latest"
302302
if [[ -n "${upstream_version}" ]]; then
303-
IFS='.' read -r major minor patch <<< "${upstream_no_v}"
304303
echo "${image}:${upstream_version}"
305-
echo "${image}:${upstream_version}-${aio_version}"
306-
if [[ -n "${major:-}" ]]; then
307-
echo "${image}:v${major}"
308-
fi
309-
if [[ -n "${major:-}" && -n "${minor:-}" ]]; then
310-
echo "${image}:v${major}.${minor}"
311-
fi
304+
echo "${image}:${upstream_version}-${aio_track}"
312305
fi
313306
echo "${sha_tag}"
314307
echo "EOF"

.github/workflows/release.yml

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
name: Release / Sure-AIO
2+
3+
on:
4+
workflow_dispatch:
5+
pull_request_target:
6+
types: [closed]
7+
8+
jobs:
9+
prepare-release:
10+
if: ${{ github.ref == 'refs/heads/main' }}
11+
runs-on: ubuntu-latest
12+
permissions:
13+
contents: write
14+
pull-requests: write
15+
outputs:
16+
release_version: ${{ steps.version.outputs.release_version }}
17+
steps:
18+
- name: Checkout
19+
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
20+
with:
21+
fetch-depth: 0
22+
23+
- name: Install git-cliff
24+
env:
25+
GIT_CLIFF_VERSION: 2.12.0
26+
run: |
27+
archive="git-cliff-${GIT_CLIFF_VERSION}-x86_64-unknown-linux-gnu.tar.gz"
28+
curl -fsSL -o "/tmp/${archive}" "https://github.com/orhun/git-cliff/releases/download/v${GIT_CLIFF_VERSION}/${archive}"
29+
tar -xzf "/tmp/${archive}" -C /tmp
30+
install "/tmp/git-cliff-${GIT_CLIFF_VERSION}/git-cliff" /usr/local/bin/git-cliff
31+
git-cliff --version
32+
33+
- name: Compute release version
34+
id: version
35+
run: |
36+
release_version="$(python3 scripts/release.py next-version)"
37+
echo "release_version=${release_version}" >> "${GITHUB_OUTPUT}"
38+
39+
- name: Generate changelog
40+
env:
41+
GITHUB_REPO: ${{ github.repository }}
42+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
43+
RELEASE_VERSION: ${{ steps.version.outputs.release_version }}
44+
run: |
45+
git-cliff --config cliff.toml --tag "${RELEASE_VERSION}" --output CHANGELOG.md
46+
parsed_version="$(python3 scripts/release.py latest-changelog-version)"
47+
if [[ "${parsed_version}" != "${RELEASE_VERSION}" ]]; then
48+
echo "Generated changelog top section ${parsed_version} does not match ${RELEASE_VERSION}" >&2
49+
exit 1
50+
fi
51+
52+
- name: Create release PR
53+
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
54+
with:
55+
commit-message: "chore(release): ${{ steps.version.outputs.release_version }}"
56+
title: "chore(release): ${{ steps.version.outputs.release_version }}"
57+
body: |
58+
This PR prepares `${{ steps.version.outputs.release_version }}`.
59+
60+
- updates `CHANGELOG.md` with `git-cliff`
61+
- is intended to be merged to `main`
62+
- will trigger GitHub Release publishing after merge
63+
branch: "release/${{ steps.version.outputs.release_version }}"
64+
delete-branch: true
65+
66+
publish-release-on-merge:
67+
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): ') }}"
68+
runs-on: ubuntu-latest
69+
permissions:
70+
contents: write
71+
steps:
72+
- name: Checkout merge commit
73+
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
74+
with:
75+
ref: ${{ github.event.pull_request.merge_commit_sha }}
76+
fetch-depth: 0
77+
78+
- name: Determine release version
79+
id: version
80+
env:
81+
PR_TITLE: ${{ github.event.pull_request.title }}
82+
run: |
83+
release_version="${PR_TITLE#chore(release): }"
84+
echo "release_version=${release_version}" >> "${GITHUB_OUTPUT}"
85+
changelog_version="$(python3 scripts/release.py latest-changelog-version)"
86+
if [[ "${changelog_version}" != "${release_version}" ]]; then
87+
echo "CHANGELOG top entry ${changelog_version} does not match ${release_version}" >&2
88+
exit 1
89+
fi
90+
91+
- name: Extract release notes
92+
id: notes
93+
env:
94+
RELEASE_VERSION: ${{ steps.version.outputs.release_version }}
95+
run: |
96+
{
97+
echo "release_notes<<EOF"
98+
python3 scripts/release.py extract-release-notes "${RELEASE_VERSION}"
99+
echo "EOF"
100+
} >> "${GITHUB_OUTPUT}"
101+
102+
- name: Create Git tag if missing
103+
env:
104+
RELEASE_VERSION: ${{ steps.version.outputs.release_version }}
105+
MERGE_SHA: ${{ github.event.pull_request.merge_commit_sha }}
106+
run: |
107+
if git rev-parse "${RELEASE_VERSION}" >/dev/null 2>&1; then
108+
echo "Tag ${RELEASE_VERSION} already exists; skipping."
109+
exit 0
110+
fi
111+
112+
git config user.name "github-actions[bot]"
113+
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
114+
git tag "${RELEASE_VERSION}" "${MERGE_SHA}"
115+
git push origin "${RELEASE_VERSION}"
116+
117+
- name: Publish GitHub release
118+
env:
119+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
120+
RELEASE_VERSION: ${{ steps.version.outputs.release_version }}
121+
RELEASE_NOTES: ${{ steps.notes.outputs.release_notes }}
122+
MERGE_SHA: ${{ github.event.pull_request.merge_commit_sha }}
123+
run: |
124+
if gh release view "${RELEASE_VERSION}" >/dev/null 2>&1; then
125+
echo "GitHub release ${RELEASE_VERSION} already exists; skipping."
126+
exit 0
127+
fi
128+
129+
notes_file="$(mktemp)"
130+
printf '%s\n' "${RELEASE_NOTES}" > "${notes_file}"
131+
gh release create "${RELEASE_VERSION}" \
132+
--title "${RELEASE_VERSION}" \
133+
--notes-file "${notes_file}" \
134+
--target "${MERGE_SHA}"

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,9 @@ Just make sure `/mnt/user/appdata/sure-aio` is covered by your standard Unraid C
6565

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

7072
## License & Acknowledgements
7173
- The underlying application code is maintained by the incredible [community at we-promise/sure](https://github.com/we-promise/sure).

cliff.toml

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
[changelog]
2+
header = """
3+
# Changelog
4+
5+
All notable changes to this project will be documented in this file.
6+
"""
7+
body = """
8+
{% if version %}## {{ version }} - {{ timestamp | date(format="%Y-%m-%d") }}{% else %}## Unreleased{% endif %}
9+
{% for group, commits in commits | group_by(attribute="group") %}
10+
### {{ group }}
11+
{% for commit in commits %}
12+
- {{ commit.message | split(pat="\n") | first | trim | upper_first }}
13+
{% endfor %}
14+
15+
{% endfor %}
16+
"""
17+
trim = true
18+
footer = "<!-- generated by git-cliff -->"
19+
20+
[git]
21+
conventional_commits = true
22+
filter_unconventional = false
23+
require_conventional = false
24+
split_commits = false
25+
protect_breaking_commits = true
26+
tag_pattern = '^v?[0-9].*-aio\\.[0-9]+$'
27+
sort_commits = "oldest"
28+
commit_preprocessors = [
29+
{ pattern = " \\(#\\d+\\)$", replace = "" },
30+
]
31+
commit_parsers = [
32+
{ message = "^Merge pull request", skip = true },
33+
{ message = "^chore\\(release\\):", skip = true },
34+
{ message = "^feat", group = "Features" },
35+
{ message = "^fix", group = "Fixes" },
36+
{ message = "^perf", group = "Performance" },
37+
{ message = "^refactor", group = "Refactors" },
38+
{ message = "^docs?", group = "Documentation" },
39+
{ message = "^ci", group = "CI" },
40+
{ message = "^test", group = "Tests" },
41+
{ message = "^build", group = "Build" },
42+
{ message = "^chore\\(deps", group = "Dependency Updates" },
43+
{ message = "^chore", group = "Maintenance" },
44+
{ message = "^revert", group = "Reverts" },
45+
{ message = "^[A-Z].*", group = "Other Changes" },
46+
]

docs/releases.md

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
# Releases
2+
3+
`sure-aio` uses GitHub container packages and GitHub releases together, but they mean different things:
4+
5+
- container packages are the images published to GHCR on `main`
6+
- GitHub releases are intentional, versioned milestones for the AIO wrapper itself
7+
8+
## Version format
9+
10+
`sure-aio` follows the pinned upstream Sure version and adds an AIO wrapper revision:
11+
12+
- first wrapper release for upstream `v0.6.8`: `v0.6.8-aio.1`
13+
- second wrapper-only release on the same upstream: `v0.6.8-aio.2`
14+
- first wrapper release after upgrading upstream to `v0.6.9`: `v0.6.9-aio.1`
15+
16+
This keeps the repo honest about what changed:
17+
18+
- the upstream application version
19+
- the JSONbored AIO packaging revision
20+
21+
## Published image tags
22+
23+
Every `main` build publishes:
24+
25+
- `latest`
26+
- the exact pinned upstream version such as `v0.6.8`
27+
- the current packaging line tag such as `v0.6.8-aio-v1`
28+
- `sha-<commit>`
29+
30+
## Release flow
31+
32+
1. Trigger the **Release / Sure-AIO** workflow from `main`.
33+
2. The workflow computes the next version and opens a PR titled `chore(release): <version>`.
34+
3. Merge that PR into `main`.
35+
4. After merge, the release workflow creates the Git tag and GitHub Release automatically from the merged changelog.
36+
37+
This design avoids direct pushes from Actions into a protected `main` branch while still keeping release bookkeeping automated.

scripts/release.py

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
#!/usr/bin/env python3
2+
from __future__ import annotations
3+
4+
import argparse
5+
import pathlib
6+
import re
7+
import subprocess
8+
import sys
9+
10+
11+
ROOT = pathlib.Path(__file__).resolve().parents[1]
12+
DEFAULT_CHANGELOG = ROOT / "CHANGELOG.md"
13+
DEFAULT_DOCKERFILE = ROOT / "Dockerfile"
14+
15+
16+
def read_upstream_version(dockerfile: pathlib.Path) -> str:
17+
pattern = re.compile(r"^ARG UPSTREAM_VERSION=(.+)$")
18+
for line in dockerfile.read_text().splitlines():
19+
match = pattern.match(line.strip())
20+
if match:
21+
return match.group(1).split("@", 1)[0]
22+
raise SystemExit(f"Unable to find ARG UPSTREAM_VERSION in {dockerfile}")
23+
24+
25+
def git_tags() -> list[str]:
26+
output = subprocess.check_output(["git", "tag", "--list"], cwd=ROOT, text=True)
27+
return [line.strip() for line in output.splitlines() if line.strip()]
28+
29+
30+
def next_release_version(dockerfile: pathlib.Path) -> str:
31+
upstream_version = read_upstream_version(dockerfile)
32+
pattern = re.compile(rf"^{re.escape(upstream_version)}-aio\.(\d+)$")
33+
revisions = []
34+
for tag in git_tags():
35+
match = pattern.match(tag)
36+
if match:
37+
revisions.append(int(match.group(1)))
38+
next_revision = max(revisions, default=0) + 1
39+
return f"{upstream_version}-aio.{next_revision}"
40+
41+
42+
def latest_changelog_version(changelog: pathlib.Path) -> str:
43+
pattern = re.compile(r"^##\s+([^\s]+)")
44+
for line in changelog.read_text().splitlines():
45+
match = pattern.match(line.strip())
46+
if match and match.group(1) != "Unreleased":
47+
return match.group(1)
48+
raise SystemExit(f"Unable to find a released version heading in {changelog}")
49+
50+
51+
def extract_release_notes(version: str, changelog: pathlib.Path) -> str:
52+
heading = re.compile(rf"^##\s+{re.escape(version)}(?:\s+-\s+.+)?$")
53+
next_heading = re.compile(r"^##\s+")
54+
55+
lines = changelog.read_text().splitlines()
56+
start = None
57+
for index, line in enumerate(lines):
58+
if heading.match(line.strip()):
59+
start = index + 1
60+
break
61+
62+
if start is None:
63+
raise SystemExit(f"Unable to find release section for {version} in {changelog}")
64+
65+
end = len(lines)
66+
for index in range(start, len(lines)):
67+
if next_heading.match(lines[index].strip()):
68+
end = index
69+
break
70+
71+
notes = "\n".join(lines[start:end]).strip()
72+
if not notes:
73+
raise SystemExit(f"Release section for {version} in {changelog} is empty")
74+
return notes
75+
76+
77+
def main() -> None:
78+
parser = argparse.ArgumentParser(description="Release helpers for sure-aio.")
79+
subparsers = parser.add_subparsers(dest="command", required=True)
80+
81+
upstream_parser = subparsers.add_parser("upstream-version")
82+
upstream_parser.add_argument("--dockerfile", type=pathlib.Path, default=DEFAULT_DOCKERFILE)
83+
84+
next_parser = subparsers.add_parser("next-version")
85+
next_parser.add_argument("--dockerfile", type=pathlib.Path, default=DEFAULT_DOCKERFILE)
86+
87+
latest_parser = subparsers.add_parser("latest-changelog-version")
88+
latest_parser.add_argument("--changelog", type=pathlib.Path, default=DEFAULT_CHANGELOG)
89+
90+
notes_parser = subparsers.add_parser("extract-release-notes")
91+
notes_parser.add_argument("version")
92+
notes_parser.add_argument("--changelog", type=pathlib.Path, default=DEFAULT_CHANGELOG)
93+
94+
args = parser.parse_args()
95+
96+
if args.command == "upstream-version":
97+
print(read_upstream_version(args.dockerfile))
98+
return
99+
if args.command == "next-version":
100+
print(next_release_version(args.dockerfile))
101+
return
102+
if args.command == "latest-changelog-version":
103+
print(latest_changelog_version(args.changelog))
104+
return
105+
if args.command == "extract-release-notes":
106+
print(extract_release_notes(args.version, args.changelog))
107+
return
108+
109+
raise SystemExit(f"Unknown command: {args.command}")
110+
111+
112+
if __name__ == "__main__":
113+
main()

0 commit comments

Comments
 (0)