diff --git a/.changelog/.create_dir b/.changelog/.create_dir new file mode 100644 index 0000000..e69de29 diff --git a/.changelog/acc2596fb367494db070e6c06abf705a.md b/.changelog/acc2596fb367494db070e6c06abf705a.md new file mode 100644 index 0000000..2f3525b --- /dev/null +++ b/.changelog/acc2596fb367494db070e6c06abf705a.md @@ -0,0 +1,4 @@ +--- +type: none +--- +Adding changelog management infra and doc \ No newline at end of file diff --git a/.git_hooks_pre-commit b/.git_hooks_pre-commit index 7938eee..de3ba67 100755 --- a/.git_hooks_pre-commit +++ b/.git_hooks_pre-commit @@ -10,3 +10,4 @@ ROOT=$(dirname "$GIT") "$ROOT/script/lint" "$ROOT/script/format" --check --quiet || (echo "Formatting check failed, run ./script/format" && exit 1) "$ROOT/script/coverage" +"$ROOT/script/changelog" check diff --git a/.github/workflows/changelog.yml b/.github/workflows/changelog.yml new file mode 100644 index 0000000..18c9285 --- /dev/null +++ b/.github/workflows/changelog.yml @@ -0,0 +1,24 @@ +name: OctoDNS Changelog +on: + pull_request: + workflow_dispatch: + +jobs: + changelog: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Fetch main + run: git fetch origin main --depth 1 + - name: Setup python + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + architecture: x64 + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + - name: Changelog Check + run: | + ./script/changelog check diff --git a/CHANGELOG.md b/CHANGELOG.md index d69254e..0df45b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +## v1.11.? - 2025-??-?? - ??? + +* Correct type-o in name of AcmeManagingProcessor, backwards compatible alias + in place + ## v1.11.0 - 2025-02-03 - Cleanup & deprecations with meta planning * Deprecation warning for Source.populate w/o the lenient param, to be removed diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6fed5bd..e26e8a1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -17,8 +17,11 @@ This project uses the [GitHub Flow](https://guides.github.com/introduction/flow/ 0. Create a new branch: `git checkout -b my-branch-name` 0. Make your change, add tests, and make sure the tests still pass 0. Make sure that `./script/lint` passes without any warnings +0. Run `./script/format` to make sure your changes follow Python's preferred + coding style +0. Run `./script/changelog create ...` to add a changelog entry to your PR 0. Make sure that coverage is at :100:% `./script/coverage` and open `htmlcov/index.html` - * You can open PRs for :eyes: & discussion prior to this + * You can open a draft PR for :eyes: & discussion prior to this 0. Push to your fork and submit a pull request We will handle updating the version, tagging the release, and releasing the gem. Please don't bump the version or otherwise attempt to take on these administrative internal tasks as part of your pull request. diff --git a/README.md b/README.md index 97fa57b..50332aa 100644 --- a/README.md +++ b/README.md @@ -330,7 +330,7 @@ Similar to providers, but can only serve to populate records into a zone, cannot | Processor | Description | |--|--| -| [AcmeMangingProcessor](/octodns/processor/acme.py) | Useful when processes external to octoDNS are managing acme challenge DNS records, e.g. LetsEncrypt | +| [AcmeManagingProcessor](/octodns/processor/acme.py) | Useful when processes external to octoDNS are managing acme challenge DNS records, e.g. LetsEncrypt | | [AutoArpa](/octodns/processor/arpa.py) | See [Automatic PTR generation](#automatic-ptr-generation) below | | [EnsureTrailingDots](/octodns/processor/trailing_dots.py) | Processor that ensures ALIAS, CNAME, DNAME, MX, NS, PTR, and SRVs have trailing dots | | [ExcludeRootNsChanges](/octodns/processor/filter.py) | Filter that errors or warns on planned root/APEX NS records changes. | diff --git a/octodns/processor/acme.py b/octodns/processor/acme.py index 793f95a..c8c7b1e 100644 --- a/octodns/processor/acme.py +++ b/octodns/processor/acme.py @@ -7,14 +7,14 @@ from logging import getLogger from .base import BaseProcessor -class AcmeMangingProcessor(BaseProcessor): - log = getLogger('AcmeMangingProcessor') +class AcmeManagingProcessor(BaseProcessor): + log = getLogger('AcmeManagingProcessor') def __init__(self, name): ''' processors: acme: - class: octodns.processor.acme.AcmeMangingProcessor + class: octodns.processor.acme.AcmeManagingProcessor ... @@ -59,3 +59,6 @@ class AcmeMangingProcessor(BaseProcessor): existing.remove_record(record) return existing + + +AcmeMangingProcessor = AcmeManagingProcessor diff --git a/script/changelog b/script/changelog index c270fb9..0c7dd42 100755 --- a/script/changelog +++ b/script/changelog @@ -1,7 +1,308 @@ -#!/bin/bash +#!/usr/bin/env python -set -e +from argparse import ArgumentParser, RawTextHelpFormatter +from datetime import datetime +from importlib import import_module +from io import StringIO +from json import loads +from os import getcwd, listdir, makedirs, remove +from os.path import basename, isdir, join +from subprocess import PIPE, run +from sys import argv, exit, path, stderr +from uuid import uuid4 -VERSION=v$(grep __version__ octodns/__init__.py | sed -e "s/^[^']*'//" -e "s/'$//") -echo $VERSION -git log --pretty="%h - %cr - %s (%an)" "${VERSION}..HEAD" +from yaml import safe_load_all + + +def create(argv): + prog = basename(argv.pop(0)) + parser = ArgumentParser( + prog=f'{prog} create', + description='Creates a new changelog entry.', + add_help=True, + formatter_class=RawTextHelpFormatter, + ) + + parser.add_argument( + '-t', + '--type', + choices=('none', 'patch', 'minor', 'major'), + required=True, + help='''The scope of the change. + +* patch - This is a bug fix +* minor - This adds new functionality or makes changes in a fully backwards + compatible way +* major - This includes substantial new functionality and/or changes that break + compatibility and may require careful migration +* none - This change does not need to be mentioned in the changelog + +See https://semver.org/ for more info''', + ) + parser.add_argument( + 'md', + metavar='change-description-markdown', + nargs='+', + help='''A short description of the changes in this PR, suitable as an entry in +CHANGELOG.md. Should be a single line. Can include simple markdown formatting +and links.''', + ) + + args = parser.parse_args(argv) + + if not isdir('.changelog'): + makedirs('.changelog') + filepath = join('.changelog', f'{uuid4().hex}.md') + with open(filepath, 'w') as fh: + fh.write('---\ntype: ') + fh.write(args.type) + fh.write('\n---\n') + fh.write(' '.join(args.md)) + + print( + f'Created {filepath}, it can be further edited and should be committed to your branch.' + ) + + +def check(argv): + if isdir('.changelog'): + result = run( + ['git', 'diff', '--name-only', 'origin/main', '.changelog/'], + check=False, + stdout=PIPE, + ) + entries = { + l + for l in result.stdout.decode('utf-8').split() + if l.endswith('.md') + } + if not result.returncode and entries: + exit(0) + + print( + 'PR is missing required changelog file, run ./script/changelog create', + file=stderr, + ) + exit(1) + + +def _get_current_version(module_name): + cwd = getcwd() + path.append(cwd) + module = import_module(module_name) + return tuple(int(v) for v in module.__version__.split('.', 2)) + + +class _ChangeMeta: + _pr_cache = None + + @classmethod + def get(cls, filepath): + if cls._pr_cache is None: + result = run( + [ + 'gh', + 'pr', + 'list', + '--base', + 'main', + '--state', + 'merged', + '--limit=50', + '--json', + 'files,mergedAt,number', + ], + check=True, + stdout=PIPE, + ) + cls._pr_cache = {} + for pr in loads(result.stdout): + for file in pr['files']: + path = file['path'] + if path.startswith('.changelog'): + cls._pr_cache[path] = ( + pr['number'], + datetime.fromisoformat(pr['mergedAt']).replace( + tzinfo=None + ), + ) + + try: + return cls._pr_cache[filepath] + except KeyError: + return None, datetime(year=1970, month=1, day=1) + + +def _get_changelogs(): + ret = [] + dirname = '.changelog' + for filename in listdir(dirname): + if not filename.endswith('.md'): + continue + filepath = join(dirname, filename) + with open(filepath) as fh: + data, md = safe_load_all(fh) + pr, time = _ChangeMeta.get(filepath) + if not pr: + continue + ret.append( + { + 'filepath': filepath, + 'md': md, + 'pr': pr, + 'time': time, + 'type': data.get('type', '').lower(), + } + ) + + ordering = {'major': 0, 'minor': 1, 'patch': 2, 'none': 3, '': 3} + ret.sort(key=lambda c: (ordering[c['type']], c['time'])) + return ret + + +def _get_new_version(current_version, changelogs): + try: + bump_type = changelogs[0]['type'] + except IndexError: + return None + new_version = list(current_version) + if bump_type == 'major': + new_version[0] += 1 + new_version[1] = 0 + new_version[2] = 0 + elif bump_type == 'minor': + new_version[1] += 1 + new_version[2] = 0 + else: + new_version[2] += 1 + return tuple(new_version) + + +def _format_version(version): + return '.'.join(str(v) for v in version) + + +def bump(argv): + prog = basename(argv.pop(0)) + parser = ArgumentParser( + prog=f'{prog} bump', + description='Builds a changelog update and calculates a new version number.', + add_help=True, + ) + + parser.add_argument( + '--make-changes', + action='store_true', + help='Write changelog update and bump version number', + ) + + args = parser.parse_args(argv) + + buf = StringIO() + + cwd = getcwd() + module_name = basename(cwd).replace('-', '_') + + buf.write('## ') + current_version = _get_current_version(module_name) + changelogs = _get_changelogs() + new_version = _get_new_version(current_version, changelogs) + if not new_version: + print('No changelog entries found that would bump, nothing to do') + exit(1) + new_version = _format_version(new_version) + buf.write(new_version) + buf.write(' - ') + buf.write(datetime.now().strftime('%Y-%m-%d')) + buf.write(' - ') + buf.write(' '.join(argv[1:])) + buf.write('\n') + + current_type = None + for changelog in changelogs: + md = changelog['md'] + if not md: + continue + + _type = changelog['type'] + if _type != current_type: + buf.write('\n') + buf.write(_type.capitalize()) + buf.write(':\n') + current_type = _type + buf.write('* ') + buf.write(md) + + pr = changelog['pr'] + if pr: + pr = str(pr) + buf.write(' [#') + buf.write(pr) + buf.write('](https://github.com/octodns/') + buf.write(module_name) + buf.write('/pull/') + buf.write(pr) + buf.write(')') + + buf.write('\n') + + buf.write('\n') + + if not args.make_changes: + print(f'New version number {new_version}\n') + print(buf.getvalue()) + exit(0) + + with open('CHANGELOG.md') as fh: + existing = fh.read() + + with open('CHANGELOG.md', 'w') as fh: + fh.write(buf.getvalue()) + fh.write(existing) + + with open(f'{module_name}/__init__.py') as fh: + existing = fh.read() + + current_version = _format_version(current_version) + with open(f'{module_name}/__init__.py', 'w') as fh: + fh.write(existing.replace(current_version, new_version)) + + for changelog in changelogs: + remove(changelog['filepath']) + + +cmds = {'create': create, 'check': check, 'bump': bump} + + +def general_usage(msg=None): + global cmds + + exe = basename(argv[0]) + cmds = ','.join(sorted(cmds.keys())) + print(f'usage: {exe} {{{cmds}}} ...') + if msg: + print(msg) + else: + print( + ''' +Creates and checks or changelog entries, located in the .changelog directory. +Additionally supports updating CHANGELOG.md and bumping the package version +based on one or more entries in that directory. +''' + ) + + +try: + cmd = cmds[argv[1].lower()] + argv.pop(1) +except IndexError: + general_usage('missing command') + exit(1) +except KeyError: + if argv[1] in ('-h', '--help', 'help'): + general_usage() + exit(0) + general_usage(f'unknown command "{argv[1]}"') + exit(1) + +cmd(argv) diff --git a/tests/test_octodns_processor_acme.py b/tests/test_octodns_processor_acme.py index 38d4e9d..5b0ee7d 100644 --- a/tests/test_octodns_processor_acme.py +++ b/tests/test_octodns_processor_acme.py @@ -4,7 +4,7 @@ from unittest import TestCase -from octodns.processor.acme import AcmeMangingProcessor +from octodns.processor.acme import AcmeManagingProcessor from octodns.record import Record from octodns.zone import Zone @@ -46,9 +46,9 @@ records = { } -class TestAcmeMangingProcessor(TestCase): +class TestAcmeManagingProcessor(TestCase): def test_process_zones(self): - acme = AcmeMangingProcessor('acme') + acme = AcmeManagingProcessor('acme') source = Zone(zone.name, []) # Unrelated stuff that should be untouched