From 008543b9d9b968b62dde37900bb48e665f04aa70 Mon Sep 17 00:00:00 2001 From: Steven Welch Date: Tue, 1 Sep 2026 19:07:47 -0600 Subject: [PATCH 1/2] Add GitHub-Release-only CD workflow triggered by successful ci on main - Add .github/workflows/release.yml: workflow_run on ci completion, gated to successful push runs on main, least-privilege contents:write, per-revision concurrency, checkout of the exact CI head SHA, Python 3.11 build of wheel+sdist, SHA256SUMS generation, and gh release create targeting the checked-out SHA with generated notes. - Skip cleanly when the release already exists; fail closed when the tag exists without a release so tags are never moved or reused. - Document the CD lifecycle in README and CHANGELOG. --- .github/workflows/release.yml | 88 +++++++++++++++++++++++++++++++++++ CHANGELOG.md | 4 ++ README.md | 9 ++++ 3 files changed, 101 insertions(+) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..1084187 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,88 @@ +name: release + +on: + workflow_run: + workflows: [ci] + types: [completed] + +permissions: + contents: write + +concurrency: + group: release-${{ github.event.workflow_run.head_sha }} + cancel-in-progress: false + +jobs: + release: + runs-on: ubuntu-latest + # Only a successful ci run for a push to main may release. Pull-request + # ci runs and pushes to any other branch are rejected here. + if: > + github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.event == 'push' && + github.event.workflow_run.head_branch == 'main' + steps: + - name: Checkout the exact CI-tested commit + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.workflow_run.head_sha }} + + - name: Set up Python 3.11 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.11" + + - name: Read the package version from pyproject.toml + id: version + run: | + version=$(python - <<'PY' + import tomllib + with open("pyproject.toml", "rb") as f: + print(tomllib.load(f)["project"]["version"]) + PY + ) + echo "version=$version" >> "$GITHUB_OUTPUT" + + - name: Check existing release and tag state + id: gate + env: + GH_TOKEN: ${{ github.token }} + run: | + tag="v${{ steps.version.outputs.version }}" + if gh release view "$tag" --repo "${{ github.repository }}" >/dev/null 2>&1; then + echo "Release $tag already exists; skipping." + echo "exists=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + if git ls-remote --exit-code --tags origin "refs/tags/$tag" >/dev/null 2>&1; then + echo "ERROR: tag $tag already exists but has no GitHub Release." >&2 + echo "Refusing to move or reuse the tag for a different revision." >&2 + exit 1 + fi + echo "exists=false" >> "$GITHUB_OUTPUT" + + - name: Build wheel and source distribution + if: steps.gate.outputs.exists != 'true' + run: | + python -m pip install --upgrade pip build + python -m build + + - name: Generate SHA256 checksums for release assets + if: steps.gate.outputs.exists != 'true' + run: | + cd dist + sha256sum * > SHA256SUMS + cat SHA256SUMS + + - name: Create GitHub Release with artifacts + if: steps.gate.outputs.exists != 'true' + env: + GH_TOKEN: ${{ github.token }} + run: | + tag="v${{ steps.version.outputs.version }}" + gh release create "$tag" \ + --repo "${{ github.repository }}" \ + --target "${{ github.event.workflow_run.head_sha }}" \ + --title "$tag" \ + --generate-notes \ + dist/* diff --git a/CHANGELOG.md b/CHANGELOG.md index f3faed1..2cb5c1c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to CFLAN are documented here. ## Unreleased +### Added + +- GitHub-Release-only CD: a successful `ci` run for a push to `main` automatically creates a GitHub Release tagged `v` (from `pyproject.toml`) with the built wheel, sdist, and `SHA256SUMS` when no release for that version exists. The version must be increased before a new release; a tag that exists without a release fails closed. No PyPI publishing is used, and a release is artifact publication, not installed-host validation. + ### Changed - Migrated the updater to the supported Cloudflare Python SDK interface. diff --git a/README.md b/README.md index 82dee68..64d981d 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,15 @@ A dry run never constructs a Cloudflare client or calls the Cloudflare API, so i - Existing records are updated with Cloudflare PATCH rather than delete-and-recreate, preserving the record and avoiding an avoidable DNS gap. - SOPS plaintext exists only in the updater process memory. +## Releases + +CFLAN uses a GitHub-Release-only CD lifecycle: + +- A successful `ci` workflow run for a push to `main` automatically builds the wheel and sdist and creates a GitHub Release tagged `v` from the `version` field in `pyproject.toml`, attaching the built distributions and a `SHA256SUMS` checksum file, when no release for that version exists yet. +- To publish a new release, increase `version` in `pyproject.toml` before the change lands on `main`. If a release for the tag already exists the CD job skips cleanly; if the tag exists without a release the job fails closed and never moves or reuses the tag for a different revision. +- No PyPI publishing is performed; GitHub Releases are the only distribution channel. +- A GitHub Release records that artifacts were published for a CI-tested revision; it is not installed-host validation and does not prove the updater ran correctly on any host. + ## Development ```bash From 7a05057b3a4c587ba5991baaecc441b5bf8c61e2 Mon Sep 17 00:00:00 2001 From: Steven Welch Date: Tue, 1 Sep 2026 19:16:42 -0600 Subject: [PATCH 2/2] Enforce changelog-driven releases and PR changelog updates - Convert CHANGELOG.md to Keep-a-Changelog format with an empty Unreleased section and a versioned [1.1.0] - 2026-09-02 section. - release.yml: extract exactly the versioned CHANGELOG section and use it as the GitHub Release body via gh release create --notes-file; fail closed before tag/release creation when the section is missing or empty. - ci.yml: add a pull-request-only changelog job that fails unless CHANGELOG.md differs between the PR head and base SHAs. - Add a repository-local pull request template with a required Documentation and changelog section. - Document the policy in AGENTS.md, README.md, and CONTRIBUTING.md. --- .github/PULL_REQUEST_TEMPLATE.md | 34 ++++++++++++++++++++++++++ .github/workflows/ci.yml | 26 ++++++++++++++++++++ .github/workflows/release.yml | 41 +++++++++++++++++++++++++++++++- AGENTS.md | 7 ++++++ CHANGELOG.md | 21 ++++++++++++---- CONTRIBUTING.md | 12 +++++++++- README.md | 9 +++---- 7 files changed, 139 insertions(+), 11 deletions(-) create mode 100644 .github/PULL_REQUEST_TEMPLATE.md diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..b807741 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,34 @@ +## Summary + +Fixes # + +## Type of change + +- [ ] Bug fix +- [ ] Feature / enhancement +- [ ] Documentation +- [ ] Infrastructure (OpenTofu root or module) +- [ ] GitOps desired state (manifests, kustomize, charts, SOPS/KSOPS secrets) +- [ ] Container image +- [ ] CI / reusable workflow +- [ ] Refactor / cleanup +- [ ] Breaking change + +## Documentation and changelog + +- [ ] CHANGELOG.md updated with a reader-ready, user-facing note under `## Unreleased` (CI fails pull requests that do not change the changelog) +- [ ] When changing `version` in `pyproject.toml`, the Unreleased notes are promoted into a `## [] - YYYY-MM-DD` section in the same commit — the release CD publishes exactly that section as the GitHub Release body and fails closed if it is missing or empty +- [ ] N/A — justification given in Summary + +## Validation + +- [ ] Required pull-request checks pass +- [ ] Generated or centrally distributed files were regenerated by their owning automation, not hand-edited + +## Impact and rollout + +## Safety and secrets + +- [ ] Contains no plaintext secrets, decrypted SOPS values, state files, kubeconfigs, tokens, or private endpoints +- [ ] No local OpenTofu init/plan/apply/destroy/import/state operations were run or claimed — plans come from pull-request checks +- [ ] Breaking or irreversible effects are described above with rollback notes diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5dd002d..3dcb8a7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -88,3 +88,29 @@ jobs: - name: Run mypy type checking run: mypy set_dns.py + + changelog: + runs-on: ubuntu-latest + # Changelog enforcement applies only to pull requests. Release publication + # is gated separately by release.yml on the versioned section. + if: github.event_name == 'pull_request' + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + + - name: Require a CHANGELOG.md update in this pull request + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: | + if git diff --quiet "$BASE_SHA" "$HEAD_SHA" -- CHANGELOG.md; then + echo "ERROR: this pull request does not change CHANGELOG.md." >&2 + echo "Every pull request must add a reader-ready, user-facing note under" >&2 + echo "'## Unreleased' in CHANGELOG.md. When the change increases 'version' in" >&2 + echo "pyproject.toml, promote the Unreleased notes into a" >&2 + echo "'## [] - YYYY-MM-DD' section in the same commit; the release CD" >&2 + echo "publishes exactly that versioned section as the GitHub Release body." >&2 + exit 1 + fi + echo "CHANGELOG.md changed between $BASE_SHA and $HEAD_SHA." diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1084187..ddab3d2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -61,6 +61,45 @@ jobs: fi echo "exists=false" >> "$GITHUB_OUTPUT" + # The release body must come from an explicit, versioned changelog + # section. If the heading or its content is missing, fail closed here, + # before any tag or release is created. + - name: Extract the versioned release notes from CHANGELOG.md + id: notes + if: steps.gate.outputs.exists != 'true' + run: | + python - "${{ steps.version.outputs.version }}" <<'PY' + import re + import sys + + version = sys.argv[1] + with open("CHANGELOG.md", encoding="utf-8") as f: + changelog = f.read() + match = re.search( + rf"^## \[{re.escape(version)}\][^\n]*\n(.*?)(?=^## |\Z)", + changelog, + re.MULTILINE | re.DOTALL, + ) + if match is None or not match.group(1).strip(): + print( + f"ERROR: CHANGELOG.md has no '## [{version}]' section with content.", + file=sys.stderr, + ) + print( + "Every pull request must add a user-facing note under '## Unreleased'; " + "when the version in pyproject.toml is increased, those notes must be " + f"promoted into a '## [{version}] - YYYY-MM-DD' section in the same commit. " + "Refusing to create a tag or release without explicit release notes.", + file=sys.stderr, + ) + sys.exit(1) + notes = match.group(1).strip() + with open("RELEASE_NOTES.md", "w", encoding="utf-8") as f: + f.write(notes + "\n") + print(f"Extracted release notes for {version}:") + print(notes) + PY + - name: Build wheel and source distribution if: steps.gate.outputs.exists != 'true' run: | @@ -84,5 +123,5 @@ jobs: --repo "${{ github.repository }}" \ --target "${{ github.event.workflow_run.head_sha }}" \ --title "$tag" \ - --generate-notes \ + --notes-file RELEASE_NOTES.md \ dist/* diff --git a/AGENTS.md b/AGENTS.md index d91b77f..7ba4e82 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,3 +5,10 @@ Python utility for NetworkManager-dispatcher-driven Cloudflare DNS updates. Preserve dispatcher integration, idempotent DNS behavior, and test coverage. Use GitHub MCP and PR CI as validation authority; do not install packages, run local network hooks, or execute live DNS updates from this server. Never expose Cloudflare tokens, API responses containing credentials, host-specific private data, or production DNS values not already intended for public repository content. + +## Changelog and release policy + +- Every pull request must update `CHANGELOG.md` with a reader-ready, user-facing note under `## Unreleased`. CI enforces this and fails pull requests that do not change the changelog. +- When `version` in `pyproject.toml` is increased, promote the accumulated Unreleased notes into a `## [] - YYYY-MM-DD` section in the same commit. +- The release CD extracts exactly the `## []` section and publishes it as the GitHub Release body; it fails closed before creating any tag or release when that section is missing or empty. +- Release publication is artifact publication only. It does not prove installation, host, or DNS behavior; never claim installed-host validation from a published release. diff --git a/CHANGELOG.md b/CHANGELOG.md index 2cb5c1c..b5cd20f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,16 +2,27 @@ All notable changes to CFLAN are documented here. +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +Every pull request must add a reader-ready, user-facing note under `## Unreleased`; +CI fails pull requests that do not change this file. When `version` in +`pyproject.toml` is increased, the accumulated Unreleased notes are promoted into a +`## [] - YYYY-MM-DD` section in the same commit. The release CD extracts +exactly that versioned section and publishes it as the GitHub Release body. + ## Unreleased +## [1.1.0] - 2026-09-02 + ### Added -- GitHub-Release-only CD: a successful `ci` run for a push to `main` automatically creates a GitHub Release tagged `v` (from `pyproject.toml`) with the built wheel, sdist, and `SHA256SUMS` when no release for that version exists. The version must be increased before a new release; a tag that exists without a release fails closed. No PyPI publishing is used, and a release is artifact publication, not installed-host validation. +- Preferred root-volume configuration names `cflan_vars.yaml` and `cflan_sops_vars.yaml`, with `vars.yaml` and `sops_vars.yaml` preserved as root-volume compatibility aliases; no configuration migration is required. +- Non-mutating preflight dry run (`set_dns.py --dry-run [--config PATH]`) that validates root-volume configuration selection and parsing, the resolved local IPv4 address, the dispatcher positional arguments, and the derived FQDN, then prints the intended reconciliation without constructing a Cloudflare client or performing any Cloudflare API call. +- Configuration validation, safer IPv4 checks, duplicate-record protection, package build verification, unit tests for the updater and installer, and public contributor/security guidance. +- GitHub-Release-only CD: a successful `ci` run for a push to `main` automatically creates a GitHub Release tagged `v` (from `pyproject.toml`) with the built wheel, sdist, and `SHA256SUMS` when no release for that version exists. The release body is exactly this changelog section; a missing or empty section fails closed before any tag or release is created, and a tag that exists without a release is never moved or reused. No PyPI publishing is used, and a release is artifact publication, not installed-host validation. ### Changed - Migrated the updater to the supported Cloudflare Python SDK interface. -- Replaced delete-and-create record changes with an in-place Cloudflare PATCH update. -- Added preferred root-volume configuration names: `cflan_vars.yaml` and `cflan_sops_vars.yaml`. -- Preserved `vars.yaml` and `sops_vars.yaml` as root-volume compatibility aliases. -- Added configuration validation, safer IPv4 checks, duplicate-record protection, package build verification, and public contributor/security guidance. +- Replaced delete-and-create record changes with an in-place Cloudflare PATCH update, preserving the record and avoiding an avoidable DNS gap. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1370fec..a239fc0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -15,7 +15,8 @@ Preserve these compatibility contracts unless a change explicitly documents a mi 1. Create a focused branch and add unit tests for behavior changes. 2. Install development dependencies with `python -m pip install '.[dev]'`. 3. Run `pre-commit run --all-files`, `python -m pytest --cov`, `mypy set_dns.py`, and `python -m build`. -4. Open a pull request explaining configuration, DNS, and rollback impact. +4. Add a reader-ready, user-facing note under `## Unreleased` in `CHANGELOG.md` (required; see below). +5. Open a pull request explaining configuration, DNS, and rollback impact. `python3 set_dns.py --dry-run [--config PATH]` is available as a non-mutating preflight for local configuration checks. It never constructs a Cloudflare client or calls the Cloudflare API, so it does not validate Cloudflare credentials. When the selected configuration is SOPS-encrypted (`cflan_sops_vars.yaml` or `sops_vars.yaml`), it does invoke SOPS locally to decrypt the file, so it exercises SOPS and key availability for the invoking user without writing plaintext to disk. It does not install or execute the actual NetworkManager dispatcher hook, and it is not a substitute for CI. @@ -24,3 +25,12 @@ CI is the validation authority. A passing unit-test suite does not prove that a ## Pull requests Keep changes narrow. Document any changed default configuration name, API permission, record behavior, package version, or installed-path contract. Reviewers must be able to determine whether the change is source-only or requires a separate installation step. + +Changelog and release policy: + +- Every pull request must update `CHANGELOG.md` with a reader-ready, user-facing note under `## Unreleased`. CI enforces this and fails any pull request that does not change the changelog. +- When a change increases `version` in `pyproject.toml`, promote the accumulated Unreleased notes into a `## [] - YYYY-MM-DD` section in the same commit. +- The release CD publishes exactly the `## []` section as the GitHub Release body via `gh release create --notes-file`, and fails closed before creating any tag or release when that section is missing or empty. +- Release publication is artifact publication only; it does not prove installation, host, or DNS behavior. + +Fill in every section of the pull request template, including the required `## Documentation and changelog` checklist. diff --git a/README.md b/README.md index 64d981d..b5c996a 100644 --- a/README.md +++ b/README.md @@ -70,12 +70,13 @@ A dry run never constructs a Cloudflare client or calls the Cloudflare API, so i ## Releases -CFLAN uses a GitHub-Release-only CD lifecycle: +CFLAN uses a GitHub-Release-only CD lifecycle with an enforced changelog: +- Every pull request must update [CHANGELOG.md](CHANGELOG.md) with a reader-ready, user-facing note under `## Unreleased`; a dedicated CI job fails any pull request that does not change the changelog. When `version` in `pyproject.toml` is increased, the accumulated Unreleased notes are promoted into a `## [] - YYYY-MM-DD` section in the same commit. - A successful `ci` workflow run for a push to `main` automatically builds the wheel and sdist and creates a GitHub Release tagged `v` from the `version` field in `pyproject.toml`, attaching the built distributions and a `SHA256SUMS` checksum file, when no release for that version exists yet. -- To publish a new release, increase `version` in `pyproject.toml` before the change lands on `main`. If a release for the tag already exists the CD job skips cleanly; if the tag exists without a release the job fails closed and never moves or reuses the tag for a different revision. +- The release body is exactly the `## []` section of `CHANGELOG.md`, extracted at release time and passed to `gh release create --notes-file`. If that heading or its content is absent, the CD job fails closed before any tag or release is created. If a release for the tag already exists the job skips cleanly; if the tag exists without a release the job fails closed and never moves or reuses the tag for a different revision. - No PyPI publishing is performed; GitHub Releases are the only distribution channel. -- A GitHub Release records that artifacts were published for a CI-tested revision; it is not installed-host validation and does not prove the updater ran correctly on any host. +- A GitHub Release records that artifacts were published for a CI-tested revision; it is not installed-host validation and does not prove installation, host, or DNS behavior. ## Development @@ -88,7 +89,7 @@ mypy set_dns.py python -m build ``` -CI runs formatting/linting hooks, unit tests and coverage on Python 3.10–3.13, mypy, and a wheel build/install smoke test. Unit tests do not contact Cloudflare or invoke NetworkManager. +CI runs formatting/linting hooks, unit tests and coverage on Python 3.10–3.13, mypy, a wheel build/install smoke test, and changelog enforcement for pull requests. Unit tests do not contact Cloudflare or invoke NetworkManager. See [CONTRIBUTING.md](CONTRIBUTING.md) and [SECURITY.md](SECURITY.md) before opening an issue or pull request.