This repository has been archived on 2026-11-08. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
qqq-orb/tools/rollout_version.py
2026-10-21 15:06:44 -05:00

93 lines
3.7 KiB
Python

#!/usr/bin/env python3
"""Upgrade canonical CircleCI callers in one batch; dry-run unless --write.
Only the orb pin changes. Repository publication policy and profile parameters
are preserved. Git commits, pushes and hosted verification remain separate.
"""
import argparse
import importlib.util
import json
from pathlib import Path
import re
import sys
import yaml
ROOT = Path(__file__).resolve().parents[1]
def module(name):
spec = importlib.util.spec_from_file_location(name, ROOT / 'src/scripts' / (name + '.py'))
result = importlib.util.module_from_spec(spec)
spec.loader.exec_module(result)
return result
standard = module('validate_standard')
pipeline = module('generate_pipeline')
def plan(checkout, version):
root = Path(checkout).resolve(strict=True)
paths = [root / '.circleci/config.yml', root / '.qqq-ci.yml']
if any(not path.resolve(strict=True).is_relative_to(root) for path in paths):
raise ValueError('CI declarations must remain inside their checkout')
originals = [path.read_bytes() for path in paths]
caller_raw, manifest_raw = [data.decode('utf-8') for data in originals]
manifest = standard.load_yaml(manifest_raw)
if not isinstance(manifest, dict):
raise ValueError('Existing manifest must be a mapping')
previous = manifest.get('orb_version')
standard.validate(caller_raw, manifest_raw, previous)
pipeline.load_manifest(paths[1], previous)
caller = standard.load_yaml(caller_raw)
if previous == version:
replacements = originals
else:
manifest['orb_version'] = version
caller['orbs']['qqq-orb'] = 'kingsrook/qqq-orb@' + version
caller['workflows']['setup']['jobs'][0]['qqq-orb/generate_pipeline']['orb_version'] = version
new_caller = yaml.safe_dump(caller, sort_keys=False)
new_manifest = yaml.safe_dump(manifest, sort_keys=False)
standard.validate(new_caller, new_manifest, version)
replacements = [new_caller.encode('utf-8'), new_manifest.encode('utf-8')]
return {'checkout': str(root), 'profile': manifest['profile'], 'from': previous, 'to': version,
'files': list(zip(paths, originals, replacements))}
def rollout(checkouts, version, write=False):
if not re.fullmatch(r'\d+\.\d+(?:\.\d+)?', version):
raise ValueError('Fleet upgrades require a stable minor track or exact patch version')
# Validate the entire batch before writing any repository.
plans = [plan(path, version) for path in checkouts]
if len({item['checkout'] for item in plans}) != len(plans):
raise ValueError('Each checkout may appear only once')
if write:
for item in plans:
for path, original, replacement in item['files']:
if path.read_bytes() != original:
raise ValueError('CI declaration changed after preflight; rerun the rollout')
for item in plans:
for path, original, replacement in item['files']:
if replacement != original:
path.write_bytes(replacement)
return [{**{key: value for key, value in item.items() if key != 'files'},
'changed': any(old != new for _, old, new in item['files']), 'written': write}
for item in plans]
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('checkouts', nargs='+', type=Path)
parser.add_argument('--orb-version', required=True)
parser.add_argument('--write', action='store_true')
args = parser.parse_args()
print(json.dumps(rollout(args.checkouts, args.orb_version, args.write), indent=2))
if __name__ == '__main__':
try:
main()
except (ValueError, OSError) as error:
sys.exit('Version rollout rejected: ' + str(error))