Skip to content

Commit 3a6cc80

Browse files
committed
feat: incorporate update.nix and update.py logic with improved semantics
1 parent 0b86fcd commit 3a6cc80

File tree

5 files changed

+317
-76
lines changed

5 files changed

+317
-76
lines changed

maintainers/scripts/update.py

Lines changed: 0 additions & 57 deletions
This file was deleted.

maintainers/scripts/update.sh

Lines changed: 0 additions & 19 deletions
This file was deleted.
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
{
2+
flakePath ? ./.,
3+
system ? "x86_64-linux",
4+
input ? "nixpkgs",
5+
max-workers ? null,
6+
keep-going ? null,
7+
commit ? null,
8+
no-confirm ? null,
9+
}: let
10+
flake = builtins.getFlake (builtins.toString flakePath);
11+
12+
pkgs = flake.inputs.${input}.legacyPackages.${system};
13+
inherit (pkgs) lib;
14+
15+
filterPkgsWithUpdateScript = pkgs:
16+
lib.filterAttrs (
17+
_name: pkg:
18+
lib.isDerivation pkg && lib.hasAttrByPath ["passthru" "updateScript"] pkg
19+
)
20+
pkgs;
21+
22+
flakePkgs = flake.packages.${system};
23+
24+
filteredPackages = filterPkgsWithUpdateScript flakePkgs;
25+
26+
packageData = name: package: {
27+
inherit name;
28+
pname = lib.getName package;
29+
oldVersion = lib.getVersion package;
30+
inherit (package.passthru) updateScript;
31+
attrPath = name;
32+
};
33+
34+
packagesJson = pkgs.writeText "packages.json" (builtins.toJSON (lib.mapAttrsToList packageData filteredPackages));
35+
36+
optionalArgs =
37+
lib.optional (max-workers != null) "--max-workers=${toString max-workers}"
38+
++ lib.optional (keep-going == "true") "--keep-going"
39+
++ lib.optional (no-confirm == "true") "--no-confirm"
40+
++ lib.optional (commit == "true") "--commit";
41+
42+
args = [packagesJson] ++ optionalArgs;
43+
in
44+
pkgs.stdenv.mkDerivation {
45+
name = "flake-packages-update-script";
46+
buildCommand = ''
47+
echo ""
48+
echo "----------------------------------------------------------------"
49+
echo ""
50+
echo "Not possible to update packages using \`nix-build\`"
51+
echo "Please use \`nix-shell\` with this derivation."
52+
echo ""
53+
echo "----------------------------------------------------------------"
54+
exit 1
55+
'';
56+
shellHook = ''
57+
unset shellHook # Prevent contamination in nested shells.
58+
exec ${pkgs.python3}/bin/python ${./update.py} ${builtins.concatStringsSep " " args}
59+
'';
60+
}
Lines changed: 244 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,244 @@
1+
from typing import Dict, Generator, List, Optional, Tuple
2+
import argparse
3+
import asyncio
4+
import contextlib
5+
import json
6+
import os
7+
import re
8+
import subprocess
9+
import sys
10+
import tempfile
11+
12+
class CalledProcessError(Exception):
13+
process: asyncio.subprocess.Process
14+
15+
class UpdateFailedException(Exception):
16+
pass
17+
18+
def eprint(*args, **kwargs):
19+
print(*args, file=sys.stderr, **kwargs)
20+
21+
async def check_subprocess(*args, **kwargs):
22+
"""
23+
Emulate check argument of subprocess.run function.
24+
"""
25+
process = await asyncio.create_subprocess_exec(*args, **kwargs)
26+
returncode = await process.wait()
27+
28+
if returncode != 0:
29+
error = CalledProcessError()
30+
error.process = process
31+
32+
raise error
33+
34+
return process
35+
36+
async def run_update_script(nixpkgs_root: str, merge_lock: asyncio.Lock, temp_dir: Optional[Tuple[str, str]], package: Dict, keep_going: bool):
37+
worktree: Optional[str] = None
38+
39+
update_script_command = package['updateScript']
40+
41+
if temp_dir is not None:
42+
worktree, _branch = temp_dir
43+
44+
# Ensure the worktree is clean before update.
45+
await check_subprocess('git', 'reset', '--hard', '--quiet', 'HEAD', cwd=worktree)
46+
47+
# Update scripts can use $(dirname $0) to get their location but we want to run
48+
# their clones in the git worktree, not in the main nixpkgs repo.
49+
update_script_command = map(lambda arg: re.sub(r'^{0}'.format(re.escape(nixpkgs_root)), worktree, arg), update_script_command)
50+
51+
eprint(f" - {package['name']}: UPDATING ...")
52+
53+
try:
54+
update_process = await check_subprocess(
55+
'env',
56+
f"UPDATE_NIX_NAME={package['name']}",
57+
f"UPDATE_NIX_PNAME={package['pname']}",
58+
f"UPDATE_NIX_OLD_VERSION={package['oldVersion']}",
59+
f"UPDATE_NIX_ATTR_PATH={package['attrPath']}",
60+
*update_script_command,
61+
stdout=asyncio.subprocess.PIPE,
62+
stderr=asyncio.subprocess.PIPE,
63+
cwd=worktree,
64+
)
65+
update_info = await update_process.stdout.read()
66+
67+
await merge_changes(merge_lock, package, update_info, temp_dir)
68+
except KeyboardInterrupt as e:
69+
eprint('Cancelling…')
70+
raise asyncio.exceptions.CancelledError()
71+
except CalledProcessError as e:
72+
eprint(f" - {package['name']}: ERROR")
73+
eprint()
74+
eprint(f"--- SHOWING ERROR LOG FOR {package['name']} ----------------------")
75+
eprint()
76+
stderr = await e.process.stderr.read()
77+
eprint(stderr.decode('utf-8'))
78+
with open(f"{package['pname']}.log", 'wb') as logfile:
79+
logfile.write(stderr)
80+
eprint()
81+
eprint(f"--- SHOWING ERROR LOG FOR {package['name']} ----------------------")
82+
83+
if not keep_going:
84+
raise UpdateFailedException(f"The update script for {package['name']} failed with exit code {e.process.returncode}")
85+
86+
@contextlib.contextmanager
87+
def make_worktree() -> Generator[Tuple[str, str], None, None]:
88+
with tempfile.TemporaryDirectory() as wt:
89+
branch_name = f'update-{os.path.basename(wt)}'
90+
target_directory = f'{wt}/nixpkgs'
91+
92+
subprocess.run(['git', 'worktree', 'add', '-b', branch_name, target_directory])
93+
yield (target_directory, branch_name)
94+
subprocess.run(['git', 'worktree', 'remove', '--force', target_directory])
95+
subprocess.run(['git', 'branch', '-D', branch_name])
96+
97+
async def commit_changes(name: str, merge_lock: asyncio.Lock, worktree: str, branch: str, changes: List[Dict]) -> None:
98+
for change in changes:
99+
# Git can only handle a single index operation at a time
100+
async with merge_lock:
101+
await check_subprocess('git', 'add', *change['files'], cwd=worktree)
102+
commit_message = '{attrPath}: {oldVersion} -> {newVersion}'.format(**change)
103+
if 'commitMessage' in change:
104+
commit_message = change['commitMessage']
105+
elif 'commitBody' in change:
106+
commit_message = commit_message + '\n\n' + change['commitBody']
107+
await check_subprocess('git', 'commit', '--quiet', '-m', commit_message, cwd=worktree)
108+
await check_subprocess('git', 'cherry-pick', branch)
109+
110+
async def check_changes(package: Dict, worktree: str, update_info: str):
111+
if 'commit' in package['supportedFeatures']:
112+
changes = json.loads(update_info)
113+
else:
114+
changes = [{}]
115+
116+
# Try to fill in missing attributes when there is just a single change.
117+
if len(changes) == 1:
118+
# Dynamic data from updater take precedence over static data from passthru.updateScript.
119+
if 'attrPath' not in changes[0]:
120+
# update.nix is always passing attrPath
121+
changes[0]['attrPath'] = package['attrPath']
122+
123+
if 'oldVersion' not in changes[0]:
124+
# update.nix is always passing oldVersion
125+
changes[0]['oldVersion'] = package['oldVersion']
126+
127+
if 'newVersion' not in changes[0]:
128+
attr_path = changes[0]['attrPath']
129+
obtain_new_version_process = await check_subprocess('nix-instantiate', '--expr', f'with import ./. {{}}; lib.getVersion {attr_path}', '--eval', '--strict', '--json', stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, cwd=worktree)
130+
changes[0]['newVersion'] = json.loads((await obtain_new_version_process.stdout.read()).decode('utf-8'))
131+
132+
if 'files' not in changes[0]:
133+
changed_files_process = await check_subprocess('git', 'diff', '--name-only', 'HEAD', stdout=asyncio.subprocess.PIPE, cwd=worktree)
134+
changed_files = (await changed_files_process.stdout.read()).splitlines()
135+
changes[0]['files'] = changed_files
136+
137+
if len(changed_files) == 0:
138+
return []
139+
140+
return changes
141+
142+
async def merge_changes(merge_lock: asyncio.Lock, package: Dict, update_info: str, temp_dir: Optional[Tuple[str, str]]) -> None:
143+
if temp_dir is not None:
144+
worktree, branch = temp_dir
145+
changes = await check_changes(package, worktree, update_info)
146+
147+
if len(changes) > 0:
148+
await commit_changes(package['name'], merge_lock, worktree, branch, changes)
149+
else:
150+
eprint(f" - {package['name']}: DONE, no changes.")
151+
else:
152+
eprint(f" - {package['name']}: DONE.")
153+
154+
async def updater(nixpkgs_root: str, temp_dir: Optional[Tuple[str, str]], merge_lock: asyncio.Lock, packages_to_update: asyncio.Queue[Optional[Dict]], keep_going: bool, commit: bool):
155+
while True:
156+
package = await packages_to_update.get()
157+
if package is None:
158+
# A sentinel received, we are done.
159+
return
160+
161+
if not ('commit' in package['supportedFeatures'] or 'attrPath' in package):
162+
temp_dir = None
163+
164+
await run_update_script(nixpkgs_root, merge_lock, temp_dir, package, keep_going)
165+
166+
async def start_updates(max_workers: int, keep_going: bool, commit: bool, packages: List[Dict]):
167+
merge_lock = asyncio.Lock()
168+
packages_to_update: asyncio.Queue[Optional[Dict]] = asyncio.Queue()
169+
170+
with contextlib.ExitStack() as stack:
171+
temp_dirs: List[Optional[Tuple[str, str]]] = []
172+
173+
# Do not create more workers than there are packages.
174+
num_workers = min(max_workers, len(packages))
175+
176+
nixpkgs_root_process = await check_subprocess('git', 'rev-parse', '--show-toplevel', stdout=asyncio.subprocess.PIPE)
177+
nixpkgs_root = (await nixpkgs_root_process.stdout.read()).decode('utf-8').strip()
178+
179+
# Set up temporary directories when using auto-commit.
180+
for i in range(num_workers):
181+
temp_dir = stack.enter_context(make_worktree()) if commit else None
182+
temp_dirs.append(temp_dir)
183+
184+
# Fill up an update queue,
185+
for package in packages:
186+
await packages_to_update.put(package)
187+
188+
# Add sentinels, one for each worker.
189+
# A workers will terminate when it gets sentinel from the queue.
190+
for i in range(num_workers):
191+
await packages_to_update.put(None)
192+
193+
# Prepare updater workers for each temp_dir directory.
194+
# At most `num_workers` instances of `run_update_script` will be running at one time.
195+
updaters = asyncio.gather(*[updater(nixpkgs_root, temp_dir, merge_lock, packages_to_update, keep_going, commit) for temp_dir in temp_dirs])
196+
197+
try:
198+
# Start updater workers.
199+
await updaters
200+
except asyncio.exceptions.CancelledError:
201+
# When one worker is cancelled, cancel the others too.
202+
updaters.cancel()
203+
except UpdateFailedException as e:
204+
# When one worker fails, cancel the others, as this exception is only thrown when keep_going is false.
205+
updaters.cancel()
206+
eprint(e)
207+
sys.exit(1)
208+
209+
def main(max_workers: int, keep_going: bool, commit: bool, no_confirm: bool, packages_path: str) -> None:
210+
with open(packages_path) as f:
211+
packages = json.load(f)
212+
213+
eprint()
214+
eprint('Going to be running update for following packages:')
215+
for package in packages:
216+
eprint(f" - {package['name']}")
217+
eprint()
218+
219+
if not no_confirm:
220+
confirm = input('Press Enter key to continue...')
221+
if confirm != '':
222+
eprint('Aborting!')
223+
sys.exit(130)
224+
225+
eprint('Running update for:')
226+
asyncio.run(start_updates(max_workers, keep_going, commit, packages))
227+
eprint('Packages updated!')
228+
sys.exit()
229+
230+
parser = argparse.ArgumentParser(description='Update packages')
231+
parser.add_argument('--max-workers', '-j', dest='max_workers', type=int, help='Number of updates to run concurrently', nargs='?', default=4)
232+
parser.add_argument('--keep-going', '-k', dest='keep_going', action='store_true', help='Do not stop after first failure')
233+
parser.add_argument('--commit', '-c', dest='commit', action='store_true', help='Commit the changes')
234+
parser.add_argument('--no-confirm', '-n', action='store_true', help='Skip the confirmation prompt and proceed with updates automatically')
235+
parser.add_argument('packages', help='JSON file containing the list of package names and their update scripts')
236+
237+
if __name__ == '__main__':
238+
args = parser.parse_args()
239+
240+
try:
241+
main(args.max_workers, args.keep_going, args.commit, args.no_confirm, args.packages)
242+
except KeyboardInterrupt as e:
243+
# Let’s cancel outside of the main loop too.
244+
sys.exit(130)
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
#!/usr/bin/env bash
2+
3+
set -e
4+
5+
rootDir="$(git rev-parse --show-toplevel)"
6+
updateScript="maintainers/scripts/update/update.nix"
7+
8+
nix-shell "${rootDir}/${updateScript}" \
9+
--argstr flakePath "${rootDir}" \
10+
--arg keep-going 'true' \
11+
--arg commit "true" \
12+
--arg max-workers "4" \
13+
--arg no-confirm "true"

0 commit comments

Comments
 (0)