Skip to content
Closed
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
62 changes: 62 additions & 0 deletions .github/workflows/pr_conventional_title.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
name: "PR: Conventional title"

# Enforce Conventional Commits on the PR TITLE (issue #4829). PRs are
# squash-merged into `develop`, and GitHub uses the PR title as the squash
# commit subject, so the title is what later drives version bumping (svu) and
# the release changelog (GoReleaser).
#
# Only PRs targeting `develop` are checked: `main` only receives the
# `develop -> main` merge commit, which is not a conventional commit.
#
# Provider-specific changes use the "p/<name>" scope, e.g.
# fix(p/route53): ... feat(p/azure_dns): ...
# Scopes are otherwise free-form (we validate the type, not the scope), so
# there is no per-provider list to maintain.

on:
pull_request:
types:
- opened
- edited
- reopened
- synchronize

permissions:
contents: read

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:
conventional-title:
name: Validate PR title
# Only gate PRs into develop. PRs into main (the release merge) are exempt.
if: github.base_ref == 'develop'
runs-on: ubuntu-latest
steps:
- name: Check that the PR title is a Conventional Commit
uses: amannn/action-semantic-pull-request@v6
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
# Allowed commit types. A trailing "!" (e.g. feat!:) marks a breaking
# change and is always allowed.
types: |
feat
fix
docs
chore
ci
build
refactor
perf
test
revert
# A scope is optional (global changes have none). When present it is
# free-form; provider changes should use "p/<name>".
requireScope: false
# Subject must be non-empty and not start with an uppercase letter is
# NOT enforced here to keep the barrier low; type correctness is what
# the release automation depends on.
validateSingleCommit: false
86 changes: 86 additions & 0 deletions .github/workflows/release_gate.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
name: "RELEASE gate: full integration"

# The release gate for the `develop -> main` merge (issue #4829).
#
# Releases happen when a human merges `develop -> main`. That merge is
# infrequent (~weekly), so we can afford to run the ENTIRE longtest
# integration suite here as a required check before the merge is allowed.
#
# Emergency bypass: add the `skip-longtest` label to the PR. The gate then
# reports success WITHOUT running the suite, but records a loud warning and a
# job-summary note so the skip is auditable.
#
# Make the `release-gate` job a REQUIRED status check on `main` in branch
# protection. It is a single job that always runs and reflects either the
# longtest result or the emergency skip, so branch protection never gets stuck
# on a skipped check.

on:
pull_request:
branches:
- main
types:
- opened
- reopened
- synchronize
- labeled
- unlabeled

permissions:
contents: read

concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
cancel-in-progress: true

jobs:
# Decide whether the emergency bypass label is present.
guard:
if: github.repository == 'DNSControl/dnscontrol'
runs-on: ubuntu-latest
outputs:
skip: ${{ steps.decide.outputs.skip }}
steps:
- id: decide
env:
HAS_LABEL: ${{ contains(github.event.pull_request.labels.*.name, 'skip-longtest') }}
run: echo "skip=$HAS_LABEL" >> "$GITHUB_OUTPUT"

# Run the full integration suite unless the bypass label is set.
longtest:
needs: guard
if: needs.guard.outputs.skip == 'false'
uses: ./.github/workflows/pr_integration_tests.yml
with:
full: true
secrets: inherit

# The single required check. Always runs; green if tests passed OR the
# emergency skip was requested; red if the suite failed.
release-gate:
needs: [guard, longtest]
if: always() && github.repository == 'DNSControl/dnscontrol'
runs-on: ubuntu-latest
steps:
- name: Evaluate gate
env:
SKIP: ${{ needs.guard.outputs.skip }}
LONGTEST_RESULT: ${{ needs.longtest.result }}
run: |
set -eu
if [ "$SKIP" = "true" ]; then
echo "::warning title=longtest SKIPPED::Release longtest gate was skipped via the 'skip-longtest' label on PR #${{ github.event.pull_request.number }}."
{
echo "## ⚠️ Release longtest gate SKIPPED"
echo ""
echo "The full integration suite was **not run** because the \`skip-longtest\` label is present on this PR."
echo "This should only be used for emergencies."
} >> "$GITHUB_STEP_SUMMARY"
exit 0
fi
if [ "$LONGTEST_RESULT" = "success" ]; then
echo "Full longtest integration suite passed."
exit 0
fi
echo "::error title=longtest failed::Release gate failed (longtest result: ${LONGTEST_RESULT})."
exit 1
179 changes: 179 additions & 0 deletions .github/workflows/release_on_merge.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
name: "RELEASE: auto-release on merge to main"

# Fully-automated release (issue #4829).
#
# Development happens on `develop`; `main` only ever receives a `develop -> main`
# merge. So any push to `main` means "release". This workflow:
#
# 1. Computes the next version from Conventional Commits with `svu`
# (feat -> minor, fix/perf -> patch, `!`/BREAKING CHANGE -> major;
# chore/ci/docs/etc. -> no bump).
# 2. If nothing releasable landed since the last tag, it does nothing.
# 3. Otherwise it tags `vX.Y.Z` and runs GoReleaser, which publishes the
# release (draft: false in .goreleaser.yml).
#
# The tag is pushed with GITHUB_TOKEN. GitHub deliberately does NOT re-trigger
# workflows for GITHUB_TOKEN tag pushes, so this job runs GoReleaser itself
# rather than relying on the tag-push path in release_draft.yml (which remains
# the manual escape hatch). There is therefore no double release.

on:
push:
branches:
- main

permissions:
contents: write
packages: write

concurrency:
# Never cancel an in-flight release; serialize if two merges land quickly.
group: release-on-merge
cancel-in-progress: false

jobs:
# version: compute the next semver and, if there is one, create the tag.
version:
name: compute version
# Safety switch for rollout: this workflow is inert until the repository
# variable AUTO_RELEASE_ENABLED is set to "true"
# (Settings -> Secrets and variables -> Actions -> Variables). That lets the
# automation land on main without cutting a release, and lets you turn it
# off instantly if something goes wrong.
if: github.repository == 'DNSControl/dnscontrol' && vars.AUTO_RELEASE_ENABLED == 'true'
runs-on: ubuntu-latest
outputs:
release: ${{ steps.svu.outputs.release }}
version: ${{ steps.svu.outputs.version }}
steps:
- name: Checkout repo
uses: actions/checkout@v7
with:
fetch-depth: 0

- name: Set up Go
uses: actions/setup-go@v7
with:
go-version: "stable"

- name: Compute next version with svu
id: svu
run: |
set -eu
go install github.com/caarlos0/svu/v3@latest
SVU="$(go env GOPATH)/bin/svu"
CURRENT="$("$SVU" current)"
NEXT="$("$SVU" next)"
echo "current=$CURRENT next=$NEXT"
if [ "$NEXT" = "$CURRENT" ]; then
echo "No releasable commits since ${CURRENT} (only chore/ci/docs/etc.). Nothing to release."
echo "release=false" >> "$GITHUB_OUTPUT"
{
echo "## No release"
echo ""
echo "No \`feat\`/\`fix\`/breaking commits since \`${CURRENT}\`, so no version was cut."
} >> "$GITHUB_STEP_SUMMARY"
else
echo "release=true" >> "$GITHUB_OUTPUT"
echo "version=$NEXT" >> "$GITHUB_OUTPUT"
{
echo "## Releasing ${NEXT}"
echo ""
echo "Previous release: \`${CURRENT}\`"
} >> "$GITHUB_STEP_SUMMARY"
fi

- name: Create and push tag
if: steps.svu.outputs.release == 'true'
env:
VERSION: ${{ steps.svu.outputs.version }}
run: |
set -eu
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git tag -a "$VERSION" -m "Release ${VERSION}"
git push origin "refs/tags/${VERSION}"

# release: build and publish with GoReleaser (only if a version was cut).
release:
name: goreleaser
needs: version
if: needs.version.outputs.release == 'true'
runs-on: ubuntu-latest
permissions:
contents: write
packages: write
steps:
- name: Set up QEMU
uses: docker/setup-qemu-action@v4

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4

- name: Checkout repo
uses: actions/checkout@v7
with:
fetch-depth: 0
# Build from the tag we just created.
ref: ${{ needs.version.outputs.version }}

- name: Login to Docker Hub
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_ACCESS_TOKEN }}

- name: Login to GitHub Container Registry
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: Set up Go
uses: actions/setup-go@v7
with:
go-version: "stable"

# Stringer is needed because .goreleaser includes "go generate ./...".
- name: Install stringer
run: go install golang.org/x/tools/cmd/stringer@latest

# Compute the changelog range: current = the tag we just cut; previous =
# the most recent non-rc release older than it (so notes cover the full
# range since the last stable release). Mirrors release_draft.yml.
- name: Compute changelog previous/current tags
env:
CURRENT_TAG: ${{ needs.version.outputs.version }}
run: |
set -eu
cur="$CURRENT_TAG"
echo "GORELEASER_CURRENT_TAG=$cur" >> "$GITHUB_ENV"
base="${cur%%-*}" # strip any -rc suffix
prev="$(
{ git tag --list 'v[0-9]*.[0-9]*.[0-9]*'; echo "$base"; } \
| awk '!/-/' \
| sort -V -u \
| awk -v base="$base" '$0==base{print prev; exit} {prev=$0}'
)"
if [ -n "$prev" ]; then
echo "GORELEASER_PREVIOUS_TAG=$prev" >> "$GITHUB_ENV"
echo "current=$cur previous=$prev (most recent non-rc release)"
else
echo "No prior non-rc release found for $cur; letting GoReleaser auto-detect."
fi

- name: Goreleaser release
uses: goreleaser/goreleaser-action@v7
with:
distribution: goreleaser
version: latest
args: release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
MACOS_SIGN_P12: ${{ secrets.MACOS_SIGN_P12 }}
MACOS_SIGN_PASSWORD: ${{ secrets.MACOS_SIGN_PASSWORD }}
MACOS_NOTARY_ISSUER_ID: ${{ secrets.MACOS_NOTARY_ISSUER_ID }}
MACOS_NOTARY_KEY_ID: ${{ secrets.MACOS_NOTARY_KEY_ID }}
MACOS_NOTARY_KEY: ${{ secrets.MACOS_NOTARY_KEY }}
44 changes: 29 additions & 15 deletions .goreleaser.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,30 +29,42 @@ before:
changelog:
sort: asc
use: github
# Grouping is driven by Conventional Commits (see issue #4829). Two rules to
# know when editing this list:
# 1. A commit lands in the FIRST group (by list order) whose regexp
# matches; `order:` only controls the DISPLAY position. So the
# provider-specific group must be LISTED before the generic feat/fix
# groups, and the catch-all "Other" group must be LISTED last.
# 2. Provider-specific changes are identified by the "p/<name>" scope,
# e.g. "fix(p/route53): ...". This means there is NO per-provider list
# to maintain here: adding a new provider needs no changes to this file.
groups:
- title: 'Breaking changes:'
regexp: "(?i)^.*breaking[(\\w)]*:+.*$"
regexp: '(?i)^[a-z]+(\([^)]*\))?!:|breaking change'
order: 0
- title: 'Major features:'
regexp: "(?i)^.*(major|new provider|feature)[(\\w)]*:+.*$"
order: 1
- title: 'Provider-specific changes:'
regexp: "(?i)((adguardhome|akamaiedgedns|alidns|autodns|axfrddns|azure_dns|azure_private_dns|azuredns|bind|bunny_dns|bunnydns|cloudflare|cloudflareapi|cloudns|cnr|cscglobal|desec|digitalocean|dnscale|dnsimple|dnsmadeeasy|dnsoverhttps|doh|domainnameshop|dynadot|dynu|easyname|exoscale|fortigate|gandi|gandi_v5|gcloud|gcore|gidinet|gigahost|hedns|hetzner_v2|hexonet|hostingde|huaweicloud|infomaniak|internetbs|inwx|joker|linode|loopia|luadns|mikrotik|mythicbeasts|namecheap|namedotcom|netbird|netcup|netlify|netnod|nexdns|ns1|opensrs|openwrt|oracle|ovh|packetframe|porkbun|powerdns|realtimeregister|route53|rwth|sakuracloud|scaleway|softlayer|tencentdns|transip|unifi|vercel|vultr|websupport).*:)+.*"
regexp: '(?i)^(feat|fix|refactor|perf)\(p/[^)]+\):'
order: 2
- title: 'Documentation:'
regexp: "(?i)^.*(docs)[(\\w)]*:+.*$"
- title: 'New features:'
regexp: '(?i)^feat(\([^)]*\))?:'
order: 1
- title: 'Bug fixes:'
regexp: '(?i)^fix(\([^)]*\))?:'
order: 3
- title: 'CI/CD:'
regexp: "(?i)^.*(build|ci|cicd)[(\\w)]*:+.*$"
- title: 'Documentation:'
regexp: '(?i)^docs(\([^)]*\))?:'
order: 4
- title: 'Dependencies:'
regexp: "(?i)^.*\\b(deps|dependencies)\\b.*$"
- title: 'CI/CD:'
regexp: '(?i)^(build|ci)(\([^)]*\))?:'
order: 5
- title: 'Dependencies:'
regexp: '(?i)^\w+\([^)]*deps[^)]*\):|\bdependencies\b'
order: 6
- title: 'Deprecation warnings:'
regexp: '(?i)deprecate'
order: 8
- title: 'Other changes and improvements:'
order: 9
- title: 'Deprecation warnings:'
regexp: "(?i)^.*Deprecate[(\\w)]*:+.*$"
order: 10
filters:
exclude:
- '^test:'
Expand Down Expand Up @@ -146,7 +158,9 @@ homebrew_casks:
token: "{{ .Env.HOMEBREW_TAP_TOKEN }}"

release:
draft: true
# Fully automated releases (issue #4829): publish immediately, no draft to
# hand-edit. The human gate is the `develop -> main` merge, not note editing.
draft: false
prerelease: auto
mode: append
header: |
Expand Down
Loading
Loading