From ff9417adba7e743efd2444ec999f7bd758db1b88 Mon Sep 17 00:00:00 2001 From: EaZyCheaZ Date: Mon, 26 Oct 2026 10:46:20 -0500 Subject: [PATCH 1/2] Version numbers follow the release flow: patch per dev change, minor per QA cycle, beta count on staging (version 0.5.0) The Changelog check enforces the rules (.github/version_rules.py, with a self-test): into dev the next patch, or the next minor once QA has dev's line; a fix into qa or staging the next free patch on its own line; never a version another branch has; from 1.0 a major only with a 'Before you upgrade' line. promote-qa and promote-staging take --dry-run and report the versions they produce; promote-staging records the beta version, which the Image workflow publishes as the staging image's tag, bakes into the image and records as a vx.y.z-beta.N git tag. merge-back merges the changelog and QA checklist itself, every version highest first. Dependency updates take dev's next version. The beta server shows its beta version on its version cards, in System > Status and on the logo's hover text. The live QA check runs as the newest version with checklist items, so a version with nothing to test by hand doesn't break it. --- .github/dependency_updates.py | 19 +- .github/version_rules.py | 308 ++++++++++++++++++ .github/workflows/build.yml | 6 +- .github/workflows/dependency-updates.yml | 2 +- .github/workflows/image.yml | 35 +- CHANGELOG.md | 7 + CLAUDE.md | 8 + QA-CHECKLIST.md | 4 + docker/Dockerfile | 5 + docs/development.md | 53 ++- docs/setup-guide.md | 4 +- .../src/Components/Page/Header/HeaderLogo.tsx | 6 +- frontend/src/System/Status/About/About.tsx | 9 +- frontend/typings/Globals.d.ts | 1 + release.sh | 190 ++++++++--- src/Directory.Build.props | 2 +- .../Item4dVersionPostsFixture.cs | 45 +++ .../Requestarr/Qa/LiveQaFixture.cs | 5 + .../Announcements/AnnouncementService.cs | 18 +- .../Announcements/ReleaseEnvironment.cs | 17 + .../Requestarr/Announcements/VersionCards.cs | 19 +- .../Requestarr/Discord/DiscordText.cs | 6 +- .../Frontend/InitializeJsonController.cs | 1 + 23 files changed, 674 insertions(+), 96 deletions(-) create mode 100644 .github/version_rules.py diff --git a/.github/dependency_updates.py b/.github/dependency_updates.py index f6f58e8..8498cc6 100644 --- a/.github/dependency_updates.py +++ b/.github/dependency_updates.py @@ -20,6 +20,8 @@ import sys import time +import version_rules + AUTOMATIC = "automatic update" REVIEW = "needs review" SECURITY = "security" @@ -195,9 +197,11 @@ def top_version(changelog): return tuple(int(x) for x in match.groups()) if match else (0, 0, 0) -def next_version(*changelogs): - major, minor, patch = max(top_version(c) for c in changelogs) - return (major, minor, patch + 1) +def next_version(changelogs): + """The version for an update into dev, like any pull request into dev (docs/development.md#versions): the next + patch on dev's line, or the next minor once QA has that line. changelogs: {"dev": ..., "qa": ..., "staging": ...}.""" + versions = {b: ["%s.%s.%s" % m.groups() for m in version_rules.HEADING.finditer(c)] for b, c in changelogs.items()} + return version_rules.next_version("dev", versions) def with_section(changelog, version, day, security_line): @@ -317,7 +321,7 @@ def update_next(repo): current = open("CHANGELOG.md", encoding="utf-8").read() up_to_date = run("git", "merge-base", "--is-ancestor", "origin/dev", "HEAD", check=False) is not None and \ subprocess.run(["git", "merge-base", "--is-ancestor", "origin/dev", "HEAD"]).returncode == 0 - versioned = top_version(current) > max(top_version(t) for t in tops.values()) + versioned = top_version(current) == next_version(tops) if not (up_to_date and versioned): if not up_to_date: @@ -332,7 +336,7 @@ def update_next(repo): # Dependabot never changes these three: start from dev's and add this update's version on top. Nobody can test a # dependency update by hand, so its QA checklist section is the heading only. git("checkout", "origin/dev", "--", "CHANGELOG.md", "QA-CHECKLIST.md", "src/Directory.Build.props") - version = next_version(*tops.values()) + version = next_version(tops) day = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%d") line = SECURITY_LINE if FOR_ADMINS in labels else "" changelog = with_section(open("CHANGELOG.md", encoding="utf-8").read(), version, day, line) @@ -417,7 +421,10 @@ def self_test(): (not ships("npm_and_yarn", ["tool-dep"], package, LOCK), "a build tool's package doesn't ship"), (ships("nuget", ["NLog"]) and ships("docker", ["dotnet/aspnet"]) and not ships("github_actions", ["x"]), "backend and images ship, workflow actions don't"), - (next_version("## 0.4.2 (x)", "## 0.3.4 (x)", "## 0.5.0 (x)") == (0, 5, 1), "next version is above all three"), + (next_version({"dev": "## 0.5.0 (x)\n## 0.4.2 (x)", "qa": "## 0.4.2 (x)", "staging": "## 0.3.4 (x)"}) == (0, 5, 1), + "an update takes the next patch on dev's line"), + (next_version({"dev": "## 0.5.0 (x)\n## 0.4.2 (x)", "qa": "## 0.5.0 (x)", "staging": "## 0.3.4 (x)"}) == (0, 6, 0), + "an update after a promotion to QA starts the next minor"), (with_section("# Changelog\n\nintro\n\n## 0.4.2 (2026-10-25)\n", (0, 4, 3), "2026-10-26", "") == "# Changelog\n\nintro\n\n## 0.4.3 (2026-10-26)\n\n## 0.4.2 (2026-10-25)\n", "a heading-only version"), ("### For admins\n- **Security update:**" in with_section("## 0.1.0 (x)\n", (0, 1, 1), "d", SECURITY_LINE), diff --git a/.github/version_rules.py b/.github/version_rules.py new file mode 100644 index 0000000..0612d17 --- /dev/null +++ b/.github/version_rules.py @@ -0,0 +1,308 @@ +#!/usr/bin/env python3 +"""Requestarr's version numbers follow the release flow (docs/development.md#versions): + + 1. Patch = dev builds. Every pull request into dev bumps the patch number on dev's current line: 0.5.0, 0.5.1, ... + 2. Minor = QA cycles. A promotion to QA takes dev's version as it is; the next pull request into dev after it starts + the next minor at .0 (QA has 0.4.17, so dev's next is 0.5.0). + 3. Staging adds a beta pre-release. QA's version is published to staging as x.y.z-beta.N; N goes up each time the + same line (x.y) is published to staging again: a fix into staging, or a newer QA version of that line. + 4. Production removes it. "Ship it" releases staging's exact code as x.y.z. + 5. Majors. 1.0.0 is the first public release with the version 1 scope. After that a major only when admins must + act on upgrade, said in a "**Before you upgrade:**" line under For admins; until 1.0 nothing triggers a major. + +A fix into qa or staging takes the next patch on that branch's own line that none of dev, qa and staging has yet +(QA on 0.4.17 while dev is on 0.5.x: 0.4.18). Versions are never shared between the three branches. A merge-back keeps +every version of both branches, in version order, highest first, so each line's versions stay together. + + python3 .github/version_rules.py check < branches.json + branches.json: {"dev": ["0.4.17", ...], "qa": [...], "staging": [...]}, each branch's versions newest first, + the target's as the pull request's base has them. Exit 1 with the reason when the version breaks a rule. + python3 .github/version_rules.py next < branches.json + the version the next pull request into that branch takes + python3 .github/version_rules.py beta < tags + staging's beta version for that commit, from `git ls-remote --tags origin` (v0.4.17-beta.1 tags record each + publish; a commit already published keeps its number) + python3 .github/version_rules.py merge + a merge-back of CHANGELOG.md or QA-CHECKLIST.md: every version section of both, highest first + python3 .github/version_rules.py --self-test +""" +import json +import re +import sys + +VERSION = re.compile(r"^(\d+)\.(\d+)\.(\d+)$") +HEADING = re.compile(r"^## (\d+)\.(\d+)\.(\d+)\b", re.MULTILINE) +BETA_TAG = re.compile(r"^v(\d+)\.(\d+)\.(\d+)-beta\.(\d+)$") +UPGRADE_LEAD = "- **Before you upgrade:**" + + +def parse(text): + match = VERSION.match(text.strip()) + if not match: + raise ValueError("not a version: %r" % text) + return tuple(int(x) for x in match.groups()) + + +def text(version): + return "%d.%d.%d" % version + + +def line(version): + return version[:2] + + +def versions_of(branches, name): + return [parse(v) for v in branches.get(name) or []] + + +def used(branches): + return {v for name in ("dev", "qa", "staging") for v in versions_of(branches, name)} + + +def next_dev(branches): + """Rule 1 and 2: the patch after dev's top, or the next minor once QA (or staging) has dev's line.""" + dev = versions_of(branches, "dev") + if not dev: + return (0, 0, 1) + top = dev[0] + promoted = any(versions_of(branches, other) and line(versions_of(branches, other)[0]) == line(top) + for other in ("qa", "staging")) + return (top[0], top[1] + 1, 0) if promoted else (top[0], top[1], top[2] + 1) + + +def next_fix(target, branches): + """A fix into qa or staging: the next patch on that branch's line that no branch has yet.""" + top = versions_of(branches, target)[0] + taken = used(branches) + patch = top[2] + 1 + while (top[0], top[1], patch) in taken: + patch += 1 + return (top[0], top[1], patch) + + +def next_version(target, branches): + return next_dev(branches) if target == "dev" else next_fix(target, branches) + + +def section(changelog, version): + """The lines of one version's section in a changelog.""" + lines, inside = [], False + for row in changelog.splitlines(): + heading = HEADING.match(row) + if heading: + inside = tuple(int(x) for x in heading.groups()) == version + continue + if inside: + lines.append(row) + return lines + + +def admins_must_act(changelog, version): + """A "**Before you upgrade:**" line under For admins: admins have something to do when they upgrade.""" + heading = None + for row in section(changelog, version): + if row.startswith("### "): + heading = row[4:].strip() + elif heading == "For admins" and row.startswith(UPGRADE_LEAD): + return True + return False + + +def check(target, new, changelog, branches): + """None when `new` follows the rules for a pull request into `target`, else the reason it doesn't.""" + if target not in ("dev", "qa", "staging"): + return None + for other in ("dev", "qa", "staging"): + if other != target and new in versions_of(branches, other): + return "%s is already %s's: dev, qa and staging never share a version." % (text(new), other) + must_act = admins_must_act(changelog, new) + + if target == "dev": + top = versions_of(branches, "dev")[0] if versions_of(branches, "dev") else (0, 0, 0) + expected = next_dev(branches) + why = ("QA has dev's %s line, so the next pull request into dev starts the next minor" % ".".join(map(str, line(top))) + if expected[2] == 0 else "every pull request into dev takes the next patch on dev's line") + major = (top[0] + 1, 0, 0) + if new == expected: + if must_act and top[0] >= 1: + return ("%s has a 'Before you upgrade' line: admins must act on upgrade, so it's a major: %s." + % (text(new), text(major))) + return None + if new == major: + if top[0] == 0 and new == (1, 0, 0): + return None + if top[0] >= 1 and must_act: + return None + if top[0] >= 1: + return ("%s is a major, which is only for a change admins must act on: add a '**Before you upgrade:**' " + "line under For admins saying what to do, or take %s." % (text(new), text(expected))) + return "until 1.0 nothing triggers a major: take %s (%s)." % (text(expected), why) + return "this pull request into dev is %s: %s, not %s." % (text(expected), why, text(new)) + + expected = next_fix(target, branches) + if new != expected: + return ("a fix into %s takes the next patch on %s's own line that no branch has yet: %s, not %s." + % (target, target, text(expected), text(new))) + if must_act and versions_of(branches, target)[0][0] >= 1: + return "a fix into %s can't need admins to act on upgrade: that's a major, which only starts on dev." % target + return None + + +def beta(version, commit, refs): + """Staging's x.y.z-beta.N for a commit: the number it already has, or one more than its line's highest.""" + tags = {} + for row in refs.splitlines(): + parts = row.split() + if len(parts) != 2 or not parts[1].startswith("refs/tags/"): + continue + name = parts[1][len("refs/tags/"):] + peeled = name.endswith("^{}") + name = name[:-3] if peeled else name + if peeled or name not in tags: + tags[name] = parts[0] + highest = 0 + for name, sha in tags.items(): + match = BETA_TAG.match(name) + if not match: + continue + tagged = tuple(int(x) for x in match.groups()[:3]) + number = int(match.group(4)) + if tagged == version and sha == commit: + return "%s-beta.%d" % (text(version), number) + if line(tagged) == line(version): + highest = max(highest, number) + return "%s-beta.%d" % (text(version), highest + 1) + + +def split(document): + """The text before the first version, and each version's section by version.""" + matches = list(HEADING.finditer(document)) + if not matches: + return document, {} + sections = {} + for i, match in enumerate(matches): + end = matches[i + 1].start() if i + 1 < len(matches) else len(document) + body = document[match.start():end] + sections[tuple(int(x) for x in match.groups())] = body if body.endswith("\n") else body + "\n" + return document[:matches[0].start()], sections + + +def merge(ours, theirs): + """Every version section of both, highest first; a version both have must read the same.""" + preamble, mine = split(ours) + _, others = split(theirs) + for version in mine.keys() & others.keys(): + if mine[version].rstrip() != others[version].rstrip(): + raise ValueError("%s reads differently on the two branches: merge it by hand" % text(version)) + merged = dict(others) + merged.update(mine) + body = "".join(merged[v] if merged[v].endswith("\n\n") else merged[v] + "\n" for v in sorted(merged, reverse=True)) + return preamble + body.rstrip("\n") + "\n" + + +def self_test(): + on = lambda dev, qa, staging: {"dev": dev, "qa": qa, "staging": staging} + log = lambda *sections: "# Changelog\n\n" + "".join(sections) + upgrade = "## 2.0.0 (2027-01-02)\n\n### For admins\n- **Before you upgrade:** move the bot settings into the new file.\n\n" + plain = lambda v: "## %s (2026-10-27)\n\n" % v + now = on(["0.4.17", "0.4.16"], ["0.4.17", "0.4.16"], ["0.4.2"]) + working = on(["0.5.2", "0.5.1", "0.5.0", "0.4.17"], ["0.4.17"], ["0.4.2"]) + qa_fixed = on(["0.5.2", "0.5.1", "0.5.0", "0.4.17"], ["0.4.18", "0.4.17"], ["0.4.17"]) + older = on(["0.5.5", "0.5.4", "0.5.3"], ["0.5.3"], ["0.4.17"]) + v1 = on(["1.2.3", "1.2.2"], ["1.1.4"], ["1.1.4"]) + ok = lambda target, new, branches, changelog=None: check(target, parse(new), changelog or log(plain(new)), branches) is None + cases = [ + (ok("dev", "0.5.3", working), "a normal dev bump: the next patch"), + (not ok("dev", "0.5.4", working), "a dev bump that skips a patch fails"), + (not ok("dev", "0.6.0", working), "a dev bump to the next minor while QA has an older line fails"), + (ok("dev", "0.5.0", now), "the first dev pull request after a promotion starts the next minor"), + (not ok("dev", "0.4.18", now), "a patch after a promotion fails"), + (not ok("dev", "0.5.1", now), "the next minor starts at .0"), + (ok("dev", "0.6.0", older), "QA took an older version of dev's line: still the next minor"), + (ok("qa", "0.4.18", working), "a QA fix: the next patch on QA's line"), + (not ok("qa", "0.5.3", working), "a QA fix on dev's line fails"), + (not ok("qa", "0.4.19", working), "a QA fix that skips a free patch fails"), + (ok("qa", "0.5.6", older), "a QA fix skips patches dev already has"), + (ok("staging", "0.4.18", on(["0.5.0", "0.4.17"], ["0.4.17"], ["0.4.17"])), "a staging fix: the next patch on staging's line"), + (ok("staging", "0.4.19", qa_fixed), "a staging fix skips QA's fix number"), + (not ok("staging", "0.4.18", qa_fixed), "a staging fix can't share QA's version"), + (not ok("staging", "0.5.0", qa_fixed), "a staging fix on another line fails"), + (ok("dev", "1.0.0", on(["0.9.3"], ["0.8.1"], [])), "1.0.0 from 0.x is the first public release"), + (not ok("dev", "2.0.0", on(["0.9.3"], ["0.8.1"], [])), "until 1.0 there's no other major"), + (not ok("dev", "2.0.0", v1), "a major without a 'Before you upgrade' line fails"), + (ok("dev", "2.0.0", v1, log(upgrade)), "a major with a 'Before you upgrade' line passes"), + (not ok("dev", "1.2.4", v1, log(upgrade.replace("2.0.0", "1.2.4"))), "from 1.0, admins acting on upgrade means a major"), + (ok("dev", "0.5.3", working, log(upgrade.replace("2.0.0", "0.5.3"))), "before 1.0 a 'Before you upgrade' line is fine"), + (not ok("dev", "0.4.17", on(["0.4.16"], ["0.4.17"], [])), "dev can't take a version QA has"), + (next_version("dev", now) == (0, 5, 0) and next_version("dev", working) == (0, 5, 3), "next version for dev"), + (next_version("qa", working) == (0, 4, 18) and next_version("staging", qa_fixed) == (0, 4, 19), "next fix versions"), + (next_version("dev", on(["0.4.18", "0.4.17"], ["0.4.18", "0.4.17"], [])) == (0, 5, 0), + "a QA fix merged back into a dev that hadn't moved: dev's next is the next minor"), + (next_version("dev", on(["0.5.0", "0.4.18", "0.4.17"], ["0.4.18", "0.4.17"], [])) == (0, 5, 1), + "a QA fix merged back below dev's new line: dev carries on"), + ] + refs = "\n".join([ + "aaa\trefs/tags/v0.4.17-beta.1", + "bbb\trefs/tags/v0.4.18-beta.2", + "ccc\trefs/tags/v0.3.9-beta.4", + "ddd\trefs/tags/v0.4.18-beta.2^{}", + "eee\trefs/tags/v0.4.0", + ]) + cases += [ + (beta((0, 4, 17), "new", "") == "0.4.17-beta.1", "the first beta of a line is beta.1"), + (beta((0, 4, 19), "new", refs) == "0.4.19-beta.3", "the same line again: one more than its highest"), + (beta((0, 4, 17), "aaa", refs) == "0.4.17-beta.1", "a commit already published keeps its number"), + (beta((0, 4, 18), "ddd", refs) == "0.4.18-beta.2", "an annotated tag is read by the commit it points to"), + (beta((0, 5, 3), "new", refs) == "0.5.3-beta.1", "a new line starts at beta.1 again"), + ] + dev_log = "# Changelog\n\nintro\n\n## 0.5.1 (2026-10-29)\n- one\n\n## 0.5.0 (2026-10-28)\n\n## 0.4.17 (2026-10-26)\n- old\n" + qa_log = "# Changelog\n\nintro\n\n## 0.4.18 (2026-10-30)\n- **Fix:** a QA fix.\n\n## 0.4.17 (2026-10-26)\n- old\n" + merged = merge(dev_log, qa_log) + order = [m.group(0)[3:] for m in HEADING.finditer(merged)] + cases += [ + (order == ["0.5.1", "0.5.0", "0.4.18", "0.4.17"] and merged.startswith("# Changelog\n\nintro\n\n"), + "a merge-back keeps every version, highest first"), + ("## 0.5.0 (2026-10-28)\n\n## 0.4.18" in merged and merged.endswith("- old\n"), "a heading-only version stays as it is"), + ] + try: + merge(dev_log, qa_log.replace("- old", "- changed")) + cases.append((False, "a version that reads differently isn't merged")) + except ValueError: + cases.append((True, "a version that reads differently isn't merged")) + + failed = [name for passed, name in cases if not passed] + if failed: + print("SELF-TEST FAILED: " + "; ".join(failed)) + return 1 + print("Version rules self-test passed (%d cases)." % len(cases)) + return 0 + + +def main(argv): + if argv[1:] == ["--self-test"]: + return self_test() + if len(argv) == 5 and argv[1] == "check": + with open(argv[4], encoding="utf-8") as changelog: + problem = check(argv[2], parse(argv[3]), changelog.read(), json.load(sys.stdin)) + if problem: + print(problem) + return 1 + return 0 + if len(argv) == 3 and argv[1] == "next": + print(text(next_version(argv[2], json.load(sys.stdin)))) + return 0 + if len(argv) == 4 and argv[1] == "beta": + print(beta(parse(argv[2]), argv[3], sys.stdin.read())) + return 0 + if len(argv) == 5 and argv[1] == "merge": + with open(argv[2], encoding="utf-8") as a, open(argv[3], encoding="utf-8") as b: + merged = merge(a.read(), b.read()) + with open(argv[4], "w", encoding="utf-8", newline="\n") as out: + out.write(merged) + return 0 + print(__doc__) + return 2 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a1dd4b8..3ff7f86 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -135,9 +135,10 @@ jobs: echo "Scanning commits $range" docker run --rm -v "$PWD:/repo" --entrypoint sh "$GITLEAKS" -c "git config --global --add safe.directory /repo && gitleaks git /repo --log-opts='$range' --config /repo/.gitleaks.toml --gitleaks-ignore-path /repo/.gitleaksignore --no-banner --redact --verbose" - # Every pull request records its changes in CHANGELOG.md under a new, higher version, written to the changelog rule + # Every pull request records its changes in CHANGELOG.md under a new version, written to the changelog rule # (docs/development.md#changelog; .github/changelog_check.py says what it checks). Into dev, qa or staging, the - # version must also be free on the other two (release.sh check-changelog). + # version follows the version rules and no other branch has it (docs/development.md#versions, + # .github/version_rules.py; release.sh check-changelog). changelog: name: Changelog runs-on: ubuntu-latest @@ -150,6 +151,7 @@ jobs: run: | python3 .github/changelog_check.py --self-test python3 .github/checklist_check.py --self-test + python3 .github/version_rules.py --self-test python3 docker/qa-gate.py --self-test - name: CHANGELOG.md has a new version for this change, written to the rule env: diff --git a/.github/workflows/dependency-updates.yml b/.github/workflows/dependency-updates.yml index 8c4dc9e..54ded1c 100644 --- a/.github/workflows/dependency-updates.yml +++ b/.github/workflows/dependency-updates.yml @@ -4,7 +4,7 @@ name: Dependency updates # work). A new update is labelled: patch and minor "automatic update", major "needs review" (held, with a comment). # Automatic updates go one at a time: the next version and its changelog section are added, build is started on that # commit and waited for, then the update is merged (or held if build failed) and the next one starts. It also runs when -# dev moves and every hour, so a waiting update always carries a version above dev's, qa's and staging's. +# dev moves and every hour, so a waiting update always carries dev's next version (docs/development.md#versions). on: pull_request: types: [opened, reopened, synchronize] diff --git a/.github/workflows/image.yml b/.github/workflows/image.yml index 23dd526..9042b7f 100644 --- a/.github/workflows/image.yml +++ b/.github/workflows/image.yml @@ -1,7 +1,8 @@ name: Image # Publishes Requestarr's container image to GitHub's container registry (docs/development.md#published-images): -# push to staging (a promotion, or a fix into staging): ghcr.io/runemarklabs/requestarr:staging and :x.y.z +# push to staging (a promotion, or a fix into staging): ghcr.io/runemarklabs/requestarr:staging and :x.y.z-beta.N, +# and the commit is tagged vx.y.z-beta.N: N counts the line's (x.y) publishes to staging (docs/development.md#versions) # push to main (a release, only on the owner's "ship it"): :x.y.z, :x.y and :latest # Run workflow on any branch with a tag starting "test-": that tag only, for trying the workflow (delete it after). # Two architectures: linux/amd64 (most home servers and NAS boxes) and linux/arm64 (Raspberry Pi 4 and 5, ARM NAS @@ -15,8 +16,9 @@ on: description: A test tag to publish this branch under, starting "test-" (nothing else is tagged) required: true +# contents: write only to record each staging publish as a vx.y.z-beta.N tag. permissions: - contents: read + contents: write packages: write concurrency: @@ -47,8 +49,13 @@ jobs: echo "::error::The build's version ($version) and CHANGELOG.md's top version ($top) must match." exit 1 fi + release="$version" case "$EVENT:$BRANCH" in - push:staging) tags="$IMAGE:staging,$IMAGE:$version" ;; + push:staging) + # The beta number this commit already has (a re-run), or one more than its line's highest. + release=$(git ls-remote --tags origin | python3 .github/version_rules.py beta "$version" "$GITHUB_SHA") + tags="$IMAGE:staging,$IMAGE:$release" + ;; push:main) tags="$IMAGE:$version,$IMAGE:${version%.*},$IMAGE:latest" ;; workflow_dispatch:*) if ! [[ "$TEST_TAG" =~ ^test-[a-z0-9.-]+$ ]]; then @@ -63,9 +70,11 @@ jobs: ;; esac echo "version=$version" >> "$GITHUB_OUTPUT" + echo "release=$release" >> "$GITHUB_OUTPUT" + echo "beta=$([ "$release" != "$version" ] && echo "$release")" >> "$GITHUB_OUTPUT" echo "tags=$tags" >> "$GITHUB_OUTPUT" echo "first=${tags%%,*}" >> "$GITHUB_OUTPUT" - echo "Publishing version $version as: $tags" + echo "Publishing version $release as: $tags" - uses: docker/setup-qemu-action@v3 - uses: docker/setup-buildx-action@v3 @@ -84,17 +93,33 @@ jobs: push: true provenance: false tags: ${{ steps.tags.outputs.tags }} + build-args: | + RELEASE_VERSION=${{ steps.tags.outputs.beta }} labels: | org.opencontainers.image.title=Requestarr org.opencontainers.image.description=Discord requests for Sonarr and Radarr, with per-user access, approvals and removal votes. org.opencontainers.image.source=https://github.com/${{ github.repository }} org.opencontainers.image.licenses=GPL-3.0-only - org.opencontainers.image.version=${{ steps.tags.outputs.version }} + org.opencontainers.image.version=${{ steps.tags.outputs.release }} org.opencontainers.image.revision=${{ github.sha }} org.opencontainers.image.ref.name=${{ github.ref_name }} cache-from: type=gha,scope=image cache-to: type=gha,scope=image,mode=max + # The record the next beta number is counted from (and what promote-staging reads): a tag on this commit. + - name: Record the beta publish + if: steps.tags.outputs.beta != '' + env: + BETA: ${{ steps.tags.outputs.beta }} + run: | + if git ls-remote --exit-code --tags origin "refs/tags/v$BETA" >/dev/null; then + echo "v$BETA is already recorded (a re-run of this publish)." + else + git tag "v$BETA" "$GITHUB_SHA" + git push origin "refs/tags/v$BETA" + fi + echo "Beta version: \`$BETA\` (tag \`v$BETA\`)" >> "$GITHUB_STEP_SUMMARY" + # Each platform's image, as published: no file that could hold secrets, no secret-looking environment variable or # build step, and it starts and answers on its port. - name: Check the published image diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c4cb5e..5c7924e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,13 @@ with nothing in it is left out, and a version with nothing for either has only i in Discord: both sections in the admin channel, and **For users** in the announcements channel, word for word. Each version heading carries the day it was made, which its card in Discord shows. +## 0.5.0 (2026-10-26) + +### For admins +- **Reading a version number:** the last number goes up with every change, the middle one each time a new round of testing on the QA server begins, and the first one only for an upgrade you'll have to do something for, which its notes then say. +- **Beta versions:** the beta server shows and announces its version with "beta" and a count after it, such as 0.5.0-beta.2, and the count goes up with each new beta build of that release; the release itself has the plain number. +- **Version on the logo:** point at the logo at the top of the web app to see which version you're running. + ## 0.4.17 (2026-10-26) ## 0.4.16 (2026-10-26) diff --git a/CLAUDE.md b/CLAUDE.md index 13b137e..b8d71d6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,6 +17,14 @@ Code workspace. - Namespaces: copied Servarr foundation keeps `NzbDrone.*`; all new code is `Requestarr.*`. - Changelog (every pull request): bump `` in `src/Directory.Build.props` and add a `## x.y.z (YYYY-MM-DD)` section (the day it was made) at the top of `CHANGELOG.md` with only **For users** and **For admins**, in plain words with a bold lead per line, no code-style text, no placeholders, empty sections left out (work neither sees is not listed). Requestarr posts it in Discord as a lasting record. CI's Changelog job (`.github/changelog_check.py`) and a unit test enforce it. Details: `docs/development.md#changelog`. +- Versions (every pull request; details: `docs/development.md#versions`, enforced by `.github/version_rules.py` in the Changelog check): + 1. Patch = dev builds: every pull request into `dev` (dependency updates too) takes the next patch on `dev`'s line. + 2. Minor = QA cycles: a promotion to QA takes `dev`'s version as it is; the next pull request into `dev` starts the next minor at .0. + 3. Staging adds a beta pre-release: published as `x.y.z-beta.N`, N counting the line's publishes to staging (git tags `vx.y.z-beta.N`). + 4. Production removes it: "ship it" releases staging's code as `x.y.z`. + 5. Majors: 1.0.0 is the first public release; after that a major only when admins must act on upgrade (a For admins line led by **Before you upgrade:**), and the banner and reply say so. Until 1.0, never. + A fix into `qa` or `staging` takes the next free patch on that branch's own line. `./release.sh next-version [dev|qa|staging]` prints the number. + - QA checklist (every pull request): add this version's test items at the top of `QA-CHECKLIST.md` under the same `## x.y.z (YYYY-MM-DD)` heading as the changelog: `- **Name:** what to do. You should see what a pass looks like.`, in plain words an admin can follow in Discord; a version nobody can test by hand has the heading only. CI (`.github/checklist_check.py`) enforces it. Details: `docs/development.md#qa-checklist`. - QA gate (start of every goal, before the task itself): run `./release.sh qa-gate`. When it's clear (QA's checklist complete, no item failing, no bug report from QA open), promote QA to staging first, through the normal release flow (`./release.sh promote-staging "the QA checklist, completed by "`, after the full suite and every live check), and say so in the reply and banner. When it's blocked, don't promote, and say what's left in one line. Staging moves otherwise only on the owner's explicit word, with `--owner-override ""`. If the QA stack isn't running or the gate can't be read, say so and don't promote. diff --git a/QA-CHECKLIST.md b/QA-CHECKLIST.md index 35c62a1..e2a4182 100644 --- a/QA-CHECKLIST.md +++ b/QA-CHECKLIST.md @@ -4,6 +4,10 @@ What admins on the QA server test, in Discord, before a version goes to staging. Every pull request adds its version's items here, the way it adds its changelog entry: each one says what to do and what you should see, in plain words. A version with nothing anyone can test by hand has only its heading. See docs/development.md#qa-checklist. +## 0.5.0 (2026-10-26) +- **Version on the logo:** In the web app, point at the logo at the top left and wait a moment. You should see "Requestarr 0.5.0". +- **Version in Status:** In the web app, open System, then Status. You should see version 0.5.0 followed by its build number, with no "beta" in it. + ## 0.4.17 (2026-10-26) ## 0.4.16 (2026-10-26) diff --git a/docker/Dockerfile b/docker/Dockerfile index cf6ad21..d2bfd24 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -36,6 +36,11 @@ COPY LICENSE /app/LICENSE # Updates come from new images, never from inside the container. ENV REQUESTARR__UPDATE__MECHANISM=Docker +# Staging's beta version (x.y.z-beta.N, docs/development.md#versions), which it shows and posts; the Image workflow +# sets it for staging only, so other builds show their plain version. +ARG RELEASE_VERSION= +ENV REQUESTARR_RELEASE_VERSION=$RELEASE_VERSION + USER app VOLUME /config EXPOSE 9797 diff --git a/docs/development.md b/docs/development.md index 67968cb..8d330b3 100644 --- a/docs/development.md +++ b/docs/development.md @@ -38,7 +38,7 @@ On Linux, swap `Requestarr.Windows.Test.dll` for `Requestarr.Mono.Test.dll` and | Backend (Windows) | solution build (`Platform=Windows`); test suites Api.V1, Common, Core, Host, Libraries, Update, Windows, excluding categories ManualTest, IntegrationTest, AutomationTest, LINUX | | Frontend | `tsc --noEmit`, ESLint, Stylelint, production webpack build | | Docker image | full `docker/Dockerfile` build (not pushed) | -| Changelog | the checker's self-test, then `CHANGELOG.md` against the changelog rule; on pull requests also: `CHANGELOG.md` changed, its top version is higher than the base branch's, and into `dev`, `qa` or `staging` free on the other two (see [Changelog](#changelog)) | +| Changelog | the checkers' self-tests (changelog, QA checklist, QA gate, version rules), then `CHANGELOG.md` against the changelog rule; on pull requests also: `CHANGELOG.md` changed, its top version is higher than the base branch's, and into `dev`, `qa` or `staging` it follows the version rules and no other branch has it (see [Changelog](#changelog) and [Versions](#versions)) | | Promotion record | pull requests that promote `dev`'s commits into `qa`, or `qa`'s into `staging`: the description has a `Requested by:` line (see [Release flow](#release-flow)) | `.github/workflows/drift.yml` runs on every push to `dev`, `qa` or `staging`, daily, and by hand (Actions → Drift → Run workflow, with any branches): it fails while `qa` has a commit `dev` doesn't, or `staging` one `qa` doesn't. It isn't a required check, because a fix merged into `qa` or `staging` is drift until it is merged back, in the same task. @@ -55,7 +55,7 @@ Not run in CI: `CHANGELOG.md` at the repo root records what changed for the people who use Requestarr, and Requestarr posts it in Discord: when a server starts on a new version, the whole entry goes to the admin channel and the **For users** lines go to the announcements channel, word for word (which versions each server shows: [Version posts](#version-posts)). So every pull request does this: -1. **Bump the version** in `src/Directory.Build.props` (`x.y.z.*`): the patch number for fixes and internal work, the minor number for new things people can do. Only `x.y.z` counts; the last part is the build number. +1. **Bump the version** in `src/Directory.Build.props` (`x.y.z.*`) to the one the [version rules](#versions) give the branch the pull request goes into; `./release.sh next-version` (or `next-version qa`, `next-version staging` for a fix there) prints it. Only `x.y.z` counts; the last part is the build number. 2. **Add a `## x.y.z (YYYY-MM-DD)` section at the top of `CHANGELOG.md`**, with the day the version was made (the version cards in Discord show it), following the changelog rule: ```markdown @@ -76,7 +76,34 @@ Not run in CI: - **Summary words only with substance.** "Minor fixes" or "various improvements" may lead a line only if the same line says what changes for the reader. No placeholders ("nothing this time", "n/a"). - **Group related changes**, three to seven lines per section is normal; fixes say what was wrong in the reader's terms ("Fixed: approving a request sometimes left the old Cancel button working."). -**The checks.** CI's `Changelog` job runs `.github/changelog_check.py` (its own self-test first, so a broken rule can't pass silently) and fails a pull request whose changelog has: a section other than For users and For admins, or the same one twice; an empty section heading; loose text that isn't a heading or a `- ` line; code-style text in a line (backticks, a file name, a path, a command flag, a settings key or an environment variable name); a placeholder; a summary phrase with fewer than five words of substance on its line; a version heading without its day; or versions out of order or repeated. Run it yourself with `python3 .github/changelog_check.py CHANGELOG.md`. Then `./release.sh check-changelog` fails a pull request that doesn't change `CHANGELOG.md` or whose top version isn't higher than the base branch's. Into `dev`, `qa` or `staging`, the version must also be higher than the other two branches', unless the pull request already contains that branch, so a fix made in QA or staging never shares a number with `dev`. A merge-back keeps every version from both branches instead of adding one. A unit test checks that the top version matches ``, that the file is embedded in the build, and that **For users** lines avoid technical words. The pull request template has the same checklist. +**The checks.** CI's `Changelog` job runs `.github/changelog_check.py` (its own self-test first, so a broken rule can't pass silently) and fails a pull request whose changelog has: a section other than For users and For admins, or the same one twice; an empty section heading; loose text that isn't a heading or a `- ` line; code-style text in a line (backticks, a file name, a path, a command flag, a settings key or an environment variable name); a placeholder; a summary phrase with fewer than five words of substance on its line; a version heading without its day; or versions out of order or repeated. Run it yourself with `python3 .github/changelog_check.py CHANGELOG.md`. Then `./release.sh check-changelog` fails a pull request that doesn't change `CHANGELOG.md` or whose top version isn't higher than the base branch's. Into `dev`, `qa` or `staging`, the version must also follow the [version rules](#versions) (`.github/version_rules.py`, which has its own self-test): the next patch on `dev`'s line, or the next minor once QA has that line; a fix into `qa` or `staging` the next free patch on that branch's own line; never a version another of the three has. A promotion's versions were checked on the branch below. A merge-back keeps every version from both branches instead of adding one. The QA checklist has the same versions as the changelog, so a wrong number fails its check too. A unit test checks that the top version matches ``, that the file is embedded in the build, and that **For users** lines avoid technical words. The pull request template has the same checklist. + +## Versions + +Version numbers follow the release flow, so a number says how far a build has travelled: + +1. **Patch = dev builds.** Every pull request into `dev` bumps the patch number on `dev`'s current line: 0.5.0, 0.5.1, 0.5.2. Dependency updates too. +2. **Minor = QA cycles.** A promotion to QA takes `dev`'s current version as it is. The next pull request into `dev` after it starts the next minor at .0: QA has 0.4.17, so `dev`'s next is 0.5.0 (no zero padding). +3. **Staging adds a beta pre-release.** A promotion to staging publishes QA's version as `0.4.17-beta.1`. The count goes up each time the same line (0.4) is published to staging again, by a fix into staging or a newer QA version of that line: `-beta.2`, `-beta.3`. A new line starts at `-beta.1`. +4. **Production removes it.** "Ship it" releases staging's exact code as `0.4.17`: no new number, the suffix dropped. +5. **Majors.** 1.0.0 is the first public release with the version 1 scope (movies, TV, music and the admin web portal). After that, a major only when admins must act on upgrade (a settings or provisioning format change, a manual migration, dropped support): the pull request that brings such a change bumps the major on `dev`, says what to do in a For admins line led by **Before you upgrade:**, and the banner and reply say so. Until 1.0 nothing triggers a major, and from 1.0 on a "Before you upgrade" line in anything but a major fails the check. + +**Fixes into QA or staging** take the next patch on that branch's own line that none of `dev`, `qa` and `staging` has (QA on 0.4.17 while `dev` is on 0.5.x: 0.4.18). A merge-back keeps every version of both branches, in version order, highest first, so each line's versions stay together and the branch keeps its own line's number on top: `dev` reads 0.5.2, 0.5.1, 0.5.0, 0.4.18, 0.4.17. + +**Where the beta suffix lives.** The build's own version is numeric (`` can't carry letters), so `CHANGELOG.md`, `QA-CHECKLIST.md` and the build say 0.4.17. The suffix is on the published `staging` image's version tag, in the version the beta server shows and posts (its version card for the version it runs, System → Status, and the logo's hover text) and in the promotion pull request. The Image workflow builds staging's image with it (`REQUESTARR_RELEASE_VERSION`) and records each publish as a git tag on the commit, `v0.4.17-beta.1`. The next number is one more than the highest tag on the line; a commit that already has one (a re-run) keeps it. `promote-staging` reads the same tags (`git ls-remote --tags`, no login) to say which beta version it will publish. Staging's earlier plain version tags stay as they are. + +**Worked example.** + +| Step | dev | qa | staging | production | +|---|---|---|---|---| +| Today | 0.4.17 | 0.4.17 | 0.3.4 | | +| Three pull requests into `dev` | 0.5.0, 0.5.1, 0.5.2 | 0.4.17 | 0.3.4 | | +| "QA passed": promote 0.4.17 to staging | 0.5.2 | 0.4.17 | 0.4.17-beta.1 | | +| A fix found in QA, merged back | 0.5.2 (with 0.4.18 below) | 0.4.18 | 0.4.17-beta.1 | | +| "QA passed": promote 0.4.18 to staging | 0.5.2 | 0.4.18 | 0.4.18-beta.2 | | +| A fix found in staging, merged back | 0.5.2 (with 0.4.19 below) | 0.4.19 | 0.4.19-beta.3 | | +| "Ship it" | 0.5.2 | 0.4.19 | 0.4.19-beta.3 | 0.4.19 | +| "Promote to QA" (dev's 0.5.2), then a pull request into `dev` | 0.6.0 | 0.5.2 | 0.4.19-beta.3 | 0.4.19 | ## QA checklist @@ -114,36 +141,38 @@ Four branches, each with its own environment. `dev` is the repository's default branch, so Dependabot's pull requests (security fixes included) land there and move up like any other change (see [Dependency updates](#dependency-updates)). All four branches have the same rules on GitHub (one ruleset): a pull request is required, `build` must pass, no force push, no deletion, nobody bypasses. -**A change.** Branch from `dev`, open the pull request into `dev`, merge it after `build` passes. Bump the version above `dev`'s, `qa`'s and `staging`'s (the Changelog check says so if it isn't). +**A change.** Branch from `dev`, open the pull request into `dev`, merge it after `build` passes. Take `dev`'s next version ([Versions](#versions); `./release.sh next-version` prints it, and the Changelog check says so if it's wrong). **Promote to QA** (only when the owner says "promote to QA", or names a version): ```bash ./release.sh promote-qa 0.2.3 'the owner: "promote 0.2.3 to QA"' +./release.sh promote-qa 0.2.3 'a dry run' --dry-run ``` -It takes a version (the newest `dev` commit with that version on top of `CHANGELOG.md`) or a commit, and refuses anything that isn't on `dev`, or that `qa` can't move to without losing a commit. It opens a pull request into `qa` whose description says who asked, in their words (`Requested by: …`), and lists the versions and commits moving. The Promotion record check fails a promotion without that line, and the pull request stays as the record. Claude Code works with the owner's GitHub account, so GitHub can't tell the owner's approval from Claude Code's; the rule is that Claude Code promotes only on the owner's word, and the record says whose. Once `build` passes, `qa` is fast-forwarded to exactly that commit: the same commits as on `dev`, nothing newer, no merge commit. GitHub marks the pull request merged. Then `.\docker\dev.ps1 reset-qa` (or `docker/dev.sh reset-qa`) so the QA stack runs `qa`. Work on `dev` carries on meanwhile. +QA takes `dev`'s current version as it is, so "promote to QA" means `dev`'s top version; the script says what the next pull request into `dev` will be (the next minor). `--dry-run` prints the commits and versions that would move, and the version `dev` goes on to, and changes nothing. It takes a version (the newest `dev` commit with that version on top of `CHANGELOG.md`) or a commit, and refuses anything that isn't on `dev`, or that `qa` can't move to without losing a commit. It opens a pull request into `qa` whose description says who asked, in their words (`Requested by: …`), and lists the versions and commits moving. The Promotion record check fails a promotion without that line, and the pull request stays as the record. Claude Code works with the owner's GitHub account, so GitHub can't tell the owner's approval from Claude Code's; the rule is that Claude Code promotes only on the owner's word, and the record says whose. Once `build` passes, `qa` is fast-forwarded to exactly that commit: the same commits as on `dev`, nothing newer, no merge commit. GitHub marks the pull request merged. Then `.\docker\dev.ps1 reset-qa` (or `docker/dev.sh reset-qa`) so the QA stack runs `qa`. Work on `dev` carries on meanwhile. **Promote to staging** (when the QA gate is clear, checked at the start of every goal, or when the owner says so). First run the full suite and every live check, and report them ("Testing scope" in the workspace rules: nothing reaches staging without this). Then: ```bash ./release.sh promote-staging 0.3.0 'the QA checklist, completed by ' ./release.sh promote-staging 0.3.0 'the owner: "promote it anyway"' --owner-override 'the owner: "promote it anyway"' +./release.sh promote-staging 0.3.0 'a dry run' --dry-run ``` Without `--owner-override`, it runs the QA gate ([QA checklist](#qa-checklist)) and refuses unless it's clear; the pull request records the checklist and who completed it, or the owner's override. -The same as a promotion to QA, one step up: only `qa`'s commits, a pull request into `staging` with `Requested by:`, then a fast-forward of `staging`. The push to `staging` runs the Image workflow, which publishes `ghcr.io/runemarklabs/requestarr:staging` and `:0.3.0`. Then `.\docker\dev.ps1 up` so the QA stack drops the versions staging now has from its posts, and on the owner's server `./update.sh` in the staging folder (the setup guide's "Running the beta (staging) environment"). +The same as a promotion to QA, one step up: only `qa`'s commits, a pull request into `staging` with `Requested by:` and the beta version it publishes (`Published to staging as: 0.3.0-beta.1`), then a fast-forward of `staging`. The push to `staging` runs the Image workflow, which publishes `ghcr.io/runemarklabs/requestarr:staging` and `:0.3.0-beta.1` and tags the commit `v0.3.0-beta.1` ([Versions](#versions)). `--dry-run` prints the versions that would move, the beta version and the QA gate's verdict, and changes nothing. Then `.\docker\dev.ps1 up` so the QA stack drops the versions staging now has from its posts, and on the owner's server `./update.sh` in the staging folder (the setup guide's "Running the beta (staging) environment"). -**Ship it** (staging to `main`, only on the owner's "ship it"): not scripted yet. It will be the same shape, and the push to `main` publishes the version tags and `latest`. +**Ship it** (staging to `main`, only on the owner's "ship it"): not scripted yet. It will be the same shape: a pull request into `main` with `Requested by:`, then a fast-forward of `main` to exactly `staging`'s commit. No new number and no changelog change: `main`'s top version is staging's (0.3.0), the beta suffix dropped. The push to `main` publishes `:0.3.0`, `:0.3` and `latest`, built from that same commit without the beta version, so production shows 0.3.0; the commit is tagged `v0.3.0`. -**A fix found in QA or staging.** Branch from that branch (`qa` or `staging`) and open a pull request into it with only the fix, versioned above all of `dev`, `qa` and `staging`. After it merges, in the same task, merge it back down: +**A fix found in QA or staging.** Branch from that branch (`qa` or `staging`) and open a pull request into it with only the fix, versioned as the next free patch on that branch's own line (`./release.sh next-version qa` or `next-version staging`; [Versions](#versions)). After it merges, in the same task, merge it back down: ```bash ./release.sh merge-back ``` -It merges `staging` into `qa` (if `staging` has anything `qa` doesn't), then `qa` into `dev`, each through a pull request merged once `build` passes, and runs the drift check. If two branches conflict (usually `CHANGELOG.md` and the version, when the branch below has moved on), it says how: on a branch from the lower one named `merge-back/…`, merge the upper one, keep every changelog section with the highest version on top, and keep the higher version. Then `.\docker\dev.ps1 up` updates the QA stack, keeping its data. A fix into `staging` publishes a new `staging` image on its own. +It merges `staging` into `qa` (if `staging` has anything `qa` doesn't), then `qa` into `dev`, each through a pull request merged once `build` passes, and runs the drift check. When the branch below has moved on, `CHANGELOG.md`, `QA-CHECKLIST.md` and the version conflict; it merges them itself on a branch from the lower one named `merge-back/…`: every version of both, in version order, highest first, and the build version is the changelog's top (the lower branch's own line). Any other conflict it leaves to you, saying how. Then `.\docker\dev.ps1 up` updates the QA stack, keeping its data. A fix into `staging` publishes a new `staging` image on its own. **Drift.** `dev` must always contain every commit on `qa`, and `qa` every commit on `staging`: @@ -160,7 +189,7 @@ It lists any commit a lower branch is missing and fails; the Drift workflow runs | When | Tags | |---|---| -| a push to `staging` (a promotion, or a fix into `staging`) | `staging`, and the exact version (`0.3.0`) | +| a push to `staging` (a promotion, or a fix into `staging`) | `staging`, and the beta version (`0.3.0-beta.1`, [Versions](#versions)); the commit is tagged `v0.3.0-beta.1`. Before 0.5.0, staging was tagged with the plain version, and those tags stay | | a push to `main` (a release) | the version (`0.3.0`), `0.3` and `latest` | | Actions → Image → Run workflow on any branch, with a tag starting `test-` | that tag only, to try the workflow (delete it afterwards from the package's page) | @@ -172,7 +201,7 @@ The "what's new" posts in `#requestarr-admin` (both sections) and `#requestarr-a | Environment | Shows | How it knows | |---|---|---| -| production, staging | every version, each posted once, oldest first, never removed. A new install's first start posts the full history. | It records the versions it has posted. If its data is lost but the channels are kept, it finds its posts in the channels and doesn't repeat them. | +| production, staging | every version, each posted once, oldest first, never removed. A new install's first start posts the full history. Staging's card for the version it runs shows its beta version (`Requestarr 0.3.0-beta.1 · …`); cards already posted keep their words. | It records the versions it has posted. If its data is lost but the channels are kept, it finds its posts in the channels and doesn't repeat them. | | QA | every version in QA that staging doesn't have yet; versions drop off when they're promoted, and a reset rebuilds the list instead of wiping it | Staging has what was last published for it. When the QA stack is built, `docker/staged-versions.py` asks GitHub (its public record, no login) which commit the newest successful Image workflow run on `staging` published, reads the versions in that commit's changelog, and the scripts pass them as `REQUESTARR_STAGED_VERSIONS`. Before anything is published for staging that's nothing, so QA shows every version; offline, it falls back to the `staging` branch. After a promotion to staging, once its image is published, `dev.ps1 up` refreshes them. | | dev | only the version running now; a new version replaces the previous post | | @@ -196,7 +225,7 @@ So each kind sends at most one pull request a week for its patch and minor updat **What happens to them** (`.github/workflows/dependency-updates.yml`, `.github/dependency_updates.py`): - A new update is sorted. Patch and minor get the **automatic update** label. Major updates, and updates Dependabot can't size, get **needs review** and a comment, and wait. A security fix follows the same rule by size, and also gets **security**. It gets **for admins** too when the fix is in something Requestarr ships: the backend, the base images, or a web app package the built app contains (followed through `yarn.lock` from `dependencies`, not `devDependencies`). -- Automatic updates go one at a time, oldest first. The workflow brings the update up to date with `dev`, adds the next version above `dev`'s, `qa`'s and `staging`'s to `src/Directory.Build.props` and `CHANGELOG.md` (in a commit made through GitHub's API, which GitHub signs: `dev`'s rules ask for an extra approval for changes they can't attribute, and a commit the workflow pushed with git is unsigned), starts `build` on that commit, and turns on GitHub's auto-merge. The workflow waits for that `build`, then merges the update if it passed (the full build: backend tests on Linux and Windows, the web app, the image, the secret scan and the Changelog check) and starts on the next one. +- Automatic updates go one at a time, oldest first. The workflow brings the update up to date with `dev`, adds `dev`'s next version ([Versions](#versions): the next patch, or the next minor after a promotion to QA) to `src/Directory.Build.props` and `CHANGELOG.md` (in a commit made through GitHub's API, which GitHub signs: `dev`'s rules ask for an extra approval for changes they can't attribute, and a commit the workflow pushed with git is unsigned), starts `build` on that commit, and turns on GitHub's auto-merge. The workflow waits for that `build`, then merges the update if it passed (the full build: backend tests on Linux and Windows, the web app, the image, the secret scan and the Changelog check) and starts on the next one. - The changelog section is the version heading only, which the changelog rule allows for a change nobody would notice. A security fix labelled **for admins** gets one line under For admins: "**Security update:** parts Requestarr is built from were updated to fix known security problems, so update when you can." - If an automatic update conflicts with `dev` (other than the version and changelog, which it rewrites), can't be pushed, or fails `build`, it is moved to **needs review** with a comment. - The workflow runs when an update opens or changes, when `dev` moves, and every hour as a fallback (GitHub can run scheduled workflows late or skip them). It waits for the builds it starts itself, because GitHub starts no workflow when they finish or when auto-merge it turned on merges. diff --git a/docs/setup-guide.md b/docs/setup-guide.md index ee01173..537291e 100644 --- a/docs/setup-guide.md +++ b/docs/setup-guide.md @@ -877,7 +877,9 @@ When a new version reaches staging, update with one command in the beta folder: It pulls the newest `staging` image. If it's newer than what runs, it stops Requestarr, backs up its data to `backups/`, and starts the new version (Sonarr and Radarr are left alone). It prints the old and new versions. -**Check:** it ends with "Requestarr updated: 0.3.0 -> 0.3.1" (your versions), and `#requestarr-admin` gets the new version's "what's new". +**Check:** it ends with "Requestarr updated: 0.4.17-beta.1 -> 0.4.18-beta.2" (your versions), and `#requestarr-admin` gets the new version's "what's new". + +Beta versions are the version being tested with "beta" and a count after it: 0.4.18-beta.2 is the second beta build of the 0.4 releases, here version 0.4.18. The release itself has the plain number. Versions from before 0.5.0 reached the beta with plain numbers (0.3.4). To go back to the version that ran before the last update, with the data as it was then: diff --git a/frontend/src/Components/Page/Header/HeaderLogo.tsx b/frontend/src/Components/Page/Header/HeaderLogo.tsx index 4d060ea..f84954d 100644 --- a/frontend/src/Components/Page/Header/HeaderLogo.tsx +++ b/frontend/src/Components/Page/Header/HeaderLogo.tsx @@ -8,10 +8,11 @@ interface HeaderLogoProps { // The logo of the environment the app runs in (production, dev, qa or beta), in the theme the app shows; Auto // follows the system setting while the page is open. The light theme's header is purple, so it has its own set // (light-purple: beta's bar is white there). Dev, QA and beta have their branch bar beside the R; production's bar -// shows the running version, which the server draws. +// shows the running version, which the server draws. Pointing at the logo shows the version everywhere (beta's is its +// beta version, 0.4.17-beta.1). function HeaderLogo({ className }: HeaderLogoProps) { const theme = useTheme() === 'dark' ? 'dark' : 'light-purple'; - const { urlBase } = window.Requestarr; + const { urlBase, releaseVersion, version } = window.Requestarr; const iconSet = window.Requestarr.iconSet || 'production'; const file = iconSet === 'production' ? 'logo-version.svg' : 'logo-header.png'; @@ -21,6 +22,7 @@ function HeaderLogo({ className }: HeaderLogoProps) { className={className} src={`${urlBase}/Content/Images/Icons/${theme}/${iconSet}/${file}`} alt="Requestarr Logo" + title={`Requestarr ${releaseVersion || version}`} /> ); } diff --git a/frontend/src/System/Status/About/About.tsx b/frontend/src/System/Status/About/About.tsx index 807fbbd..79fc95b 100644 --- a/frontend/src/System/Status/About/About.tsx +++ b/frontend/src/System/Status/About/About.tsx @@ -35,10 +35,17 @@ function About() { dispatch(fetchStatus()); }, [dispatch]); + // Beta shows the beta version it was published as beside the build number. + const { releaseVersion } = window.Requestarr; + const shownVersion = + releaseVersion && releaseVersion.includes('-') + ? `${releaseVersion} (${version})` + : version; + return (
- + {packageVersion && ( string; version: string; }; diff --git a/release.sh b/release.sh index e3eb091..853ed5e 100755 --- a/release.sh +++ b/release.sh @@ -5,17 +5,21 @@ # # ./release.sh drift [ []] # fails if qa has a commit dev doesn't, or staging one qa doesn't (default: origin/dev, origin/qa, origin/staging) -# ./release.sh promote-qa "" -# ./release.sh promote-staging "" [--owner-override ""] +# ./release.sh promote-qa "" [--dry-run] +# ./release.sh promote-staging "" [--owner-override ""] [--dry-run] # moves qa (or staging) to exactly that point of dev (or qa): a pull request that records who asked, the build -# check, then a fast-forward to that commit (nothing newer, no merge commit). Staging's push publishes its image. +# check, then a fast-forward to that commit (nothing newer, no merge commit). Staging's push publishes its image +# as the beta version (x.y.z-beta.N, docs/development.md#versions), which the pull request records. # Staging only moves when the QA gate is clear (qa-gate below), unless the owner overrides it in so many words. +# --dry-run says what would move and which versions come out of it, and changes nothing. +# ./release.sh next-version [dev | qa | staging] +# the version the next pull request into that branch takes (default dev) # ./release.sh qa-gate [--json] # is QA's test checklist complete, with no item failing and no bug report from QA still open? Read from the QA # stack on this machine (docker/qa-gate.py); exit 0 when clear # ./release.sh merge-back # after a fix merged into staging or qa: pull requests that merge staging into qa and qa into dev, each merged -# once the build check passes +# once the build check passes. The changelog and QA checklist keep every version of both, highest first. # ./release.sh check-changelog [ []] # the changelog rules CI's Changelog job applies to a pull request # @@ -48,6 +52,21 @@ has_ref() { git rev-parse -q --verify "$1^{commit}" >/dev/null } +# {"dev": [...], "qa": [...], "staging": [...]}: each branch's versions, newest first, for .github/version_rules.py. +# Optional overrides: name=ref (the pull request's base for its target, or what a promotion would move a branch to). +branch_versions() { + local name ref override out="{" + for name in dev qa staging; do + ref="origin/$name" + for override in "$@"; do + [ "${override%%=*}" = "$name" ] && ref="${override#*=}" + done + has_ref "$ref" || continue + out+="\"$name\": [$(all_versions "$ref" | sed 's/.*/"&"/' | paste -sd, -)]," + done + echo "${out%,}}" +} + # The first Python that really runs (a stub can exit 0 without running anything). python() { local candidate @@ -165,25 +184,19 @@ check_changelog() { return 0 fi - # dev, qa and staging never use the same version for different changes: a fix into qa or staging takes the next - # number above all three. - local other theirs - for other in dev qa staging; do - [ "$other" != "$target" ] || continue - has_ref "origin/$other" || continue - theirs=$(top_version "origin/$other") - [ -n "$theirs" ] || continue - if git merge-base --is-ancestor "origin/$other" "$head"; then - if higher "$theirs" "$new"; then - echo "::error::$new is lower than $other's $theirs, which this change contains." - return 1 - fi - elif ! higher "$new" "$theirs"; then - echo "::error::$other already has $theirs, so this change needs a version higher than every release branch's (a fix into qa or staging takes the next number above all of them)." - return 1 - fi - done - echo "Version $new is free on dev, qa and staging." + # The version rules (docs/development.md#versions): into dev the next patch on dev's line, or the next minor once + # QA has that line; a fix into qa or staging the next free patch on that branch's own line; never a version + # another branch has. + local changelog problem + changelog=$(mktemp) + git show "$head:CHANGELOG.md" >"$changelog" + problem=$(branch_versions "$target=$base" | python "$here/.github/version_rules.py" check "$target" "$new" "$changelog") || { + rm -f "$changelog" + echo "::error::$problem Bump in src/Directory.Build.props to match (docs/development.md#versions)." + return 1 + } + rm -f "$changelog" + echo "Version $new follows the version rules for $target, and no other branch has it." } # The newest commit on a branch's first-parent line whose CHANGELOG.md tops out at the version. @@ -213,12 +226,27 @@ wait_for_build() { [ "$state" = "SUCCESS" ] } -# promote +# promote [--owner-override ] [--dry-run] promote() { - local from="$1" to="$2" what="${3:-}" who="${4:-}" name + local from="$1" to="$2" what="${3:-}" who="${4:-}" name dry=0 override="" arg + [ $# -ge 4 ] && shift 4 || shift $# + while [ $# -gt 0 ]; do + arg="$1" + shift + case "$arg" in + --dry-run) dry=1 ;; + --owner-override) + override="${1:-}" + [ -n "$override" ] || die "--owner-override needs the owner's words" + shift + ;; + *) die "unknown option $arg" ;; + esac + done name=$([ "$to" = qa ] && echo QA || echo staging) [ -n "$what" ] && [ -n "$who" ] || die "promote-$to \"\"" - git fetch -q origin "$from" "$to" + git fetch -q origin dev qa + git fetch -q origin staging 2>/dev/null || true local target if [[ "$what" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then @@ -235,28 +263,48 @@ promote() { # Staging: only with QA's test checklist complete and no bug report from QA open, or on the owner's explicit word. local gate="" if [ "$to" = staging ]; then - if [ "${5:-}" = "--owner-override" ]; then - [ -n "${6:-}" ] || die "--owner-override needs the owner's words" - gate="Owner override: ${6} (the QA gate was not checked or not clear)" + if [ -n "$override" ]; then + gate="Owner override: $override (the QA gate was not checked or not clear)" else local verdict - verdict=$("$here/docker/dev.sh" qa-gate --json) || { + if verdict=$("$here/docker/dev.sh" qa-gate --json); then + gate=$(printf '%s' "$verdict" | python -c 'import json,sys; v=json.load(sys.stdin); print("QA checklist: complete for %s (%d items), the last item passed by %s at %s; no bug report from QA open." % (", ".join(v["versions"]), v["total"], v["completedBy"], v["completedAt"]))') + elif [ "$dry" = 1 ]; then + gate="QA gate: blocked (a real promotion stops here unless the owner overrides it)." + else "$here/docker/dev.sh" qa-gate || true die "promotion to staging is blocked: QA's test checklist isn't complete, an item is failing, or a bug report from QA is still open. Only the owner can override it: promote-staging \"\" --owner-override \"\"" - } - gate=$(printf '%s' "$verdict" | python -c 'import json,sys; v=json.load(sys.stdin); print("QA checklist: complete for %s (%d items), the last item passed by %s at %s; no bug report from QA open." % (", ".join(v["versions"]), v["total"], v["completedBy"], v["completedAt"]))') + fi fi fi - local version short branch commits versions + local version short branch commits versions published="" next="" version=$(top_version "$target") short=$(git rev-parse --short "$target") branch="promote/$to-$version" commits=$(git log --format='- %h %s' "origin/$to..$target") versions=$(comm -23 <(all_versions "$target" | sort -u) <(all_versions "origin/$to" | sort -u) | sort -rV | sed 's/^/- /') + if [ "$to" = staging ]; then + # Each publish to staging is recorded as a v-beta. tag on its commit; n counts the line's publishes. + published=$(git ls-remote --tags origin | python "$here/.github/version_rules.py" beta "$version" "$(git rev-parse "$target")") + else + next=$(branch_versions "qa=$target" | python "$here/.github/version_rules.py" next dev) + fi echo "Promoting $version ($short) to $name. Commits moving to $to:" echo "$commits" + echo "Versions now in $name:" + echo "$versions" + if [ -n "$published" ]; then + echo "Published to staging as $published (image tags staging and $published; the commit is tagged v$published)." + else + echo "QA gets $version as it is; the next pull request into dev starts the next minor: $next." + fi + [ -z "$gate" ] || echo "$gate" + if [ "$dry" = 1 ]; then + echo "Dry run: nothing pushed, no pull request opened; $to stays at $(top_version "origin/$to") ($(git rev-parse --short "origin/$to"))." + return 0 + fi git push -q origin "$target:refs/heads/$branch" local url url=$(gh pr create --base "$to" --head "$branch" --title "Promote $version to $name" --body "$(cat < : when and conflict, the merge on a branch merge-back/- +# (made in a separate worktree, so the checkout here is untouched): CHANGELOG.md and QA-CHECKLIST.md keep every version +# of both, highest first, and the build version is the merged changelog's top. Prints the branch; fails, saying what to +# do, if anything else conflicts. +resolve_merge_back() { + local from="$1" to="$2" version="$3" branch dir file conflicted others="" top + branch="merge-back/$from-$version" + dir=$(mktemp -d) + git worktree add -q -b "$branch" "$dir" "origin/$to" >&2 + if ! git -C "$dir" merge -q --no-edit -m "Merge $from fix $version back into $to" "origin/$from" >/dev/null 2>&1; then + conflicted=$(git -C "$dir" diff --name-only --diff-filter=U) + for file in $conflicted; do + case "$file" in + CHANGELOG.md | QA-CHECKLIST.md) + git -C "$dir" show ":2:$file" >"$dir/.ours" + git -C "$dir" show ":3:$file" >"$dir/.theirs" + python "$here/.github/version_rules.py" merge "$dir/.ours" "$dir/.theirs" "$dir/$file" >&2 || others+=" $file" + rm -f "$dir/.ours" "$dir/.theirs" + ;; + src/Directory.Build.props) git -C "$dir" checkout -q --ours src/Directory.Build.props ;; + *) others+=" $file" ;; + esac + done + if [ -n "$others" ]; then + git -C "$dir" merge --abort + git worktree remove --force "$dir" + git branch -q -D "$branch" + cat >&2 < (every version, highest first). + src/Directory.Build.props: the changelog's top version. + git commit, push, then: gh pr create --base $to --title "Merge $from fix $version back into $to" +EOF + return 1 + fi + top=$(grep -m1 -E '^## [0-9]+\.[0-9]+\.[0-9]+' "$dir/CHANGELOG.md" | sed -E 's/^## +//; s/ .*//') + sed -i -E "s|[^<]*|$top.*|" "$dir/src/Directory.Build.props" + git -C "$dir" add CHANGELOG.md QA-CHECKLIST.md src/Directory.Build.props + git -C "$dir" commit -q --no-edit >&2 + fi + git -C "$dir" push -q origin "$branch" >&2 + git worktree remove --force "$dir" + echo "$branch" +} + # merge_into : a pull request that merges branch into , merged once the build check passes. merge_into() { - local from="$1" to="$2" version url + local from="$1" to="$2" version url head="$1" version=$(top_version "origin/$from") - url=$(gh pr create --base "$to" --head "$from" --title "Merge $from fix $version back into $to" \ - --body "Merges \`$from\` (version $version) into \`$to\`, so $to has every fix made in $from and the drift check passes.") + if ! git merge-tree --write-tree --no-messages "origin/$to" "origin/$from" >/dev/null 2>&1; then + echo "$from and $to conflict; merging them on a branch, with every version of both kept, highest first." + head=$(resolve_merge_back "$from" "$to" "$version") || return 1 + fi + url=$(gh pr create --base "$to" --head "$head" --title "Merge $from fix $version back into $to" \ + --body "Merges \`$from\` (version $version) into \`$to\`, so $to has every fix made in $from and the drift check passes. The changelog and QA checklist keep every version of both, highest first.") echo "$url" sleep 10 - if [ "$(gh pr view "$url" --json mergeable --jq .mergeable)" = "CONFLICTING" ]; then - cat </dev/null || true + branch_versions | python "$here/.github/version_rules.py" next "${2:-dev}" + ;; qa-gate) shift; "$here/docker/dev.sh" qa-gate "$@" ;; merge-back) merge_back ;; *) - sed -n '2,21p' "$0" + sed -n '2,26p' "$0" exit 1 ;; esac diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 1d06954..b9d0fe9 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -74,7 +74,7 @@ Copyright 2026-$([System.DateTime]::Now.ToString('yyyy')) Runemark Labs. Portions copyright 2014-2026 prowlarr.com and the Servarr team (GNU General Public v3) - 0.4.17.* + 0.5.0.* $(Configuration)-dev en diff --git a/src/NzbDrone.Core.Test/Requestarr/Announcements/Item4dVersionPostsFixture.cs b/src/NzbDrone.Core.Test/Requestarr/Announcements/Item4dVersionPostsFixture.cs index 22b42df..51a4192 100644 --- a/src/NzbDrone.Core.Test/Requestarr/Announcements/Item4dVersionPostsFixture.cs +++ b/src/NzbDrone.Core.Test/Requestarr/Announcements/Item4dVersionPostsFixture.cs @@ -3,6 +3,7 @@ using System.Linq; using System.Threading.Tasks; using FluentAssertions; +using Moq; using NUnit.Framework; using Requestarr.Core.Announcements; using Requestarr.Core.Discord.Interactions; @@ -227,6 +228,50 @@ public async Task staging_never_removes_a_card() AdminHeaders.Should().Equal(Admin("0.0.9"), Admin("0.1.0"), Admin("0.2.0"), Admin("0.3.0")); } + [Test] + public void the_beta_version_is_the_one_the_image_was_published_as_and_only_for_its_own_version() + { + string Beta(string variable, string version) => + new ReleaseEnvironment { Variable = v => v == ReleaseEnvironment.ReleaseVersionVariable ? variable : null }.BetaVersion(new Version(version)); + + Beta("0.4.17-beta.1", "0.4.17.11354").Should().Be("0.4.17-beta.1"); + Beta(" 0.4.18-beta.12 ", "0.4.18").Should().Be("0.4.18-beta.12"); + Beta("0.4.17-beta.1", "0.4.16").Should().BeNull("an older version keeps its number"); + Beta(null, "0.4.17").Should().BeNull("QA, dev and production builds have none"); + Beta(string.Empty, "0.4.17").Should().BeNull(); + Beta("0.4.17", "0.4.17").Should().BeNull("only a beta version counts"); + Beta("0.4.17-beta.0", "0.4.17").Should().BeNull(); + Beta("0.4.17-rc.1", "0.4.17").Should().BeNull(); + } + + [Test] + public async Task staging_shows_the_version_it_runs_as_its_beta_version() + { + Environment("staging"); + var environment = new ReleaseEnvironment { Variable = v => v == ReleaseEnvironment.ReleaseVersionVariable ? "0.3.0-beta.2" : null }; + Mocker.GetMock().Setup(e => e.BetaVersion(It.IsAny())).Returns(environment.BetaVersion); + + await StartOn("0.3.0"); + + var titles = Discord.Channels[AdminChannel].Messages.Where(m => m.Embeds.Any()).Select(m => m.Embeds[0].Title).ToList(); + titles.Should().Equal("Requestarr 0.1.0 · 1 change for users", "Requestarr 0.2.0 · 1 change for admins, 1 for users", "Requestarr 0.3.0-beta.2 · 1 change for admins"); + AdminHeaders.Should().Equal(new[] { Admin("0.1.0"), Admin("0.2.0"), Admin("0.3.0") }, "a beta title is still found as its version's card"); + Service.FullCard(VersionAudience.Admins, new Version(0, 3, 0))[0].Cards[0].Text.Should().StartWith("## Requestarr 0.3.0-beta.2\n"); + Service.FullCard(VersionAudience.Admins, new Version(0, 2, 0))[0].Cards[0].Text.Should().StartWith("## Requestarr 0.2.0\n"); + + await StartOn("0.3.0"); + + Discord.Channels[AdminChannel].Messages.Count(m => m.Embeds.Any()).Should().Be(3, "a restart posts nothing again"); + } + + [Test] + public void a_beta_title_reads_as_its_version() + { + VersionCards.Parse("Requestarr 0.4.17-beta.3 · 2 changes for admins", VersionAudience.Admins).Should().Be(new Version(0, 4, 17)); + VersionCards.Parse("Requestarr 0.4.17-beta.3 · 1 new thing for you", VersionAudience.Users).Should().Be(new Version(0, 4, 17)); + VersionCards.Parse("Requestarr 0.4.17-rc.3 · 1 new thing for you", VersionAudience.Users).Should().BeNull(); + } + [Test] public async Task staging_replaces_its_posts_from_before_cards_once_keeping_the_history() { diff --git a/src/NzbDrone.Core.Test/Requestarr/Qa/LiveQaFixture.cs b/src/NzbDrone.Core.Test/Requestarr/Qa/LiveQaFixture.cs index 42659c5..679925e 100644 --- a/src/NzbDrone.Core.Test/Requestarr/Qa/LiveQaFixture.cs +++ b/src/NzbDrone.Core.Test/Requestarr/Qa/LiveQaFixture.cs @@ -104,6 +104,11 @@ public async Task checklist_failing_item_bug_reports_limit_and_completion_in_the panels = Mocker.Resolve(); Mocker.SetConstant(panels); var checklist = Mocker.Resolve(); + + // Dev shows only the running version's items; a version with nothing to test by hand has none, so the check + // runs as the newest version that has some. + var tested = QaChecklist.Parse(checklist.ChecklistText()).Where(v => v.Items.Count > 0).Max(v => v.Version); + checklist.CurrentVersion = () => tested; Mocker.SetConstant(checklist); var reports = Mocker.Resolve(); Mocker.SetConstant(reports); diff --git a/src/NzbDrone.Core/Requestarr/Announcements/AnnouncementService.cs b/src/NzbDrone.Core/Requestarr/Announcements/AnnouncementService.cs index ca5f491..ea02302 100644 --- a/src/NzbDrone.Core/Requestarr/Announcements/AnnouncementService.cs +++ b/src/NzbDrone.Core/Requestarr/Announcements/AnnouncementService.cs @@ -171,11 +171,11 @@ public async Task PostVersionNotesAsync() var news = versions.Where(v => v.HasNews && (mode == VersionPostMode.CurrentOnly ? v.Version == current : !staged.Contains(v.Version))).ToList(); var users = _guildSetup.AnnouncementsChannelId; - await KeepExactlyAsync(adminChannel, await FindPostsAsync(adminChannel, VersionAudience.Admins), news.Select(v => VersionCards.Collapsed(v, VersionAudience.Admins)).ToList()); + await KeepExactlyAsync(adminChannel, await FindPostsAsync(adminChannel, VersionAudience.Admins), news.Select(v => Card(v, VersionAudience.Admins)).ToList()); if (users.HasValue) { - await KeepExactlyAsync(users.Value, await FindPostsAsync(users.Value, VersionAudience.Users), news.Where(v => v.Users.Count > 0).Select(v => VersionCards.Collapsed(v, VersionAudience.Users)).ToList()); + await KeepExactlyAsync(users.Value, await FindPostsAsync(users.Value, VersionAudience.Users), news.Where(v => v.Users.Count > 0).Select(v => Card(v, VersionAudience.Users)).ToList()); } _logger.Info("What's new ({0}): {1}", mode, news.Count == 0 ? "no versions to show" : string.Join(", ", news.Select(v => v.Version))); @@ -193,9 +193,13 @@ public IReadOnlyList FullCard(VersionAudience audience, Versio return found == null || !found.HasNews ? new[] { new InteractionReply { Content = DiscordText.VersionNotFound(version), Ephemeral = true } } - : VersionCards.Full(found, audience); + : VersionCards.Full(found, audience, _environment.BetaVersion(version)); } + // On staging, the version running is shown as its beta version. + private VersionCard Card(ChangelogVersion version, VersionAudience audience) => + VersionCards.Collapsed(version, audience, _environment.BetaVersion(version.Version)); + // Production and staging: each version with something in it gets a card, oldest first, and keeps it. A card // already in the channel isn't posted again (a server set up again with its channels kept). Posts from before // version cards are replaced once by cards for the whole history. @@ -209,11 +213,11 @@ private async Task PostStandingAsync(DiscordGuildConfig config, ulong adminChann if (inAdmin.Concat(inUsers).Any(p => p.Card == null)) { var history = versions.Where(v => v.HasNews).ToList(); - await KeepExactlyAsync(adminChannel, inAdmin, history.Select(v => VersionCards.Collapsed(v, VersionAudience.Admins)).ToList()); + await KeepExactlyAsync(adminChannel, inAdmin, history.Select(v => Card(v, VersionAudience.Admins)).ToList()); if (users.HasValue) { - await KeepExactlyAsync(users.Value, inUsers, history.Where(v => v.Users.Count > 0).Select(v => VersionCards.Collapsed(v, VersionAudience.Users)).ToList()); + await KeepExactlyAsync(users.Value, inUsers, history.Where(v => v.Users.Count > 0).Select(v => Card(v, VersionAudience.Users)).ToList()); } _logger.Info("What's new: the history is now shown as cards ({0} versions)", history.Count); @@ -231,12 +235,12 @@ private async Task PostStandingAsync(DiscordGuildConfig config, ulong adminChann { if (!shownAdmin.Contains(version.Version)) { - await SendAsync(adminChannel, VersionCards.Collapsed(version, VersionAudience.Admins)); + await SendAsync(adminChannel, Card(version, VersionAudience.Admins)); } if (users.HasValue && version.Users.Count > 0 && !shownUsers.Contains(version.Version)) { - await SendAsync(users.Value, VersionCards.Collapsed(version, VersionAudience.Users)); + await SendAsync(users.Value, Card(version, VersionAudience.Users)); } _logger.Info("Announced what's new in {0} ({1} for users, {2} for admins)", version.Version, version.Users.Count, version.Admins.Count); diff --git a/src/NzbDrone.Core/Requestarr/Announcements/ReleaseEnvironment.cs b/src/NzbDrone.Core/Requestarr/Announcements/ReleaseEnvironment.cs index f33d4fe..cfda19c 100644 --- a/src/NzbDrone.Core/Requestarr/Announcements/ReleaseEnvironment.cs +++ b/src/NzbDrone.Core/Requestarr/Announcements/ReleaseEnvironment.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Text.RegularExpressions; namespace Requestarr.Core.Announcements { @@ -28,6 +29,10 @@ public interface IReleaseEnvironment // The web app's icons and logo for this environment: "dev", "qa", "beta" (staging) or "production". The same // published image runs on staging and production, so this follows where it runs, not how it was built. string IconSet { get; } + + // Staging's beta version of the version it was published as ("0.4.17-beta.1", docs/development.md#versions), + // or null: every other build, and every other version, shows its plain number. + string BetaVersion(Version version); } public class ReleaseEnvironment : IReleaseEnvironment @@ -38,6 +43,11 @@ public class ReleaseEnvironment : IReleaseEnvironment // QA only: staging's versions ("0.1.0,0.2.0,..."), from the staging branch's changelog when the QA stack starts. public const string StagedVersionsVariable = "REQUESTARR_STAGED_VERSIONS"; + // Staging's image only: the beta version it was published as, set when the image is built. + public const string ReleaseVersionVariable = "REQUESTARR_RELEASE_VERSION"; + + private static readonly Regex Beta = new(@"^(?\d+\.\d+\.\d+)-beta\.[1-9]\d*$"); + public Func Variable { get; set; } = Environment.GetEnvironmentVariable; public VersionPostMode VersionPosts => (Variable(EnvironmentVariable) ?? string.Empty).Trim().ToLowerInvariant() switch @@ -58,6 +68,13 @@ public class ReleaseEnvironment : IReleaseEnvironment _ => "production" }; + public string BetaVersion(Version version) + { + var match = Beta.Match((Variable(ReleaseVersionVariable) ?? string.Empty).Trim()); + + return match.Success && version != null && match.Groups["version"].Value == Changelog.Release(version).ToString() ? match.Value : null; + } + public IReadOnlySet Staged => (Variable(StagedVersionsVariable) ?? string.Empty) .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) .Select(v => Version.TryParse(v, out var parsed) ? parsed : null) diff --git a/src/NzbDrone.Core/Requestarr/Announcements/VersionCards.cs b/src/NzbDrone.Core/Requestarr/Announcements/VersionCards.cs index 3efec90..c6f49b8 100644 --- a/src/NzbDrone.Core/Requestarr/Announcements/VersionCards.cs +++ b/src/NzbDrone.Core/Requestarr/Announcements/VersionCards.cs @@ -20,7 +20,8 @@ public static class VersionCardRoutes public const string ReadChanges = "whatsnew"; // whatsnew:{a|u}:{version} } - // One version's card as posted: its title and description are what a server compares to find and update it. + // One version's card as posted: its title and description are what a server compares to find and update it. On + // staging the version it runs is shown as its beta version (0.4.17-beta.1); every other version as its number. public record VersionCard(Version Version, string Title, string Description, InteractionReply Reply); // What's new, as cards. In the channels each version is one line with a Read changes button; pressing it shows the @@ -37,15 +38,17 @@ public static class VersionCards public const uint UsersColor = 0x2BB3A3; public const uint AdminsColor = 0xE0A030; - private static readonly Regex AdminTitle = new(@"^Requestarr (?\d+\.\d+\.\d+) · \d+ changes? for (admins|users)"); - private static readonly Regex UsersTitle = new(@"^Requestarr (?\d+\.\d+\.\d+) · \d+ new things? for you"); + private static readonly Regex AdminTitle = new(@"^Requestarr (?\d+\.\d+\.\d+)(?:-beta\.\d+)? · \d+ changes? for (admins|users)"); + private static readonly Regex UsersTitle = new(@"^Requestarr (?\d+\.\d+\.\d+)(?:-beta\.\d+)? · \d+ new things? for you"); private static readonly Regex BoldLead = new(@"^\*\*(?[^*]+?):?\*\*"); - public static VersionCard Collapsed(ChangelogVersion version, VersionAudience audience) + // shown: how the version reads in the title (its beta version on staging), its number when null. + public static VersionCard Collapsed(ChangelogVersion version, VersionAudience audience, string shown = null) { + shown ??= version.Version.ToString(); var title = audience == VersionAudience.Admins - ? DiscordText.VersionCardAdminTitle(version.Version, version.Admins.Count, version.Users.Count) - : DiscordText.VersionCardUsersTitle(version.Version, version.Users.Count); + ? DiscordText.VersionCardAdminTitle(shown, version.Admins.Count, version.Users.Count) + : DiscordText.VersionCardUsersTitle(shown, version.Users.Count); // Members see the main change in a few words, so they know whether to open it. var description = audience == VersionAudience.Users ? MainChange(version.Users[0]) : null; @@ -71,7 +74,7 @@ public static Version Parse(string title, VersionAudience audience) // The full card, private: one coloured card per section (a long section carries on in more cards, split between // lines), in as many messages as Discord's limits need. Cards show text at a message's size, with headings. - public static IReadOnlyList Full(ChangelogVersion version, VersionAudience audience) + public static IReadOnlyList Full(ChangelogVersion version, VersionAudience audience, string shown = null) { var sections = new List<(string Heading, IReadOnlyList Lines, uint Color)>(); @@ -85,7 +88,7 @@ public static IReadOnlyList Full(ChangelogVersion version, Ver sections.Add((Changelog.AdminsHeading, version.Admins, AdminsColor)); } - var title = $"## {DiscordText.VersionCardFullTitle(version.Version, version.Date)}\n"; + var title = $"## {DiscordText.VersionCardFullTitle(shown ?? version.Version.ToString(), version.Date)}\n"; var cards = new List(); foreach (var (heading, lines, color) in sections) diff --git a/src/NzbDrone.Core/Requestarr/Discord/DiscordText.cs b/src/NzbDrone.Core/Requestarr/Discord/DiscordText.cs index b347736..4579deb 100644 --- a/src/NzbDrone.Core/Requestarr/Discord/DiscordText.cs +++ b/src/NzbDrone.Core/Requestarr/Discord/DiscordText.cs @@ -1034,15 +1034,15 @@ public static string OutageOverNotice(ContentType type, TimeSpan downFor) => public const string ReadChangesLabel = "Read changes"; public const string VersionNotesContinued = " (continued)"; - public static string VersionCardAdminTitle(Version version, int admins, int users) => + public static string VersionCardAdminTitle(string version, int admins, int users) => $"Requestarr {version} · " + (admins > 0 ? $"{admins} change{(admins == 1 ? string.Empty : "s")} for admins{(users > 0 ? $", {users} for users" : string.Empty)}" : $"{users} change{(users == 1 ? string.Empty : "s")} for users"); - public static string VersionCardUsersTitle(Version version, int count) => + public static string VersionCardUsersTitle(string version, int count) => $"Requestarr {version} · {count} new thing{(count == 1 ? string.Empty : "s")} for you"; - public static string VersionCardFullTitle(Version version, DateTime? date) => + public static string VersionCardFullTitle(string version, DateTime? date) => $"Requestarr {version}{(date.HasValue ? $" · {date.Value.ToString("d MMMM yyyy", System.Globalization.CultureInfo.InvariantCulture)}" : string.Empty)}"; public static string VersionNotFound(Version version) => $"Requestarr {version}'s changes aren't in this version of Requestarr."; diff --git a/src/Requestarr.Http/Frontend/InitializeJsonController.cs b/src/Requestarr.Http/Frontend/InitializeJsonController.cs index 2b02a3b..634fa8e 100644 --- a/src/Requestarr.Http/Frontend/InitializeJsonController.cs +++ b/src/Requestarr.Http/Frontend/InitializeJsonController.cs @@ -53,6 +53,7 @@ private string GetContent() builder.AppendLine($" \"apiKey\": \"{_apiKey}\","); builder.AppendLine($" \"release\": \"{BuildInfo.Release}\","); builder.AppendLine($" \"version\": \"{BuildInfo.Version.ToString()}\","); + builder.AppendLine($" \"releaseVersion\": \"{_environment.BetaVersion(BuildInfo.Version) ?? Changelog.Release(BuildInfo.Version).ToString()}\","); builder.AppendLine($" \"instanceName\": \"{_configFileProvider.InstanceName.ToString()}\","); builder.AppendLine($" \"theme\": \"{_configFileProvider.Theme.ToString()}\","); builder.AppendLine($" \"iconSet\": \"{_environment.IconSet}\","); From 7ad365bb0e1100b82643cdada5db9823cac574e3 Mon Sep 17 00:00:00 2001 From: EaZyCheaZ Date: Mon, 26 Oct 2026 10:54:42 -0500 Subject: [PATCH 2/2] Release tests follow the beta tag, its git tag record and the shared version rules --- .../Requestarr/Releases/Item4dStagingFixture.cs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/NzbDrone.Core.Test/Requestarr/Releases/Item4dStagingFixture.cs b/src/NzbDrone.Core.Test/Requestarr/Releases/Item4dStagingFixture.cs index f900b9b..c8771f8 100644 --- a/src/NzbDrone.Core.Test/Requestarr/Releases/Item4dStagingFixture.cs +++ b/src/NzbDrone.Core.Test/Requestarr/Releases/Item4dStagingFixture.cs @@ -89,7 +89,9 @@ public void images_are_published_for_two_architectures_on_staging_and_main_only( var image = Read(".github", "workflows", "image.yml"); image.Should().Contain("branches: [staging, main]").And.Contain("platforms: linux/amd64,linux/arm64").And.Contain("packages: write"); - image.Should().Contain("push:staging) tags=\"$IMAGE:staging,$IMAGE:$version\"").And.Contain("push:main) tags=\"$IMAGE:$version,$IMAGE:${version%.*},$IMAGE:latest\""); + image.Should().Contain("version_rules.py beta \"$version\" \"$GITHUB_SHA\"").And.Contain("tags=\"$IMAGE:staging,$IMAGE:$release\"").And.Contain("push:main) tags=\"$IMAGE:$version,$IMAGE:${version%.*},$IMAGE:latest\""); + image.Should().Contain("RELEASE_VERSION=${{ steps.tags.outputs.beta }}").And.Contain("git push origin \"refs/tags/v$BETA\"", "each staging publish is recorded as a tag the next beta number is counted from"); + Read("docker", "Dockerfile").Should().Contain($"ENV {ReleaseEnvironment.ReleaseVersionVariable}=$RELEASE_VERSION"); image.Should().Contain("^test-[a-z0-9.-]+$", "a hand-run publishes only a test tag"); image.Should().Contain("org.opencontainers.image.source=").And.Contain("secret-looking environment variable").And.Contain("answers /ping"); Read("docker", "Dockerfile").Should().Contain("FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk").And.Contain("ARG TARGETARCH"); @@ -102,7 +104,9 @@ public void staging_has_its_promotion_fix_back_and_drift() var build = Read(".github", "workflows", "build.yml"); release.Should().Contain("promote-staging) shift; promote qa staging").And.Contain("merge_into staging qa").And.Contain("merge_into qa dev"); - release.Should().Contain("drift_pair \"$qa\" \"$staging\"").And.Contain("for other in dev qa staging"); + release.Should().Contain("drift_pair \"$qa\" \"$staging\"").And.Contain("version_rules.py\" check \"$target\" \"$new\""); + release.Should().Contain("version_rules.py\" beta").And.Contain("version_rules.py\" merge").And.Contain("--dry-run) dry=1"); + build.Should().Contain("python3 .github/version_rules.py --self-test"); build.Should().Contain("pull_request:staging) below=qa ;;").And.Contain("HEAD_REF: ${{ github.event.pull_request.head.ref }}"); Read(".github", "workflows", "drift.yml").Should().Contain("staging_ref"); }