name: Release
on:
push:
tags:
- "v[0-9]+.[0-9]+.[0-9]+"
- "v[0-9]+.[0-9]+.[0-9]+-*"
permissions:
contents: write packages: write id-token: write
env:
CARGO_TERM_COLOR: always
RUST_BACKTRACE: 1
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
test:
name: Test (release gate)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Install Rust stable
uses: dtolnay/rust-toolchain@stable
- name: Cache cargo
uses: actions/cache@v5
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: ${{ runner.os }}-cargo-release-test-${{ hashFiles('**/Cargo.lock') }}
- name: Run tests
run: cargo test --all
build:
name: Build ${{ matrix.target }}
needs: test
runs-on: ${{ matrix.runner }}
permissions:
contents: read
id-token: write attestations: write strategy:
fail-fast: false
matrix:
include:
- target: x86_64-unknown-linux-musl
runner: ubuntu-latest
artifact: nanodns
archive: nanodns-linux-x86_64.tar.gz
- target: aarch64-unknown-linux-musl
runner: ubuntu-latest
artifact: nanodns
archive: nanodns-linux-aarch64.tar.gz
- target: armv7-unknown-linux-musleabihf
runner: ubuntu-latest
artifact: nanodns
archive: nanodns-linux-armv7.tar.gz
- target: x86_64-apple-darwin
runner: macos-latest
artifact: nanodns
archive: nanodns-macos-x86_64.tar.gz
- target: aarch64-apple-darwin
runner: macos-latest
artifact: nanodns
archive: nanodns-macos-aarch64.tar.gz
- target: x86_64-pc-windows-msvc
runner: windows-latest
artifact: nanodns.exe
archive: nanodns-windows-x86_64.zip
steps:
- uses: actions/checkout@v6
- name: Install Rust stable + target
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- name: Install cross (Linux musl targets)
if: contains(matrix.target, 'musl')
uses: taiki-e/install-action@v2
with:
tool: cross
- name: Cache cargo
uses: actions/cache@v5
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: ${{ runner.os }}-cargo-build-${{ matrix.target }}-${{ hashFiles('**/Cargo.lock') }}
restore-keys: ${{ runner.os }}-cargo-build-${{ matrix.target }}-
- name: Set version from tag
shell: bash
run: |
VERSION="${GITHUB_REF_NAME#v}"
echo "RELEASE_VERSION=$VERSION" >> "$GITHUB_ENV"
# Patch Cargo.toml version so --version matches the tag
sed -i.bak "s/^version = \".*\"/version = \"$VERSION\"/" Cargo.toml
- name: Build (cross — musl)
if: contains(matrix.target, 'musl')
run: cross build --release --target ${{ matrix.target }}
- name: Build (cargo — native)
if: "!contains(matrix.target, 'musl')"
run: cargo build --release --target ${{ matrix.target }}
- name: Generate example config
shell: python3 {0}
run: |
import json, pathlib
cfg = {
"server": {
"host": "0.0.0.0", "port": 53,
"upstream": ["8.8.8.8", "1.1.1.1"],
"cache_enabled": True, "cache_ttl": 300, "cache_size": 1000,
"log_level": "INFO", "log_queries": False,
"hot_reload": True, "mgmt_port": 9053, "peers": []
},
"zones": {
"internal.lan": {
"soa": {
"mname": "ns1.internal.lan", "rname": "admin.internal.lan",
"serial": 2024010101, "refresh": 3600, "retry": 900,
"expire": 604800, "minimum": 300
},
"ns": ["ns1.internal.lan"]
}
},
"records": [
{"name": "web.internal.lan", "type": "A", "value": "192.168.1.100", "ttl": 300},
{"name": "db.internal.lan", "type": "A", "value": "192.168.1.101"},
{"name": "api.internal.lan", "type": "CNAME", "value": "web.internal.lan"},
{"name": "internal.lan", "type": "MX", "value": "mail.internal.lan", "priority": 10},
{"name": "*.app.internal.lan", "type": "A", "value": "192.168.1.200", "wildcard": True}
],
"rewrites": [
{"match": "ads.example.com", "action": "nxdomain"},
{"match": "*.tracker.net", "action": "nxdomain"}
],
"version": 0
}
pathlib.Path("nanodns.example.json").write_text(
json.dumps(cfg, indent=2), encoding="utf-8"
)
print("Generated nanodns.example.json")
- name: Package (tar.gz — Unix)
if: runner.os != 'Windows'
shell: bash
run: |
BIN=target/${{ matrix.target }}/release/${{ matrix.artifact }}
strip "$BIN" 2>/dev/null || true
# Collect everything into a staging dir so tar paths are predictable
mkdir -p staging
cp "$BIN" staging/nanodns
cp README.md staging/README.md
cp nanodns.example.json staging/nanodns.example.json
cp nanodns.service staging/nanodns.service
tar czf "${{ matrix.archive }}" -C staging .
rm -rf staging
- name: Package (zip — Windows)
if: runner.os == 'Windows'
shell: pwsh
run: |
$bin = "target\${{ matrix.target }}\release\${{ matrix.artifact }}"
Compress-Archive `
-Path $bin, "README.md", "nanodns.example.json" `
-DestinationPath "${{ matrix.archive }}"
- name: SHA-256 checksum (Unix)
if: runner.os != 'Windows'
run: sha256sum "${{ matrix.archive }}" > "${{ matrix.archive }}.sha256"
- name: SHA-256 checksum (Windows)
if: runner.os == 'Windows'
shell: pwsh
run: |
$hash = (Get-FileHash "${{ matrix.archive }}" -Algorithm SHA256).Hash.ToLower()
"$hash ${{ matrix.archive }}" | Out-File "${{ matrix.archive }}.sha256" -Encoding utf8
- name: Generate artifact attestation
uses: actions/attest-build-provenance@v2
with:
subject-path: ${{ matrix.archive }}
- name: Upload artifact
uses: actions/upload-artifact@v6
with:
name: ${{ matrix.archive }}
path: |
${{ matrix.archive }}
${{ matrix.archive }}.sha256
retention-days: 1
docker:
name: Docker (GHCR)
needs: test
runs-on: ubuntu-latest
permissions:
contents: read
packages: write id-token: write attestations: write steps:
- uses: actions/checkout@v6
- name: Set up QEMU (for ARM builds)
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract Docker metadata (tags + labels)
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
# vX.Y.Z → tag + major.minor + major + latest
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=raw,value=latest,enable=${{ !contains(github.ref_name, '-') }}
# short commit sha for traceability
type=sha,prefix=sha-,format=short
- name: Build and push multi-arch image
id: docker-build
uses: docker/build-push-action@v5
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
build-args: |
VERSION=${{ github.ref_name }}
- name: Install cosign
uses: sigstore/cosign-installer@v3
- name: Sign image with cosign (keyless)
env:
DIGEST: ${{ steps.docker-build.outputs.digest }}
run: |
cosign sign --yes \
"${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${DIGEST}"
- name: Generate Docker image attestation
uses: actions/attest-build-provenance@v2
with:
subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
subject-digest: ${{ steps.docker-build.outputs.digest }}
push-to-registry: true
- name: Output image digest
run: echo "Image digest ${{ steps.docker-build.outputs.digest }}"
changelog:
name: Generate Changelog
runs-on: ubuntu-latest
needs: test
permissions:
contents: read
outputs:
previous-tag: ${{ steps.tags.outputs.previous }}
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Resolve tag range
id: tags
run: |
CURRENT="${GITHUB_REF_NAME}"
PREVIOUS=$(git tag --sort=-version:refname \
| grep -E '^v[0-9]' \
| sed -n '2p')
echo "current=${CURRENT}" >> "$GITHUB_OUTPUT"
echo "previous=${PREVIOUS}" >> "$GITHUB_OUTPUT"
echo "Current : ${CURRENT}"
echo "Previous: ${PREVIOUS:-<first release>}"
- name: Build author map (batch API fetch)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }}
PREV: ${{ steps.tags.outputs.previous }}
run: |
RANGE="${PREV:+${PREV}..}HEAD"
: > /tmp/authors.tsv
git log ${RANGE} --format="%H %h" | while IFS=' ' read -r FULL SHORT; do
LOGIN=$(gh api \
-H "Accept: application/vnd.github+json" \
-H "X-GitHub-Api-Version: 2022-11-28" \
"/repos/${REPO}/commits/${FULL}" \
--jq '.author.login // empty' 2>/dev/null || true)
[ -n "$LOGIN" ] && printf '%s\t@%s\n' "$SHORT" "$LOGIN" >> /tmp/authors.tsv
done
echo "Author map:"
cat /tmp/authors.tsv || true
- name: Write CHANGELOG.md
env:
CURRENT: ${{ steps.tags.outputs.current }}
PREVIOUS: ${{ steps.tags.outputs.previous }}
REPO: ${{ github.repository }}
run: |
RANGE="${PREVIOUS:+${PREVIOUS}..}HEAD"
DATE=$(date +'%Y-%m-%d')
mkdir -p changelog
annotate() {
while IFS= read -r line; do
HASH=$(echo "$line" | grep -oE '\([a-f0-9]{7}\)' | tr -d '()')
AUTHOR=$(grep "^${HASH}" /tmp/authors.tsv 2>/dev/null | cut -f2 || true)
echo "$line${AUTHOR:+ ${AUTHOR}}"
done
}
{
echo "# Changelog"
echo
echo "## ${CURRENT} (${DATE})"
echo
FEATS=$(git log ${RANGE} --pretty=format:"- %s (%h)" --reverse \
| grep -E '^- feat(\([^)]+\))?:' \
| sed -E 's/^- feat(\([^)]+\))?:/- /' | annotate)
[ -n "$FEATS" ] && { echo "### ⭐ New Features"; echo "$FEATS"; echo; }
PERFS=$(git log ${RANGE} --pretty=format:"- %s (%h)" --reverse \
| grep -E '^- (perf|refactor)(\([^)]+\))?:' \
| sed -E 's/^- (perf|refactor)(\([^)]+\))?:/- /' | annotate)
[ -n "$PERFS" ] && { echo "### ⚡️ Optimizations"; echo "$PERFS"; echo; }
FIXES=$(git log ${RANGE} --pretty=format:"- %s (%h)" --reverse \
| grep -E '^- fix(\([^)]+\))?:' \
| sed -E 's/^- fix(\([^)]+\))?:/- /' | annotate)
[ -n "$FIXES" ] && { echo "### 🐞 Bug Fixes"; echo "$FIXES"; echo; }
DOCS=$(git log ${RANGE} --pretty=format:"- %s (%h)" --reverse \
| grep -E '^- docs(\([^)]+\))?:' \
| sed -E 's/^- docs(\([^)]+\))?:/- /' | annotate)
[ -n "$DOCS" ] && { echo "### 📚 Documentation"; echo "$DOCS"; echo; }
DEPS=$(git log ${RANGE} --pretty=format:"- %s (%h)" --reverse \
| grep -E '^- (build|deps)(\([^)]+\))?:' \
| sed -E 's/^- (build|deps)(\([^)]+\))?:/- /' | annotate)
[ -n "$DEPS" ] && { echo "### ⬆️ Dependency Updates"; echo "$DEPS"; echo; }
OTHERS=$(git log ${RANGE} --pretty=format:"- %s (%h)" --reverse \
| grep -vE '^- (feat|fix|docs|refactor|perf|build|deps)(\([^)]+\))?:' \
| annotate)
[ -n "$OTHERS" ] && { echo "### 🔨 Other Changes"; echo "$OTHERS"; echo; }
CONTRIBUTORS=$(cut -f2 /tmp/authors.tsv 2>/dev/null | sort -u | tr '\n' ' ')
[ -n "$CONTRIBUTORS" ] && { echo "### 👥 Contributors"; echo "$CONTRIBUTORS"; echo; }
[ -n "$PREVIOUS" ] && \
echo "[Full Changelog](https://github.com/${REPO}/compare/${PREVIOUS}...${CURRENT})"
} > changelog/CHANGELOG.md
echo "--- CHANGELOG.md ---"
cat changelog/CHANGELOG.md
- uses: actions/upload-artifact@v6
with:
name: changelog
path: changelog/CHANGELOG.md
retention-days: 3
publish-crates:
name: Publish to crates.io
needs: test runs-on: ubuntu-latest
if: "!contains(github.ref_name, '-')"
permissions:
id-token: write contents: read
attestations: write
environment:
name: release url: https://crates.io/crates/nanodns
steps:
- uses: actions/checkout@v6
- name: Install Rust stable
uses: dtolnay/rust-toolchain@stable
- name: Cache cargo registry
uses: actions/cache@v5
with:
path: |
~/.cargo/registry
~/.cargo/git
key: ${{ runner.os }}-cargo-publish-${{ hashFiles('**/Cargo.lock') }}
- name: Verify Cargo.toml version matches tag
shell: bash
run: |
TAG_VERSION="${GITHUB_REF_NAME#v}"
CARGO_VERSION=$(grep '^version' Cargo.toml | head -1 | sed 's/version = "\(.*\)"/\1/')
echo "Tag version : ${TAG_VERSION}"
echo "Cargo version : ${CARGO_VERSION}"
if [ "${TAG_VERSION}" != "${CARGO_VERSION}" ]; then
echo "::error::Version mismatch — Cargo.toml has ${CARGO_VERSION} but tag is ${TAG_VERSION}"
echo "::error::Please bump Cargo.toml version before pushing the tag."
exit 1
fi
- name: Package crate
run: cargo package --no-verify
- name: Generate artifact attestation
uses: actions/attest-build-provenance@v2
with:
subject-path: target/package/*.crate
- name: Authenticate with crates.io (OIDC)
uses: rust-lang/crates-io-auth-action@v1
id: auth
- name: Publish to crates.io
run: cargo publish --no-verify
env:
CARGO_REGISTRY_TOKEN: ${{ steps.auth.outputs.token }}
github-release:
name: GitHub Release
needs: [build, docker, changelog, publish-crates]
if: always() && !contains(needs.*.result, 'failure')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Download binary artifacts
uses: actions/download-artifact@v6
with:
path: dist
merge-multiple: true
pattern: "nanodns-*"
- name: Download changelog artifact
uses: actions/download-artifact@v6
with:
name: changelog
path: changelog
- name: List release assets
run: ls -lh dist/
- name: Merge checksums
run: |
cat dist/*.sha256 | sort > dist/CHECKSUMS.txt
echo "=== CHECKSUMS.txt ==="
cat dist/CHECKSUMS.txt
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
name: "NanoDNS ${{ github.ref_name }}"
body_path: changelog/CHANGELOG.md
draft: false
prerelease: ${{ contains(github.ref_name, '-') }}
files: |
dist/*.tar.gz
dist/*.zip
dist/CHECKSUMS.txt
token: ${{ secrets.GITHUB_TOKEN }}
- name: Annotate release with Docker info
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
IMAGE: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
TAG: ${{ github.ref_name }}
run: |
EXISTING=$(gh release view "$TAG" --json body -q .body)
# Write annotation to a temp file to avoid YAML --- ambiguity
cat > /tmp/docker_note.md << 'DOCKERNOTE'
---
**Docker image** (multi-arch: amd64 / arm64 )
```
docker pull IMAGE_PLACEHOLDER:TAG_PLACEHOLDER
```
Verify signature:
```
cosign verify \
--certificate-identity-regexp='https://github.com/REPO_PLACEHOLDER/.github/workflows/release.yml@refs/tags/.*' \
--certificate-oidc-issuer='https://token.actions.githubusercontent.com' \
IMAGE_PLACEHOLDER:TAG_PLACEHOLDER
```
DOCKERNOTE
REPO="${{ github.repository }}"
sed -i "s|IMAGE_PLACEHOLDER|${IMAGE}|g; s|TAG_PLACEHOLDER|${TAG#v}|g; s|REPO_PLACEHOLDER|${REPO}|g" /tmp/docker_note.md
NOTES="${EXISTING}$(cat /tmp/docker_note.md)"
gh release edit "$TAG" --notes "$NOTES" 2>/dev/null || true