1 #!/usr/bin/env nix-shell
2 #! nix-shell -i python3 -p "python3.withPackages (ps: with ps; [ requests click click-log packaging ])" bundix bundler nix-update nurl prefetch-yarn-deps
3 from __future__
import annotations
17 from functools
import total_ordering
18 from packaging
.version
import Version
19 from pathlib
import Path
20 from typing
import Union
, Iterable
23 logger
= logging
.getLogger(__name__
)
27 class DiscourseVersion
:
28 """Represents a Discourse style version number and git tag.
30 This takes either a tag or version string as input and
31 extrapolates the other. Sorting is implemented to work as expected
32 in regard to A.B.C.betaD version numbers - 2.0.0.beta1 is
33 considered lower than 2.0.0.
39 split_version
: Iterable
[Union
[None, int, str]] = []
41 def __init__(self
, version
: str):
42 """Take either a tag or version number, calculate the other."""
43 if version
.startswith('v'):
45 self
.version
= version
.lstrip('v')
47 self
.tag
= 'v' + version
48 self
.version
= version
50 self
._version
= Version(self
.version
)
52 def __eq__(self
, other
: DiscourseVersion
):
53 """Versions are equal when their individual parts are."""
54 return self
._version
== other
._version
56 def __gt__(self
, other
: DiscourseVersion
):
57 """Check if this version is greater than the other."""
58 return self
._version
> other
._version
62 version_regex
= re
.compile(r
'^v\d+\.\d+\.\d+(\.beta\d+)?$')
63 _latest_commit_sha
= None
65 def __init__(self
, owner
: str = 'discourse', repo
: str = 'discourse'):
70 def versions(self
) -> Iterable
[str]:
71 r
= requests
.get(f
'https://api.github.com/repos/{self.owner}/{self.repo}/git/refs/tags').json()
72 tags
= [x
['ref'].replace('refs/tags/', '') for x
in r
]
74 # filter out versions not matching version_regex
75 versions
= filter(self
.version_regex
.match
, tags
)
76 versions
= [DiscourseVersion(x
) for x
in versions
]
77 versions
.sort(reverse
=True)
81 def latest_commit_sha(self
) -> str:
82 if self
._latest
_commit
_sha
is None:
83 r
= requests
.get(f
'https://api.github.com/repos/{self.owner}/{self.repo}/commits?per_page=1')
85 self
._latest
_commit
_sha
= r
.json()[0]['sha']
87 return self
._latest
_commit
_sha
89 def get_yarn_lock_hash(self
, rev
: str, path
: str):
90 yarnLockText
= self
.get_file(path
, rev
)
91 with tempfile
.NamedTemporaryFile(mode
='w') as lockFile
:
92 lockFile
.write(yarnLockText
)
93 hash = subprocess
.check_output(['prefetch-yarn-deps', lockFile
.name
]).decode().strip()
94 return subprocess
.check_output(["nix", "hash", "to-sri", "--type", "sha256", hash]).decode().strip()
96 def get_file(self
, filepath
, rev
):
97 """Return file contents at a given rev.
99 :param str filepath: the path to the file, relative to the repo root
100 :param str rev: the rev to fetch at :return:
103 r
= requests
.get(f
'https://raw.githubusercontent.com/{self.owner}/{self.repo}/{rev}/{filepath}')
108 def _call_nix_update(pkg
, version
):
109 """Call nix-update from nixpkgs root dir."""
110 nixpkgs_path
= Path(__file__
).parent
/ '../../../../'
111 return subprocess
.check_output(['nix-update', pkg
, '--version', version
], cwd
=nixpkgs_path
)
114 def _nix_eval(expr
: str):
115 nixpkgs_path
= Path(__file__
).parent
/ '../../../../'
117 output
= subprocess
.check_output(['nix-instantiate', '--strict', '--json', '--eval', '-E', f
'(with import {nixpkgs_path} {{}}; {expr})'], text
=True)
118 except subprocess
.CalledProcessError
:
120 return json
.loads(output
)
123 def _get_current_package_version(pkg
: str):
124 return _nix_eval(f
'{pkg}.version')
127 def _diff_file(filepath
: str, old_version
: DiscourseVersion
, new_version
: DiscourseVersion
):
128 repo
= DiscourseRepo()
130 current_dir
= Path(__file__
).parent
132 old
= repo
.get_file(filepath
, old_version
.tag
)
133 new
= repo
.get_file(filepath
, new_version
.tag
)
136 click
.secho(f
'{filepath} is unchanged', fg
='green')
139 with tempfile
.NamedTemporaryFile(mode
='w') as o
, tempfile
.NamedTemporaryFile(mode
='w') as n
:
140 o
.write(old
), n
.write(new
)
141 width
= shutil
.get_terminal_size((80, 20)).columns
142 diff_proc
= subprocess
.run(
143 ['diff', '--color=always', f
'--width={width}', '-y', o
.name
, n
.name
],
144 stdout
=subprocess
.PIPE
,
149 click
.secho(f
'Diff for {filepath} ({old_version.version} -> {new_version.version}):', fg
='bright_blue', bold
=True)
150 click
.echo(diff_proc
.stdout
+ '\n')
154 def _remove_platforms(rubyenv_dir
: Path
):
155 for platform
in ['arm64-darwin-20', 'x86_64-darwin-18',
156 'x86_64-darwin-19', 'x86_64-darwin-20',
157 'x86_64-linux', 'aarch64-linux']:
158 with
open(rubyenv_dir
/ 'Gemfile.lock', 'r') as f
:
161 subprocess
.check_output(
162 ['bundle', 'lock', '--remove-platform', platform
], cwd
=rubyenv_dir
)
166 @click_log.simple_verbosity_option(logger
)
175 @click.argument('rev', default
='latest')
176 @click.option('--reverse/--no-reverse', default
=False, help='Print diffs from REV to current.')
177 def print_diffs(rev
, reverse
):
178 """Print out diffs for files used as templates for the NixOS module.
180 The current package version found in the nixpkgs worktree the
181 script is run from will be used to download the "from" file and
182 REV used to download the "to" file for the diff, unless the
183 '--reverse' flag is specified.
185 REV should be the git rev to find changes in ('vX.Y.Z') or
186 'latest'; defaults to 'latest'.
190 repo
= DiscourseRepo()
191 rev
= repo
.versions
[0].tag
193 old_version
= DiscourseVersion(_get_current_package_version('discourse'))
194 new_version
= DiscourseVersion(rev
)
197 old_version
, new_version
= new_version
, old_version
199 for f
in ['config/nginx.sample.conf', 'config/discourse_defaults.conf']:
200 _diff_file(f
, old_version
, new_version
)
204 @click.argument('rev', default
='latest')
206 """Update gem files and version.
208 REV: the git rev to update to ('vX.Y.Z[.betaA]') or
209 'latest'; defaults to 'latest'.
212 repo
= DiscourseRepo()
215 version
= repo
.versions
[0]
217 version
= DiscourseVersion(rev
)
219 logger
.debug(f
"Using rev {version.tag}")
220 logger
.debug(f
"Using version {version.version}")
222 rubyenv_dir
= Path(__file__
).parent
/ "rubyEnv"
224 for fn
in ['Gemfile.lock', 'Gemfile']:
225 with
open(rubyenv_dir
/ fn
, 'w') as f
:
226 f
.write(repo
.get_file(fn
, version
.tag
))
228 # work around https://github.com/nix-community/bundix/issues/8
229 os
.environ
["BUNDLE_FORCE_RUBY_PLATFORM"] = "true"
230 subprocess
.check_output(['bundle', 'lock'], cwd
=rubyenv_dir
)
231 _remove_platforms(rubyenv_dir
)
232 subprocess
.check_output(['bundix'], cwd
=rubyenv_dir
)
234 _call_nix_update('discourse', version
.version
)
236 old_yarn_hash
= _nix_eval('discourse.assets.yarnOfflineCache.outputHash')
237 new_yarn_hash
= repo
.get_yarn_lock_hash(version
.tag
, "app/assets/javascripts/yarn-ember5.lock")
238 click
.echo(f
"Updating yarn lock hash: {old_yarn_hash} -> {new_yarn_hash}")
240 old_yarn_dev_hash
= _nix_eval('discourse.assets.yarnDevOfflineCache.outputHash')
241 new_yarn_dev_hash
= repo
.get_yarn_lock_hash(version
.tag
, "yarn.lock")
242 click
.echo(f
"Updating yarn dev lock hash: {old_yarn_dev_hash} -> {new_yarn_dev_hash}")
244 with
open(Path(__file__
).parent
/ "default.nix", 'r+') as f
:
246 content
= content
.replace(old_yarn_hash
, new_yarn_hash
)
247 content
= content
.replace(old_yarn_dev_hash
, new_yarn_dev_hash
)
254 @click.argument('rev', default
='latest')
255 def update_mail_receiver(rev
):
256 """Update discourse-mail-receiver.
258 REV: the git rev to update to ('vX.Y.Z') or 'latest'; defaults to
262 repo
= DiscourseRepo(repo
="mail-receiver")
265 version
= repo
.versions
[0]
267 version
= DiscourseVersion(rev
)
269 _call_nix_update('discourse-mail-receiver', version
.version
)
273 def update_plugins():
274 """Update plugins to their latest revision."""
276 {'name': 'discourse-assign'},
277 {'name': 'discourse-bbcode-color'},
278 {'name': 'discourse-calendar'},
279 {'name': 'discourse-canned-replies'},
280 {'name': 'discourse-chat-integration'},
281 {'name': 'discourse-checklist'},
282 {'name': 'discourse-data-explorer'},
283 {'name': 'discourse-docs'},
284 {'name': 'discourse-github'},
285 {'name': 'discourse-ldap-auth', 'owner': 'jonmbake'},
286 {'name': 'discourse-math'},
287 {'name': 'discourse-migratepassword', 'owner': 'discoursehosting'},
288 {'name': 'discourse-openid-connect'},
289 {'name': 'discourse-prometheus'},
290 {'name': 'discourse-reactions'},
291 {'name': 'discourse-saved-searches'},
292 {'name': 'discourse-solved'},
293 {'name': 'discourse-spoiler-alert'},
294 {'name': 'discourse-voting'},
295 {'name': 'discourse-yearly-review'},
298 for plugin
in plugins
:
299 fetcher
= plugin
.get('fetcher') or "fetchFromGitHub"
300 owner
= plugin
.get('owner') or "discourse"
301 name
= plugin
.get('name')
302 repo_name
= plugin
.get('repo_name') or name
304 if fetcher
== "fetchFromGitHub":
305 url
= f
"https://github.com/{owner}/{repo_name}"
307 raise NotImplementedError(f
"Missing URL pattern for {fetcher}")
309 repo
= DiscourseRepo(owner
=owner
, repo
=repo_name
)
311 # implement the plugin pinning algorithm laid out here:
312 # https://meta.discourse.org/t/pinning-plugin-and-theme-versions-for-older-discourse-installs/156971
313 # this makes sure we don't upgrade plugins to revisions that
314 # are incompatible with the packaged Discourse version
316 compatibility_spec
= repo
.get_file('.discourse-compatibility', repo
.latest_commit_sha
)
317 versions
= [(DiscourseVersion(discourse_version
), plugin_rev
.strip(' '))
318 for [discourse_version
, plugin_rev
]
319 in [line
.lstrip("< ").split(':')
321 in compatibility_spec
.splitlines() if line
!= '']]
322 discourse_version
= DiscourseVersion(_get_current_package_version('discourse'))
323 versions
= list(filter(lambda ver
: ver
[0] >= discourse_version
, versions
))
325 rev
= repo
.latest_commit_sha
329 except requests
.exceptions
.HTTPError
:
330 rev
= repo
.latest_commit_sha
332 filename
= _nix_eval(f
'builtins.unsafeGetAttrPos "src" discourse.plugins.{name}')
334 filename
= Path(__file__
).parent
/ 'plugins' / name
/ 'default.nix'
335 filename
.parent
.mkdir()
337 has_ruby_deps
= False
338 for line
in repo
.get_file('plugin.rb', rev
).splitlines():
343 with
open(filename
, 'w') as f
:
344 f
.write(textwrap
.dedent(f
"""
345 {{ lib, mkDiscoursePlugin, fetchFromGitHub }}:
348 name = "{name}";"""[1:] + ("""
349 bundlerEnvArgs.gemdir = ./.;""" if has_ruby_deps
else "") + f
"""
352 repo = "{repo_name}";
353 rev = "replace-with-git-rev";
354 sha256 = "replace-with-sha256";
358 maintainers = with maintainers; [ ];
359 license = licenses.mit; # change to the correct license!
364 all_plugins_filename
= Path(__file__
).parent
/ 'plugins' / 'all-plugins.nix'
365 with
open(all_plugins_filename
, 'r+') as f
:
368 while content
[pos
] != '}':
370 content
= content
[:pos
] + f
' {name} = callPackage ./{name} {{}};' + os
.linesep
+ content
[pos
:]
376 filename
= filename
['file']
378 prev_commit_sha
= _nix_eval(f
'discourse.plugins.{name}.src.rev')
380 if prev_commit_sha
== rev
:
381 click
.echo(f
'Plugin {name} is already at the latest revision')
384 prev_hash
= _nix_eval(f
'discourse.plugins.{name}.src.outputHash')
385 new_hash
= subprocess
.check_output([
387 "--fetcher", fetcher
,
391 ], text
=True).strip("\n")
393 click
.echo(f
"Update {name}, {prev_commit_sha} -> {rev} in {filename}")
395 with
open(filename
, 'r+') as f
:
397 content
= content
.replace(prev_commit_sha
, rev
)
398 content
= content
.replace(prev_hash
, new_hash
)
403 rubyenv_dir
= Path(filename
).parent
404 gemfile
= rubyenv_dir
/ "Gemfile"
405 version_file_regex
= re
.compile(r
'.*File\.expand_path\("\.\./(.*)", __FILE__\)')
407 plugin_file
= repo
.get_file('plugin.rb', rev
)
408 plugin_file
= plugin_file
.replace(",\n", ", ") # fix split lines
409 for line
in plugin_file
.splitlines():
411 line
= ','.join(filter(lambda x
: ":require_name" not in x
, line
.split(',')))
412 gemfile_text
= gemfile_text
+ line
+ os
.linesep
414 version_file_match
= version_file_regex
.match(line
)
415 if version_file_match
is not None:
416 filename
= version_file_match
.groups()[0]
417 content
= repo
.get_file(filename
, rev
)
418 with
open(rubyenv_dir
/ filename
, 'w') as f
:
421 if len(gemfile_text
) > 0:
422 if os
.path
.isfile(gemfile
):
425 subprocess
.check_output(['bundle', 'init'], cwd
=rubyenv_dir
)
426 os
.chmod(gemfile
, stat
.S_IREAD | stat
.S_IWRITE | stat
.S_IRGRP | stat
.S_IROTH
)
428 with
open(gemfile
, 'a') as f
:
429 f
.write(gemfile_text
)
431 subprocess
.check_output(['bundle', 'lock', '--add-platform', 'ruby'], cwd
=rubyenv_dir
)
432 subprocess
.check_output(['bundle', 'lock', '--update'], cwd
=rubyenv_dir
)
433 _remove_platforms(rubyenv_dir
)
434 subprocess
.check_output(['bundix'], cwd
=rubyenv_dir
)
437 if __name__
== '__main__':