#!/usr/bin/env python3 """Download released confBuild handover packages. Python 3, standard library only. Set CONFBUILD_API_KEY. Optional CONFBUILD_API_BASE and CONFBUILD_OUTPUT_DIR. The cursor advances only after all documents of a release pass SHA-256 checks. """ import hashlib import json import os from pathlib import Path import re import urllib.error import urllib.request def main(): token = os.environ['CONFBUILD_API_KEY'] base = os.environ.get('CONFBUILD_API_BASE', 'https://app.confbuild.com/api/v1').rstrip('/') if not base.startswith('https://'): raise ValueError('Use an HTTPS API base URL.') output = Path(os.environ.get('CONFBUILD_OUTPUT_DIR', 'confbuild-handover')) output.mkdir(parents=True, exist_ok=True) checkpoint = output / 'cursor.json' # Bind the checkpoint to the credential and endpoint to avoid mixing projects. binding = hashlib.sha256((base + '\n' + token).encode()).hexdigest() state = json.loads(checkpoint.read_text()) if checkpoint.exists() else {'cursor': 0, 'binding': binding} if state['binding'] != binding: raise ValueError('Credential/base changed. Use a fresh output directory or deliberately reset cursor.json.') class NoRedirect(urllib.request.HTTPRedirectHandler): def redirect_request(self, req, fp, code, msg, headers, newurl): return None # Never forward credentials to a redirect target. opener = urllib.request.build_opener(NoRedirect()) def get(path): req = urllib.request.Request(base + path, headers={'Authorization': 'Bearer ' + token}) with opener.open(req, timeout=120) as response: return response.read() cursor = int(state['cursor']) while True: page = json.loads(get('/releases?limit=25&cursor=' + str(cursor))) for release in page['items']: release_id = release['id'] if not re.fullmatch(r'[a-f0-9]{40}', release_id): raise ValueError('Invalid release identifier') directory = output / release_id directory.mkdir(exist_ok=True) for doc in release['documents']: if not re.fullmatch(r'[A-Za-z0-9_-]{1,128}', doc['id']): raise ValueError('Invalid document identifier') # Use manifest IDs, not caller-supplied filenames, as local paths. target = directory / doc['id'] content = get('/releases/' + release_id + '/documents/' + doc['id']) if len(content) != doc['bytes'] or hashlib.sha256(content).hexdigest() != doc['sha256']: raise ValueError('Document checksum mismatch') temporary = target.with_suffix('.tmp') temporary.write_bytes(content) temporary.replace(target) (directory / 'manifest.json').write_text(json.dumps(release, indent=2), encoding='utf-8') cursor = release['sequence'] temporary = checkpoint.with_suffix('.tmp') temporary.write_text(json.dumps({'cursor': cursor, 'binding': binding}), encoding='utf-8') temporary.replace(checkpoint) print('Downloaded release', release_id, 'sequence', cursor) if page['nextCursor'] is None: break if __name__ == '__main__': try: main() except urllib.error.HTTPError as error: # Do not print request headers/credentials. Retry on 429/503 without advancing the cursor. raise SystemExit('API HTTP ' + str(error.code) + '; check credentials or retry later.') from None