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
7 changes: 7 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
# CI
# --
# Continuous integration pipeline. Runs on push to main and
# pull requests. Validates code quality (vet, govulncheck),
# runs tests with race detection, enforces per-package
# coverage ratchets, and verifies the binary builds.

name: CI

on:
Expand Down
238 changes: 74 additions & 164 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -1,3 +1,20 @@
# Release
# =======
# Release pipeline triggered via workflow_dispatch.
# Delegates to org-infra reusable workflows for preflight
# validation and GoReleaser execution with supply chain
# artifacts (cosign signatures and SBOMs).
#
# Preflight validates semver format, tag uniqueness (with
# re-run resilience), semver ordering, CI check status,
# and unreleased commits before creating an annotated tag.
#
# After release, signs macOS archives with Apple Developer
# ID, notarizes them, patches Homebrew cask checksums, and
# pushes to the Homebrew tap.
#
# Fixes: https://github.com/unbound-force/unbound-force/issues/428

name: Release

on:
Expand All @@ -7,136 +24,59 @@ on:
description: 'Release tag (e.g., v0.2.0)'
required: true
type: string
skip_semver_check:
description: 'Skip semver ordering verification'
type: boolean
default: false
skip_ci_checks:
description: 'Skip CI check verification on HEAD'
type: boolean
default: false
skip_unreleased_check:
description: 'Skip unreleased commits verification'
type: boolean
default: false

permissions: {}

concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false

jobs:
preflight:
runs-on: ubuntu-latest
name: Preflight
uses: complytime/org-infra/.github/workflows/reusable_release_preflight.yml@0c784711926c9864f027ec565fd7c06a382d80f8 # v0.7.1
with:
tag: ${{ inputs.tag }}
allow_prerelease: true
ci_checks: '["Build and Test"]'
skip_semver_check: ${{ inputs.skip_semver_check }}
skip_ci_checks: ${{ inputs.skip_ci_checks }}
skip_unreleased_check: ${{ inputs.skip_unreleased_check }}
permissions:
contents: write
checks: read
timeout-minutes: 10
env:
RELEASE_TAG: ${{ inputs.tag }}
outputs:
has_signing_secrets: ${{ steps.check-secrets.outputs.has_signing_secrets }}
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0

- name: Validate branch
env:
TRIGGER_REF: ${{ github.ref }}
run: |
if [[ "$TRIGGER_REF" != "refs/heads/main" ]]; then
echo "::error::Release must be triggered from main branch, not '$TRIGGER_REF'."
exit 1
fi
echo "Branch validation passed: triggered from main."

- name: Validate tag format
run: |
if ! echo "$RELEASE_TAG" | grep -qE '^v[0-9]+\.[0-9]+\.[0-9]+$'; then
echo "::error::Invalid tag format: '$RELEASE_TAG'. Must match vMAJOR.MINOR.PATCH (e.g., v0.2.0)."
exit 1
fi
echo "Tag format valid: $RELEASE_TAG"

- name: Check tag uniqueness
run: |
REMOTE_REF=$(git ls-remote --tags origin "refs/tags/${RELEASE_TAG}" | awk '{print $1}')
if [ -n "$REMOTE_REF" ]; then
HEAD_SHA=$(git rev-parse HEAD)
if [ "$REMOTE_REF" = "$HEAD_SHA" ]; then
echo "Tag '$RELEASE_TAG' already exists and points to HEAD (re-run case). Continuing."
else
echo "::error::Tag '$RELEASE_TAG' already exists and points to a different commit. Choose a different version."
exit 1
fi
else
echo "Tag '$RELEASE_TAG' does not exist yet."
fi

- name: Verify semver ordering
run: |
LATEST=$(git tag -l 'v[0-9]*' --sort=-v:refname | head -1)
if [ -z "$LATEST" ]; then
echo "No existing tags found. First release."
exit 0
fi
echo "Latest existing tag: $LATEST"
# Compare using sort -V: if TAG sorts after LATEST, it is greater
HIGHER=$(printf '%s\n%s' "$LATEST" "$RELEASE_TAG" | sort -V | tail -1)
if [ "$HIGHER" = "$LATEST" ]; then
echo "::error::Tag '$RELEASE_TAG' is not greater than latest release '$LATEST'."
exit 1
fi
echo "Version ordering valid: $RELEASE_TAG > $LATEST"

- name: Verify CI passed on HEAD
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
run: |
HEAD_SHA=$(git rev-parse HEAD)
echo "Checking CI status for commit $HEAD_SHA"

REQUIRED_CHECKS=(
"Build and Test"
)

for CHECK_NAME in "${REQUIRED_CHECKS[@]}"; do
STATUS=$(gh api "repos/${GH_REPO}/commits/${HEAD_SHA}/check-runs" \
--jq ".check_runs[] | select(.name == \"${CHECK_NAME}\") | .conclusion" \
2>/dev/null | head -1)
if [ "$STATUS" != "success" ]; then
echo "::error::Required check '${CHECK_NAME}' has not passed (status: ${STATUS:-not found}). Push to main and wait for CI before releasing."
exit 1
fi
echo " ✓ ${CHECK_NAME}: success"
done

echo "All required CI checks passed."

- name: Verify unreleased commits
run: |
LATEST=$(git tag -l 'v[0-9]*' --sort=-v:refname | head -1)
if [ -z "$LATEST" ]; then
COUNT=$(git rev-list --count HEAD)
else
COUNT=$(git rev-list --count "${LATEST}..HEAD")
fi
if [ "$COUNT" -eq 0 ]; then
echo "::error::No unreleased commits since ${LATEST:-initial commit}. Nothing to release."
exit 1
fi
echo "$COUNT commit(s) since ${LATEST:-initial commit}."

- name: Create and push tag
run: |
# Skip if tag was already created (e.g., re-run after
# partial failure or manual tag via GitHub API).
if git ls-remote --tags origin | grep -q "refs/tags/${RELEASE_TAG}$"; then
echo "Tag $RELEASE_TAG already exists, skipping creation."
exit 0
fi
# Annotated tags require a committer identity on the
# CI runner (git tag -a uses GIT_COMMITTER_NAME/EMAIL).
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git tag -a "$RELEASE_TAG" -m "$RELEASE_TAG"
git push origin "$RELEASE_TAG"
echo "Created and pushed tag: $RELEASE_TAG"
release:
name: Release
needs: preflight
if: needs.preflight.outputs.tag != ''
uses: complytime/org-infra/.github/workflows/reusable_release_goreleaser.yml@0c784711926c9864f027ec565fd7c06a382d80f8 # v0.7.1
with:
tag: ${{ needs.preflight.outputs.tag }}
permissions:
contents: write
id-token: write

check-signing-secrets:
name: Check Signing Secrets
needs: preflight
if: needs.preflight.outputs.tag != ''
runs-on: ubuntu-latest
permissions: {}
timeout-minutes: 5
outputs:
has_signing_secrets: ${{ steps.check.outputs.has_signing_secrets }}
steps:
- name: Check signing secrets
id: check-secrets
id: check
run: |
if [ -n "$MACOS_SIGN_P12" ]; then
echo "has_signing_secrets=true" >> "$GITHUB_OUTPUT"
Expand All @@ -146,57 +86,16 @@ jobs:
env:
MACOS_SIGN_P12: ${{ secrets.MACOS_SIGN_P12 }}

release:
runs-on: ubuntu-latest
needs: preflight
permissions:
contents: write
id-token: write
timeout-minutes: 45
env:
RELEASE_TAG: ${{ inputs.tag }}
outputs:
has_signing_secrets: ${{ needs.preflight.outputs.has_signing_secrets }}
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
ref: ${{ inputs.tag }}

- name: Set up Go
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version-file: go.mod

- name: Run GoReleaser
uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7.2.3
with:
distribution: goreleaser
version: 'v2.14.1'
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GORELEASER_CURRENT_TAG: ${{ inputs.tag }}

- name: Upload generated cask
run: |
gh release upload "$RELEASE_TAG" \
--repo "$GITHUB_REPOSITORY" \
dist/homebrew/Casks/replicator.rb \
--clobber
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

sign-macos:
name: Sign macOS
runs-on: macos-latest
needs: release
if: ${{ needs.release.outputs.has_signing_secrets == 'true' }}
needs: [preflight, release, check-signing-secrets]
if: needs.check-signing-secrets.outputs.has_signing_secrets == 'true'
permissions:
contents: write
timeout-minutes: 30
env:
RELEASE_TAG: ${{ inputs.tag }}
RELEASE_TAG: ${{ needs.preflight.outputs.tag }}
steps:
- name: Import certificate into Keychain
run: |
Expand Down Expand Up @@ -318,6 +217,11 @@ jobs:
' "$CASK_FILE" > "${CASK_FILE}.patched"
mv "${CASK_FILE}.patched" "$CASK_FILE"

if ! grep -q "$ARM64_SHA" "$CASK_FILE"; then
echo "::error::SHA patching failed — new SHA not found in cask file"
exit 1
fi

git clone "https://x-access-token:${HOMEBREW_TAP_GITHUB_TOKEN}@github.com/unbound-force/homebrew-tap.git" tap
cp "$CASK_FILE" tap/Casks/replicator.rb

Expand All @@ -331,3 +235,9 @@ jobs:
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
HOMEBREW_TAP_GITHUB_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}

- name: Cleanup signing materials
if: always()
run: |
security delete-keychain "$RUNNER_TEMP/app-signing.keychain-db" 2>/dev/null || true
rm -f "$RUNNER_TEMP/cert.p12" "$RUNNER_TEMP/notary_key.p8"
4 changes: 4 additions & 0 deletions .goreleaser.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ archives:
checksum:
name_template: checksums.txt

release:
extra_files:
- glob: dist/homebrew/Casks/replicator.rb

changelog:
sort: asc
use: github
Expand Down
Loading