Skip to content

Release

Release #51

Workflow file for this run

name: Release
on:
push:
tags:
- 'v*.*.*'
- 'v*.*.*-*'
- 'extensions/*/v*.*.*'
- 'extensions/*/v*.*.*-*'
workflow_dispatch:
inputs:
module:
description: 'Module to release'
required: true
type: choice
options:
- forge
- cli
- all
- ai
- auth
- cache
- consensus
- cron
- database
- discovery
- events
- features
- gateway
- graphql
- grpc
- hls
- kafka
- mcp
- mqtt
- orpc
- queue
- search
- security
- storage
- streaming
- webrtc
version:
description: 'Semantic version to release (e.g. 1.2.3 or 1.0.0-beta.1)'
required: true
type: string
dry_run:
description: 'Dry run — validate everything but do not publish'
required: false
type: boolean
default: false
skip_tests:
description: 'Skip tests (use for hotfixes when tests already passed)'
required: false
type: boolean
default: false
prerelease:
description: 'Mark as a pre-release'
required: false
type: boolean
default: false
permissions:
contents: write
packages: write
id-token: write
defaults:
run:
shell: bash
# -------------------------------------------------------------------
# Job: detect
# Normalises inputs from both tag-push and workflow_dispatch triggers
# into a single set of outputs consumed by downstream jobs.
# -------------------------------------------------------------------
jobs:
detect:
name: Detect release parameters
runs-on: ubuntu-latest
outputs:
module_type: ${{ steps.resolve.outputs.module_type }}
module_name: ${{ steps.resolve.outputs.module_name }}
module_path: ${{ steps.resolve.outputs.module_path }}
module_import: ${{ steps.resolve.outputs.module_import }}
version: ${{ steps.resolve.outputs.version }}
tag: ${{ steps.resolve.outputs.tag }}
is_prerelease: ${{ steps.resolve.outputs.is_prerelease }}
dry_run: ${{ steps.resolve.outputs.dry_run }}
skip_tests: ${{ steps.resolve.outputs.skip_tests }}
is_all: ${{ steps.resolve.outputs.is_all }}
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Resolve release parameters
id: resolve
run: |
DRY_RUN="false"
SKIP_TESTS="false"
IS_ALL="false"
# ---- workflow_dispatch path ----
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
MODULE="${{ inputs.module }}"
VERSION="${{ inputs.version }}"
DRY_RUN="${{ inputs.dry_run }}"
SKIP_TESTS="${{ inputs.skip_tests }}"
IS_PRERELEASE="${{ inputs.prerelease }}"
# Validate semver
if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?$ ]]; then
echo "::error::Invalid semantic version: $VERSION (expected N.N.N or N.N.N-pre)"
exit 1
fi
if [ "$MODULE" = "all" ]; then
# "all" releases the main module + CLI + every extension at the same version
IS_ALL="true"
MODULE_TYPE="main"
MODULE_NAME="forge"
MODULE_PATH="."
MODULE_IMPORT="github.com/xraph/forge"
TAG="v${VERSION}"
elif [ "$MODULE" = "forge" ]; then
MODULE_TYPE="main"
MODULE_NAME="forge"
MODULE_PATH="."
MODULE_IMPORT="github.com/xraph/forge"
TAG="v${VERSION}"
elif [ "$MODULE" = "cli" ]; then
# "cli" releases only the CLI binary (GoReleaser) — no Go module publish
MODULE_TYPE="cli"
MODULE_NAME="forge-cli"
MODULE_PATH="cmd/forge"
MODULE_IMPORT="github.com/xraph/forge/cmd/forge"
TAG="v${VERSION}"
else
MODULE_TYPE="extension"
MODULE_NAME="$MODULE"
MODULE_PATH="extensions/$MODULE"
MODULE_IMPORT="github.com/xraph/forge/extensions/$MODULE"
TAG="extensions/${MODULE}/v${VERSION}"
fi
# Auto-detect prerelease from version string
if [[ "$VERSION" == *"-"* ]]; then
IS_PRERELEASE="true"
fi
# ---- tag push path ----
else
TAG="${GITHUB_REF#refs/tags/}"
if [[ "$TAG" =~ ^extensions/([^/]+)/v(.+)$ ]]; then
MODULE_TYPE="extension"
MODULE_NAME="${BASH_REMATCH[1]}"
VERSION="${BASH_REMATCH[2]}"
MODULE_PATH="extensions/$MODULE_NAME"
MODULE_IMPORT="github.com/xraph/forge/extensions/$MODULE_NAME"
elif [[ "$TAG" =~ ^v(.+)$ ]]; then
MODULE_TYPE="main"
MODULE_NAME="forge"
VERSION="${BASH_REMATCH[1]}"
MODULE_PATH="."
MODULE_IMPORT="github.com/xraph/forge"
else
echo "::error::Unrecognised tag format: $TAG"
exit 1
fi
if [[ "$VERSION" == *"-"* ]]; then
IS_PRERELEASE="true"
else
IS_PRERELEASE="false"
fi
fi
# Verify module directory exists
if [ ! -f "$MODULE_PATH/go.mod" ]; then
echo "::error::go.mod not found at $MODULE_PATH"
exit 1
fi
# Emit outputs
{
echo "module_type=$MODULE_TYPE"
echo "module_name=$MODULE_NAME"
echo "module_path=$MODULE_PATH"
echo "module_import=$MODULE_IMPORT"
echo "version=$VERSION"
echo "tag=$TAG"
echo "is_prerelease=$IS_PRERELEASE"
echo "dry_run=$DRY_RUN"
echo "skip_tests=$SKIP_TESTS"
echo "is_all=$IS_ALL"
} >> "$GITHUB_OUTPUT"
echo "### Release Parameters" >> "$GITHUB_STEP_SUMMARY"
echo "| Key | Value |" >> "$GITHUB_STEP_SUMMARY"
echo "|-----|-------|" >> "$GITHUB_STEP_SUMMARY"
echo "| Module | \`$MODULE_NAME\` ($MODULE_TYPE) |" >> "$GITHUB_STEP_SUMMARY"
echo "| Version | \`$VERSION\` |" >> "$GITHUB_STEP_SUMMARY"
echo "| Tag | \`$TAG\` |" >> "$GITHUB_STEP_SUMMARY"
echo "| Import | \`$MODULE_IMPORT\` |" >> "$GITHUB_STEP_SUMMARY"
echo "| Pre-release | $IS_PRERELEASE |" >> "$GITHUB_STEP_SUMMARY"
echo "| Dry run | $DRY_RUN |" >> "$GITHUB_STEP_SUMMARY"
echo "| Skip tests | $SKIP_TESTS |" >> "$GITHUB_STEP_SUMMARY"
echo "| Release all | $IS_ALL |" >> "$GITHUB_STEP_SUMMARY"
# For manual dispatch, create & push the main tag if it doesn't exist
- name: Create tag (manual dispatch)
if: github.event_name == 'workflow_dispatch' && steps.resolve.outputs.dry_run == 'false'
run: |
TAG="${{ steps.resolve.outputs.tag }}"
if git rev-parse "$TAG" >/dev/null 2>&1; then
echo "Tag $TAG already exists — skipping creation"
else
git tag "$TAG"
git push origin "$TAG"
echo "Created and pushed tag $TAG"
fi
# For "all" mode, also create tags for every extension module
- name: Create extension tags (all mode)
if: github.event_name == 'workflow_dispatch' && steps.resolve.outputs.is_all == 'true' && steps.resolve.outputs.dry_run == 'false'
run: |
VERSION="${{ steps.resolve.outputs.version }}"
EXTENSIONS=(ai auth cache consensus cron database discovery events features gateway graphql grpc hls kafka mcp mqtt orpc queue search security storage streaming webrtc)
for ext in "${EXTENSIONS[@]}"; do
EXT_TAG="extensions/${ext}/v${VERSION}"
if git rev-parse "$EXT_TAG" >/dev/null 2>&1; then
echo "Tag $EXT_TAG already exists — skipping"
else
git tag "$EXT_TAG"
echo "Created tag $EXT_TAG"
fi
done
# Push all new tags in one go
git push origin --tags
echo "Pushed all extension tags"
# -------------------------------------------------------------------
# Job: test
# Runs tests for the module being released (skippable).
# -------------------------------------------------------------------
test:
name: Test (${{ matrix.os }})
needs: detect
if: needs.detect.outputs.skip_tests == 'false'
runs-on: ${{ matrix.os }}
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest]
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: ${{ needs.detect.outputs.module_path }}/go.mod
check-latest: true
- name: Cache Go modules
uses: actions/cache@v4
with:
path: |
~/go/pkg/mod
~/.cache/go-build
key: ${{ runner.os }}-go-${{ needs.detect.outputs.module_name }}-${{ hashFiles(format('{0}/go.sum', needs.detect.outputs.module_path)) }}
restore-keys: |
${{ runner.os }}-go-${{ needs.detect.outputs.module_name }}-
- name: Run tests
run: |
cd ${{ needs.detect.outputs.module_path }}
go mod download
PKGS=$(go list ./... | grep -v '/bk/')
go test -v -race -timeout=15m $PKGS
# -------------------------------------------------------------------
# Job: release-cli
# Runs GoReleaser for main module releases (CLI binaries + Docker).
# Uses the existing .goreleaser.yml without modifications.
# -------------------------------------------------------------------
release-cli:
name: Release CLI (GoReleaser)
needs: [detect, test]
if: |
always() &&
(needs.detect.outputs.module_type == 'main' || needs.detect.outputs.module_type == 'cli') &&
needs.detect.outputs.dry_run == 'false' &&
needs.detect.result == 'success' &&
(needs.test.result == 'success' || needs.test.result == 'skipped')
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v6
with:
distribution: goreleaser
version: '~> v2'
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.GORELEASER_TOKEN || secrets.GITHUB_TOKEN }}
FURY_TOKEN: ${{ secrets.FURY_TOKEN || '' }}
AUR_KEY: ${{ secrets.AUR_KEY || '' }}
# -------------------------------------------------------------------
# Job: release-module
# Creates a GitHub release and notifies the Go proxy.
# Runs for ALL release types (main & extension).
# For main, this runs in addition to release-cli.
# For extensions, this is the only release step.
# -------------------------------------------------------------------
release-module:
name: Publish Go module
needs: [detect, test]
if: |
always() &&
needs.detect.outputs.module_type != 'cli' &&
needs.detect.outputs.dry_run == 'false' &&
needs.detect.result == 'success' &&
(needs.test.result == 'success' || needs.test.result == 'skipped')
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
# Only create a GH release for extensions — GoReleaser handles main
- name: Create GitHub Release (extensions)
if: needs.detect.outputs.module_type == 'extension'
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ needs.detect.outputs.tag }}
name: "${{ needs.detect.outputs.module_name }} v${{ needs.detect.outputs.version }}"
body: |
## ${{ needs.detect.outputs.module_name }} v${{ needs.detect.outputs.version }}
**Module**: `${{ needs.detect.outputs.module_import }}`
### Installation
```bash
go get ${{ needs.detect.outputs.module_import }}@${{ needs.detect.outputs.tag }}
```
### Documentation
- [pkg.go.dev](https://pkg.go.dev/${{ needs.detect.outputs.module_import }}@${{ needs.detect.outputs.tag }})
draft: false
prerelease: ${{ needs.detect.outputs.is_prerelease == 'true' }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Notify Go proxy
run: |
MODULE="${{ needs.detect.outputs.module_import }}"
TAG="${{ needs.detect.outputs.tag }}"
echo "Requesting Go proxy to cache $MODULE@$TAG"
sleep 5
curl -sf "https://proxy.golang.org/${MODULE}/@v/${TAG}.info" || \
echo "Go proxy notification failed (normal for private repos)"
# -------------------------------------------------------------------
# Job: dry-run
# Validates the release pipeline without publishing anything.
# -------------------------------------------------------------------
dry-run:
name: Dry run
needs: [detect, test]
if: |
always() &&
needs.detect.outputs.dry_run == 'true' &&
needs.detect.result == 'success' &&
(needs.test.result == 'success' || needs.test.result == 'skipped')
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: ${{ needs.detect.outputs.module_path }}/go.mod
- name: Validate module builds
run: |
cd ${{ needs.detect.outputs.module_path }}
go build ./...
- name: GoReleaser dry run (main/cli only)
if: needs.detect.outputs.module_type == 'main' || needs.detect.outputs.module_type == 'cli'
uses: goreleaser/goreleaser-action@v6
with:
distribution: goreleaser
version: '~> v2'
args: release --snapshot --clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Dry run summary
run: |
echo "### Dry Run Complete" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "Module **${{ needs.detect.outputs.module_name }}** v${{ needs.detect.outputs.version }} validated successfully." >> "$GITHUB_STEP_SUMMARY"
echo "No artifacts were published." >> "$GITHUB_STEP_SUMMARY"
# -------------------------------------------------------------------
# Job: summary
# Provides a unified release summary.
# -------------------------------------------------------------------
summary:
name: Release summary
needs: [detect, test, release-cli, release-module, dry-run]
if: always()
runs-on: ubuntu-latest
steps:
- name: Generate summary
run: |
MODULE="${{ needs.detect.outputs.module_name }}"
VERSION="${{ needs.detect.outputs.version }}"
TAG="${{ needs.detect.outputs.tag }}"
TYPE="${{ needs.detect.outputs.module_type }}"
IMPORT="${{ needs.detect.outputs.module_import }}"
DRY="${{ needs.detect.outputs.dry_run }}"
{
echo "# Release Summary"
echo ""
if [ "$DRY" = "true" ]; then
echo "> **Dry run** — nothing was published."
echo ""
fi
echo "| Key | Value |"
echo "|-----|-------|"
echo "| Module | \`$MODULE\` ($TYPE) |"
echo "| Version | \`$VERSION\` |"
echo "| Tag | \`$TAG\` |"
if [ "$DRY" != "true" ]; then
echo ""
echo "## Installation"
echo ""
if [ "$TYPE" = "main" ] || [ "$TYPE" = "cli" ]; then
echo '```bash'
echo "go install github.com/xraph/forge/cmd/forge@${TAG}"
echo '```'
else
echo '```bash'
echo "go get ${IMPORT}@${TAG}"
echo '```'
fi
fi
echo ""
echo "## Job Results"
echo ""
echo "| Job | Status |"
echo "|-----|--------|"
echo "| Detect | ${{ needs.detect.result }} |"
echo "| Test | ${{ needs.test.result }} |"
echo "| CLI Release | ${{ needs.release-cli.result }} |"
echo "| Module Release | ${{ needs.release-module.result }} |"
echo "| Dry Run | ${{ needs.dry-run.result }} |"
} >> "$GITHUB_STEP_SUMMARY"
# -------------------------------------------------------------------
# Job: update-changelog
# Generates changelog from git log and commits to main branch.
# Only runs for main module releases (not extensions or dry runs).
# -------------------------------------------------------------------
update-changelog:
name: Update CHANGELOG.md
needs: [detect, test, release-cli, release-module]
if: |
always() &&
needs.detect.outputs.module_type == 'main' &&
needs.detect.outputs.dry_run == 'false' &&
needs.detect.result == 'success' &&
(needs.release-cli.result == 'success' || needs.release-cli.result == 'skipped') &&
(needs.release-module.result == 'success' || needs.release-module.result == 'skipped')
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
ref: main
token: ${{ secrets.GITHUB_TOKEN }}
- name: Generate changelog entry
run: |
VERSION="${{ needs.detect.outputs.version }}"
TAG="${{ needs.detect.outputs.tag }}"
DATE=$(date -u +%Y-%m-%d)
# Find previous tag for this module
PREV_TAG=$(git tag --sort=-v:refname | grep -E '^v[0-9]' | awk -v t="$TAG" 'found{print;exit} $0==t{found=1}')
if [ -z "$PREV_TAG" ]; then
echo "No previous tag found — skipping changelog generation"
echo "SKIP_CHANGELOG=true" >> "$GITHUB_ENV"
exit 0
fi
echo "Generating changelog for $TAG (since $PREV_TAG)"
# Collect commits between tags (exclude extensions/ and docs/ directories)
COMMITS=$(git log --oneline --no-merges "$PREV_TAG..$TAG" -- . ':!extensions' ':!docs' 2>/dev/null || true)
if [ -z "$COMMITS" ]; then
echo "No commits found — creating minimal entry"
COMMITS="* Minor updates and improvements"
fi
# Categorise commits by conventional commit type
FEATURES=""
FIXES=""
REFACTORS=""
BREAKING=""
MAINTENANCE=""
while IFS= read -r line; do
[ -z "$line" ] && continue
HASH=$(echo "$line" | awk '{print $1}')
MSG=$(echo "$line" | cut -d' ' -f2-)
case "$MSG" in
feat*)
SCOPE=$(echo "$MSG" | sed -n 's/^feat(\([^)]*\)).*/\1/p')
DESC=$(echo "$MSG" | sed 's/^feat([^)]*): //' | sed 's/^feat: //')
if [ -n "$SCOPE" ]; then
FEATURES="${FEATURES}* **${SCOPE}:** ${DESC} ([${HASH}](https://github.com/xraph/forge/commit/${HASH}))\n"
else
FEATURES="${FEATURES}* ${DESC} ([${HASH}](https://github.com/xraph/forge/commit/${HASH}))\n"
fi
;;
fix*)
SCOPE=$(echo "$MSG" | sed -n 's/^fix(\([^)]*\)).*/\1/p')
DESC=$(echo "$MSG" | sed 's/^fix([^)]*): //' | sed 's/^fix: //')
if [ -n "$SCOPE" ]; then
FIXES="${FIXES}* **${SCOPE}:** ${DESC} ([${HASH}](https://github.com/xraph/forge/commit/${HASH}))\n"
else
FIXES="${FIXES}* ${DESC} ([${HASH}](https://github.com/xraph/forge/commit/${HASH}))\n"
fi
;;
refactor*)
SCOPE=$(echo "$MSG" | sed -n 's/^refactor(\([^)]*\)).*/\1/p')
DESC=$(echo "$MSG" | sed 's/^refactor([^)]*): //' | sed 's/^refactor: //')
if [ -n "$SCOPE" ]; then
REFACTORS="${REFACTORS}* **${SCOPE}:** ${DESC} ([${HASH}](https://github.com/xraph/forge/commit/${HASH}))\n"
else
REFACTORS="${REFACTORS}* ${DESC} ([${HASH}](https://github.com/xraph/forge/commit/${HASH}))\n"
fi
;;
*"BREAKING"*|*"breaking"*)
BREAKING="${BREAKING}* ${MSG}\n"
;;
*)
SCOPE=$(echo "$MSG" | sed -n 's/^[a-z]*(\([^)]*\)).*/\1/p')
DESC=$(echo "$MSG" | sed 's/^[a-z]*([^)]*): //' | sed 's/^[a-z]*: //')
if [ -n "$SCOPE" ]; then
MAINTENANCE="${MAINTENANCE}* **${SCOPE}:** ${DESC} ([${HASH}](https://github.com/xraph/forge/commit/${HASH}))\n"
else
MAINTENANCE="${MAINTENANCE}* ${DESC} ([${HASH}](https://github.com/xraph/forge/commit/${HASH}))\n"
fi
;;
esac
done <<< "$COMMITS"
# Build the changelog entry
ENTRY="## [${VERSION}](https://github.com/xraph/forge/compare/${PREV_TAG}...v${VERSION}) (${DATE})\n"
if [ -n "$BREAKING" ]; then
ENTRY="${ENTRY}\n\n### ⚠ BREAKING CHANGES\n\n${BREAKING}"
fi
if [ -n "$FEATURES" ]; then
ENTRY="${ENTRY}\n\n### Features\n\n${FEATURES}"
fi
if [ -n "$FIXES" ]; then
ENTRY="${ENTRY}\n\n### Bug Fixes\n\n${FIXES}"
fi
if [ -n "$REFACTORS" ]; then
ENTRY="${ENTRY}\n\n### Refactoring\n\n${REFACTORS}"
fi
if [ -n "$MAINTENANCE" ]; then
ENTRY="${ENTRY}\n\n### Maintenance\n\n${MAINTENANCE}"
fi
# Check if this version already exists in the changelog
if grep -q "## \[${VERSION}\]" CHANGELOG.md; then
echo "Version ${VERSION} already in CHANGELOG.md — skipping"
echo "SKIP_CHANGELOG=true" >> "$GITHUB_ENV"
exit 0
fi
# Prepend to CHANGELOG.md (after the # Changelog header)
TMPFILE=$(mktemp)
echo "# Changelog" > "$TMPFILE"
echo "" >> "$TMPFILE"
printf "${ENTRY}\n" >> "$TMPFILE"
# Append existing content without the header
tail -n +3 CHANGELOG.md >> "$TMPFILE"
mv "$TMPFILE" CHANGELOG.md
echo "SKIP_CHANGELOG=false" >> "$GITHUB_ENV"
echo "Changelog entry generated for v${VERSION}"
- name: Commit and push changelog
if: env.SKIP_CHANGELOG != 'true'
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add CHANGELOG.md
git commit -m "docs(changelog): update CHANGELOG.md for v${{ needs.detect.outputs.version }}"
git push origin main