Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions .github/PULL_REQUEST_TEMPLATE.md
Original file line number Diff line number Diff line change
@@ -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 `## [<version>] - 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
26 changes: 26 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 "'## [<version>] - 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."
127 changes: 127 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
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"

# 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: |
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" \
--notes-file RELEASE_NOTES.md \
dist/*
7 changes: 7 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `## [<version>] - YYYY-MM-DD` section in the same commit.
- The release CD extracts exactly the `## [<version>]` 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.
23 changes: 19 additions & 4 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +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
`## [<version>] - 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

- 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<version>` (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.
12 changes: 11 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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 `## [<version>] - YYYY-MM-DD` section in the same commit.
- The release CD publishes exactly the `## [<version>]` 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.
12 changes: 11 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,16 @@ 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 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 `## [<version>] - 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<version>` from the `version` field in `pyproject.toml`, attaching the built distributions and a `SHA256SUMS` checksum file, when no release for that version exists yet.
- The release body is exactly the `## [<version>]` 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 installation, host, or DNS behavior.

## Development

```bash
Expand All @@ -79,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.

Expand Down
Loading