Skip to content

Commit bac6934

Browse files
authored
Merge pull request #14 from JSONbored/codex/standardize-package-tags
feat(release): standardize package tags and add release automation
2 parents d44858a + 3930142 commit bac6934

7 files changed

Lines changed: 326 additions & 35 deletions

File tree

.github/workflows/build.yml

Lines changed: 8 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -282,24 +282,19 @@ jobs:
282282
- name: Compute image tags
283283
id: prep
284284
run: |
285-
image="${REGISTRY}/${IMAGE_NAME}"
285+
image="$(printf '%s' "${REGISTRY}/${IMAGE_NAME}" | tr '[:upper:]' '[:lower:]')"
286286
sha_tag="${image}:sha-${GITHUB_SHA}"
287-
version="$(sed -n 's/^ARG UPSTREAM_VERSION=//p' Dockerfile | head -n1)"
288-
version_no_v="${version#v}"
287+
raw_version="$(sed -n 's/^ARG UPSTREAM_VERSION=//p' Dockerfile | head -n1)"
288+
upstream_version="${raw_version%%@*}"
289+
aio_track="aio-v1"
289290
290291
{
291-
echo "upstream_version=${version}"
292+
echo "upstream_version=${upstream_version}"
292293
echo "tags<<EOF"
293294
echo "${image}:latest"
294-
if [[ -n "${version}" ]]; then
295-
IFS='.' read -r major minor patch <<< "${version_no_v}"
296-
echo "${image}:${version}"
297-
if [[ -n "${major:-}" ]]; then
298-
echo "${image}:v${major}"
299-
fi
300-
if [[ -n "${major:-}" && -n "${minor:-}" ]]; then
301-
echo "${image}:v${major}.${minor}"
302-
fi
295+
if [[ -n "${upstream_version}" ]]; then
296+
echo "${image}:${upstream_version}"
297+
echo "${image}:${upstream_version}-${aio_track}"
303298
fi
304299
echo "${sha_tag}"
305300
echo "EOF"

.github/workflows/release.yml

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
name: Release / simplelogin-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+
steps:
16+
- name: Checkout
17+
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
18+
with:
19+
fetch-depth: 0
20+
- name: Install git-cliff
21+
env:
22+
GIT_CLIFF_VERSION: 2.12.0
23+
run: |
24+
archive="git-cliff-${GIT_CLIFF_VERSION}-x86_64-unknown-linux-gnu.tar.gz"
25+
curl -fsSL -o "/tmp/${archive}" "https://github.com/orhun/git-cliff/releases/download/v${GIT_CLIFF_VERSION}/${archive}"
26+
tar -xzf "/tmp/${archive}" -C /tmp
27+
install "/tmp/git-cliff-${GIT_CLIFF_VERSION}/git-cliff" /usr/local/bin/git-cliff
28+
- name: Compute release version
29+
id: version
30+
run: echo "release_version=$(python3 scripts/release.py next-version)" >> "${GITHUB_OUTPUT}"
31+
- name: Generate changelog
32+
env:
33+
GITHUB_REPO: ${{ github.repository }}
34+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
35+
RELEASE_VERSION: ${{ steps.version.outputs.release_version }}
36+
run: git-cliff --config cliff.toml --tag "${RELEASE_VERSION}" --output CHANGELOG.md
37+
- name: Create release PR
38+
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
39+
with:
40+
commit-message: "chore(release): ${{ steps.version.outputs.release_version }}"
41+
title: "chore(release): ${{ steps.version.outputs.release_version }}"
42+
body: |
43+
This PR prepares `${{ steps.version.outputs.release_version }}`.
44+
branch: "release/${{ steps.version.outputs.release_version }}"
45+
delete-branch: true
46+
47+
publish-release-on-merge:
48+
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): ') }}"
49+
runs-on: ubuntu-latest
50+
permissions:
51+
contents: write
52+
steps:
53+
- name: Checkout merge commit
54+
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
55+
with:
56+
ref: ${{ github.event.pull_request.merge_commit_sha }}
57+
fetch-depth: 0
58+
- name: Determine release version
59+
id: version
60+
env:
61+
PR_TITLE: ${{ github.event.pull_request.title }}
62+
run: |
63+
release_version="${PR_TITLE#chore(release): }"
64+
echo "release_version=${release_version}" >> "${GITHUB_OUTPUT}"
65+
test "$(python3 scripts/release.py latest-changelog-version)" = "${release_version}"
66+
- name: Extract release notes
67+
id: notes
68+
env:
69+
RELEASE_VERSION: ${{ steps.version.outputs.release_version }}
70+
run: |
71+
{
72+
echo "release_notes<<EOF"
73+
python3 scripts/release.py extract-release-notes "${RELEASE_VERSION}"
74+
echo "EOF"
75+
} >> "${GITHUB_OUTPUT}"
76+
- name: Create Git tag if missing
77+
env:
78+
RELEASE_VERSION: ${{ steps.version.outputs.release_version }}
79+
MERGE_SHA: ${{ github.event.pull_request.merge_commit_sha }}
80+
run: |
81+
if git rev-parse "${RELEASE_VERSION}" >/dev/null 2>&1; then exit 0; fi
82+
git config user.name "github-actions[bot]"
83+
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
84+
git tag "${RELEASE_VERSION}" "${MERGE_SHA}"
85+
git push origin "${RELEASE_VERSION}"
86+
- name: Publish GitHub release
87+
env:
88+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
89+
RELEASE_VERSION: ${{ steps.version.outputs.release_version }}
90+
RELEASE_NOTES: ${{ steps.notes.outputs.release_notes }}
91+
MERGE_SHA: ${{ github.event.pull_request.merge_commit_sha }}
92+
run: |
93+
if gh release view "${RELEASE_VERSION}" >/dev/null 2>&1; then exit 0; fi
94+
notes_file="$(mktemp)"
95+
printf '%s\n' "${RELEASE_NOTES}" > "${notes_file}"
96+
gh release create "${RELEASE_VERSION}" --title "${RELEASE_VERSION}" --notes-file "${notes_file}" --target "${MERGE_SHA}"

AGENTS.md

Lines changed: 31 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,40 @@
1-
# simplelogin-aio Agent Notes
1+
# AGENTS.md
22

3-
`simplelogin-aio` packages the SimpleLogin stack into one Unraid-friendly container.
3+
This repository is part of a broader portfolio of Unraid-first AIO projects.
44

5-
## Runtime Shape
5+
## Repository intent
66

7-
- SimpleLogin web app
8-
- Background jobs
9-
- Inbound email handler
10-
- Postfix
11-
- Internal PostgreSQL
12-
- Internal Redis
7+
- This repo packages an opinionated, beginner-friendly Unraid AIO deployment.
8+
- Default behavior should optimize for a reliable first boot on Unraid.
9+
- Advanced users should retain escape hatches where supported.
1310

14-
## Important Behavior
11+
## Engineering expectations
1512

16-
- This is still a mail product, so the XML and docs must stay clear about required DNS and domain setup.
17-
- Default mode should remain self-contained for app/runtime services, but users may still override `DB_URI` and `REDIS_URL`.
18-
- `/appdata` is the main persistence volume.
19-
- `/pgp` is optional and should stay an advanced path.
20-
- Current upstream image support is `linux/amd64` only.
13+
- Prefer consistency with `unraid-aio-template` over one-off repo behavior.
14+
- Keep CI and release behavior aligned with the rest of the AIO fleet.
15+
- Respect protected branches and use PR-based automation for external sync flows.
16+
- Favor operational clarity over cleverness.
2117

22-
## CI And Publish Policy
18+
## Release model
2319

24-
- Validation and smoke tests should run on PRs and branch pushes.
25-
- Publish should happen only from the default branch.
26-
- GHCR image naming must stay lowercase.
20+
- Container packages publish automatically from `main`.
21+
- Formal changelog updates and GitHub Releases are release-driven.
22+
- Releases use `upstream version + aio revision`, for example `v4.79.0-aio.1`.
23+
- Keep changelog-friendly Conventional Commit titles and PR titles.
2724

28-
## What To Preserve
25+
## Unraid expectations
2926

30-
- Keep beginner docs explicit about mail-routing reality; this is not a "just click once and receive internet mail" product.
31-
- Smoke tests should validate initialization, restart, and persistence without pretending to fully simulate real DNS/mail delivery.
27+
- Unraid-facing XML/icon assets should stay aligned with `awesome-unraid`.
28+
- User-facing metadata should remain accurate:
29+
- `Project`
30+
- `Support`
31+
- `TemplateURL`
32+
- `Icon`
33+
- `Overview`
34+
- `Category`
35+
36+
## Documentation expectations
37+
38+
- Be explicit about operational tradeoffs.
39+
- Do not imply the AIO model removes inherent complexity from the upstream software.
40+
- Keep beginner defaults simple, but document power-user override paths where they exist.

README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,14 @@ Local validation completed on March 29, 2026:
4646
- internal PostgreSQL, Redis, web, background jobs, and Postfix all validated in the same container
4747
- workflow hardening added with pinned action SHAs, dependency review, and upstream release tracking
4848

49+
## Releases
50+
51+
`simplelogin-aio` uses upstream-version-plus-AIO-revision releases such as `v4.79.0-aio.1`.
52+
53+
Every `main` build publishes `latest`, the exact pinned upstream version, an explicit packaging line tag, and `sha-<commit>`.
54+
55+
See [docs/releases.md](/Users/shadowbook/Documents/simplelogin-aio/docs/releases.md) for the release workflow details.
56+
4957
## Support
5058

5159
- Issues: [JSONbored/simplelogin-aio issues](https://github.com/JSONbored/simplelogin-aio/issues)

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+
{% if not loop.last %}\n{% endif -%}
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: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# Releases
2+
3+
`simplelogin-aio` uses upstream-version-plus-AIO-revision releases such as `v4.79.0-aio.1`.
4+
5+
## Published image tags
6+
7+
Every `main` build publishes:
8+
9+
- `latest`
10+
- the exact pinned upstream version
11+
- an explicit packaging line tag like `v4.79.0-aio-v1`
12+
- `sha-<commit>`
13+
14+
## Release flow
15+
16+
1. Trigger **Release / simplelogin-aio** from `main`.
17+
2. The workflow computes the next `upstream-aio.N` version and opens a release PR.
18+
3. Merge that PR into `main`.
19+
4. After merge, the workflow creates the Git tag and GitHub Release automatically.

scripts/release.py

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

0 commit comments

Comments
 (0)