diff --git a/.github/checklist_check.py b/.github/checklist_check.py
new file mode 100644
index 0000000..9a717e1
--- /dev/null
+++ b/.github/checklist_check.py
@@ -0,0 +1,142 @@
+#!/usr/bin/env python3
+"""Checks QA-CHECKLIST.md against CHANGELOG.md (docs/development.md#qa-checklist). CI's Changelog job runs it on every
+pull request; run it locally with `python3 .github/checklist_check.py QA-CHECKLIST.md CHANGELOG.md`.
+
+The QA checklist is what admins on the QA server test, in Discord, before a version goes to staging. Every pull request
+adds its version's items, the way it adds its changelog entry. Checked:
+ - versions: every changelog version from 0.4.3 on has a "## x.y.z (YYYY-MM-DD)" section here, with the same date, in
+ the same order, and there are no others;
+ - a version with nothing anyone can test by hand has only its heading (the same escape as a heading-only changelog
+ entry); otherwise only "- " item lines;
+ - each item: "- **Name:** what to do. You should see what happens.", with a name unique in its version and the
+ words "You should see" saying what a pass looks like;
+ - plain words, as in the changelog: no backticks, file names, paths, command flags, settings keys or environment
+ variable names, since admins read these in Discord.
+"""
+import os
+import re
+import sys
+
+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+from changelog_check import CODE # noqa: E402
+
+FIRST = (0, 4, 3)
+VERSION = re.compile(r"^## (\d+)\.(\d+)\.(\d+) \((\d{4}-\d{2}-\d{2})\)$")
+ITEM = re.compile(r"^- \*\*([^*:]+):\*\* (.+)$")
+
+
+def versions(text):
+ found = []
+ for number, line in enumerate(text.splitlines(), 1):
+ match = VERSION.match(line.strip())
+ if match:
+ found.append((tuple(int(match.group(i)) for i in (1, 2, 3)), match.group(4), number))
+ return found
+
+
+def check(checklist, changelog):
+ problems = []
+ wanted = [(v, d) for v, d, _ in versions(changelog) if v >= FIRST]
+ have = versions(checklist)
+ if [(v, d) for v, d, _ in have] != wanted:
+ missing = [v for v, _ in wanted if v not in [h[0] for h in have]]
+ extra = [v for v, _, _ in have if v not in [w[0] for w in wanted]]
+ problems.append((1, "versions don't match the changelog from 0.4.3 on (same versions, dates and order): missing %s, extra %s"
+ % (", ".join("%d.%d.%d" % v for v in missing) or "none", ", ".join("%d.%d.%d" % v for v in extra) or "none")))
+
+ current = None
+ names = set()
+ for number, line in enumerate(checklist.splitlines(), 1):
+ text = line.strip()
+ if VERSION.match(text):
+ current = text
+ names = set()
+ continue
+ if current is None or not text:
+ continue
+ if text.startswith("#"):
+ problems.append((number, "only version headings: '%s'" % text))
+ continue
+ item = ITEM.match(text)
+ if not item:
+ problems.append((number, "only '- **Name:** what to do. You should see ...' lines inside a version"))
+ continue
+ name, body = item.group(1).strip(), item.group(2)
+ if name.lower() in names:
+ problems.append((number, "item '%s' appears twice in this version" % name))
+ names.add(name.lower())
+ if "you should see" not in body.lower():
+ problems.append((number, "item '%s' must say 'You should see' what a pass looks like" % name))
+ for pattern, what in CODE:
+ if pattern.search(text):
+ problems.append((number, "plain words only: '%s' has %s" % (name, what)))
+ return problems
+
+
+BAD_CHANGELOG = """# Changelog
+
+## 0.4.4 (2026-10-25)
+
+## 0.4.3 (2026-10-25)
+"""
+
+BAD = """# QA checklist
+
+## 0.4.4 (2026-10-25)
+- **Logo:** look at the logo.
+- **Logo:** open `provisioning.yml`. You should see it.
+Some loose text.
+### Section
+"""
+
+GOOD = """# QA checklist
+
+Intro text is free.
+
+## 0.4.4 (2026-10-25)
+- **Logo:** open the web app and look at the header. You should see the Dev logo.
+
+## 0.4.3 (2026-10-25)
+"""
+
+
+def self_test():
+ found = check(BAD, BAD_CHANGELOG)
+ expected = ["versions don't match", "must say 'You should see'", "appears twice", "backticks", "a file name",
+ "only '- **Name:**", "only version headings"]
+ missing = [e for e in expected if not any(e in p for _, p in found)]
+ good = check(GOOD, BAD_CHANGELOG)
+ for _, p in found:
+ print(" caught: " + p)
+ if missing or good:
+ print("SELF-TEST FAILED: missed %s; good sample problems %s" % (missing, good))
+ return 1
+ print("Self-test passed: every rule caught its sample, and the good sample passed.")
+ return 0
+
+
+def main(argv):
+ if argv[1:] == ["--self-test"]:
+ return self_test()
+ if len(argv) != 3:
+ print(__doc__)
+ return 2
+ with open(argv[1], encoding="utf-8") as handle:
+ checklist = handle.read()
+ with open(argv[2], encoding="utf-8") as handle:
+ changelog = handle.read()
+ problems = check(checklist, changelog)
+ for number, problem in problems:
+ if os.environ.get("GITHUB_ACTIONS"):
+ print("::error file=%s,line=%d::%s" % (argv[1], number, problem))
+ else:
+ print("%s:%d: %s" % (argv[1], number, problem))
+ if problems:
+ print("%d problem(s): see the QA checklist rule in docs/development.md#qa-checklist." % len(problems))
+ return 1
+ print("%s matches the changelog and follows the QA checklist rule." % argv[1])
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main(sys.argv))
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 0000000..ad1ad8a
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,97 @@
+# Dependency updates (docs/development.md#dependency-updates). Everything goes to dev, the default branch, and moves up
+# to qa, staging and main like any other change. Each kind of dependency gets at most three pull requests a week, every
+# Monday: its patch and minor updates together, its major updates together, and its security fixes together (those
+# arrive as soon as GitHub publishes the advisory, not on the schedule). Patch and minor merge on their own once build
+# passes; major ones wait for review (.github/workflows/dependency-updates.yml). Group names ending in "-security" are
+# how that workflow tells a security fix from an ordinary update; when Dependabot's commit message carries no sizes
+# it can read, the workflow reads the "from A to B" lines itself.
+version: 2
+
+updates:
+ # Backend: the NuGet packages of every project in src/Requestarr.sln.
+ - package-ecosystem: nuget
+ directory: /src
+ schedule:
+ interval: weekly
+ day: monday
+ time: "06:00"
+ open-pull-requests-limit: 5
+ labels: [dependencies]
+ commit-message:
+ prefix: "Backend packages"
+ groups:
+ backend:
+ applies-to: version-updates
+ update-types: [minor, patch]
+ backend-major:
+ applies-to: version-updates
+ update-types: [major]
+ backend-security:
+ applies-to: security-updates
+ patterns: ["*"]
+
+ # Web app: package.json and yarn.lock.
+ - package-ecosystem: npm
+ directory: /
+ schedule:
+ interval: weekly
+ day: monday
+ time: "06:00"
+ open-pull-requests-limit: 5
+ labels: [dependencies]
+ commit-message:
+ prefix: "Web app packages"
+ groups:
+ frontend:
+ applies-to: version-updates
+ update-types: [minor, patch]
+ frontend-major:
+ applies-to: version-updates
+ update-types: [major]
+ frontend-security:
+ applies-to: security-updates
+ patterns: ["*"]
+
+ # The actions the workflows in .github/workflows use.
+ - package-ecosystem: github-actions
+ directory: /
+ schedule:
+ interval: weekly
+ day: monday
+ time: "06:00"
+ open-pull-requests-limit: 5
+ labels: [dependencies]
+ commit-message:
+ prefix: "GitHub Actions"
+ groups:
+ actions:
+ applies-to: version-updates
+ update-types: [minor, patch]
+ actions-major:
+ applies-to: version-updates
+ update-types: [major]
+ actions-security:
+ applies-to: security-updates
+ patterns: ["*"]
+
+ # The base images in docker/Dockerfile (Node for the web build, the .NET SDK and runtime).
+ - package-ecosystem: docker
+ directory: /docker
+ schedule:
+ interval: weekly
+ day: monday
+ time: "06:00"
+ open-pull-requests-limit: 5
+ labels: [dependencies]
+ commit-message:
+ prefix: "Base images"
+ groups:
+ images:
+ applies-to: version-updates
+ update-types: [minor, patch]
+ images-major:
+ applies-to: version-updates
+ update-types: [major]
+ images-security:
+ applies-to: security-updates
+ patterns: ["*"]
diff --git a/.github/dependency_updates.py b/.github/dependency_updates.py
new file mode 100644
index 0000000..f6f58e8
--- /dev/null
+++ b/.github/dependency_updates.py
@@ -0,0 +1,452 @@
+#!/usr/bin/env python3
+"""Dependabot's pull requests into dev (docs/development.md#dependency-updates). The Dependency updates workflow runs it.
+
+ sort a new or changed update: label it "automatic update" (patch or minor) or "needs review" (major, or a
+ change Dependabot can't size), plus "security" for a security fix and "for admins" when that fix is in
+ something Requestarr ships (the backend, the base images, or a web app package the built app contains).
+ update the oldest open "automatic update": bring it up to date with dev, give it the next version and its
+ changelog section, run build on that commit and merge it once build passes (hold it if build fails). One
+ at a time, so two updates never take the same version; the next one goes when this one has merged.
+ --self-test checks the parts that decide, against built-in samples.
+
+Needs git, the GitHub CLI (GH_TOKEN) and the repository checked out with full history.
+"""
+import base64
+import datetime
+import json
+import os
+import re
+import subprocess
+import sys
+import time
+
+AUTOMATIC = "automatic update"
+REVIEW = "needs review"
+SECURITY = "security"
+FOR_ADMINS = "for admins"
+LABELS = {
+ AUTOMATIC: ("0e8a16", "Patch or minor dependency update: merges into dev on its own once build passes"),
+ REVIEW: ("d93f0b", "Major dependency update, or one that failed: waits for Claude Code to review"),
+ SECURITY: ("b60205", "Fixes a published security problem"),
+ FOR_ADMINS: ("5319e7", "Security fix in something Requestarr ships: its changelog tells admins"),
+}
+
+# Dependabot's own names for a grouped security update when the repository groups them (before dependabot.yml did).
+DEFAULT_SECURITY_GROUPS = {"npm_and_yarn", "nuget", "github_actions", "docker"}
+
+SECURITY_LINE = ("- **Security update:** parts Requestarr is built from were updated to fix known security problems, "
+ "so update when you can.")
+
+FAILED = ("failure", "timed_out", "startup_failure")
+
+COMMIT_MESSAGE = "Version {version} and its changelog section for this dependency update"
+
+
+def run(*args, check=True, capture=True):
+ result = subprocess.run(args, check=False, text=True, capture_output=capture)
+ if check and result.returncode != 0:
+ sys.stderr.write(result.stderr or "")
+ raise SystemExit("%s failed (exit %d)" % (" ".join(args), result.returncode))
+ return (result.stdout or "").strip()
+
+
+def gh(*args, check=True):
+ return run("gh", *args, check=check)
+
+
+def git(*args, check=True):
+ return run("git", *args, check=check)
+
+
+def gh_api(method, path, payload, tries=4):
+ # Payload on stdin: a blob of yarn.lock is far longer than one command-line argument may be. Tried again after a
+ # pause, because GitHub's API can take a few seconds to know a commit that just reached dev ("not a valid tree").
+ for attempt in range(tries):
+ result = subprocess.run(["gh", "api", "-X", method, path, "--input", "-"], input=json.dumps(payload),
+ text=True, capture_output=True)
+ if result.returncode == 0:
+ return json.loads(result.stdout)
+ sys.stderr.write(result.stderr or result.stdout or "")
+ if attempt + 1 < tries:
+ time.sleep(15)
+ return None
+
+
+def publish(repo, branch):
+ """Makes the local HEAD commit again through GitHub's API and moves the branch to it; False if GitHub refused.
+
+ A commit this workflow pushes with git is unsigned, and the repository's rules want an extra approval for those.
+ One made through the API with the workflow's token is signed by GitHub, so the update can merge on its own."""
+ parents = git("rev-list", "--parents", "-n", "1", "HEAD").split()[1:]
+ # Start from the parent closest to the result (dev, when dev was merged in), so only a few files are sent.
+ base = min(parents, key=lambda p: len(git("diff", "--name-only", "--no-renames", p, "HEAD").splitlines()))
+ entries = []
+ for line in git("diff", "--name-status", "--no-renames", base, "HEAD").splitlines():
+ status, path = line.split("\t", 1)
+ if status == "D":
+ entries.append({"path": path, "mode": "100644", "type": "blob", "sha": None})
+ continue
+ mode = git("ls-tree", "HEAD", "--", path).split()[0]
+ content = base64.b64encode(subprocess.run(["git", "show", "HEAD:" + path], capture_output=True).stdout)
+ blob = gh_api("POST", "repos/%s/git/blobs" % repo, {"content": content.decode(), "encoding": "base64"})
+ if blob is None:
+ return False
+ entries.append({"path": path, "mode": mode, "type": "blob", "sha": blob["sha"]})
+ tree = gh_api("POST", "repos/%s/git/trees" % repo, {"base_tree": git("rev-parse", base + "^{tree}"),
+ "tree": entries})
+ if tree is None or tree["sha"] != git("rev-parse", "HEAD^{tree}"):
+ sys.stderr.write("the tree GitHub built differs from the local one\n")
+ return False
+ commit = gh_api("POST", "repos/%s/git/commits" % repo, {"message": git("log", "-1", "--format=%B", "HEAD"),
+ "tree": tree["sha"], "parents": parents})
+ if commit is None or gh_api("PATCH", "repos/%s/git/refs/heads/%s" % (repo, branch),
+ {"sha": commit["sha"], "force": False}) is None:
+ return False
+ git("fetch", "--quiet", "origin", branch)
+ git("reset", "--quiet", "--hard", commit["sha"])
+ return True
+
+
+# ---- deciding ---------------------------------------------------------------------------------------------------
+
+def automatic(update_type):
+ return update_type in ("version-update:semver-patch", "version-update:semver-minor")
+
+
+def _version(text):
+ numbers = re.findall(r"\d+", text.lstrip("vV"))[:3]
+ return tuple(int(n) for n in numbers) + (0,) * (3 - len(numbers)) if numbers else None
+
+
+def size_from_text(text):
+ """The biggest change among the "from A to B" lines of Dependabot's commit message, named the way fetch-metadata
+ names it, for when fetch-metadata can't read the message (Dependabot's newer grouped format)."""
+ pairs = re.findall(r"(?:Updates|Bump) `?[^`\s]+`? from (\S+) to (\S+?)(?:\s|$)", text)
+ rank = 0
+ for old, new in pairs:
+ a, b = _version(old), _version(new)
+ if a is None or b is None:
+ return ""
+ rank = max(rank, 3 if a[0] != b[0] else 2 if a[1] != b[1] else 1)
+ return {1: "version-update:semver-patch", 2: "version-update:semver-minor", 3: "version-update:semver-major"}.get(rank, "")
+
+
+def group_from_text(text):
+ match = re.search(r"Bumps the (\S+) group", text)
+ return match.group(1) if match else ""
+
+
+def is_security(group):
+ return bool(group) and (group.endswith("security") or group in DEFAULT_SECURITY_GROUPS)
+
+
+def parse_yarn_lock(text):
+ """{"name@range": (name, {dependency name: range})} for a yarn 1 lock file."""
+ entries, keys, deps, in_deps = {}, None, None, False
+ for line in text.splitlines():
+ if not line.strip() or line.startswith("#"):
+ continue
+ if not line.startswith(" "):
+ keys = [k.strip().strip('"') for k in line.rstrip(":").split(",")]
+ deps, in_deps = {}, False
+ for key in keys:
+ entries[key] = (key.rsplit("@", 1)[0], deps)
+ continue
+ stripped = line.strip()
+ if line.startswith(" ") and not line.startswith(" "):
+ in_deps = stripped in ("dependencies:", "optionalDependencies:")
+ continue
+ if in_deps and line.startswith(" "):
+ name, _, wanted = stripped.partition(" ")
+ deps[name.strip('"')] = wanted.strip().strip('"')
+ return entries
+
+
+def shipped_npm_packages(package_json, yarn_lock):
+ """Every package the built web app can contain: the dependencies (not devDependencies) and all they pull in."""
+ entries = parse_yarn_lock(yarn_lock)
+ todo = ["%s@%s" % (n, r) for n, r in json.loads(package_json).get("dependencies", {}).items()]
+ seen, names = set(), set()
+ while todo:
+ key = todo.pop()
+ if key in seen or key not in entries:
+ continue
+ seen.add(key)
+ name, deps = entries[key]
+ names.add(name)
+ todo.extend("%s@%s" % d for d in deps.items())
+ return names
+
+
+def ships(ecosystem, dependency_names, package_json="", yarn_lock=""):
+ """Whether an update changes something Requestarr ships: the backend, the image, or the built web app."""
+ if ecosystem in ("nuget", "docker", "docker_compose"):
+ return True
+ if ecosystem in ("npm_and_yarn", "npm"):
+ shipped = shipped_npm_packages(package_json, yarn_lock)
+ return any(name in shipped for name in dependency_names)
+ return False
+
+
+# ---- versions ---------------------------------------------------------------------------------------------------
+
+def top_version(changelog):
+ match = re.search(r"^## (\d+)\.(\d+)\.(\d+)", changelog, re.MULTILINE)
+ 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 with_section(changelog, version, day, security_line):
+ """The changelog with a new version section on top (above the first "## x.y.z")."""
+ heading = "## %d.%d.%d (%s)\n" % (version + (day,))
+ body = "\n### For admins\n%s\n" % security_line if security_line else ""
+ match = re.search(r"^## \d+\.\d+\.\d+", changelog, re.MULTILINE)
+ at = match.start() if match else len(changelog)
+ return changelog[:at] + heading + body + "\n" + changelog[at:]
+
+
+def with_assembly_version(props, version):
+ return re.sub(r"[^<]*", "%d.%d.%d.*" % version,
+ props, count=1)
+
+
+# ---- GitHub -----------------------------------------------------------------------------------------------------
+
+def ensure_labels(repo):
+ existing = {l["name"] for l in json.loads(gh("label", "list", "--repo", repo, "--limit", "200", "--json", "name"))}
+ for name, (color, description) in LABELS.items():
+ if name not in existing:
+ gh("label", "create", name, "--repo", repo, "--color", color, "--description", description)
+
+
+def comment_once(repo, number, marker, text):
+ bodies = json.loads(gh("pr", "view", str(number), "--repo", repo, "--json", "comments"))["comments"]
+ if not any(marker in c["body"] for c in bodies):
+ gh("pr", "comment", str(number), "--repo", repo, "--body", "%s\n\n" % (text, marker))
+
+
+def hold(repo, number, why):
+ gh("pr", "edit", str(number), "--repo", repo, "--add-label", REVIEW, "--remove-label", AUTOMATIC, check=False)
+ gh("pr", "merge", str(number), "--repo", repo, "--disable-auto", check=False)
+ comment_once(repo, number, "dependency-updates:hold", "Held for review: %s Claude Code reviews it, fixes what "
+ "breaks and merges it in a normal task (docs/development.md#dependency-updates)." % why)
+
+
+def sort():
+ repo, number = os.environ["REPO"], os.environ["PR"]
+ update_type, group = os.environ.get("UPDATE_TYPE", ""), os.environ.get("GROUP", "")
+ ecosystem = os.environ.get("ECOSYSTEM", "")
+ names = [d["dependencyName"] for d in json.loads(os.environ.get("DEPENDENCIES") or "[]")]
+ ensure_labels(repo)
+ if not update_type or not group:
+ commits = json.loads(gh("api", "repos/%s/pulls/%s/commits" % (repo, number), "--jq", "[.[].commit.message]"))
+ update_type = update_type or size_from_text("\n".join(commits))
+ group = group or group_from_text("\n".join(commits))
+
+ labels = []
+ if is_security(group):
+ labels.append(SECURITY)
+ package_json = open("package.json", encoding="utf-8").read() if os.path.exists("package.json") else "{}"
+ yarn_lock = open("yarn.lock", encoding="utf-8").read() if os.path.exists("yarn.lock") else ""
+ if ships(ecosystem, names, package_json, yarn_lock):
+ labels.append(FOR_ADMINS)
+
+ current = {l["name"] for l in json.loads(gh("pr", "view", number, "--repo", repo, "--json", "labels"))["labels"]}
+ if REVIEW in current:
+ print("#%s is already held for review" % number)
+ elif automatic(update_type):
+ labels.append(AUTOMATIC)
+ else:
+ labels.append(REVIEW)
+
+ if labels:
+ gh("pr", "edit", number, "--repo", repo, "--add-label", ",".join(labels))
+ if REVIEW in labels:
+ hold(repo, number, "a %s update (%s)." % (update_type.replace("version-update:semver-", "") or "differently "
+ "versioned", ", ".join(names) or "see the description"))
+ print("#%s: %s %s, group %s, %s -> %s" % (number, ecosystem, update_type, group or "none", ", ".join(names), labels))
+
+
+def dispatched_builds(repo, sha):
+ # Only the build this workflow starts counts: its commit can also queue pull request runs that wait for an
+ # approval nobody gives ("action_required"), and those aren't failures.
+ runs = json.loads(gh("run", "list", "--repo", repo, "--workflow", "build.yml", "--commit", sha,
+ "--json", "status,conclusion,event,databaseId"))
+ return [r for r in runs if r["event"] == "workflow_dispatch"]
+
+
+def wait_for_build(repo, sha, minutes=45):
+ deadline = time.time() + minutes * 60
+ while True:
+ runs = dispatched_builds(repo, sha)
+ if (runs and all(r["status"] == "completed" for r in runs)) or time.time() > deadline:
+ return runs
+ time.sleep(30)
+
+
+def update():
+ repo = os.environ["REPO"]
+ ensure_labels(repo)
+ # An update that merged or was held makes way for the next one in the same run.
+ for _ in range(10):
+ if not update_next(repo):
+ return
+
+
+def update_next(repo):
+ """Moves the oldest automatic update on; True when it merged or was held, so the next one can go."""
+ waiting =json.loads(gh("pr", "list", "--repo", repo, "--base", "dev", "--state", "open", "--label", AUTOMATIC,
+ "--json", "number,headRefName,labels,author,createdAt,isDraft"))
+ waiting = [p for p in waiting if p["author"]["login"] in ("app/dependabot", "dependabot[bot]", "dependabot")
+ and REVIEW not in {l["name"] for l in p["labels"]}]
+ if not waiting:
+ print("No automatic update is waiting.")
+ return False
+ pr =sorted(waiting, key=lambda p: p["createdAt"])[0]
+ number, branch = pr["number"], pr["headRefName"]
+ labels = {l["name"] for l in pr["labels"]}
+ print("Next automatic update: #%d (%s); %d waiting" % (number, branch, len(waiting)))
+
+ git("fetch", "--quiet", "origin", "dev", "qa", "staging", branch)
+ git("checkout", "--quiet", "-B", "update", "origin/" + branch)
+ tops = {b: git("show", "origin/%s:CHANGELOG.md" % b) for b in ("dev", "qa", "staging")}
+ 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())
+
+ if not (up_to_date and versioned):
+ if not up_to_date:
+ merged = subprocess.run(["git", "merge", "--no-edit", "--quiet", "origin/dev"], text=True, capture_output=True)
+ if merged.returncode != 0:
+ conflicted = git("diff", "--name-only", "--diff-filter=U").splitlines()
+ others = [f for f in conflicted if f not in ("CHANGELOG.md", "QA-CHECKLIST.md", "src/Directory.Build.props")]
+ if others:
+ git("merge", "--abort", check=False)
+ hold(repo, number, "it conflicts with dev in %s." % ", ".join(others))
+ return True
+ # 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())
+ 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)
+ open("CHANGELOG.md", "w", encoding="utf-8", newline="\n").write(changelog)
+ checklist = with_section(open("QA-CHECKLIST.md", encoding="utf-8").read(), version, day, "")
+ open("QA-CHECKLIST.md", "w", encoding="utf-8", newline="\n").write(checklist)
+ props = open("src/Directory.Build.props", encoding="utf-8").read()
+ open("src/Directory.Build.props", "w", encoding="utf-8", newline="").write(with_assembly_version(props, version))
+ git("add", "CHANGELOG.md", "QA-CHECKLIST.md", "src/Directory.Build.props")
+ merging = subprocess.run(["git", "rev-parse", "-q", "--verify", "MERGE_HEAD"], capture_output=True).returncode == 0
+ if merging or subprocess.run(["git", "diff", "--cached", "--quiet"]).returncode != 0:
+ git("commit", "--quiet", "--no-verify", "-m", COMMIT_MESSAGE.format(version="%d.%d.%d" % version))
+ if not publish(repo, branch):
+ hold(repo, number, "its version and changelog section could not be added (GitHub refused the commit).")
+ return True
+ print("#%d: version %d.%d.%d added" % ((number,) + version))
+
+ sha = git("rev-parse", "HEAD")
+ if not dispatched_builds(repo, sha):
+ # Commits made by this workflow don't start workflows, so build is started here on this commit.
+ gh("workflow", "run", "build.yml", "--repo", repo, "--ref", branch, "-f", "base=dev")
+ print("#%d: build started on %s" % (number, sha[:7]))
+ # Wait for it here: GitHub starts no workflow when a build this workflow started finishes, nor when auto-merge
+ # that it turned on merges, and the hourly run can come late or not at all.
+ runs = wait_for_build(repo, sha)
+ if any(r["conclusion"] in FAILED for r in runs) and not any(r["conclusion"] == "success" for r in runs):
+ hold(repo, number, "build failed on it.")
+ return True
+ if any(r["conclusion"] == "success" for r in runs):
+ if subprocess.run(["gh", "pr", "merge", str(number), "--repo", repo, "--merge"]).returncode == 0:
+ print("#%d: build passed, merged into dev" % number)
+ return True
+ gh("pr", "merge", str(number), "--repo", repo, "--auto", "--merge")
+ print("#%d: merges on its own once build passes" % number)
+ return False
+
+
+# ---- self-test --------------------------------------------------------------------------------------------------
+
+LOCK = '''# yarn lockfile v1
+
+
+"app-dep@^1.0.0":
+ version "1.2.0"
+ dependencies:
+ deep-dep "^2.0.0"
+
+deep-dep@^2.0.0, deep-dep@^2.1.0:
+ version "2.1.0"
+
+tool@3.0.0:
+ version "3.0.0"
+ dependencies:
+ tool-dep "~1.0.0"
+
+tool-dep@~1.0.0:
+ version "1.0.4"
+'''
+
+
+GROUPED = """Bump fast-uri
+
+Bumps the npm_and_yarn group with 2 updates in the / directory: [fast-uri](x) and [ajv](y).
+
+
+Updates `fast-uri` from 3.1.8 to 3.1.9
+- [Release notes](x)
+
+Updates `ajv` from 8.17.1 to 8.18.0
+"""
+
+
+def self_test():
+ package = json.dumps({"dependencies": {"app-dep": "^1.0.0"}, "devDependencies": {"tool": "3.0.0"}})
+ checks = [
+ (automatic("version-update:semver-patch") and automatic("version-update:semver-minor"), "patch and minor merge"),
+ (not automatic("version-update:semver-major") and not automatic(""), "major and unsized updates wait"),
+ (is_security("frontend-security") and is_security("npm_and_yarn") and not is_security("frontend"),
+ "security groups are recognised"),
+ (shipped_npm_packages(package, LOCK) == {"app-dep", "deep-dep"}, "the web app's packages, followed through"),
+ (ships("npm_and_yarn", ["deep-dep"], package, LOCK), "a package the app pulls in ships"),
+ (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"),
+ (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),
+ "a security fix that ships tells admins"),
+ (size_from_text(GROUPED) == "version-update:semver-minor", "a grouped update is sized by its biggest change"),
+ (size_from_text("Updates `fast-uri` from 3.1.8 to 3.1.9\n") == "version-update:semver-patch", "a patch update"),
+ (size_from_text("Bump actions/checkout from 4 to 5\n") == "version-update:semver-major", "a major update"),
+ (size_from_text("Bump node from 20-bookworm-slim to 24-bookworm-slim") == "version-update:semver-major",
+ "an image tag"),
+ (size_from_text("nothing to size") == "", "no versions, no size"),
+ (group_from_text(GROUPED) == "npm_and_yarn" and group_from_text("Bump x") == "", "the group from the message"),
+ (with_assembly_version("0.4.2.*", (0, 4, 3))
+ == "0.4.3.*", "the assembly version follows"),
+ ]
+ failed = [what for ok, what in checks if not ok]
+ for what in failed:
+ print("self-test failed: " + what)
+ print("self-test: %d of %d passed" % (len(checks) - len(failed), len(checks)))
+ return 1 if failed else 0
+
+
+if __name__ == "__main__":
+ command = sys.argv[1] if len(sys.argv) > 1 else ""
+ if command == "--self-test":
+ sys.exit(self_test())
+ if command == "sort":
+ sort()
+ elif command == "update":
+ update()
+ else:
+ print(__doc__)
+ sys.exit(1)
diff --git a/.github/version_logo_glyphs.py b/.github/version_logo_glyphs.py
new file mode 100644
index 0000000..ddd629c
--- /dev/null
+++ b/.github/version_logo_glyphs.py
@@ -0,0 +1,90 @@
+#!/usr/bin/env python3
+"""Writes src/Requestarr.Http/Frontend/Mappers/VersionLogoGlyphs.cs: the outlines of the letters the production header
+logo's version bar can show ("v", the digits and ".", plus "BETA", which sets the size) and the font's kerning between
+them, taken from Inter Display Black, the font of the branch labels on the dev, QA and beta logos. Requestarr draws the
+bar itself, so the version always matches the one running.
+
+Run it again only to change the font or the letters:
+
+ pip install fonttools uharfbuzz
+ python3 .github/version_logo_glyphs.py path/to/InterDisplay-Black.otf
+
+InterDisplay-Black.otf is in extras/otf/ of Inter's release zip (github.com/rsms/inter, SIL Open Font License 1.1).
+"""
+import os
+import sys
+
+import uharfbuzz as hb
+from fontTools.pens.boundsPen import BoundsPen
+from fontTools.pens.svgPathPen import SVGPathPen
+from fontTools.ttLib import TTFont
+
+LETTERS = "v0123456789.BETA"
+OUT = os.path.join(os.path.dirname(__file__), "..", "src", "Requestarr.Http", "Frontend", "Mappers", "VersionLogoGlyphs.cs")
+
+
+def main(path):
+ font = TTFont(path)
+ glyphs = font.getGlyphSet()
+ cmap = font.getBestCmap()
+ name = font["name"].getDebugName(4)
+ version = font["name"].getDebugName(5)
+
+ rows = []
+ for letter in LETTERS:
+ glyph = cmap[ord(letter)]
+ pen = SVGPathPen(glyphs, lambda v: f"{v:g}")
+ glyphs[glyph].draw(pen)
+ bounds = BoundsPen(glyphs)
+ glyphs[glyph].draw(bounds)
+ x_min, y_min, x_max, y_max = bounds.bounds
+ rows.append(f' {{ \'{letter}\', new Glyph({glyphs[glyph].width}, {x_min:g}, {y_min:g}, {x_max:g}, {y_max:g}, "{pen.getCommands()}") }},')
+
+ # Kerning as a shaper applies it: the first letter's advance in a shaped pair, less its own advance.
+ shaper = hb.Font(hb.Face(hb.Blob.from_file_path(path)))
+ kerning = []
+ for first in LETTERS:
+ for second in LETTERS:
+ buffer = hb.Buffer()
+ buffer.add_str(first + second)
+ buffer.guess_segment_properties()
+ hb.shape(shaper, buffer, {})
+ names = [font.getGlyphName(info.codepoint) for info in buffer.glyph_infos]
+ positions = buffer.glyph_positions
+ if names != [cmap[ord(first)], cmap[ord(second)]] or positions[0].x_offset or positions[1].x_offset:
+ sys.exit(f"{first + second}: the font does more than kern this pair; the logo can't draw it")
+ adjust = positions[0].x_advance - glyphs[cmap[ord(first)]].width
+ if adjust:
+ kerning.append(f' {{ "{first + second}", {adjust} }},')
+
+ text = f"""// Generated by .github/version_logo_glyphs.py from {name} ({version}). Do not edit by hand.
+// Inter: Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter), SIL Open Font License 1.1.
+using System.Collections.Generic;
+
+namespace Requestarr.Http.Frontend.Mappers
+{{
+ public static class VersionLogoGlyphs
+ {{
+ public const int UnitsPerEm = {font["head"].unitsPerEm};
+
+ public static readonly IReadOnlyDictionary Letters = new Dictionary
+ {{
+{os.linesep.join(rows)}
+ }};
+
+ public static readonly IReadOnlyDictionary Kerning = new Dictionary
+ {{
+{os.linesep.join(kerning)}
+ }};
+
+ public record Glyph(double Advance, double XMin, double YMin, double XMax, double YMax, string Path);
+ }}
+}}
+"""
+ with open(OUT, "w", newline="\n", encoding="utf-8") as f:
+ f.write(text.replace(os.linesep, "\n"))
+ print(f"wrote {os.path.normpath(OUT)}: {len(LETTERS)} letters from {name}")
+
+
+if __name__ == "__main__":
+ main(sys.argv[1])
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index f55edb3..a1dd4b8 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -4,7 +4,13 @@ on:
push:
branches: ["**"]
pull_request:
+ # Also started by the Dependency updates workflow on an update it has just versioned: commits that workflow pushes
+ # don't start workflows themselves. "base" is the branch the update goes into, for the Changelog job.
workflow_dispatch:
+ inputs:
+ base:
+ description: For a dependency update, the branch it goes into (checks its version against that branch)
+ default: ""
concurrency:
group: build-${{ github.ref }}
@@ -140,8 +146,11 @@ jobs:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- - name: Checker self-test (every rule must catch its sample)
- run: python3 .github/changelog_check.py --self-test
+ - name: Checker self-tests (every rule must catch its sample)
+ run: |
+ python3 .github/changelog_check.py --self-test
+ python3 .github/checklist_check.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:
EVENT: ${{ github.event_name }}
@@ -149,9 +158,15 @@ jobs:
BASE_REF: ${{ github.event.pull_request.base.ref }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
HEAD_REF: ${{ github.event.pull_request.head.ref }}
+ DISPATCH_BASE: ${{ inputs.base }}
run: |
+ if [ "$EVENT" = "workflow_dispatch" ] && [ -n "$DISPATCH_BASE" ]; then
+ ./release.sh check-changelog "origin/$DISPATCH_BASE" HEAD "$DISPATCH_BASE" HEAD "$GITHUB_REF_NAME"
+ exit 0
+ fi
if [ "$EVENT" != "pull_request" ]; then
python3 .github/changelog_check.py CHANGELOG.md
+ python3 .github/checklist_check.py QA-CHECKLIST.md CHANGELOG.md
exit 0
fi
./release.sh check-changelog "$BASE_SHA" HEAD "$BASE_REF" "$HEAD_SHA" "$HEAD_REF"
diff --git a/.github/workflows/dependency-updates.yml b/.github/workflows/dependency-updates.yml
new file mode 100644
index 0000000..8c4dc9e
--- /dev/null
+++ b/.github/workflows/dependency-updates.yml
@@ -0,0 +1,70 @@
+name: Dependency updates
+
+# Dependabot's pull requests into dev (docs/development.md#dependency-updates; .github/dependency_updates.py does the
+# 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.
+on:
+ pull_request:
+ types: [opened, reopened, synchronize]
+ branches: [dev]
+ push:
+ branches: [dev]
+ schedule:
+ - cron: "23 * * * *"
+ workflow_dispatch:
+
+# Pull requests opened by Dependabot get a read-only token unless the workflow asks for more.
+permissions:
+ contents: write
+ pull-requests: write
+ actions: write
+
+concurrency:
+ group: dependency-updates
+
+jobs:
+ sort:
+ name: Sort a new update
+ if: github.event_name == 'pull_request' && github.event.pull_request.user.login == 'dependabot[bot]'
+ runs-on: ubuntu-latest
+ timeout-minutes: 5
+ steps:
+ - uses: actions/checkout@v4
+ - id: meta
+ uses: dependabot/fetch-metadata@v2
+ - name: Label it automatic or held for review, and security
+ env:
+ GH_TOKEN: ${{ github.token }}
+ REPO: ${{ github.repository }}
+ PR: ${{ github.event.pull_request.number }}
+ UPDATE_TYPE: ${{ steps.meta.outputs.update-type }}
+ GROUP: ${{ steps.meta.outputs.dependency-group }}
+ ECOSYSTEM: ${{ steps.meta.outputs.package-ecosystem }}
+ DEPENDENCIES: ${{ steps.meta.outputs.updated-dependencies-json }}
+ run: python3 .github/dependency_updates.py sort
+
+ update:
+ name: Version and merge the next automatic update
+ needs: sort
+ if: >
+ always() && needs.sort.result != 'failure' &&
+ (github.event_name != 'pull_request' || github.event.pull_request.user.login == 'dependabot[bot]')
+ runs-on: ubuntu-latest
+ timeout-minutes: 120
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ ref: dev
+ fetch-depth: 0
+ - name: Self-test
+ run: python3 .github/dependency_updates.py --self-test
+ - name: Next automatic update
+ env:
+ GH_TOKEN: ${{ github.token }}
+ REPO: ${{ github.repository }}
+ run: |
+ git config user.name "github-actions[bot]"
+ git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
+ python3 .github/dependency_updates.py update
diff --git a/.gitignore b/.gitignore
index 571d582..be2e874 100644
--- a/.gitignore
+++ b/.gitignore
@@ -204,3 +204,6 @@ docker/secrets.env
deploy/staging/.env
deploy/staging/backups/
deploy/staging/.previous-version
+
+# Python bytecode from the helper scripts
+__pycache__/
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 899775b..2c4cb5e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,77 @@ 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.4.17 (2026-10-26)
+
+## 0.4.16 (2026-10-26)
+
+### For users
+- **A tidier bug reports channel:** reports now stay in the order they were sent with the Report a problem button below them, and sending one no longer leaves a private reply pointing at a deleted message. Your report's card in the channel is the confirmation.
+
+### For admins
+- **Fixed: Passes on the QA checklist:** pressing Passes no longer answers "Something went wrong"; the card shows who passed it, as before.
+- **Failing several checklist items in a row:** admins working through the checklist are no longer told to wait between reports. Members still are.
+- **Every test server report reaches GitHub:** on the dev, QA and beta servers each bug report is filed on GitHub. A report that couldn't be filed says exactly why on its card, and is filed on its own once it can be, with the card updated to show the link.
+- **The QA checklist has its own group:** its channel now sits in an admin-only Requestarr QA group, moved there with its cards, so testing stays apart from running the server.
+- **No more "edited" on panels:** the admin panel, the welcome message and the Report a problem message are no longer marked edited each time they move below a new post or Requestarr restarts.
+
+## 0.4.15 (2026-10-25)
+
+### For users
+- **Report a problem:** a new bug reports channel has a Report a problem button. Pick the part of Requestarr, say what happened and what you expected, and it reaches the people who build Requestarr. Reports are public, so the form tells you that before you send.
+
+### For admins
+- **Test checklist on the QA server:** every version on its way to staging comes with a list of things to test, posted in Discord. Admins mark each one as passing or failing, and a failing one opens a bug report linked to it.
+- **Bug reports become issues:** reports can be filed on GitHub automatically, with the version, the part of Requestarr and who reported it. It's off unless you turn it on with your own access key, and each person can only send a few reports in a row.
+
+## 0.4.14 (2026-10-25)
+
+## 0.4.13 (2026-10-25)
+
+### For admins
+- **Readable sign-in logo:** the logo on the web app's sign-in page now uses its light arrow in both themes, so it stands out against the dark gray box behind it.
+- **Real logo while loading:** while the web app starts, it now shows your environment's logo, the same one as the header, instead of the old purple square with an R.
+
+## 0.4.12 (2026-10-25)
+
+### For admins
+- **The bot keeps its own picture and banner:** Requestarr now gives its bot the Requestarr picture and your environment's banner, and updates them when a new release changes them, so you no longer upload them by hand in Discord. A picture you chose yourself is kept, and you can turn this off in the provisioning file.
+
+## 0.4.11 (2026-10-25)
+
+### For users
+- **Softer logo:** the arrow in Requestarr's logo is now a soft charcoal instead of solid black, so it sits more gently next to the teal.
+
+### For admins
+- **Logo without a box:** in the web app the header and sign-in logo, and the icon in your browser tab, no longer sit on a square of their own color; the page shows through around them in both themes.
+- **Made for the purple header:** the light theme's purple header has its own logo, and on Beta the label bar there is white so it doesn't disappear into the purple.
+- **Charcoal arrow everywhere:** the light theme's icons, home screen icons and tiles use the softer charcoal arrow too.
+
+## 0.4.10 (2026-10-25)
+
+### For admins
+- **Dark theme icons:** in the dark theme, Requestarr's icons now sit on the same charcoal gray as the app's header instead of black, so they no longer look like a hole in the page.
+- **Bigger logo in the web app:** the logo in the header and on the sign-in page is now full height, with the R beside a colored bar naming the environment (Dev, QA or Beta), so it's easier to see at a glance.
+- **Version on the logo:** on your live install, the header and sign-in logo shows the version you're running on a green bar, and it updates by itself when you upgrade.
+
+## 0.4.9 (2026-10-25)
+
+## 0.4.8 (2026-10-25)
+
+## 0.4.7 (2026-10-25)
+
+## 0.4.6 (2026-10-25)
+
+## 0.4.5 (2026-10-25)
+
+## 0.4.4 (2026-10-25)
+
+## 0.4.3 (2026-10-25)
+
+### For admins
+- **Kept up to date:** the parts Requestarr is built from are now updated every week, and a version that fixes a security problem in something Requestarr ships says so here, so you know to update.
+- **Safer logs:** a web address or user name with a line break in it can no longer add fake lines to Requestarr's log.
+
## 0.4.2 (2026-10-25)
### For users
diff --git a/CLAUDE.md b/CLAUDE.md
index b9f0d15..13b137e 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -17,6 +17,11 @@ 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`.
+- 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.
+
+- Dependency updates: Dependabot's pull requests go into `dev` (the default branch); patch and minor merge on their own, major ones are labelled "needs review" and wait for a normal task (`docs/development.md#dependency-updates`). At the end of every task, list security fixes merged into `dev` that `qa` doesn't have yet (`gh pr list --base dev --state merged --label security`) and name them in the banner's "Needs you" line, so the owner can promote sooner; mention open "needs review" updates in the reply comm.
+
## Gotchas
- License is GPL-3.0 because Servarr code is GPL-3.0. Keep Servarr copyright headers on copied files; do not add code under incompatible licenses.
diff --git a/QA-CHECKLIST.md b/QA-CHECKLIST.md
new file mode 100644
index 0000000..35c62a1
--- /dev/null
+++ b/QA-CHECKLIST.md
@@ -0,0 +1,53 @@
+# QA checklist
+
+What admins on the QA server test, in Discord, before a version goes to staging. Requestarr posts these items in the QA checklist channel for every version QA has that staging doesn't; admins press Passes or Failing on each. When every item has passed and no bug report from QA is still open, the version can go 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.4.17 (2026-10-26)
+
+## 0.4.16 (2026-10-26)
+- **Passes without errors:** Press Passes on a checklist item, then press it again. You should see the card show who passed it and when, and no "Something went wrong" message.
+- **Failing in a row:** Press Failing on three items one after another and send a short report for each. You should see a form open every time, each card marked failing with its own report, and never "You've sent a report just now".
+- **Member limit still holds:** In the bug reports channel, press Report a problem and send a report, then press it again straight away. You should see Requestarr ask you to wait.
+- **Bug reports channel order:** Open the bug reports channel after sending a report, on your phone and on your computer. You should see the reports in the order they were sent, the Report a problem message last, and no reply pointing at a deleted message.
+- **Why a report isn't on GitHub:** Look at the card of a report you sent. You should see either the link to its GitHub issue, or the exact reason it isn't there yet, such as the server having no GitHub access key.
+- **QA group:** Look at the server's channel list. You should see the QA checklist channel in its own Requestarr QA group with all its cards, and a member who isn't an admin shouldn't see that group.
+
+## 0.4.15 (2026-10-25)
+- **Test checklist:** Open the QA checklist channel in the Requestarr QA group. You should see a card for each thing to test in the versions QA has that staging doesn't, each with Passes and Failing buttons, and a summary card at the top.
+- **Passing an item:** Press Passes on one card. You should see the card say who passed it and when, and the summary count go up by one.
+- **Failing an item:** Press Failing on another card, pick the part of Requestarr, describe what went wrong and send it. You should see the card marked failing with a link to its bug report, and the summary say the checklist can't complete while it fails.
+- **Report a problem:** In the bug reports channel in the Requestarr Users group, press Report a problem, pick a part, read the note that reports are public, and send a short test report. You should see a card for your report in the channel, and the link to its issue on GitHub once it's filed.
+- **Report limit:** Send several reports one after another. You should see Requestarr ask you to wait once you reach the limit, and no more cards posted until then.
+
+## 0.4.14 (2026-10-25)
+
+## 0.4.13 (2026-10-25)
+- **Sign-in logo:** Sign out of the web app and look at the sign-in page in the light theme, then in the dark theme. You should see the logo with a light arrow, easy to read on the gray box, in both.
+- **Loading screen:** Reload the web app and watch it while it starts. You should see the QA logo, the same one as the header's, with no purple square.
+
+## 0.4.12 (2026-10-25)
+- **Bot picture and banner:** In the Requestarr QA server, click the bot's name to open its profile. You should see the plain Requestarr picture and the yellow QA banner.
+
+## 0.4.11 (2026-10-25)
+- **Logo on the purple header:** Switch the web app to the light theme and look at the header. You should see the QA bar and the R straight on the purple, with no white box around them.
+- **Charcoal arrow:** In the light theme, look at the header logo and the icon in the browser tab. You should see the arrow in a soft charcoal instead of solid black.
+
+## 0.4.10 (2026-10-25)
+- **Dark theme icons:** Switch the web app to the dark theme and look at the header and the browser tab. You should see the logo and the icon on charcoal gray, never on black.
+- **Full-height logo:** Look at the logo at the top left of the header. You should see the yellow QA bar beside a full-height R, not a small square.
+
+## 0.4.9 (2026-10-25)
+
+## 0.4.8 (2026-10-25)
+
+## 0.4.7 (2026-10-25)
+
+## 0.4.6 (2026-10-25)
+
+## 0.4.5 (2026-10-25)
+
+## 0.4.4 (2026-10-25)
+
+## 0.4.3 (2026-10-25)
diff --git a/deploy/staging/compose.yaml b/deploy/staging/compose.yaml
index 55007a7..332d1bb 100644
--- a/deploy/staging/compose.yaml
+++ b/deploy/staging/compose.yaml
@@ -109,6 +109,8 @@ services:
DISCORD_APPLICATION_ID: ${BETA_DISCORD_APPLICATION_ID:?set BETA_DISCORD_APPLICATION_ID in .env}
DISCORD_GUILD_ID: ${BETA_DISCORD_GUILD_ID:?set BETA_DISCORD_GUILD_ID in .env}
DISCORD_ADMIN_ROLE_ID: ${BETA_DISCORD_ADMIN_ROLE_ID:-}
+ # Every bug report from beta becomes a GitHub issue; without the key it waits on the server until one is set.
+ REQUESTARR_GITHUB_ISSUES_TOKEN: ${GITHUB_ISSUES_TOKEN:-}
TMDB_API_KEY: ${TMDB_API_KEY:-}
SONARR_API_KEY: ${BETA_SONARR_API_KEY}
RADARR_API_KEY: ${BETA_RADARR_API_KEY}
diff --git a/deploy/staging/env.example b/deploy/staging/env.example
index f7c3982..333903f 100644
--- a/deploy/staging/env.example
+++ b/deploy/staging/env.example
@@ -16,7 +16,8 @@ BETA_DOWNLOADS_FOLDER=
BETA_DOWNLOADS_PATH=/downloads
# --- Ports on this server -------------------------------------------------------------------------------------------
-# Three free ports, different from your production Sonarr and Radarr.
+# Three free ports, different from your production Sonarr and Radarr. Beta's band is the standard ports with a 1 in
+# front, so the first digit says which environment you're on: 19797, 18989 and 17878.
BETA_REQUESTARR_PORT=
BETA_SONARR_PORT=
BETA_RADARR_PORT=
@@ -48,6 +49,11 @@ BETA_DISCORD_GUILD_ID=
# Optional: an existing role whose members are Requestarr admins (otherwise Requestarr makes "Requestarr Admin").
BETA_DISCORD_ADMIN_ROLE_ID=
+# --- Bug reports ----------------------------------------------------------------------------------------------------
+# A fine-grained GitHub token with only "Issues: read and write" on RunemarkLabs/Requestarr. Each bug report from beta
+# becomes an issue there; until this is set, reports wait on the server and are filed once it is.
+GITHUB_ISSUES_TOKEN=
+
# --- The Movie Database (optional: cast and details on request cards) -----------------------------------------------
TMDB_API_KEY=
diff --git a/docker/Dockerfile b/docker/Dockerfile
index 9862dc0..cf6ad21 100644
--- a/docker/Dockerfile
+++ b/docker/Dockerfile
@@ -15,7 +15,7 @@ RUN yarn build --env production
FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:10.0 AS backend
ARG TARGETARCH
WORKDIR /src
-COPY .editorconfig LICENSE CHANGELOG.md ./
+COPY .editorconfig LICENSE CHANGELOG.md QA-CHECKLIST.md ./
COPY src/ src/
# global.json pins the local SDK patch; the image's 10.0 SDK is used as-is.
RUN case "$TARGETARCH" in arm64) RID=linux-arm64 ;; *) RID=linux-x64 ;; esac \
diff --git a/docker/compose.yaml b/docker/compose.yaml
index 2627a7c..d84414f 100644
--- a/docker/compose.yaml
+++ b/docker/compose.yaml
@@ -32,6 +32,8 @@ services:
DISCORD_APPLICATION_ID: ${STACK_DISCORD_APPLICATION_ID:-}
DISCORD_TEST_GUILD_ID: ${STACK_DISCORD_GUILD_ID:-}
DISCORD_ADMIN_ROLE_ID: ${STACK_DISCORD_ADMIN_ROLE_ID:-}
+ # Files bug reports as GitHub issues (QA: GITHUB_ISSUES_TOKEN, dev: DEV_GITHUB_ISSUES_TOKEN in secrets.env).
+ REQUESTARR_GITHUB_ISSUES_TOKEN: ${STACK_GITHUB_ISSUES_TOKEN:-}
volumes:
- requestarr-config:/config
- ./provisioning.yml:/provisioning/provisioning.yml:ro
diff --git a/docker/dev.ps1 b/docker/dev.ps1
index f0078a9..b1e2a68 100644
--- a/docker/dev.ps1
+++ b/docker/dev.ps1
@@ -19,6 +19,7 @@
# .\docker\dev.ps1 fake-download movie "" make a title in the test Radarr look downloaded, like a real import
# .\docker\dev.ps1 fake-download tv "" the same for a season in the test Sonarr
# .\docker\dev.ps1 fast-votes on|off|status test timings for removal votes (vote 5 min, answer 5, reminder at 3)
+# .\docker\dev.ps1 qa-gate [--json] is the QA checklist complete with no bug report from QA open? (exit 0: clear)
#
# Needs Docker Desktop and Git, and Python 3 for check, the resets and fake-download. Secret values are
# read from docker\secrets.env and never printed. If PowerShell says running scripts is disabled, run once:
@@ -143,13 +144,14 @@ function Get-StackInfo([string]$which) {
Name = 'QA'; Project = 'requestarr-qa'; Secrets = $s
Env = @{
REQUESTARR_STACK = 'qa'
- STACK_REQUESTARR_PORT = (Value $s 'REQUESTARR_PORT' '9797')
- STACK_SONARR_TEST_PORT = (Value $s 'SONARR_TEST_PORT' '18989')
- STACK_RADARR_TEST_PORT = (Value $s 'RADARR_TEST_PORT' '17878')
+ STACK_REQUESTARR_PORT = (Value $s 'REQUESTARR_PORT' '29797')
+ STACK_SONARR_TEST_PORT = (Value $s 'SONARR_TEST_PORT' '28989')
+ STACK_RADARR_TEST_PORT = (Value $s 'RADARR_TEST_PORT' '27878')
STACK_DISCORD_BOT_TOKEN = $s['DISCORD_BOT_TOKEN']
STACK_DISCORD_APPLICATION_ID = $s['DISCORD_APPLICATION_ID']
STACK_DISCORD_GUILD_ID = $s['DISCORD_TEST_GUILD_ID']
STACK_DISCORD_ADMIN_ROLE_ID = $s['DISCORD_ADMIN_ROLE_ID']
+ STACK_GITHUB_ISSUES_TOKEN = $s['GITHUB_ISSUES_TOKEN']
# Set when the stack is built (Build-Image), the only time Requestarr's container is recreated.
REQUESTARR_STAGED_VERSIONS = ''
}
@@ -160,13 +162,14 @@ function Get-StackInfo([string]$which) {
Name = 'Dev'; Project = 'requestarr-dev'; Secrets = $s
Env = @{
REQUESTARR_STACK = 'dev'
- STACK_REQUESTARR_PORT = (Value $s 'DEV_REQUESTARR_PORT' '19797')
- STACK_SONARR_TEST_PORT = (Value $s 'DEV_SONARR_TEST_PORT' '28989')
- STACK_RADARR_TEST_PORT = (Value $s 'DEV_RADARR_TEST_PORT' '27878')
+ STACK_REQUESTARR_PORT = (Value $s 'DEV_REQUESTARR_PORT' '39797')
+ STACK_SONARR_TEST_PORT = (Value $s 'DEV_SONARR_TEST_PORT' '38989')
+ STACK_RADARR_TEST_PORT = (Value $s 'DEV_RADARR_TEST_PORT' '37878')
STACK_DISCORD_BOT_TOKEN = $s['DEV_DISCORD_BOT_TOKEN']
STACK_DISCORD_APPLICATION_ID = $s['DEV_DISCORD_APPLICATION_ID']
STACK_DISCORD_GUILD_ID = $s['DEV_DISCORD_GUILD_ID']
STACK_DISCORD_ADMIN_ROLE_ID = $s['DEV_DISCORD_ADMIN_ROLE_ID']
+ STACK_GITHUB_ISSUES_TOKEN = $s['DEV_GITHUB_ISSUES_TOKEN']
}
}
}
@@ -464,8 +467,14 @@ switch ($Command) {
if ($code -ne 0) { exit $code }
}
'fast-votes' { Require-Docker; $script:Info = Get-StackInfo $Stack; Fast-Votes (First-Rest) }
+ 'qa-gate' {
+ $script:Info = Get-StackInfo $Stack
+ $vars = @{ QA_GATE_URL = "http://localhost:$($script:Info.Env.STACK_REQUESTARR_PORT)"; REQUESTARR_API_KEY = $script:Info.Secrets['REQUESTARR_API_KEY'] }
+ $code = With-Env $vars { Invoke-Python (@((Join-Path $Dir 'qa-gate.py')) + $Rest) }
+ exit $code
+ }
default {
- Get-Content $PSCommandPath | Select-Object -Skip 1 -First 25 | ForEach-Object { $_ -replace '^# ?', '' }
+ Get-Content $PSCommandPath | Select-Object -Skip 1 -First 26 | ForEach-Object { $_ -replace '^# ?', '' }
exit 1
}
}
diff --git a/docker/dev.sh b/docker/dev.sh
index 43ea67c..c91b9e6 100644
--- a/docker/dev.sh
+++ b/docker/dev.sh
@@ -19,6 +19,7 @@
# docker/dev.sh fake-download movie "" make a title in the test Radarr look downloaded, like a real import
# docker/dev.sh fake-download tv "" the same for season n in the test Sonarr
# docker/dev.sh fast-votes on|off|status test timings for removal votes (vote 5 min, answer 5, reminder at 3)
+# docker/dev.sh qa-gate [--json] is the QA checklist complete with no bug report from QA open? (exit 0: clear)
#
# On Windows, docker/dev.ps1 does the same from PowerShell. Secret values are read from docker/secrets.env
# and never printed.
@@ -85,23 +86,25 @@ prepare() {
case "$STACK" in
qa)
STACK_NAME="QA"
- STACK_REQUESTARR_PORT="${REQUESTARR_PORT:-9797}"
- STACK_SONARR_TEST_PORT="${SONARR_TEST_PORT:-18989}"
- STACK_RADARR_TEST_PORT="${RADARR_TEST_PORT:-17878}"
+ STACK_REQUESTARR_PORT="${REQUESTARR_PORT:-29797}"
+ STACK_SONARR_TEST_PORT="${SONARR_TEST_PORT:-28989}"
+ STACK_RADARR_TEST_PORT="${RADARR_TEST_PORT:-27878}"
STACK_DISCORD_BOT_TOKEN="${DISCORD_BOT_TOKEN:-}"
STACK_DISCORD_APPLICATION_ID="${DISCORD_APPLICATION_ID:-}"
STACK_DISCORD_GUILD_ID="${DISCORD_TEST_GUILD_ID:-}"
STACK_DISCORD_ADMIN_ROLE_ID="${DISCORD_ADMIN_ROLE_ID:-}"
+ STACK_GITHUB_ISSUES_TOKEN="${GITHUB_ISSUES_TOKEN:-}"
;;
dev)
STACK_NAME="Dev"
- STACK_REQUESTARR_PORT="${DEV_REQUESTARR_PORT:-19797}"
- STACK_SONARR_TEST_PORT="${DEV_SONARR_TEST_PORT:-28989}"
- STACK_RADARR_TEST_PORT="${DEV_RADARR_TEST_PORT:-27878}"
+ STACK_REQUESTARR_PORT="${DEV_REQUESTARR_PORT:-39797}"
+ STACK_SONARR_TEST_PORT="${DEV_SONARR_TEST_PORT:-38989}"
+ STACK_RADARR_TEST_PORT="${DEV_RADARR_TEST_PORT:-37878}"
STACK_DISCORD_BOT_TOKEN="${DEV_DISCORD_BOT_TOKEN:-}"
STACK_DISCORD_APPLICATION_ID="${DEV_DISCORD_APPLICATION_ID:-}"
STACK_DISCORD_GUILD_ID="${DEV_DISCORD_GUILD_ID:-}"
STACK_DISCORD_ADMIN_ROLE_ID="${DEV_DISCORD_ADMIN_ROLE_ID:-}"
+ STACK_GITHUB_ISSUES_TOKEN="${DEV_GITHUB_ISSUES_TOKEN:-}"
;;
*) die "--stack is qa or dev (got '$STACK')" ;;
esac
@@ -112,7 +115,7 @@ prepare() {
REQUESTARR_STAGED_VERSIONS=""
export REQUESTARR_STACK STACK_REQUESTARR_PORT STACK_SONARR_TEST_PORT STACK_RADARR_TEST_PORT \
STACK_DISCORD_BOT_TOKEN STACK_DISCORD_APPLICATION_ID STACK_DISCORD_GUILD_ID STACK_DISCORD_ADMIN_ROLE_ID \
- REQUESTARR_STAGED_VERSIONS
+ STACK_GITHUB_ISSUES_TOKEN REQUESTARR_STAGED_VERSIONS
}
# QA only: the versions staging has (what was last published for it, staged-versions.py), which QA's what's-new
@@ -338,8 +341,13 @@ case "${1:-}" in
python -I "$DIR/fake-download.py" "$@"
;;
fast-votes) prepare; fast_votes "${2:-}" ;;
+ qa-gate)
+ shift
+ prepare
+ QA_GATE_URL="http://localhost:$STACK_REQUESTARR_PORT" python -I "$DIR/qa-gate.py" "$@"
+ ;;
*)
- sed -n '2,24p' "$0"
+ sed -n '2,25p' "$0"
exit 1
;;
esac
diff --git a/docker/fake-download.py b/docker/fake-download.py
index cd44e7a..f180f9f 100644
--- a/docker/fake-download.py
+++ b/docker/fake-download.py
@@ -28,8 +28,8 @@
STACKS = ("requestarr-qa", "requestarr-dev")
PROJECT = os.environ.get("REQUESTARR_COMPOSE_PROJECT", "requestarr-qa")
APPS = {
- "movie": {"service": "radarr-test", "app": "Radarr", "port": "RADARR_TEST_PORT", "default_port": "17878", "key": "RADARR_TEST_API_KEY"},
- "tv": {"service": "sonarr-test", "app": "Sonarr", "port": "SONARR_TEST_PORT", "default_port": "18989", "key": "SONARR_TEST_API_KEY"},
+ "movie": {"service": "radarr-test", "app": "Radarr", "port": "RADARR_TEST_PORT", "default_port": "27878", "key": "RADARR_TEST_API_KEY"},
+ "tv": {"service": "sonarr-test", "app": "Sonarr", "port": "SONARR_TEST_PORT", "default_port": "28989", "key": "SONARR_TEST_API_KEY"},
}
diff --git a/docker/provisioning.yml b/docker/provisioning.yml
index 21871b0..c6c4f52 100644
--- a/docker/provisioning.yml
+++ b/docker/provisioning.yml
@@ -26,6 +26,12 @@ discord:
# (the server owner always is). Optionally use an existing role instead:
adminRoleId: ${DISCORD_ADMIN_ROLE_ID:-}
+# The bots' picture and banner: Requestarr's always win on the test servers, so a picture uploaded by hand before
+# Requestarr managed it is replaced (each environment's banner: dev, QA).
+botProfile:
+ keepUpToDate: true
+ takeOver: true
+
# Optional: cast from The Movie Database (leave TMDB_API_KEY unset to skip).
tmdb:
apiKey: ${TMDB_API_KEY:-}
diff --git a/docker/qa-gate.py b/docker/qa-gate.py
new file mode 100644
index 0000000..73f6119
--- /dev/null
+++ b/docker/qa-gate.py
@@ -0,0 +1,130 @@
+#!/usr/bin/env python3
+"""The gate before staging: is QA's test checklist complete, with no item failing and no bug report from QA still open?
+
+Run through the stack scripts, which pass the stack's address and API key (never printed):
+ .\\docker\\dev.ps1 qa-gate docker/dev.sh qa-gate ./release.sh qa-gate
+(-Stack dev / --stack dev reads the dev stack instead, where the checklist is tried out.)
+
+Reads the checklist from Requestarr (GET /api/v1/qa/checklist) and the open issues from GitHub (gh): any open issue
+labelled with this server's environment ("env: qa") and a version the checklist covers counts, whether or not it came
+from a checklist item. A report from the server that isn't on GitHub (filing off, no key, or GitHub refused it) blocks
+too, whatever its version: it's an open QA bug the issue list can't show.
+
+Exit 0: clear (staging may have these versions). 1: blocked. 2: couldn't tell (stack down, gh missing).
+--json prints the verdict as JSON for release.sh. --self-test checks the verdict rules (CI runs it).
+"""
+import json
+import os
+import subprocess
+import sys
+import urllib.error
+import urllib.request
+
+REPOSITORY = os.environ.get("BUG_REPORTS_REPOSITORY", "RunemarkLabs/Requestarr")
+WHY = {"Off": "filing is turned off on the server", "NoToken": "the server has no GitHub access key",
+ "Pending": "it was being filed when the server stopped", "Failed": "GitHub refused it or couldn't be reached"}
+
+
+def checklist():
+ # A saved answer instead of the stack (the live test writes one at each step, in the same format).
+ if os.environ.get("QA_GATE_FILE"):
+ with open(os.environ["QA_GATE_FILE"], encoding="utf-8") as handle:
+ return json.load(handle)
+ url = os.environ["QA_GATE_URL"].rstrip("/") + "/api/v1/qa/checklist"
+ request = urllib.request.Request(url, headers={"X-Api-Key": os.environ.get("REQUESTARR_API_KEY", ""), "Accept": "application/json"})
+ with urllib.request.urlopen(request, timeout=15) as response:
+ return json.load(response)
+
+
+def open_issues(environment, versions):
+ out = subprocess.run(["gh", "issue", "list", "--repo", REPOSITORY, "--state", "open", "--label", "env: " + environment,
+ "--limit", "500", "--json", "number,title,url,labels"], capture_output=True, text=True, check=True).stdout
+ wanted = {"version: " + v for v in versions}
+ return [i for i in json.loads(out) if wanted & {label["name"] for label in i["labels"]}]
+
+
+def decide(data, issues):
+ """The verdict from the server's answer and the open issues: clear only when every item passed, none is failing,
+ no issue from this server is open and every report from it is on GitHub."""
+ state = data["checklist"]
+ # Older servers sent only the reports for the checklist's versions.
+ unfiled = data["unfiledReports"] if "unfiledReports" in data else [r for r in data.get("bugReports", []) if r.get("filing") != "Filed"]
+ complete = state["complete"]
+ clear = complete and state["failing"] == 0 and not issues and not unfiled
+ verdict = {
+ "clear": clear, "environment": state["environment"], "versions": state["versions"], "total": state["total"], "passed": state["passed"],
+ "failing": state["failing"], "complete": complete, "completedBy": state.get("completedBy"), "completedAt": state.get("completedAt"),
+ "openIssues": [{"number": i["number"], "title": i["title"], "url": i["url"]} for i in issues],
+ "unfiledReports": [{"id": r["id"], "filing": r.get("filing"), "problem": r.get("filingProblem")} for r in unfiled],
+ }
+ return verdict, unfiled
+
+
+def self_test():
+ state = {"environment": "qa", "versions": ["0.4.16"], "total": 2, "passed": 2, "failing": 0, "complete": True, "items": []}
+ issue = {"number": 9, "title": "t", "url": "u"}
+ kept = {"id": 3, "filing": "NoToken", "filingProblem": None}
+ cases = [
+ ("complete, nothing open", {"checklist": state, "unfiledReports": []}, [], True),
+ ("an open issue", {"checklist": state, "unfiledReports": []}, [issue], False),
+ ("a report not on GitHub", {"checklist": state, "unfiledReports": [kept]}, [], False),
+ ("an older server's unfiled report", {"checklist": state, "bugReports": [dict(kept, filing="Off")]}, [], False),
+ ("an older server's filed report", {"checklist": state, "bugReports": [dict(kept, filing="Filed")]}, [], True),
+ ("a failing item", {"checklist": dict(state, failing=1, passed=1), "unfiledReports": []}, [], False),
+ ]
+ wrong = [name for name, data, issues, clear in cases if decide(data, issues)[0]["clear"] != clear]
+ for name in wrong:
+ print("qa-gate self-test: wrong verdict for %s" % name, file=sys.stderr)
+ print("qa-gate self-test: %d of %d cases right" % (len(cases) - len(wrong), len(cases)))
+ return 1 if wrong else 0
+
+
+def main(argv):
+ if "--self-test" in argv:
+ return self_test()
+ as_json = "--json" in argv
+ try:
+ data = checklist()
+ except urllib.error.HTTPError as error:
+ reason = 'it runs a Requestarr from before the QA checklist' if error.code == 404 else 'HTTP %d' % error.code
+ print('qa-gate: the stack has no QA checklist to read (%s).' % reason, file=sys.stderr)
+ return 2
+ except (urllib.error.URLError, OSError, ValueError, KeyError) as error:
+ print("qa-gate: couldn't read the checklist from the stack (%s). Is it running?" % type(error).__name__, file=sys.stderr)
+ return 2
+
+ state = data["checklist"]
+ versions = state["versions"]
+ try:
+ issues = open_issues(state["environment"], versions) if versions else []
+ except (OSError, subprocess.CalledProcessError) as error:
+ print("qa-gate: couldn't list the open issues on GitHub with gh (%s)." % type(error).__name__, file=sys.stderr)
+ return 2
+
+ verdict, unfiled = decide(data, issues)
+ clear, complete = verdict["clear"], verdict["complete"]
+
+ if as_json:
+ print(json.dumps(verdict))
+ return 0 if clear else 1
+
+ print("QA checklist (%s server) for %s: %d of %d passed, %d failing%s." % (
+ state["environment"], ", ".join(versions) or "no versions", state["passed"], state["total"], state["failing"],
+ ", complete, the last item passed by %s at %s" % (state.get("completedBy"), state.get("completedAt")) if complete else ""))
+ for item in state["items"]:
+ if item["state"] != "Passed":
+ print(" %-8s %s: %s%s" % (item["state"].lower(), item["version"], item["name"], " (%s)" % item["issueUrl"] if item.get("issueUrl") else ""))
+ print("Open bug reports from %s for these versions on GitHub: %d" % (state["environment"], len(issues)))
+ for issue in issues:
+ print(" #%d %s %s" % (issue["number"], issue["title"], issue["url"]))
+ print("Bug reports from %s not on GitHub: %d" % (state["environment"], len(unfiled)))
+ for report in unfiled:
+ print(" #%d %s: %s" % (report["id"], report.get("filing"), report.get("filingProblem") or WHY.get(report.get("filing"), "not filed")))
+ print("CLEAR: staging may have %s." % ", ".join(versions) if clear else
+ "BLOCKED: promotion to staging waits until every item has passed and every bug report from QA is on GitHub and closed "
+ "(or the owner overrides).")
+ return 0 if clear else 1
+
+
+if __name__ == "__main__":
+ sys.exit(main(sys.argv[1:]))
diff --git a/docker/reset-discord.py b/docker/reset-discord.py
index 32325b8..cb630cd 100644
--- a/docker/reset-discord.py
+++ b/docker/reset-discord.py
@@ -1,7 +1,7 @@
"""Remove everything Requestarr created in one local stack's Discord server, for a clean start.
-Deletes: Requestarr's channel groups ("Requestarr Admin", "Requestarr Users" and its numbered ones, and the older
-"Requestarr Requests") and every channel in them (including archived ones), #requestarr-admin, #requestarr-feed,
+Deletes: Requestarr's channel groups ("Requestarr Admin", "Requestarr QA", "Requestarr Users" and its numbered ones,
+and the older "Requestarr Requests") and every channel in them (including archived ones), #requestarr-admin, #requestarr-feed,
#requestarr-announcements, #requestarr-welcome, #requestarr-access-blocked and members' request channels wherever they
are, the per-user
"requestarr-*" roles and the shared "Requestarr Members" and "Requestarr Blocked" roles.
@@ -21,8 +21,9 @@
import urllib.request
API = "https://discord.com/api/v10"
-ADMIN_CHANNELS = ("requestarr-admin", "requestarr-feed", "requestarr-announcements", "requestarr-welcome", "requestarr-access-blocked")
-CATEGORIES = ("Requestarr Admin", "Requestarr Users", "Requestarr Requests")
+ADMIN_CHANNELS = ("requestarr-admin", "requestarr-feed", "requestarr-qa-checklist", "requestarr-announcements", "requestarr-welcome",
+ "requestarr-access-blocked", "requestarr-bug-reports")
+CATEGORIES = ("Requestarr Admin", "Requestarr QA", "Requestarr Users", "Requestarr Requests")
USER_ROLE_PREFIX = "requestarr-"
SHARED_ROLES = ("Requestarr Members", "Requestarr Blocked")
diff --git a/docker/secrets.env.example b/docker/secrets.env.example
index 32903c6..c82b19f 100644
--- a/docker/secrets.env.example
+++ b/docker/secrets.env.example
@@ -25,7 +25,13 @@ DEV_DISCORD_APPLICATION_ID=
DEV_DISCORD_GUILD_ID=
DEV_DISCORD_ADMIN_ROLE_ID=
-# Optional host ports (localhost only). QA: 9797, 18989, 17878. Dev: 19797, 28989, 27878.
+# Bug reports become GitHub issues (a fine-grained token with only "Issues: read and write" on RunemarkLabs/Requestarr).
+# QA files for real; dev's issues are labelled "test". The same token can go in both.
+GITHUB_ISSUES_TOKEN=
+DEV_GITHUB_ISSUES_TOKEN=
+
+# Optional host ports (localhost only). One band per environment, the first digit counting down to the standard port:
+# Dev 39797, 38989, 37878. QA 29797, 28989, 27878. (Beta 19797, 18989, 17878; production 9797, 8989, 7878.)
# REQUESTARR_PORT= SONARR_TEST_PORT= RADARR_TEST_PORT=
# DEV_REQUESTARR_PORT= DEV_SONARR_TEST_PORT= DEV_RADARR_TEST_PORT=
diff --git a/docs/development.md b/docs/development.md
index fec2413..67968cb 100644
--- a/docs/development.md
+++ b/docs/development.md
@@ -78,6 +78,29 @@ Not run in CI:
**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.
+## QA checklist
+
+`QA-CHECKLIST.md` at the repo root says what admins on the QA server test, by hand, before a version goes to staging. Requestarr is built with it and posts it in the QA server's `#requestarr-qa-checklist` channel, in an admin-only group of its own (Requestarr QA, so testing stays apart from running the server): a summary card, then one card per item with **Passes** and **Failing**, for every version QA has that staging doesn't. The dev server shows the running version's items, so the checklist can be tried out there; other servers have none.
+
+**Every pull request adds its version's items**, the way it adds its changelog entry, at the top of `QA-CHECKLIST.md`, with the same `## x.y.z (YYYY-MM-DD)` heading as the changelog:
+
+```markdown
+## 0.4.15 (2026-10-25)
+- **Report a problem:** In the bug reports channel, press Report a problem, pick a part and send a short test report. You should see a card for your report in the channel.
+```
+
+Each item says what to do and, after "You should see", what a pass looks like, in plain words an admin can follow in Discord: no code-style text, the same as the changelog. A version nobody can test by hand (build and CI work, tooling, dependency updates) has **only its heading**: that's how "nothing to test here" is marked, the same escape as a heading-only changelog entry. The automatic dependency updates add their heading themselves.
+
+**The checks.** `./release.sh check-changelog` (CI's Changelog job) fails a pull request that doesn't change `QA-CHECKLIST.md`, or whose checklist doesn't match the changelog from 0.4.3 on (same versions, dates and order), or has an item without a bold name, without "You should see", named twice in a version, or with code-style text. `.github/checklist_check.py` does the checking (with a self-test); run it with `python3 .github/checklist_check.py QA-CHECKLIST.md CHANGELOG.md`. A unit test checks the file is embedded and its top version matches the build.
+
+**In Discord.** Pressing **Passes** records who and when, and the card shows it (the press has no reply of its own: Discord can't turn a card into a plain message, which is why every press used to answer "Something went wrong"); **Failing** opens a bug report (below) linked to the item, and the card shows it. An admin failing items isn't held to the members' report limit, so several can fail in a row. The record is Requestarr's (the portal will show it later); the cards show it. The checklist is complete when every item has passed. A new promotion to QA while a checklist is half done keeps every pass and failure already recorded, adds the new versions' items as not tested yet, and drops the versions staging now has.
+
+**The gate before staging.** `./release.sh qa-gate` (or `.\docker\dev.ps1 qa-gate`) reads the QA stack's checklist (`GET /api/v1/qa/checklist` with the API key) and lists the open GitHub issues labelled `env: qa` with one of the checklist's versions. It's clear when the checklist is complete, nothing is failing, no such issue is open (whether or not it came from a checklist item) and every bug report from QA is on GitHub: one kept on the server (filing off, no access key, GitHub refused it) is an open QA bug the issue list can't show, so it blocks too, whatever its version, and the gate names it with the reason. `python3 docker/qa-gate.py --self-test` checks these rules (CI runs it). `promote-staging` refuses otherwise, unless the owner overrides it in so many words (`--owner-override ""`), which the promotion pull request records. At the start of every goal, Claude Code runs the gate; when it's clear, it promotes QA to staging first (repo `CLAUDE.md`).
+
+**Bug reports.** Every server has `#requestarr-bug-reports` in the Requestarr Users group: members and admins read it, nobody types in it, and its **Report a problem** button opens a menu of Requestarr's parts (one list, `BugReportComponents`, so it grows with the app) and a short form. Requestarr adds the environment, version, time and the reporter's Discord name, keeps the report, posts it as a card and, when the install files reports, opens a GitHub issue labelled `bug report`, `env: …`, `version: …` and `component: …` (and `test` from the dev server). Each person can send a few a day, a couple of minutes apart. The dev, QA and beta servers always file (the key comes from `REQUESTARR_GITHUB_ISSUES_TOKEN`: `DEV_GITHUB_ISSUES_TOKEN` and `GITHUB_ISSUES_TOKEN` in `docker/secrets.env`, `GITHUB_ISSUES_TOKEN` in beta's `.env`). A report that isn't filed says exactly why on its card and is filed each time the bot connects, until it is, without its reporter sending it again; the card then shows the link. Settings: `bugReports:` in the provisioning file (setup guide, "Bug reports").
+
+**How the bug reports channel reads.** Reports in the order they came in, the **Report a problem** panel last. The panel moves below each new card as a new message (the old one is removed, never edited first, so nothing is marked "edited" for nothing). The reporter's private menu becomes "Sending your report…" and is removed once the card is posted: it replied to the panel, which moves, and would otherwise be left pointing at a deleted message. A report sent from a failing checklist item answers in the checklist channel instead.
+
## Release flow
Four branches, each with its own environment.
@@ -87,9 +110,9 @@ Four branches, each with its own environment.
| `dev` | Every pull request merges here once the `build` check passes. Claude Code's automated and live testing. | The dev stack, on the dev Discord server and bot (`DEV_DISCORD_*`). |
| `qa` | The owner's manual QA of a version picked from `dev`. Moves only on the owner's word ("promote to QA"). | The QA stack, on the QA Discord server and bot (`DISCORD_*`). Never touched by automated testing. |
| `staging` | Early access testers, on a version the owner passed in QA. Moves only on "QA passed". | The beta stack on the owner's server (`deploy/staging`), the Requestarr Beta server and bot, with its own Sonarr and Radarr that really download. Runs the published `staging` image. |
-| `main` | Production: the default branch; only released code. Moves only on the owner's "ship it". | Published as `latest` and version tags. |
+| `main` | Production: only released code. Moves only on the owner's "ship it". | Published as `latest` and version tags. |
-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.
+`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).
@@ -101,12 +124,15 @@ All four branches have the same rules on GitHub (one ruleset): a pull request is
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** (only when the owner says "QA passed", or names a version). First run the full suite and every live check, and report them ("Testing scope" in the workspace rules: nothing reaches staging without this). Then:
+**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 owner: "QA passed"'
+./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"'
```
+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").
**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`.
@@ -152,6 +178,36 @@ The "what's new" posts in `#requestarr-admin` (both sections) and `#requestarr-a
On every start QA and dev make the channels match: cards for versions that no longer apply are deleted, a card whose words changed is edited in place, and from the first card out of order onward everything is posted again, so the list always reads oldest to newest. Requestarr finds its own cards by their title (`Requestarr x.y.z · 6 changes for admins, 4 for users`, `Requestarr x.y.z · 4 new things for you`) among the newest 1,000 messages of each channel. Text posts from before cards (`📋 **Requestarr x.y.z: what's new**`, `✨ **What's new in Requestarr x.y.z**`) are found too and replaced by cards: on QA and dev on every start, on production and staging once, keeping the whole history. Everything else in the channels is left alone.
+## Dependency updates
+
+`dev` is the repository's default branch, so Dependabot's pull requests, version updates and security fixes alike, go into `dev` and move up through `qa`, `staging` and `main` like any other change. `main` stays production with the same rules as before.
+
+**What arrives, and when** (`.github/dependabot.yml`). Four kinds of dependency, each checked every Monday:
+
+| Kind | Where | Groups |
+|---|---|---|
+| Backend packages (NuGet) | every project in `src/Requestarr.sln` | `backend` (patch and minor), `backend-major`, `backend-security` |
+| Web app packages (npm, yarn) | `package.json`, `yarn.lock` | `frontend`, `frontend-major`, `frontend-security` |
+| GitHub Actions | `.github/workflows` | `actions`, `actions-major`, `actions-security` |
+| Base images (Docker) | `docker/Dockerfile` | `images`, `images-major`, `images-security` |
+
+So each kind sends at most one pull request a week for its patch and minor updates and one for its major updates. Security fixes come as soon as GitHub publishes the advisory, also one pull request per kind.
+
+**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.
+- 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.
+- A package used in more than one project must have its version set in one place, or an update moves only some of them and `build` fails. NUnit, for example, comes only from `Requestarr.Test.Common`.
+
+**Major updates and held updates** are Claude Code's: review the changes, fix what breaks, and merge in a normal task (a pull request into `dev` with its own version and changelog, or the held one finished and merged by hand).
+
+**Security fixes reach the owner** three ways: the pull request carries the **security** label; a fix in something Requestarr ships puts a "Security update" line in the version's For admins section, so it shows in the admin channel's version post; and Claude Code checks for security fixes merged into `dev` that `qa` doesn't have yet at the end of every task and names them in the completion banner's "Needs you" line, so the owner can promote sooner than usual.
+
+Repository settings this relies on: default branch `dev`, auto-merge allowed, Dependabot security updates on.
+
## Docker stack
Two local stacks run side by side, each with its own Requestarr and dedicated Sonarr and Radarr test instances:
@@ -185,11 +241,13 @@ Set-ExecutionPolicy -Scope CurrentUser RemoteSigned
If you can't change the policy, `.\docker\dev.cmd` takes the same commands and runs `dev.ps1` for that one run whatever the policy is.
+Ports come in one band per environment, so the first digit says which one you're on, counting down to the standard ports: dev 3xxxx, QA 2xxxx, beta 1xxxx (19797, 18989, 17878 on the owner's server), production the standard 9797, 8989 and 7878. Inside Docker every container keeps its standard port, so Sonarr and Radarr reach Requestarr's webhook as `http://requestarr:9797` whatever the band.
+
| Service | QA stack (localhost only) | Dev stack (localhost only) | Notes |
|---|---|---|---|
-| Requestarr | http://localhost:9797 | http://localhost:19797 | provisioned from `docker/provisioning.yml` |
-| Sonarr (test) | http://localhost:18989 | http://localhost:28989 | root folder `/data/tv`, no login (localhost-bound) |
-| Radarr (test) | http://localhost:17878 | http://localhost:27878 | root folder `/data/movies`, no login (localhost-bound) |
+| Requestarr | http://localhost:29797 | http://localhost:39797 | provisioned from `docker/provisioning.yml` |
+| Sonarr (test) | http://localhost:28989 | http://localhost:38989 | root folder `/data/tv`, no login (localhost-bound) |
+| Radarr (test) | http://localhost:27878 | http://localhost:37878 | root folder `/data/movies`, no login (localhost-bound) |
Other commands:
diff --git a/docs/setup-guide.md b/docs/setup-guide.md
index 974347e..ee01173 100644
--- a/docs/setup-guide.md
+++ b/docs/setup-guide.md
@@ -20,7 +20,7 @@ Every Requestarr install uses its own bot. Discord's official reference is https
3. **Installation:** set Installation Contexts to **Guild Install** only, set **Install Link** to **None**, and save.
Do this before step 4. Discord refuses to make a bot private while an install link is set ("Private application cannot have a default authorization link").
4. **Bot:**
- - Set the bot username.
+ - Set the bot username. Leave its icon and banner empty: Requestarr sets them itself (5.13).
- Turn **Public Bot** off, so nobody else can add your bot.
- Under Privileged Gateway Intents, turn **Server Members Intent** on. Requestarr needs it to detect members leaving. Leave Presence and Message Content off.
- Ignore the Bot Permissions grid on this page. It is only a calculator and saves nothing.
@@ -698,6 +698,44 @@ accessRequests:
blockWithinHours: 24
```
+### 5.13 The bot's picture and banner
+
+Requestarr gives its bot the Requestarr picture and a banner, and updates them when a new release changes them, so you never upload them by hand. The picture is the same everywhere. The banner is the teal one, or the dev, QA or beta one on those test servers.
+
+- It happens when the bot starts, and only when something differs. An unchanged picture is never uploaded again, so it stays well inside Discord's limit on how often a bot can change its picture. If Discord refuses anyway, the log says so and Requestarr tries again on a later start. The bot runs normally either way.
+- It needs no permission and no change to the invite link: a bot changes its own picture and banner.
+- **Your own picture stays.** Requestarr only replaces a picture or banner it set itself, or puts one in when the bot has none. It remembers Discord's id for each one it sets. If the id changes, someone changed it by hand, and Requestarr leaves it alone from then on. A picture the bot already had before Requestarr managed it counts as your choice too.
+- **To let Requestarr replace your own,** set `takeOver: true`: Requestarr's picture and banner then always win, and one changed by hand is replaced on the next start.
+
+Settings, under `botProfile:` in `provisioning.yml` (each optional), stored so the admin web portal can change them later:
+
+```yaml
+botProfile:
+ keepUpToDate: true # false: Requestarr never touches the bot's picture or banner
+ takeOver: false # true: replace one chosen by hand too (Requestarr's always wins)
+```
+
+### 5.14 Bug reports
+
+`#requestarr-bug-reports`, in the Requestarr Users group, lets members and admins tell the people who build Requestarr about a problem. Nobody types in it: **Report a problem** opens a menu of Requestarr's parts, then a short form (what happened, what they expected). Requestarr adds your server's environment, its version, the time and the reporter's Discord name, nothing else from your server, and posts the report as a card in the channel, so you see every report.
+
+**Filing on GitHub is off by default.** Turned on, each report also becomes an issue on Requestarr's public GitHub repository, which anyone can read; the form says so before anything is sent. It needs an access key of your own that can only read and write issues on that repository (GitHub: Settings, Developer settings, Fine-grained tokens, Issues "Read and write", nothing else). Leave it off unless you want your members' reports to be public. Requestarr's own test servers (dev, QA and beta) always file, whatever the setting.
+
+The access key can also come from the `REQUESTARR_GITHUB_ISSUES_TOKEN` environment variable, when the provisioning file names none.
+
+**Each card says where its report is.** The link to its GitHub issue, or exactly why it isn't there: filing is turned off, the server has no access key, or what GitHub said (the key was refused, it can't create issues there, the repository wasn't found) or that GitHub couldn't be reached. A report that couldn't be filed is filed the next time the bot connects, for example after you add the key and restart, without anyone sending it again, and its card then shows the link.
+
+```yaml
+bugReports:
+ fileIssues: false # true: each report becomes a public GitHub issue
+ githubToken: ${GITHUB_ISSUES_TOKEN} # in secrets.env, never here
+ repository: RunemarkLabs/Requestarr
+ reportsPerDay: 5 # per person
+ minutesBetween: 2 # per person, between two reports
+```
+
+The limits stop the button flooding anything: someone who reaches them is told how long to wait, and nothing is posted.
+
## 6. Connect Plex (optional)
_Coming in Phase 3:_ Plex token and library-to-content-type mapping.
@@ -767,7 +805,7 @@ Open `.env` and fill in every value; the comments say what each is. In particula
- **`BETA_TV_FOLDER`, `BETA_MOVIES_FOLDER`:** the two folders from B.3, full paths.
- **`BETA_DOWNLOADS_FOLDER`, `BETA_DOWNLOADS_PATH`:** the same downloads folder, and the same path inside the container, as your production Sonarr's compose file mounts (look for its downloads line, for example `/srv/downloads:/downloads`). Only the downloads folder: if production mounts one folder that holds both downloads and media (a single `/data`), mount just its downloads subfolder here. Imports then copy instead of hardlinking, which uses extra space only while a download is still seeding.
-- **The three ports:** free ports, different from production's. `ss -ltn | grep :` prints nothing for a free port.
+- **The three ports:** free ports, different from production's. Use beta's band, 19797, 18989 and 17878 (the standard ports with a 1 in front, so production keeps 9797, 8989 and 7878 and the first digit says which environment you're on). `ss -ltn | grep :` prints nothing for a free port.
- **`PUID`, `PGID`:** the same as your production Sonarr and Radarr, so Plex can read what beta downloads.
- **`BETA_SONARR_API_KEY`, `BETA_RADARR_API_KEY`, `REQUESTARR_API_KEY`:** made up here, one each: `openssl rand -hex 16`.
- **`BETA_DISCORD_BOT_TOKEN`, `BETA_DISCORD_APPLICATION_ID`, `BETA_DISCORD_GUILD_ID`:** the beta bot and the Requestarr Beta server (B.1). This file is the only place they're kept.
diff --git a/frontend/build/webpack.config.js b/frontend/build/webpack.config.js
index ac7c2ed..0e6bc6e 100644
--- a/frontend/build/webpack.config.js
+++ b/frontend/build/webpack.config.js
@@ -120,7 +120,8 @@ module.exports = (env) => {
destination: path.join(distFolder, 'Content/Fonts')
},
- // Icon Images: Icons/{light|dark}/{production|dev|qa|beta}/ per theme and environment
+ // Icon Images: Icons/{light|dark}/{production|dev|qa|beta}/ per theme and environment, and
+ // Icons/light-purple/ for the light theme's purple header
{
source: 'frontend/src/Content/Images/Icons/*.*',
destination: path.join(distFolder, 'Content/Images/Icons')
@@ -129,6 +130,10 @@ module.exports = (env) => {
source: 'frontend/src/Content/Images/Icons/light',
destination: path.join(distFolder, 'Content/Images/Icons/light')
},
+ {
+ source: 'frontend/src/Content/Images/Icons/light-purple',
+ destination: path.join(distFolder, 'Content/Images/Icons/light-purple')
+ },
{
source: 'frontend/src/Content/Images/Icons/dark',
destination: path.join(distFolder, 'Content/Images/Icons/dark')
diff --git a/frontend/src/Components/Page/Header/HeaderLogo.tsx b/frontend/src/Components/Page/Header/HeaderLogo.tsx
index 58bc1d6..4d060ea 100644
--- a/frontend/src/Components/Page/Header/HeaderLogo.tsx
+++ b/frontend/src/Components/Page/Header/HeaderLogo.tsx
@@ -6,17 +6,20 @@ 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.
+// 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.
function HeaderLogo({ className }: HeaderLogoProps) {
- const theme = useTheme() === 'dark' ? 'dark' : 'light';
- const { urlBase, iconSet } = window.Requestarr;
+ const theme = useTheme() === 'dark' ? 'dark' : 'light-purple';
+ const { urlBase } = window.Requestarr;
+ const iconSet = window.Requestarr.iconSet || 'production';
+ const file =
+ iconSet === 'production' ? 'logo-version.svg' : 'logo-header.png';
return (
);
diff --git a/frontend/src/Components/Page/Header/PageHeader.css b/frontend/src/Components/Page/Header/PageHeader.css
index f364e0b..77cdae3 100644
--- a/frontend/src/Components/Page/Header/PageHeader.css
+++ b/frontend/src/Components/Page/Header/PageHeader.css
@@ -33,8 +33,9 @@
height: 40px;
}
+/* The environment's logo is wider than tall (its branch bar beside the R), so it's sized by height. */
.logo {
- width: 40px;
+ width: auto;
height: 40px;
border-radius: 4px;
}
@@ -60,7 +61,7 @@
@media only screen and (max-width: $breakpointSmall) {
.logoContainer {
- flex: 0 0 60px;
+ flex: 0 0 80px;
}
.sidebarToggleContainer {
diff --git a/frontend/src/Components/Page/LoadingPage.css b/frontend/src/Components/Page/LoadingPage.css
index 8683ed4..57d7080 100644
--- a/frontend/src/Components/Page/LoadingPage.css
+++ b/frontend/src/Components/Page/LoadingPage.css
@@ -4,10 +4,11 @@
background-color: var(--pageBackground);
}
-.logoFull {
+/* The environment's logo is wider than tall, so it's sized by height like the header's. */
+.logo {
margin-top: 50px;
margin-right: auto;
margin-left: auto;
- width: 120px;
- height: 40px;
+ width: auto;
+ height: 48px;
}
diff --git a/frontend/src/Components/Page/LoadingPage.css.d.ts b/frontend/src/Components/Page/LoadingPage.css.d.ts
index 561cb4a..d9c2e2e 100644
--- a/frontend/src/Components/Page/LoadingPage.css.d.ts
+++ b/frontend/src/Components/Page/LoadingPage.css.d.ts
@@ -1,7 +1,7 @@
// This file is automatically generated.
// Please do not change this file!
interface CssExports {
- 'logoFull': string;
+ 'logo': string;
'page': string;
}
export const cssExports: CssExports;
diff --git a/frontend/src/Components/Page/LoadingPage.js b/frontend/src/Components/Page/LoadingPage.js
index 5d9ec5e..d6f812c 100644
--- a/frontend/src/Components/Page/LoadingPage.js
+++ b/frontend/src/Components/Page/LoadingPage.js
@@ -1,17 +1,14 @@
import React from 'react';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import LoadingMessage from 'Components/Loading/LoadingMessage';
+import HeaderLogo from 'Components/Page/Header/HeaderLogo';
import styles from './LoadingPage.css';
-const requestarrLogo = 'data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz4KPHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI1MTIiIGhlaWdodD0iNTEyIiB2aWV3Qm94PSIwIDAgNTEyIDUxMiI+CiAgPHRpdGxlPlJlcXVlc3RhcnI8L3RpdGxlPgogIDxyZWN0IHg9IjAiIHk9IjAiIHdpZHRoPSI1MTIiIGhlaWdodD0iNTEyIiByeD0iMTEyIiByeT0iMTEyIiBmaWxsPSIjNzA0OGU4Ii8+CiAgPHBhdGggZD0iTTE3NiAzOTIgVjEyOCBIMjc2IEE4MCA4MCAwIDAgMSAyNzYgMjg4IEgxNzYgTTI2OCAyODggTDM0NCAzOTIiIGZpbGw9Im5vbmUiIHN0cm9rZT0iI2ZmZmZmZiIgc3Ryb2tlLXdpZHRoPSI1NiIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIi8+Cjwvc3ZnPgo=';
-
+// While the app loads: the same logo the header shows for the environment and theme.
function LoadingPage() {
return (
-

+
diff --git a/frontend/src/Content/Images/Icons/dark/beta/android-chrome-192x192.png b/frontend/src/Content/Images/Icons/dark/beta/android-chrome-192x192.png
index 2249759..3890e5f 100644
Binary files a/frontend/src/Content/Images/Icons/dark/beta/android-chrome-192x192.png and b/frontend/src/Content/Images/Icons/dark/beta/android-chrome-192x192.png differ
diff --git a/frontend/src/Content/Images/Icons/dark/beta/android-chrome-512x512.png b/frontend/src/Content/Images/Icons/dark/beta/android-chrome-512x512.png
index 29ddb05..06edd64 100644
Binary files a/frontend/src/Content/Images/Icons/dark/beta/android-chrome-512x512.png and b/frontend/src/Content/Images/Icons/dark/beta/android-chrome-512x512.png differ
diff --git a/frontend/src/Content/Images/Icons/dark/beta/apple-touch-icon.png b/frontend/src/Content/Images/Icons/dark/beta/apple-touch-icon.png
index d87fa15..f752faf 100644
Binary files a/frontend/src/Content/Images/Icons/dark/beta/apple-touch-icon.png and b/frontend/src/Content/Images/Icons/dark/beta/apple-touch-icon.png differ
diff --git a/frontend/src/Content/Images/Icons/dark/beta/favicon-16x16.png b/frontend/src/Content/Images/Icons/dark/beta/favicon-16x16.png
index 83b65f0..b5ad8c5 100644
Binary files a/frontend/src/Content/Images/Icons/dark/beta/favicon-16x16.png and b/frontend/src/Content/Images/Icons/dark/beta/favicon-16x16.png differ
diff --git a/frontend/src/Content/Images/Icons/dark/beta/favicon-32x32.png b/frontend/src/Content/Images/Icons/dark/beta/favicon-32x32.png
index 4edba72..db9e757 100644
Binary files a/frontend/src/Content/Images/Icons/dark/beta/favicon-32x32.png and b/frontend/src/Content/Images/Icons/dark/beta/favicon-32x32.png differ
diff --git a/frontend/src/Content/Images/Icons/dark/beta/favicon.ico b/frontend/src/Content/Images/Icons/dark/beta/favicon.ico
index f72f297..cbf31a0 100644
Binary files a/frontend/src/Content/Images/Icons/dark/beta/favicon.ico and b/frontend/src/Content/Images/Icons/dark/beta/favicon.ico differ
diff --git a/frontend/src/Content/Images/Icons/dark/beta/logo-header.png b/frontend/src/Content/Images/Icons/dark/beta/logo-header.png
new file mode 100644
index 0000000..c939e7d
Binary files /dev/null and b/frontend/src/Content/Images/Icons/dark/beta/logo-header.png differ
diff --git a/frontend/src/Content/Images/Icons/dark/beta/logo.png b/frontend/src/Content/Images/Icons/dark/beta/logo.png
index 4e4c447..ebc234f 100644
Binary files a/frontend/src/Content/Images/Icons/dark/beta/logo.png and b/frontend/src/Content/Images/Icons/dark/beta/logo.png differ
diff --git a/frontend/src/Content/Images/Icons/dark/beta/mstile-144x144.png b/frontend/src/Content/Images/Icons/dark/beta/mstile-144x144.png
index c6f7214..b22f588 100644
Binary files a/frontend/src/Content/Images/Icons/dark/beta/mstile-144x144.png and b/frontend/src/Content/Images/Icons/dark/beta/mstile-144x144.png differ
diff --git a/frontend/src/Content/Images/Icons/dark/beta/mstile-150x150.png b/frontend/src/Content/Images/Icons/dark/beta/mstile-150x150.png
index d571bca..531f98b 100644
Binary files a/frontend/src/Content/Images/Icons/dark/beta/mstile-150x150.png and b/frontend/src/Content/Images/Icons/dark/beta/mstile-150x150.png differ
diff --git a/frontend/src/Content/Images/Icons/dark/beta/mstile-310x150.png b/frontend/src/Content/Images/Icons/dark/beta/mstile-310x150.png
index d5542ec..f116e11 100644
Binary files a/frontend/src/Content/Images/Icons/dark/beta/mstile-310x150.png and b/frontend/src/Content/Images/Icons/dark/beta/mstile-310x150.png differ
diff --git a/frontend/src/Content/Images/Icons/dark/beta/mstile-310x310.png b/frontend/src/Content/Images/Icons/dark/beta/mstile-310x310.png
index 69d2d99..c39294d 100644
Binary files a/frontend/src/Content/Images/Icons/dark/beta/mstile-310x310.png and b/frontend/src/Content/Images/Icons/dark/beta/mstile-310x310.png differ
diff --git a/frontend/src/Content/Images/Icons/dark/beta/mstile-70x70.png b/frontend/src/Content/Images/Icons/dark/beta/mstile-70x70.png
index dbc494f..25e25b1 100644
Binary files a/frontend/src/Content/Images/Icons/dark/beta/mstile-70x70.png and b/frontend/src/Content/Images/Icons/dark/beta/mstile-70x70.png differ
diff --git a/frontend/src/Content/Images/Icons/dark/dev/android-chrome-192x192.png b/frontend/src/Content/Images/Icons/dark/dev/android-chrome-192x192.png
index 9b5b68f..15701e1 100644
Binary files a/frontend/src/Content/Images/Icons/dark/dev/android-chrome-192x192.png and b/frontend/src/Content/Images/Icons/dark/dev/android-chrome-192x192.png differ
diff --git a/frontend/src/Content/Images/Icons/dark/dev/android-chrome-512x512.png b/frontend/src/Content/Images/Icons/dark/dev/android-chrome-512x512.png
index c3d4d43..594fe6b 100644
Binary files a/frontend/src/Content/Images/Icons/dark/dev/android-chrome-512x512.png and b/frontend/src/Content/Images/Icons/dark/dev/android-chrome-512x512.png differ
diff --git a/frontend/src/Content/Images/Icons/dark/dev/apple-touch-icon.png b/frontend/src/Content/Images/Icons/dark/dev/apple-touch-icon.png
index 80e26cd..00e29b9 100644
Binary files a/frontend/src/Content/Images/Icons/dark/dev/apple-touch-icon.png and b/frontend/src/Content/Images/Icons/dark/dev/apple-touch-icon.png differ
diff --git a/frontend/src/Content/Images/Icons/dark/dev/favicon-16x16.png b/frontend/src/Content/Images/Icons/dark/dev/favicon-16x16.png
index b2cb025..e9e0702 100644
Binary files a/frontend/src/Content/Images/Icons/dark/dev/favicon-16x16.png and b/frontend/src/Content/Images/Icons/dark/dev/favicon-16x16.png differ
diff --git a/frontend/src/Content/Images/Icons/dark/dev/favicon-32x32.png b/frontend/src/Content/Images/Icons/dark/dev/favicon-32x32.png
index c99710a..f849464 100644
Binary files a/frontend/src/Content/Images/Icons/dark/dev/favicon-32x32.png and b/frontend/src/Content/Images/Icons/dark/dev/favicon-32x32.png differ
diff --git a/frontend/src/Content/Images/Icons/dark/dev/favicon.ico b/frontend/src/Content/Images/Icons/dark/dev/favicon.ico
index c3a8d1b..4c2ca36 100644
Binary files a/frontend/src/Content/Images/Icons/dark/dev/favicon.ico and b/frontend/src/Content/Images/Icons/dark/dev/favicon.ico differ
diff --git a/frontend/src/Content/Images/Icons/dark/dev/logo-header.png b/frontend/src/Content/Images/Icons/dark/dev/logo-header.png
new file mode 100644
index 0000000..f43ec2c
Binary files /dev/null and b/frontend/src/Content/Images/Icons/dark/dev/logo-header.png differ
diff --git a/frontend/src/Content/Images/Icons/dark/dev/logo.png b/frontend/src/Content/Images/Icons/dark/dev/logo.png
index c697ffb..8c9d03b 100644
Binary files a/frontend/src/Content/Images/Icons/dark/dev/logo.png and b/frontend/src/Content/Images/Icons/dark/dev/logo.png differ
diff --git a/frontend/src/Content/Images/Icons/dark/dev/mstile-144x144.png b/frontend/src/Content/Images/Icons/dark/dev/mstile-144x144.png
index d50773c..e04be1d 100644
Binary files a/frontend/src/Content/Images/Icons/dark/dev/mstile-144x144.png and b/frontend/src/Content/Images/Icons/dark/dev/mstile-144x144.png differ
diff --git a/frontend/src/Content/Images/Icons/dark/dev/mstile-150x150.png b/frontend/src/Content/Images/Icons/dark/dev/mstile-150x150.png
index 9c2d8b6..8a9f088 100644
Binary files a/frontend/src/Content/Images/Icons/dark/dev/mstile-150x150.png and b/frontend/src/Content/Images/Icons/dark/dev/mstile-150x150.png differ
diff --git a/frontend/src/Content/Images/Icons/dark/dev/mstile-310x150.png b/frontend/src/Content/Images/Icons/dark/dev/mstile-310x150.png
index 7043410..a01de01 100644
Binary files a/frontend/src/Content/Images/Icons/dark/dev/mstile-310x150.png and b/frontend/src/Content/Images/Icons/dark/dev/mstile-310x150.png differ
diff --git a/frontend/src/Content/Images/Icons/dark/dev/mstile-310x310.png b/frontend/src/Content/Images/Icons/dark/dev/mstile-310x310.png
index f4d32b0..19976f5 100644
Binary files a/frontend/src/Content/Images/Icons/dark/dev/mstile-310x310.png and b/frontend/src/Content/Images/Icons/dark/dev/mstile-310x310.png differ
diff --git a/frontend/src/Content/Images/Icons/dark/dev/mstile-70x70.png b/frontend/src/Content/Images/Icons/dark/dev/mstile-70x70.png
index aadc0ca..2ecfd47 100644
Binary files a/frontend/src/Content/Images/Icons/dark/dev/mstile-70x70.png and b/frontend/src/Content/Images/Icons/dark/dev/mstile-70x70.png differ
diff --git a/frontend/src/Content/Images/Icons/dark/production/android-chrome-192x192.png b/frontend/src/Content/Images/Icons/dark/production/android-chrome-192x192.png
index be08030..42f6dc3 100644
Binary files a/frontend/src/Content/Images/Icons/dark/production/android-chrome-192x192.png and b/frontend/src/Content/Images/Icons/dark/production/android-chrome-192x192.png differ
diff --git a/frontend/src/Content/Images/Icons/dark/production/android-chrome-512x512.png b/frontend/src/Content/Images/Icons/dark/production/android-chrome-512x512.png
index 232fe54..aa8d424 100644
Binary files a/frontend/src/Content/Images/Icons/dark/production/android-chrome-512x512.png and b/frontend/src/Content/Images/Icons/dark/production/android-chrome-512x512.png differ
diff --git a/frontend/src/Content/Images/Icons/dark/production/apple-touch-icon.png b/frontend/src/Content/Images/Icons/dark/production/apple-touch-icon.png
index 00576f6..16a9e10 100644
Binary files a/frontend/src/Content/Images/Icons/dark/production/apple-touch-icon.png and b/frontend/src/Content/Images/Icons/dark/production/apple-touch-icon.png differ
diff --git a/frontend/src/Content/Images/Icons/dark/production/favicon-16x16.png b/frontend/src/Content/Images/Icons/dark/production/favicon-16x16.png
index 25afb0f..f246092 100644
Binary files a/frontend/src/Content/Images/Icons/dark/production/favicon-16x16.png and b/frontend/src/Content/Images/Icons/dark/production/favicon-16x16.png differ
diff --git a/frontend/src/Content/Images/Icons/dark/production/favicon-32x32.png b/frontend/src/Content/Images/Icons/dark/production/favicon-32x32.png
index d2c5af4..d54dadd 100644
Binary files a/frontend/src/Content/Images/Icons/dark/production/favicon-32x32.png and b/frontend/src/Content/Images/Icons/dark/production/favicon-32x32.png differ
diff --git a/frontend/src/Content/Images/Icons/dark/production/favicon.ico b/frontend/src/Content/Images/Icons/dark/production/favicon.ico
index 26939ac..9adccbe 100644
Binary files a/frontend/src/Content/Images/Icons/dark/production/favicon.ico and b/frontend/src/Content/Images/Icons/dark/production/favicon.ico differ
diff --git a/frontend/src/Content/Images/Icons/dark/production/logo-header.png b/frontend/src/Content/Images/Icons/dark/production/logo-header.png
new file mode 100644
index 0000000..98056c7
Binary files /dev/null and b/frontend/src/Content/Images/Icons/dark/production/logo-header.png differ
diff --git a/frontend/src/Content/Images/Icons/dark/production/logo.png b/frontend/src/Content/Images/Icons/dark/production/logo.png
index 4969138..b0405c8 100644
Binary files a/frontend/src/Content/Images/Icons/dark/production/logo.png and b/frontend/src/Content/Images/Icons/dark/production/logo.png differ
diff --git a/frontend/src/Content/Images/Icons/dark/production/mstile-144x144.png b/frontend/src/Content/Images/Icons/dark/production/mstile-144x144.png
index 9d4e00b..01bb7c5 100644
Binary files a/frontend/src/Content/Images/Icons/dark/production/mstile-144x144.png and b/frontend/src/Content/Images/Icons/dark/production/mstile-144x144.png differ
diff --git a/frontend/src/Content/Images/Icons/dark/production/mstile-150x150.png b/frontend/src/Content/Images/Icons/dark/production/mstile-150x150.png
index dd43ecb..518e850 100644
Binary files a/frontend/src/Content/Images/Icons/dark/production/mstile-150x150.png and b/frontend/src/Content/Images/Icons/dark/production/mstile-150x150.png differ
diff --git a/frontend/src/Content/Images/Icons/dark/production/mstile-310x150.png b/frontend/src/Content/Images/Icons/dark/production/mstile-310x150.png
index 372c4cd..e19924a 100644
Binary files a/frontend/src/Content/Images/Icons/dark/production/mstile-310x150.png and b/frontend/src/Content/Images/Icons/dark/production/mstile-310x150.png differ
diff --git a/frontend/src/Content/Images/Icons/dark/production/mstile-310x310.png b/frontend/src/Content/Images/Icons/dark/production/mstile-310x310.png
index a3a1093..503db88 100644
Binary files a/frontend/src/Content/Images/Icons/dark/production/mstile-310x310.png and b/frontend/src/Content/Images/Icons/dark/production/mstile-310x310.png differ
diff --git a/frontend/src/Content/Images/Icons/dark/production/mstile-70x70.png b/frontend/src/Content/Images/Icons/dark/production/mstile-70x70.png
index feb2a80..eb824ad 100644
Binary files a/frontend/src/Content/Images/Icons/dark/production/mstile-70x70.png and b/frontend/src/Content/Images/Icons/dark/production/mstile-70x70.png differ
diff --git a/frontend/src/Content/Images/Icons/dark/qa/android-chrome-192x192.png b/frontend/src/Content/Images/Icons/dark/qa/android-chrome-192x192.png
index 1fb5a46..9936b85 100644
Binary files a/frontend/src/Content/Images/Icons/dark/qa/android-chrome-192x192.png and b/frontend/src/Content/Images/Icons/dark/qa/android-chrome-192x192.png differ
diff --git a/frontend/src/Content/Images/Icons/dark/qa/android-chrome-512x512.png b/frontend/src/Content/Images/Icons/dark/qa/android-chrome-512x512.png
index 1e7bee2..30fcfcb 100644
Binary files a/frontend/src/Content/Images/Icons/dark/qa/android-chrome-512x512.png and b/frontend/src/Content/Images/Icons/dark/qa/android-chrome-512x512.png differ
diff --git a/frontend/src/Content/Images/Icons/dark/qa/apple-touch-icon.png b/frontend/src/Content/Images/Icons/dark/qa/apple-touch-icon.png
index e1fcf43..93aacb1 100644
Binary files a/frontend/src/Content/Images/Icons/dark/qa/apple-touch-icon.png and b/frontend/src/Content/Images/Icons/dark/qa/apple-touch-icon.png differ
diff --git a/frontend/src/Content/Images/Icons/dark/qa/favicon-16x16.png b/frontend/src/Content/Images/Icons/dark/qa/favicon-16x16.png
index d1fdd1f..6c70ad6 100644
Binary files a/frontend/src/Content/Images/Icons/dark/qa/favicon-16x16.png and b/frontend/src/Content/Images/Icons/dark/qa/favicon-16x16.png differ
diff --git a/frontend/src/Content/Images/Icons/dark/qa/favicon-32x32.png b/frontend/src/Content/Images/Icons/dark/qa/favicon-32x32.png
index b0dc22c..5883a8e 100644
Binary files a/frontend/src/Content/Images/Icons/dark/qa/favicon-32x32.png and b/frontend/src/Content/Images/Icons/dark/qa/favicon-32x32.png differ
diff --git a/frontend/src/Content/Images/Icons/dark/qa/favicon.ico b/frontend/src/Content/Images/Icons/dark/qa/favicon.ico
index b058223..4405f46 100644
Binary files a/frontend/src/Content/Images/Icons/dark/qa/favicon.ico and b/frontend/src/Content/Images/Icons/dark/qa/favicon.ico differ
diff --git a/frontend/src/Content/Images/Icons/dark/qa/logo-header.png b/frontend/src/Content/Images/Icons/dark/qa/logo-header.png
new file mode 100644
index 0000000..d7b642a
Binary files /dev/null and b/frontend/src/Content/Images/Icons/dark/qa/logo-header.png differ
diff --git a/frontend/src/Content/Images/Icons/dark/qa/logo.png b/frontend/src/Content/Images/Icons/dark/qa/logo.png
index 4040851..6af3754 100644
Binary files a/frontend/src/Content/Images/Icons/dark/qa/logo.png and b/frontend/src/Content/Images/Icons/dark/qa/logo.png differ
diff --git a/frontend/src/Content/Images/Icons/dark/qa/mstile-144x144.png b/frontend/src/Content/Images/Icons/dark/qa/mstile-144x144.png
index b1b4edd..dc834f9 100644
Binary files a/frontend/src/Content/Images/Icons/dark/qa/mstile-144x144.png and b/frontend/src/Content/Images/Icons/dark/qa/mstile-144x144.png differ
diff --git a/frontend/src/Content/Images/Icons/dark/qa/mstile-150x150.png b/frontend/src/Content/Images/Icons/dark/qa/mstile-150x150.png
index ebcfad5..af71d2b 100644
Binary files a/frontend/src/Content/Images/Icons/dark/qa/mstile-150x150.png and b/frontend/src/Content/Images/Icons/dark/qa/mstile-150x150.png differ
diff --git a/frontend/src/Content/Images/Icons/dark/qa/mstile-310x150.png b/frontend/src/Content/Images/Icons/dark/qa/mstile-310x150.png
index 372510f..3756915 100644
Binary files a/frontend/src/Content/Images/Icons/dark/qa/mstile-310x150.png and b/frontend/src/Content/Images/Icons/dark/qa/mstile-310x150.png differ
diff --git a/frontend/src/Content/Images/Icons/dark/qa/mstile-310x310.png b/frontend/src/Content/Images/Icons/dark/qa/mstile-310x310.png
index 0430721..4045d4b 100644
Binary files a/frontend/src/Content/Images/Icons/dark/qa/mstile-310x310.png and b/frontend/src/Content/Images/Icons/dark/qa/mstile-310x310.png differ
diff --git a/frontend/src/Content/Images/Icons/dark/qa/mstile-70x70.png b/frontend/src/Content/Images/Icons/dark/qa/mstile-70x70.png
index 6ddcca7..eca1c8a 100644
Binary files a/frontend/src/Content/Images/Icons/dark/qa/mstile-70x70.png and b/frontend/src/Content/Images/Icons/dark/qa/mstile-70x70.png differ
diff --git a/frontend/src/Content/Images/Icons/light-purple/beta/logo-header.png b/frontend/src/Content/Images/Icons/light-purple/beta/logo-header.png
new file mode 100644
index 0000000..a9af2fa
Binary files /dev/null and b/frontend/src/Content/Images/Icons/light-purple/beta/logo-header.png differ
diff --git a/frontend/src/Content/Images/Icons/light-purple/dev/logo-header.png b/frontend/src/Content/Images/Icons/light-purple/dev/logo-header.png
new file mode 100644
index 0000000..9cd59d1
Binary files /dev/null and b/frontend/src/Content/Images/Icons/light-purple/dev/logo-header.png differ
diff --git a/frontend/src/Content/Images/Icons/light-purple/production/logo-header.png b/frontend/src/Content/Images/Icons/light-purple/production/logo-header.png
new file mode 100644
index 0000000..cbb22fa
Binary files /dev/null and b/frontend/src/Content/Images/Icons/light-purple/production/logo-header.png differ
diff --git a/frontend/src/Content/Images/Icons/light-purple/qa/logo-header.png b/frontend/src/Content/Images/Icons/light-purple/qa/logo-header.png
new file mode 100644
index 0000000..7c6f633
Binary files /dev/null and b/frontend/src/Content/Images/Icons/light-purple/qa/logo-header.png differ
diff --git a/frontend/src/Content/Images/Icons/light/beta/android-chrome-192x192.png b/frontend/src/Content/Images/Icons/light/beta/android-chrome-192x192.png
index 4c8eb82..dda0137 100644
Binary files a/frontend/src/Content/Images/Icons/light/beta/android-chrome-192x192.png and b/frontend/src/Content/Images/Icons/light/beta/android-chrome-192x192.png differ
diff --git a/frontend/src/Content/Images/Icons/light/beta/android-chrome-512x512.png b/frontend/src/Content/Images/Icons/light/beta/android-chrome-512x512.png
index 7419317..45f47f6 100644
Binary files a/frontend/src/Content/Images/Icons/light/beta/android-chrome-512x512.png and b/frontend/src/Content/Images/Icons/light/beta/android-chrome-512x512.png differ
diff --git a/frontend/src/Content/Images/Icons/light/beta/apple-touch-icon.png b/frontend/src/Content/Images/Icons/light/beta/apple-touch-icon.png
index 4bd910b..80f4a21 100644
Binary files a/frontend/src/Content/Images/Icons/light/beta/apple-touch-icon.png and b/frontend/src/Content/Images/Icons/light/beta/apple-touch-icon.png differ
diff --git a/frontend/src/Content/Images/Icons/light/beta/favicon-16x16.png b/frontend/src/Content/Images/Icons/light/beta/favicon-16x16.png
index 516bb16..157d133 100644
Binary files a/frontend/src/Content/Images/Icons/light/beta/favicon-16x16.png and b/frontend/src/Content/Images/Icons/light/beta/favicon-16x16.png differ
diff --git a/frontend/src/Content/Images/Icons/light/beta/favicon-32x32.png b/frontend/src/Content/Images/Icons/light/beta/favicon-32x32.png
index 50bfdb2..011bac3 100644
Binary files a/frontend/src/Content/Images/Icons/light/beta/favicon-32x32.png and b/frontend/src/Content/Images/Icons/light/beta/favicon-32x32.png differ
diff --git a/frontend/src/Content/Images/Icons/light/beta/favicon.ico b/frontend/src/Content/Images/Icons/light/beta/favicon.ico
index 18b28fa..708af3c 100644
Binary files a/frontend/src/Content/Images/Icons/light/beta/favicon.ico and b/frontend/src/Content/Images/Icons/light/beta/favicon.ico differ
diff --git a/frontend/src/Content/Images/Icons/light/beta/logo-header.png b/frontend/src/Content/Images/Icons/light/beta/logo-header.png
new file mode 100644
index 0000000..ef9ad97
Binary files /dev/null and b/frontend/src/Content/Images/Icons/light/beta/logo-header.png differ
diff --git a/frontend/src/Content/Images/Icons/light/beta/logo.png b/frontend/src/Content/Images/Icons/light/beta/logo.png
index 7cf0320..65c0e15 100644
Binary files a/frontend/src/Content/Images/Icons/light/beta/logo.png and b/frontend/src/Content/Images/Icons/light/beta/logo.png differ
diff --git a/frontend/src/Content/Images/Icons/light/beta/mstile-144x144.png b/frontend/src/Content/Images/Icons/light/beta/mstile-144x144.png
index 78f5d28..482f26b 100644
Binary files a/frontend/src/Content/Images/Icons/light/beta/mstile-144x144.png and b/frontend/src/Content/Images/Icons/light/beta/mstile-144x144.png differ
diff --git a/frontend/src/Content/Images/Icons/light/beta/mstile-150x150.png b/frontend/src/Content/Images/Icons/light/beta/mstile-150x150.png
index bf0f7b2..d043690 100644
Binary files a/frontend/src/Content/Images/Icons/light/beta/mstile-150x150.png and b/frontend/src/Content/Images/Icons/light/beta/mstile-150x150.png differ
diff --git a/frontend/src/Content/Images/Icons/light/beta/mstile-310x150.png b/frontend/src/Content/Images/Icons/light/beta/mstile-310x150.png
index f20d778..bc6a044 100644
Binary files a/frontend/src/Content/Images/Icons/light/beta/mstile-310x150.png and b/frontend/src/Content/Images/Icons/light/beta/mstile-310x150.png differ
diff --git a/frontend/src/Content/Images/Icons/light/beta/mstile-310x310.png b/frontend/src/Content/Images/Icons/light/beta/mstile-310x310.png
index 25fe3a0..336a8cb 100644
Binary files a/frontend/src/Content/Images/Icons/light/beta/mstile-310x310.png and b/frontend/src/Content/Images/Icons/light/beta/mstile-310x310.png differ
diff --git a/frontend/src/Content/Images/Icons/light/beta/mstile-70x70.png b/frontend/src/Content/Images/Icons/light/beta/mstile-70x70.png
index 8697bd3..37f900c 100644
Binary files a/frontend/src/Content/Images/Icons/light/beta/mstile-70x70.png and b/frontend/src/Content/Images/Icons/light/beta/mstile-70x70.png differ
diff --git a/frontend/src/Content/Images/Icons/light/dev/android-chrome-192x192.png b/frontend/src/Content/Images/Icons/light/dev/android-chrome-192x192.png
index 04b7e9c..51f2d8c 100644
Binary files a/frontend/src/Content/Images/Icons/light/dev/android-chrome-192x192.png and b/frontend/src/Content/Images/Icons/light/dev/android-chrome-192x192.png differ
diff --git a/frontend/src/Content/Images/Icons/light/dev/android-chrome-512x512.png b/frontend/src/Content/Images/Icons/light/dev/android-chrome-512x512.png
index 363a95d..885c515 100644
Binary files a/frontend/src/Content/Images/Icons/light/dev/android-chrome-512x512.png and b/frontend/src/Content/Images/Icons/light/dev/android-chrome-512x512.png differ
diff --git a/frontend/src/Content/Images/Icons/light/dev/apple-touch-icon.png b/frontend/src/Content/Images/Icons/light/dev/apple-touch-icon.png
index 5dc3f5d..3fb63aa 100644
Binary files a/frontend/src/Content/Images/Icons/light/dev/apple-touch-icon.png and b/frontend/src/Content/Images/Icons/light/dev/apple-touch-icon.png differ
diff --git a/frontend/src/Content/Images/Icons/light/dev/favicon-16x16.png b/frontend/src/Content/Images/Icons/light/dev/favicon-16x16.png
index 13d7717..791770c 100644
Binary files a/frontend/src/Content/Images/Icons/light/dev/favicon-16x16.png and b/frontend/src/Content/Images/Icons/light/dev/favicon-16x16.png differ
diff --git a/frontend/src/Content/Images/Icons/light/dev/favicon-32x32.png b/frontend/src/Content/Images/Icons/light/dev/favicon-32x32.png
index d759d86..27712c1 100644
Binary files a/frontend/src/Content/Images/Icons/light/dev/favicon-32x32.png and b/frontend/src/Content/Images/Icons/light/dev/favicon-32x32.png differ
diff --git a/frontend/src/Content/Images/Icons/light/dev/favicon.ico b/frontend/src/Content/Images/Icons/light/dev/favicon.ico
index a7aa206..d9ee64c 100644
Binary files a/frontend/src/Content/Images/Icons/light/dev/favicon.ico and b/frontend/src/Content/Images/Icons/light/dev/favicon.ico differ
diff --git a/frontend/src/Content/Images/Icons/light/dev/logo-header.png b/frontend/src/Content/Images/Icons/light/dev/logo-header.png
new file mode 100644
index 0000000..9cd59d1
Binary files /dev/null and b/frontend/src/Content/Images/Icons/light/dev/logo-header.png differ
diff --git a/frontend/src/Content/Images/Icons/light/dev/logo.png b/frontend/src/Content/Images/Icons/light/dev/logo.png
index bf60e18..0db1727 100644
Binary files a/frontend/src/Content/Images/Icons/light/dev/logo.png and b/frontend/src/Content/Images/Icons/light/dev/logo.png differ
diff --git a/frontend/src/Content/Images/Icons/light/dev/mstile-144x144.png b/frontend/src/Content/Images/Icons/light/dev/mstile-144x144.png
index 05affc4..978a319 100644
Binary files a/frontend/src/Content/Images/Icons/light/dev/mstile-144x144.png and b/frontend/src/Content/Images/Icons/light/dev/mstile-144x144.png differ
diff --git a/frontend/src/Content/Images/Icons/light/dev/mstile-150x150.png b/frontend/src/Content/Images/Icons/light/dev/mstile-150x150.png
index a760996..2ac37a1 100644
Binary files a/frontend/src/Content/Images/Icons/light/dev/mstile-150x150.png and b/frontend/src/Content/Images/Icons/light/dev/mstile-150x150.png differ
diff --git a/frontend/src/Content/Images/Icons/light/dev/mstile-310x150.png b/frontend/src/Content/Images/Icons/light/dev/mstile-310x150.png
index c243cea..491166b 100644
Binary files a/frontend/src/Content/Images/Icons/light/dev/mstile-310x150.png and b/frontend/src/Content/Images/Icons/light/dev/mstile-310x150.png differ
diff --git a/frontend/src/Content/Images/Icons/light/dev/mstile-310x310.png b/frontend/src/Content/Images/Icons/light/dev/mstile-310x310.png
index bab1322..fcebf18 100644
Binary files a/frontend/src/Content/Images/Icons/light/dev/mstile-310x310.png and b/frontend/src/Content/Images/Icons/light/dev/mstile-310x310.png differ
diff --git a/frontend/src/Content/Images/Icons/light/dev/mstile-70x70.png b/frontend/src/Content/Images/Icons/light/dev/mstile-70x70.png
index 91c82bc..38255e1 100644
Binary files a/frontend/src/Content/Images/Icons/light/dev/mstile-70x70.png and b/frontend/src/Content/Images/Icons/light/dev/mstile-70x70.png differ
diff --git a/frontend/src/Content/Images/Icons/light/production/android-chrome-192x192.png b/frontend/src/Content/Images/Icons/light/production/android-chrome-192x192.png
index 38cc673..1f252d1 100644
Binary files a/frontend/src/Content/Images/Icons/light/production/android-chrome-192x192.png and b/frontend/src/Content/Images/Icons/light/production/android-chrome-192x192.png differ
diff --git a/frontend/src/Content/Images/Icons/light/production/android-chrome-512x512.png b/frontend/src/Content/Images/Icons/light/production/android-chrome-512x512.png
index 925d5b2..bd3b489 100644
Binary files a/frontend/src/Content/Images/Icons/light/production/android-chrome-512x512.png and b/frontend/src/Content/Images/Icons/light/production/android-chrome-512x512.png differ
diff --git a/frontend/src/Content/Images/Icons/light/production/apple-touch-icon.png b/frontend/src/Content/Images/Icons/light/production/apple-touch-icon.png
index 8d2def7..1088d6b 100644
Binary files a/frontend/src/Content/Images/Icons/light/production/apple-touch-icon.png and b/frontend/src/Content/Images/Icons/light/production/apple-touch-icon.png differ
diff --git a/frontend/src/Content/Images/Icons/light/production/favicon-16x16.png b/frontend/src/Content/Images/Icons/light/production/favicon-16x16.png
index e1ea7dd..62dad0d 100644
Binary files a/frontend/src/Content/Images/Icons/light/production/favicon-16x16.png and b/frontend/src/Content/Images/Icons/light/production/favicon-16x16.png differ
diff --git a/frontend/src/Content/Images/Icons/light/production/favicon-32x32.png b/frontend/src/Content/Images/Icons/light/production/favicon-32x32.png
index f915e02..c688afc 100644
Binary files a/frontend/src/Content/Images/Icons/light/production/favicon-32x32.png and b/frontend/src/Content/Images/Icons/light/production/favicon-32x32.png differ
diff --git a/frontend/src/Content/Images/Icons/light/production/favicon.ico b/frontend/src/Content/Images/Icons/light/production/favicon.ico
index bd8e899..51488cc 100644
Binary files a/frontend/src/Content/Images/Icons/light/production/favicon.ico and b/frontend/src/Content/Images/Icons/light/production/favicon.ico differ
diff --git a/frontend/src/Content/Images/Icons/light/production/logo-header.png b/frontend/src/Content/Images/Icons/light/production/logo-header.png
new file mode 100644
index 0000000..cbb22fa
Binary files /dev/null and b/frontend/src/Content/Images/Icons/light/production/logo-header.png differ
diff --git a/frontend/src/Content/Images/Icons/light/production/logo.png b/frontend/src/Content/Images/Icons/light/production/logo.png
index 846622b..7b56fff 100644
Binary files a/frontend/src/Content/Images/Icons/light/production/logo.png and b/frontend/src/Content/Images/Icons/light/production/logo.png differ
diff --git a/frontend/src/Content/Images/Icons/light/production/mstile-144x144.png b/frontend/src/Content/Images/Icons/light/production/mstile-144x144.png
index 7c74ceb..2897ce7 100644
Binary files a/frontend/src/Content/Images/Icons/light/production/mstile-144x144.png and b/frontend/src/Content/Images/Icons/light/production/mstile-144x144.png differ
diff --git a/frontend/src/Content/Images/Icons/light/production/mstile-150x150.png b/frontend/src/Content/Images/Icons/light/production/mstile-150x150.png
index 3afa259..dbf33ce 100644
Binary files a/frontend/src/Content/Images/Icons/light/production/mstile-150x150.png and b/frontend/src/Content/Images/Icons/light/production/mstile-150x150.png differ
diff --git a/frontend/src/Content/Images/Icons/light/production/mstile-310x150.png b/frontend/src/Content/Images/Icons/light/production/mstile-310x150.png
index 1a93be8..2cf41c2 100644
Binary files a/frontend/src/Content/Images/Icons/light/production/mstile-310x150.png and b/frontend/src/Content/Images/Icons/light/production/mstile-310x150.png differ
diff --git a/frontend/src/Content/Images/Icons/light/production/mstile-310x310.png b/frontend/src/Content/Images/Icons/light/production/mstile-310x310.png
index dac5baf..ecc44b9 100644
Binary files a/frontend/src/Content/Images/Icons/light/production/mstile-310x310.png and b/frontend/src/Content/Images/Icons/light/production/mstile-310x310.png differ
diff --git a/frontend/src/Content/Images/Icons/light/production/mstile-70x70.png b/frontend/src/Content/Images/Icons/light/production/mstile-70x70.png
index d0b467e..133ffa1 100644
Binary files a/frontend/src/Content/Images/Icons/light/production/mstile-70x70.png and b/frontend/src/Content/Images/Icons/light/production/mstile-70x70.png differ
diff --git a/frontend/src/Content/Images/Icons/light/qa/android-chrome-192x192.png b/frontend/src/Content/Images/Icons/light/qa/android-chrome-192x192.png
index e85fab4..9d617c9 100644
Binary files a/frontend/src/Content/Images/Icons/light/qa/android-chrome-192x192.png and b/frontend/src/Content/Images/Icons/light/qa/android-chrome-192x192.png differ
diff --git a/frontend/src/Content/Images/Icons/light/qa/android-chrome-512x512.png b/frontend/src/Content/Images/Icons/light/qa/android-chrome-512x512.png
index b77c4b7..b75b0ff 100644
Binary files a/frontend/src/Content/Images/Icons/light/qa/android-chrome-512x512.png and b/frontend/src/Content/Images/Icons/light/qa/android-chrome-512x512.png differ
diff --git a/frontend/src/Content/Images/Icons/light/qa/apple-touch-icon.png b/frontend/src/Content/Images/Icons/light/qa/apple-touch-icon.png
index f63d6f8..3a3c3a4 100644
Binary files a/frontend/src/Content/Images/Icons/light/qa/apple-touch-icon.png and b/frontend/src/Content/Images/Icons/light/qa/apple-touch-icon.png differ
diff --git a/frontend/src/Content/Images/Icons/light/qa/favicon-16x16.png b/frontend/src/Content/Images/Icons/light/qa/favicon-16x16.png
index 9f786be..b250966 100644
Binary files a/frontend/src/Content/Images/Icons/light/qa/favicon-16x16.png and b/frontend/src/Content/Images/Icons/light/qa/favicon-16x16.png differ
diff --git a/frontend/src/Content/Images/Icons/light/qa/favicon-32x32.png b/frontend/src/Content/Images/Icons/light/qa/favicon-32x32.png
index 0cd8aa3..a8937db 100644
Binary files a/frontend/src/Content/Images/Icons/light/qa/favicon-32x32.png and b/frontend/src/Content/Images/Icons/light/qa/favicon-32x32.png differ
diff --git a/frontend/src/Content/Images/Icons/light/qa/favicon.ico b/frontend/src/Content/Images/Icons/light/qa/favicon.ico
index 9961cb7..4a79e68 100644
Binary files a/frontend/src/Content/Images/Icons/light/qa/favicon.ico and b/frontend/src/Content/Images/Icons/light/qa/favicon.ico differ
diff --git a/frontend/src/Content/Images/Icons/light/qa/logo-header.png b/frontend/src/Content/Images/Icons/light/qa/logo-header.png
new file mode 100644
index 0000000..7c6f633
Binary files /dev/null and b/frontend/src/Content/Images/Icons/light/qa/logo-header.png differ
diff --git a/frontend/src/Content/Images/Icons/light/qa/logo.png b/frontend/src/Content/Images/Icons/light/qa/logo.png
index 0af0249..115d25c 100644
Binary files a/frontend/src/Content/Images/Icons/light/qa/logo.png and b/frontend/src/Content/Images/Icons/light/qa/logo.png differ
diff --git a/frontend/src/Content/Images/Icons/light/qa/mstile-144x144.png b/frontend/src/Content/Images/Icons/light/qa/mstile-144x144.png
index ee58c3b..4659c0a 100644
Binary files a/frontend/src/Content/Images/Icons/light/qa/mstile-144x144.png and b/frontend/src/Content/Images/Icons/light/qa/mstile-144x144.png differ
diff --git a/frontend/src/Content/Images/Icons/light/qa/mstile-150x150.png b/frontend/src/Content/Images/Icons/light/qa/mstile-150x150.png
index 57c97a1..8cff71b 100644
Binary files a/frontend/src/Content/Images/Icons/light/qa/mstile-150x150.png and b/frontend/src/Content/Images/Icons/light/qa/mstile-150x150.png differ
diff --git a/frontend/src/Content/Images/Icons/light/qa/mstile-310x150.png b/frontend/src/Content/Images/Icons/light/qa/mstile-310x150.png
index 9165032..52065f1 100644
Binary files a/frontend/src/Content/Images/Icons/light/qa/mstile-310x150.png and b/frontend/src/Content/Images/Icons/light/qa/mstile-310x150.png differ
diff --git a/frontend/src/Content/Images/Icons/light/qa/mstile-310x310.png b/frontend/src/Content/Images/Icons/light/qa/mstile-310x310.png
index 04903d6..afd2e56 100644
Binary files a/frontend/src/Content/Images/Icons/light/qa/mstile-310x310.png and b/frontend/src/Content/Images/Icons/light/qa/mstile-310x310.png differ
diff --git a/frontend/src/Content/Images/Icons/light/qa/mstile-70x70.png b/frontend/src/Content/Images/Icons/light/qa/mstile-70x70.png
index 2a95343..c35b726 100644
Binary files a/frontend/src/Content/Images/Icons/light/qa/mstile-70x70.png and b/frontend/src/Content/Images/Icons/light/qa/mstile-70x70.png differ
diff --git a/frontend/src/login.html b/frontend/src/login.html
index 96f1f6e..3b7af20 100644
--- a/frontend/src/login.html
+++ b/frontend/src/login.html
@@ -84,7 +84,7 @@
}
.logo {
- width: 48px;
+ width: auto;
height: 48px;
border-radius: 4px;
}
@@ -217,9 +217,9 @@