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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 71 additions & 18 deletions .github/dependency_updates.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,14 @@

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"
Expand Down Expand Up @@ -56,6 +58,51 @@ def git(*args, check=True):
return run("git", *args, check=check)


def gh_api(method, path, payload):
# Payload on stdin: a blob of yarn.lock is far longer than one command-line argument may be.
result = subprocess.run(["gh", "api", "-X", method, path, "--input", "-"], input=json.dumps(payload), text=True,
capture_output=True)
if result.returncode != 0:
sys.stderr.write(result.stderr or result.stdout or "")
return None
return json.loads(result.stdout)


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):
Expand Down Expand Up @@ -220,9 +267,21 @@ def sort():
print("#%s: %s %s, group %s, %s -> %s" % (number, ecosystem, update_type, group or "none", ", ".join(names), labels))


def build_runs(repo, sha):
return json.loads(gh("run", "list", "--repo", repo, "--workflow", "build.yml", "--commit", sha,
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():
Expand Down Expand Up @@ -279,32 +338,26 @@ def update_next(repo):
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))
pushed = subprocess.run(["git", "push", "--quiet", "origin", "HEAD:refs/heads/" + branch], text=True,
capture_output=True)
if pushed.returncode != 0:
sys.stderr.write(pushed.stderr)
hold(repo, number, "its version and changelog section could not be added (the push was refused).")
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")
# Only the build this workflow starts counts: its push can also queue pull request runs that wait for an approval
# nobody gives ("action_required"), and those aren't failures.
runs = [r for r in build_runs(repo, sha) if r["event"] == "workflow_dispatch"]
if any(r["status"] == "completed" and r["conclusion"] in FAILED for r in runs) \
and not any(r["conclusion"] == "success" for r in runs):
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):
# Build passed on this commit (this run was started when it finished): merge now rather than wait for
# auto-merge, whose merge into dev wouldn't start this workflow again for the next update.
if subprocess.run(["gh", "pr", "merge", str(number), "--repo", repo, "--merge"]).returncode == 0:
print("#%d: build passed, merged into dev" % number)
return True
elif not any(r["status"] != "completed" for r in runs):
# Commits pushed 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]))
gh("pr", "merge", str(number), "--repo", repo, "--auto", "--merge")
print("#%d: merges on its own once build passes" % number)
return False
Expand Down
15 changes: 5 additions & 10 deletions .github/workflows/dependency-updates.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,15 @@ 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 and build is started on that
# commit. When that build finishes this runs again and merges the update (or holds it if build failed), then starts the
# next one. 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.
# 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]
workflow_run:
workflows: [Build]
types: [completed]
schedule:
- cron: "23 * * * *"
workflow_dispatch:
Expand Down Expand Up @@ -54,10 +50,9 @@ jobs:
needs: sort
if: >
always() && needs.sort.result != 'failure' &&
(github.event_name != 'pull_request' || github.event.pull_request.user.login == 'dependabot[bot]') &&
(github.event_name != 'workflow_run' || github.event.workflow_run.event == 'workflow_dispatch')
(github.event_name != 'pull_request' || github.event.pull_request.user.login == 'dependabot[bot]')
runs-on: ubuntu-latest
timeout-minutes: 10
timeout-minutes: 120
steps:
- uses: actions/checkout@v4
with:
Expand Down
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ 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.7 (2026-10-25)

## 0.4.6 (2026-10-25)

## 0.4.5 (2026-10-25)
Expand Down
4 changes: 2 additions & 2 deletions docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -170,10 +170,10 @@ So each kind sends at most one pull request a week for its patch and minor updat
**What happens to them** (`.github/workflows/dependency-updates.yml`, `.github/dependency_updates.py`):

- A new update is sorted. Patch and minor get the **automatic update** label. Major updates, and updates Dependabot can't size, get **needs review** and a comment, and wait. A security fix follows the same rule by size, and also gets **security**. It gets **for admins** too when the fix is in something Requestarr ships: the backend, the base images, or a web app package the built app contains (followed through `yarn.lock` from `dependencies`, not `devDependencies`).
- Automatic updates go one at a time, oldest first. The workflow brings the update up to date with `dev`, adds the next version above `dev`'s, `qa`'s and `staging`'s to `src/Directory.Build.props` and `CHANGELOG.md`, starts `build` on that commit, and turns on GitHub's auto-merge. When that `build` finishes the workflow runs again: it merges the update if `build` passed (the full build: backend tests on Linux and Windows, the web app, the image, the secret scan and the Changelog check) and starts on the next one.
- Automatic updates go one at a time, oldest first. The workflow brings the update up to date with `dev`, adds 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, when a `build` it started finishes, and every hour as a fallback (GitHub can run scheduled workflows late or skip them).
- 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).
Expand Down
2 changes: 1 addition & 1 deletion src/Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@
<Copyright>Copyright 2026-$([System.DateTime]::Now.ToString('yyyy')) Runemark Labs. Portions copyright 2014-2026 prowlarr.com and the Servarr team (GNU General Public v3)</Copyright>

<!-- Should be replaced by CI -->
<AssemblyVersion>0.4.6.*</AssemblyVersion>
<AssemblyVersion>0.4.7.*</AssemblyVersion>
<AssemblyConfiguration>$(Configuration)-dev</AssemblyConfiguration>
<NeutralLanguage>en</NeutralLanguage>

Expand Down
Loading