Skip to content

Replace direct branch modification with PR-based approach for v2+ rel… #8

Replace direct branch modification with PR-based approach for v2+ rel…

Replace direct branch modification with PR-based approach for v2+ rel… #8

Workflow file for this run

name: Release
run-name: Release ${{ github.event.inputs.version || github.event.inputs.releaseType }}
on:
workflow_dispatch:
inputs:
version:
description: 'Version to release (leave blank for auto-increment)'
required: false
type: string
releaseType:
description: 'Release type'
required: true
type: choice
options:
- patch
- minor
- major
default: 'patch'
skipModuleBump:
description: 'Skip running module bump (used when orchestrated by release-all)'
required: false
type: boolean
default: false
workflow_call:
inputs:
version:
description: 'Version to release (leave blank for auto-increment)'
required: false
type: string
releaseType:
description: 'Release type'
required: true
type: string
skipModuleBump:
description: 'Skip running module bump (used when orchestrated by release-all)'
required: false
type: boolean
outputs:
released_version:
description: 'Version tag produced by the release job'
value: ${{ jobs.release.outputs.released_version }}
jobs:
release:
runs-on: ubuntu-latest
outputs:
released_version: ${{ steps.version.outputs.next_version }}
core_changed: ${{ steps.detect.outputs.core_changed }}
skipped: ${{ steps.detect.outputs.skipped }}
steps:
- name: Checkout code
uses: actions/checkout@v5
with:
fetch-depth: 0
- name: Detect core code changes since last tag
id: detect
shell: bash
run: |
set -euo pipefail
LATEST_TAG=$(git tag -l 'v*' | grep -v '/' | sort -V | tail -n1 || echo '')
echo "Latest core tag: ${LATEST_TAG:-<none>}"
CHANGED=false
if [ -z "$LATEST_TAG" ]; then
# No prior release; treat as changed if any go files or go.mod/go.sum exist (initial release scenario)
if git ls-files '*.go' 'go.mod' 'go.sum' | grep -v '^modules/' | head -n1 >/dev/null 2>&1; then CHANGED=true; fi
else
DIFF=$(git diff --name-only ${LATEST_TAG}..HEAD | grep -v '^modules/' || true)
RELEVANT=""
if [ -n "$DIFF" ]; then
while IFS= read -r f; do
[[ $f == *_test.go ]] && continue
[[ $f == *.md ]] && continue
[[ $f == .github/* ]] && continue
[[ $f == examples/* ]] && continue
if [[ $f == *.go ]] || [[ $f == go.mod ]] || [[ $f == go.sum ]] || [[ $f == ./go.mod ]] || [[ $f == ./go.sum ]]; then
RELEVANT+="$f "
fi
done <<< "$DIFF"
fi
[ -n "$RELEVANT" ] && CHANGED=true || CHANGED=false
echo "Relevant changed files: ${RELEVANT:-<none>}"
fi
echo "core_changed=$CHANGED" >> $GITHUB_OUTPUT
if [ "$CHANGED" != true ]; then
echo "No core changes since last tag; skipping release steps."; echo 'skipped=true' >> $GITHUB_OUTPUT; else echo 'skipped=false' >> $GITHUB_OUTPUT; fi
- name: Set up Go
if: steps.detect.outputs.core_changed == 'true'
uses: actions/setup-go@v6
with:
go-version: '^1.25'
check-latest: true
- name: Build modcli
if: steps.detect.outputs.core_changed == 'true'
run: |
cd cmd/modcli
go build -o modcli
- name: Determine release version (contract-aware)
if: steps.detect.outputs.core_changed == 'true'
id: version
run: |
set -euo pipefail
INPUT_RELEASE_TYPE='${{ github.event.inputs.releaseType }}'
INPUT_MANUAL_VERSION='${{ github.event.inputs.version }}'
echo "Requested releaseType: $INPUT_RELEASE_TYPE"
if [ -n "$INPUT_MANUAL_VERSION" ]; then
echo "Manual version provided: $INPUT_MANUAL_VERSION"
fi
LATEST_TAG=$(git tag -l "v*" | grep -v "/" | sort -V | tail -n1 || echo "")
if [ -z "$LATEST_TAG" ]; then
BASE_VERSION="v0.0.0"; PREV_CONTRACT_REF="";
else
BASE_VERSION="$LATEST_TAG"; PREV_CONTRACT_REF="$LATEST_TAG";
fi
echo "Latest base version: $BASE_VERSION"
echo "base_version=$BASE_VERSION" >> $GITHUB_OUTPUT
mkdir -p artifacts/contracts/prev artifacts/contracts/current artifacts/diffs
if [ -n "$PREV_CONTRACT_REF" ]; then
TMPDIR=$(mktemp -d)
git archive $PREV_CONTRACT_REF | tar -x -C "$TMPDIR"
mkdir -p "$TMPDIR/cmd/modcli" && cp cmd/modcli/modcli "$TMPDIR/cmd/modcli/modcli" || true
(cd "$TMPDIR" && ./cmd/modcli/modcli contract extract . -o core.json) || echo "Failed to extract previous contract"
[ -f "$TMPDIR/core.json" ] && mv "$TMPDIR/core.json" artifacts/contracts/prev/core.json
fi
./cmd/modcli/modcli contract extract . -o artifacts/contracts/current/core.json || echo "Failed to extract current contract"
CHANGE_CLASS="none"; REASON="no contract changes"; BREAKING=0; ADDITIONS=0; MODIFICATIONS=0
DIFF_MD_PATH="artifacts/diffs/core.md"; DIFF_JSON_PATH="artifacts/diffs/core.json"
if [ -f artifacts/contracts/prev/core.json ] && [ -f artifacts/contracts/current/core.json ]; then
echo "Generating contract diffs (markdown + json)..."
# Generate JSON diff first; capture exit for breaking changes detection
if ./cmd/modcli/modcli contract compare artifacts/contracts/prev/core.json artifacts/contracts/current/core.json -o "$DIFF_JSON_PATH" --format=json >/dev/null 2>&1; then
# No breaking changes (exit 0). Generate markdown for additions/modifications detail.
./cmd/modcli/modcli contract compare artifacts/contracts/prev/core.json artifacts/contracts/current/core.json -o "$DIFF_MD_PATH" --format=markdown >/dev/null 2>&1 || true
if [ -s "$DIFF_JSON_PATH" ]; then
# Parse counts via jq if available, else fallback simple grep/length heuristics
if command -v jq >/dev/null 2>&1; then
BREAKING=$(jq '.Summary.TotalBreakingChanges // 0' "$DIFF_JSON_PATH" 2>/dev/null || echo 0)
ADDITIONS=$(jq '.Summary.TotalAdditions // 0' "$DIFF_JSON_PATH" 2>/dev/null || echo 0)
MODIFICATIONS=$(jq '.Summary.TotalModifications // 0' "$DIFF_JSON_PATH" 2>/dev/null || echo 0)
else
# Fallback: count occurrences in JSON text
BREAKING=$(grep -c '"BreakingChanges"\s*:\s*\[' "$DIFF_JSON_PATH" || true)
ADDITIONS=$(grep -c '"AddedItems"\s*:\s*\[' "$DIFF_JSON_PATH" || true)
MODIFICATIONS=$(grep -c '"ModifiedItems"\s*:\s*\[' "$DIFF_JSON_PATH" || true)
fi
fi
else
echo "Breaking changes detected (non-zero exit)"
# Even if breaking, attempt to capture markdown for human-readable diff
./cmd/modcli/modcli contract compare artifacts/contracts/prev/core.json artifacts/contracts/current/core.json -o "$DIFF_MD_PATH" --format=markdown >/dev/null 2>&1 || true
# JSON diff may still have been produced (exit non-zero because breaking). If present parse counts.
if [ -s "$DIFF_JSON_PATH" ]; then
if command -v jq >/dev/null 2>&1; then
BREAKING=$(jq '.Summary.TotalBreakingChanges // 1' "$DIFF_JSON_PATH" 2>/dev/null || echo 1)
ADDITIONS=$(jq '.Summary.TotalAdditions // 0' "$DIFF_JSON_PATH" 2>/dev/null || echo 0)
MODIFICATIONS=$(jq '.Summary.TotalModifications // 0' "$DIFF_JSON_PATH" 2>/dev/null || echo 0)
else
BREAKING=1
fi
else
BREAKING=1
fi
fi
if [ "$BREAKING" -gt 0 ]; then
CHANGE_CLASS="major"; REASON="breaking changes ($BREAKING)";
elif [ "$ADDITIONS" -gt 0 ]; then
CHANGE_CLASS="minor"; REASON="additive changes ($ADDITIONS additions, $MODIFICATIONS modifications)";
else
CHANGE_CLASS="none"; REASON="no API surface changes";
fi
else
echo "No previous contract found; treating as initial state"
CHANGE_CLASS="none"; REASON="initial baseline (no previous contract)";
fi
echo "Contract change classification: $CHANGE_CLASS ($REASON)"
echo "breaking_changes=$BREAKING" >> $GITHUB_OUTPUT
echo "additions=$ADDITIONS" >> $GITHUB_OUTPUT
echo "modifications=$MODIFICATIONS" >> $GITHUB_OUTPUT
CUR=${BASE_VERSION#v}; MAJOR=${CUR%%.*}; REST=${CUR#*.}; MINOR=${REST%%.*}; PATCH=${CUR##*.}
if [ -n "$INPUT_MANUAL_VERSION" ]; then V="$INPUT_MANUAL_VERSION"; [[ $V == v* ]] || V="v$V"; NEXT_VERSION="$V"; REASON="manual override"; else
case "$CHANGE_CLASS" in
major) NEXT_VERSION="v$((MAJOR + 1)).0.0"; REASON="contract breaking change" ;;
minor) if [ "$INPUT_RELEASE_TYPE" = "major" ]; then NEXT_VERSION="v$((MAJOR + 1)).0.0"; REASON="user requested major"; else NEXT_VERSION="v${MAJOR}.$((MINOR + 1)).0"; REASON="contract additive change"; fi ;;
none) if [ "$INPUT_RELEASE_TYPE" = "major" ]; then NEXT_VERSION="v$((MAJOR + 1)).0.0"; REASON="user requested major (no contract change)"; elif [ "$INPUT_RELEASE_TYPE" = "minor" ]; then NEXT_VERSION="v${MAJOR}.$((MINOR + 1)).0"; REASON="user requested minor (no contract change)"; else NEXT_VERSION="v${MAJOR}.${MINOR}.$((PATCH + 1))"; REASON="patch (no contract change)"; fi ;;
esac
fi
echo "next_version=$NEXT_VERSION" >> $GITHUB_OUTPUT
echo "change_class=$CHANGE_CLASS" >> $GITHUB_OUTPUT
echo "reason=$REASON" >> $GITHUB_OUTPUT
echo "Next version: $NEXT_VERSION ($REASON)"
- name: Check go.mod for major version v2+
if: steps.detect.outputs.core_changed == 'true'
id: check_module_path
run: |
set -euo pipefail
VERSION="${{ steps.version.outputs.next_version }}"
# Extract major version from VERSION (e.g., v2.0.0 -> 2)
MAJOR_VERSION="${VERSION#v}"
MAJOR_VERSION="${MAJOR_VERSION%%.*}"
echo "Major version: $MAJOR_VERSION"
echo "major_version=$MAJOR_VERSION" >> $GITHUB_OUTPUT
# Only check go.mod if major version is 2 or greater
if [ "$MAJOR_VERSION" -ge 2 ]; then
GO_MOD_PATH="go.mod"
CURRENT_MODULE_PATH=$(grep "^module " "$GO_MOD_PATH" | awk '{print $2}')
echo "Current module path: $CURRENT_MODULE_PATH"
echo "current_module_path=$CURRENT_MODULE_PATH" >> $GITHUB_OUTPUT
# Check if module path already has version suffix
if [[ "$CURRENT_MODULE_PATH" =~ /v[0-9]+$ ]]; then
# Extract current major version from module path
CURRENT_MAJOR="${CURRENT_MODULE_PATH##*/v}"
echo "Current module path has version suffix: v$CURRENT_MAJOR"
if [ "$CURRENT_MAJOR" -ne "$MAJOR_VERSION" ]; then
echo "ERROR: Module path has /v${CURRENT_MAJOR} but releasing v${MAJOR_VERSION}"
echo "Please manually update module path in ${GO_MOD_PATH} to include /v${MAJOR_VERSION}"
exit 1
fi
echo "Module path already correct for v${MAJOR_VERSION}"
echo "needs_update=false" >> $GITHUB_OUTPUT
else
# No version suffix, need to add it
echo "Module path needs v${MAJOR_VERSION} suffix"
echo "needs_update=true" >> $GITHUB_OUTPUT
NEW_MODULE_PATH="${CURRENT_MODULE_PATH}/v${MAJOR_VERSION}"
echo "new_module_path=$NEW_MODULE_PATH" >> $GITHUB_OUTPUT
fi
else
echo "Major version is $MAJOR_VERSION (< 2), no module path update needed"
echo "needs_update=false" >> $GITHUB_OUTPUT
fi
- name: Create PR for module path update
if: steps.detect.outputs.core_changed == 'true' && steps.check_module_path.outputs.needs_update == 'true'
run: |
set -euo pipefail
VERSION="${{ steps.version.outputs.next_version }}"
MAJOR_VERSION="${{ steps.check_module_path.outputs.major_version }}"
CURRENT_MODULE_PATH="${{ steps.check_module_path.outputs.current_module_path }}"
NEW_MODULE_PATH="${{ steps.check_module_path.outputs.new_module_path }}"
# Create a new branch for the module path update
BRANCH_NAME="chore/update-module-path-v${MAJOR_VERSION}"
git checkout -b "$BRANCH_NAME"
# Update the module path in go.mod
sed -i "s|^module ${CURRENT_MODULE_PATH}|module ${NEW_MODULE_PATH}|" go.mod
# Run go mod tidy to ensure consistency
go mod tidy
# Commit the change
git config user.name 'github-actions[bot]'
git config user.email 'github-actions[bot]@users.noreply.github.com'
git add go.mod go.sum
git commit -m "chore: update module path for v${MAJOR_VERSION}
This updates the module path from:
${CURRENT_MODULE_PATH}
to:
${NEW_MODULE_PATH}
This is required for releasing version ${VERSION} according to Go module versioning requirements.

Check failure on line 277 in .github/workflows/release.yml

View workflow run for this annotation

GitHub Actions / .github/workflows/release.yml

Invalid workflow file

You have an error in your yaml syntax on line 277
See https://go.dev/blog/v2-go-modules for more information."
# Push the branch
git push origin "$BRANCH_NAME"
# Create the PR
gh pr create \
--title "chore: update module path for v${MAJOR_VERSION}" \
--body "## Module Path Update for ${VERSION}
This PR updates the module path in \`go.mod\` to include the \`/v${MAJOR_VERSION}\` suffix as required by Go module versioning for major versions 2 and above.
### Changes:
- **Old module path**: \`${CURRENT_MODULE_PATH}\`
- **New module path**: \`${NEW_MODULE_PATH}\`
### Why This Change Is Needed:
According to Go module versioning requirements, when releasing a major version v2 or higher, the module path must include a version suffix. This ensures proper dependency resolution and prevents conflicts with v0/v1 versions.
Reference: https://go.dev/blog/v2-go-modules
### Next Steps:
1. Review and merge this PR
2. After merging, re-run the release workflow to complete the ${VERSION} release
This PR was automatically created by the release workflow." \
--base "${{ github.ref_name }}" \
--head "$BRANCH_NAME"
echo "✓ Created PR for module path update"
echo ""
echo "============================================"
echo "⚠️ RELEASE PAUSED"
echo "============================================"
echo ""
echo "A PR has been created to update the module path for v${MAJOR_VERSION}."
echo "Please merge the PR, then re-run this release workflow to complete the release."
echo ""
exit 1
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Run tests
if: steps.detect.outputs.core_changed == 'true' && steps.check_module_path.outputs.needs_update != 'true'
run: |
go test -v ./...
- name: Generate changelog
if: steps.detect.outputs.core_changed == 'true' && steps.check_module_path.outputs.needs_update != 'true'
run: |
TAG=${{ steps.version.outputs.next_version }}
CHANGE_CLASS=${{ steps.version.outputs.change_class }}
BASE_VERSION=${{ steps.version.outputs.base_version }}
PREV_TAG="$BASE_VERSION"
if [ "$PREV_TAG" = "v0.0.0" ]; then
echo "No previous tag (initial or first release) – using full history for changelog"
RAW_LOG=$(git log --no-merges --pretty=format:"%H;%s" -- . ':!modules')
else
echo "Using previous tag $PREV_TAG for changelog range"
RAW_LOG=$(git log --no-merges --pretty=format:"%H;%s" ${PREV_TAG}..HEAD -- . ':!modules')
fi
# Deduplicate commit subjects while preserving first occurrence order.
CHANGELOG=$(echo "$RAW_LOG" | awk -F';' 'BEGIN{OFS=""} { if(!seen[$2]++){ print "- " $2 " (" substr($1,1,7) ")" } }')
[ -n "$CHANGELOG" ] || CHANGELOG="- No specific changes to the main library since last release"
{
echo "# Release ${TAG}"; echo; echo "## Changes"; echo; echo "$CHANGELOG"; echo; echo "## API Contract Changes"; echo;
if [ -f artifacts/diffs/core.md ] && [ -s artifacts/diffs/core.md ]; then
case "$CHANGE_CLASS" in
major) echo "⚠️ Breaking changes detected (major bump)."; echo ;;
minor) echo "✅ Additive, backward-compatible changes (minor bump)."; echo ;;
none) echo "ℹ️ No public API surface changes detected."; echo ;;
esac
echo '```diff'
cat artifacts/diffs/core.md
echo '```'
# Also embed the raw JSON diff for direct inspection
if [ -f artifacts/diffs/core.json ] && [ -s artifacts/diffs/core.json ]; then
echo
echo "<details><summary>Raw Contract JSON Diff</summary>"
echo
echo '```json'
if command -v jq >/dev/null 2>&1; then jq . artifacts/diffs/core.json || cat artifacts/diffs/core.json; else cat artifacts/diffs/core.json; fi
echo '```'
echo
echo '</details>'
fi
else
echo "No API contract differences compared to previous release."
fi
} > changelog.md
# Changelog consumed directly by release step; no workflow outputs needed.
- name: Create release
if: steps.detect.outputs.core_changed == 'true' && steps.check_module_path.outputs.needs_update != 'true'
id: create_release
run: |
set -euo pipefail
RELEASE_TAG=${{ steps.version.outputs.next_version }}
# Build core source archive excluding non-library artifacts:
# - modules/ and examples/ (distributed separately)
# - go.work / go.work.sum workspace files
# - scripts/ helper scripts
# - any testdata/ directories
# - CI / tooling config files (.golangci.yml, codecov config, workflows)
# - coverage profiles, bench outputs (*.out)
# - markdown docs except top-level README and LICENSE (keep README.md & LICENSE)
ARCHIVE=modular-${RELEASE_TAG}.tar.gz
git ls-files \
| grep -Ev '^(modules/|examples/)' \
| grep -Ev '^go\.work(\.sum)?$' \
| grep -Ev '^(scripts/)' \
| grep -Ev '(^|/)testdata(/|$)' \
| grep -Ev '^\.github/' \
| grep -Ev '^\.golangci\.yml$' \
| grep -Ev '^codecov\.yml$' \
| grep -Ev '\.(out|coverage)$' \
| grep -Ev '^(CONTRIBUTING\.md|MIGRATION_GUIDE\.md|DOCUMENTATION\.md|CLOUDEVENTS\.md|OBSERVER_PATTERN\.md|API_CONTRACT_MANAGEMENT\.md|CONCURRENCY_GUIDELINES\.md|RECOMMENDED_MODULES\.md)$' \
| tar -czf "$ARCHIVE" -T -
gh release create "$RELEASE_TAG" \
--title "Modular $RELEASE_TAG" \
--notes-file changelog.md \
--repo ${{ github.repository }} \
"$ARCHIVE" \
--latest
# Capture HTML URL (gh release view returns web URL in .url field)
RELEASE_URL=$(gh release view "$RELEASE_TAG" --json url --jq .url)
echo "html_url=$RELEASE_URL" >> $GITHUB_OUTPUT
echo "Release created: $RELEASE_URL"
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Announce to Go proxy
if: steps.detect.outputs.core_changed == 'true' && steps.check_module_path.outputs.needs_update != 'true'
run: |
set -euo pipefail
VERSION=${{ steps.version.outputs.next_version }}
# Extract major version from VERSION
MAJOR_VERSION="${VERSION#v}"
MAJOR_VERSION="${MAJOR_VERSION%%.*}"
# Construct correct module path with version suffix for v2+
if [ "$MAJOR_VERSION" -ge 2 ]; then
MODULE_NAME="github.com/CrisisTextLine/modular/v${MAJOR_VERSION}"
else
MODULE_NAME="github.com/CrisisTextLine/modular"
fi
echo "Announcing ${MODULE_NAME}@${VERSION} to Go proxy..."
GOPROXY=proxy.golang.org go list -m ${MODULE_NAME}@${VERSION}
echo "✓ Announced version ${VERSION} to Go proxy"
bump-modules:
needs: release
if: needs.release.result == 'success' && needs.release.outputs.core_changed == 'true' && inputs.skipModuleBump != true
uses: ./.github/workflows/auto-bump-modules.yml
with:
coreVersion: ${{ needs.release.outputs.released_version }}
secrets:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}