aboutsummaryrefslogtreecommitdiff
path: root/releng/create_release.xsh
blob: c9725c44c7c8eae6ff2736feaa3323358ca301ba (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
import json
import subprocess
import itertools
import textwrap
from pathlib import Path
import tempfile
import hashlib
import datetime
from . import environment
from . import keys
from .version import VERSION, RELEASE_NAME, MAJOR

$RAISE_SUBPROC_ERROR = True
$XONSH_SHOW_TRACEBACK = True

RELENG_ENV = environment.STAGING

RELEASES_BUCKET = RELENG_ENV.releases_bucket
CACHE_STORE = RELENG_ENV.cache_store_uri()
REPO = RELENG_ENV.git_repo

GCROOTS_DIR = Path('./release/gcroots')
BUILT_GCROOTS_DIR = Path('./release/gcroots-build')
DRVS_TXT = Path('./release/drvs.txt')
ARTIFACTS = Path('./release/artifacts')

RELENG_MSG = "Release created with releng/create_release.xsh"

BUILD_CORES = 16
MAX_JOBS = 2

# TODO
RELEASE_SYSTEMS = ["x86_64-linux"]


def setup_creds():
    key = keys.get_ephemeral_key(RELENG_ENV)
    $AWS_SECRET_ACCESS_KEY = key.secret_key
    $AWS_ACCESS_KEY_ID = key.id
    $AWS_DEFAULT_REGION = 'garage'
    $AWS_ENDPOINT_URL = environment.S3_ENDPOINT


def git_preconditions():
    # verify there is nothing in index ready to stage
    proc = !(git diff-index --quiet --cached HEAD --)
    assert proc.rtn == 0
    # verify there is nothing *stageable* and tracked
    proc = !(git diff-files --quiet)
    assert proc.rtn == 0


def official_release_commit_tag(force_tag=False):
    print('[+] Setting officialRelease in flake.nix and tagging')
    prev_branch = $(git symbolic-ref --short HEAD).strip()

    git switch --detach
    sed -i 's/officialRelease = false/officialRelease = true/' flake.nix
    git add flake.nix
    message = f'release: {VERSION} "{RELEASE_NAME}"\n\nRelease produced with releng/create_release.xsh'
    git commit -m @(message)
    git tag @(['-f'] if force_tag else []) -a -m @(message) @(VERSION)

    return prev_branch


def merge_to_release(prev_branch):
    git switch @(prev_branch)
    # Create a merge back into the release branch so that git tools understand
    # that the release branch contains the tag, without the release commit
    # actually influencing the tree.
    merge_msg = textwrap.dedent("""\
        release: merge release {VERSION} back to mainline

        This merge commit returns to the previous state prior to the release but leaves the tag in the branch history.
        {RELENG_MSG}
    """).format(VERSION=VERSION, RELENG_MSG=RELENG_MSG)
    git merge -m @(merge_msg) -s ours @(VERSION)


def realise(paths: list[str]):
    args = [
        '--realise',
        '--max-jobs',
        MAX_JOBS,
        '--cores',
        BUILD_CORES,
        '--log-format',
        'bar-with-logs',
        '--add-root',
        BUILT_GCROOTS_DIR
    ]
    nix-store @(args) @(paths)


def eval_jobs():
    nej_output = $(nix-eval-jobs --workers 4 --gc-roots-dir @(GCROOTS_DIR) --force-recurse --flake '.#release-jobs')
    return [x for x in (json.loads(s) for s in nej_output.strip().split('\n'))
        if x['system'] in RELEASE_SYSTEMS
    ]


def upload_drv_paths_and_outputs(paths: list[str]):
    proc = subprocess.Popen([
            'nix',
            'copy',
            '-v',
            '--to',
            CACHE_STORE,
            '--stdin',
        ],
        stdin=subprocess.PIPE,
        env=__xonsh__.env.detype(),
    )

    proc.stdin.write('\n'.join(itertools.chain(paths, x + '^*' for x in paths)).encode())
    proc.stdin.close()
    rv = proc.wait()
    if rv != 0:
        raise subprocess.CalledProcessError(rv, proc.args)


def make_manifest(eval_result):
    manifest = {vs['system']: vs['outputs']['out'] for vs in eval_result}
    def manifest_line(system, out):
        return f'  {system} = "{out}";'

    manifest_text = textwrap.dedent("""\
        # This file was generated by releng/create_release.xsh in Lix
        {{
          {lines}
        }}
    """).format(lines='\n'.join(manifest_line(s, p) for (s, p) in manifest.items()))

    return manifest_text


def make_git_tarball(to: Path):
    git archive --verbose --prefix=lix-@(VERSION)/ --format=tar.gz -o @(to) @(VERSION)


def confirm(prompt, expected):
    resp = input(prompt)

    if resp != expected:
        raise ValueError('Unconfirmed')


def sha256_file(f: Path):
    hasher = hashlib.sha256()

    with open(f, 'rb') as h:
        while data := h.read(1024 * 1024):
            hasher.update(data)

    return hasher.hexdigest()


def make_artifacts_dir(eval_result, d: Path):
    d.mkdir(exist_ok=True, parents=True)
    version_dir = d / 'lix' / f'lix-{VERSION}'
    version_dir.mkdir(exist_ok=True, parents=True)

    tarballs_drv = next(p for p in eval_result if p['attr'] == 'tarballs')
    cp --no-preserve=mode -r @(tarballs_drv['outputs']['out'])/* @(version_dir)

    # FIXME: upgrade-nix searches for manifest.nix at root, which is rather annoying
    with open(d / 'manifest.nix', 'w') as h:
        h.write(make_manifest(eval_result))

    with open(version_dir / 'manifest.nix', 'w') as h:
        h.write(make_manifest(eval_result))

    print('[+] Make sources tarball')

    filename = f'lix-{VERSION}.tar.gz'
    git_tarball = version_dir / filename
    make_git_tarball(git_tarball)

    file_hash = sha256_file(git_tarball)

    print(f'Hash: {file_hash}')
    with open(version_dir / f'{filename}.sha256', 'w') as h:
        h.write(file_hash)


def prepare_release_notes():
    print('[+] Preparing release notes')
    RELEASE_NOTES_PATH = Path('doc/manual/rl-next')

    if RELEASE_NOTES_PATH.isdir():
        notes_body = subprocess.check_output(['build-release-notes', '--change-authors', 'doc/manual/change-authors.yml', 'doc/manual/rl-next']).decode()
    else:
        # I guess nobody put release notes on their changes?
        print('[-] Warning: seemingly missing any release notes, not worrying about it')
        notes_body = ''

    rl_path = Path(f'doc/manual/src/release-notes/rl-{MAJOR}.md')

    existing_rl = ''
    try:
        with open(rl_path, 'r') as fh:
            existing_rl = fh.read()
    except FileNotFoundError:
        pass

    date = datetime.datetime.now().strftime('%Y-%m-%d')

    minor_header = f'# Lix {VERSION} ({date})'

    header = f'# Lix {MAJOR} "{RELEASE_NAME}"'
    if existing_rl.startswith(header):
        # strip the header off for minor releases
        lines = existing_rl.splitlines()
        header = lines[0]
        existing_rl = '\n'.join(lines[1:])
    else:
        header += f' ({date})\n\n'

    header += '\n' + minor_header + '\n'

    notes = header
    notes += notes_body
    notes += "\n\n"
    notes += existing_rl

    # make pre-commit happy about one newline
    notes = notes.rstrip()
    notes += "\n"

    with open(rl_path, 'w') as fh:
        fh.write(notes)

    commit_msg = textwrap.dedent("""\
        release: release notes for {VERSION}

        {RELENG_MSG}
    """).format(VERSION=VERSION, RELENG_MSG=RELENG_MSG)

    git add @(rl_path)
    git rm doc/manual/rl-next/*.md

    git commit -m @(commit_msg)


def verify_are_on_tag():
    current_tag = $(git describe --tag).strip()
    assert current_tag == VERSION


def upload_artifacts(noconfirm=False, force_push_tag=False):
    assert 'AWS_SECRET_ACCESS_KEY' in __xonsh__.env

    tree @(ARTIFACTS)

    not noconfirm and confirm(
        f'Would you like to release {ARTIFACTS} as {VERSION}? Type "I want to release this" to confirm\n',
        'I want to release this'
    )

    print('[+] Upload to cache')
    with open(DRVS_TXT) as fh:
        upload_drv_paths_and_outputs([x.strip() for x in fh.readlines() if x])


    print('[+] Upload to release bucket')
    aws s3 cp --recursive @(ARTIFACTS)/ @(RELEASES_BUCKET)/

    print('[+] git push tag')
    git push @(['-f'] if force_push_tag else []) @(REPO) f'{VERSION}:refs/tags/{VERSION}'


def do_tag_merge(force_tag=False, no_check_git=False):
    if not no_check_git:
        git_preconditions()
    prev_branch = official_release_commit_tag(force_tag=force_tag)
    merge_to_release(prev_branch)
    git switch --detach @(VERSION)


def build_artifacts(no_check_git=False):
    if not no_check_git:
        verify_are_on_tag()
        git_preconditions()

    print('[+] Evaluating')
    eval_result = eval_jobs()
    drv_paths = [x['drvPath'] for x in eval_result]

    print('[+] Building')
    realise(drv_paths)

    with open(DRVS_TXT, 'w') as fh:
        fh.write('\n'.join(drv_paths))

    make_artifacts_dir(eval_result, ARTIFACTS)
    print(f'[+] Done! See {ARTIFACTS}')