Skip to content

chore(release): @uniswap/[email protected]

chore(release): @uniswap/[email protected] #482

name: Publish Packages
# Unified publishing workflow that handles both CI (automatic) and force (manual) publishing.
# This consolidation is required due to npm OIDC trusted publishing constraints - the workflow_ref
# in the OIDC token must match the configured trusted workflow.
#
# MODES:
# - Auto (CI): Triggered on push to main/next, detects affected packages, publishes, generates
# changelog, sends notifications, and syncs branches.
# - Force: Manually triggered, publishes user-specified packages with prerelease versions.
#
# TRIGGERS:
# - Push to main: Auto mode with 'latest' npm tag, conventional versioning
# - Push to next: Auto mode with 'next' npm tag, prerelease versioning
# - workflow_dispatch: Force mode with 'next' npm tag, prerelease versioning
#
# WORKFLOW CHANGE DETECTION:
# In addition to package detection, this workflow also detects changes to reusable workflow
# files (files prefixed with '_' in .github/workflows/). When workflow files change:
# - The 'next' branch is synced with 'main' to propagate workflow updates
# - A Slack notification is sent about the workflow changes
# - These actions happen even when no npm packages need to be published
#
# REQUIRED SECRETS:
# - WORKFLOW_PAT: For pushing commits/tags
# - NODE_AUTH_TOKEN: For npm publishing
# - SERVICE_ACCOUNT_GPG_PRIVATE_KEY: For signing commits/tags
# - ANTHROPIC_API_KEY: For AI-powered changelog generation (auto mode only)
# - SLACK_WEBHOOK_URL: For release notifications (auto mode only)
# - NOTION_API_KEY: For Notion publishing (auto mode only)
# - RELEASE_NOTES_NOTION_DATABASE_ID: For Notion database (auto mode only)
on:
push:
branches:
- main
- next
workflow_dispatch:
inputs:
packages:
description: 'Packages to publish (single name, comma-separated list, or "all")'
required: true
default: "all"
type: string
dryRun:
description: "Perform a dry run without publishing"
required: false
default: "false"
type: choice
options:
- "true"
- "false"
# Ensure only one publish workflow runs at a time per branch
concurrency:
group: publish-${{ github.ref }}
cancel-in-progress: false
jobs:
# ============================================================================
# DETECT: Determine which packages to publish
# ============================================================================
# For push events: Uses Nx affected detection
# For workflow_dispatch: Uses user-specified packages (force mode)
# ============================================================================
detect:
name: Detect packages to publish
runs-on: ubuntu-24.04
environment: ${{ github.event_name == 'push' && 'Production' || null }}
outputs:
has_packages: ${{ steps.resolve.outputs.has_packages }}
projects: ${{ steps.resolve.outputs.projects }}
packages: ${{ steps.resolve.outputs.packages }}
package_count: ${{ steps.resolve.outputs.package_count }}
base_sha: ${{ steps.base-sha.outputs.base }}
npm_tag: ${{ steps.config.outputs.npm_tag }}
version_strategy: ${{ steps.config.outputs.version_strategy }}
is_dry_run: ${{ steps.config.outputs.is_dry_run }}
is_force_mode: ${{ steps.config.outputs.is_force_mode }}
is_prerelease: ${{ steps.config.outputs.is_prerelease }}
has_workflow_changes: ${{ steps.workflow-changes.outputs.has_changes }}
changed_workflows: ${{ steps.workflow-changes.outputs.changed_files }}
should_continue: ${{ steps.resolve.outputs.has_packages == 'true' || steps.workflow-changes.outputs.has_changes == 'true' }}
# Skip workflow for version bump commits and branch sync commits (prevents loops)
if: ${{ !startsWith(github.event.head_commit.message, 'chore(release):') && !startsWith(github.event.head_commit.message, 'chore(sync):') }}
steps:
- uses: bullfrogsec/bullfrog@1831f79cce8ad602eef14d2163873f27081ebfb3 # v0.8.4
- name: Checkout repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with:
node-version: ${{ vars.NODE_VERSION }}
registry-url: "https://registry.npmjs.org"
scope: "@uniswap"
- name: Install npm
run: npm install -g npm@${{ vars.NPM_VERSION }}
- name: Install dependencies
run: npm ci --prefer-offline --no-audit
- name: Determine workflow configuration
id: config
env:
EVENT_NAME: ${{ github.event_name }}
BRANCH_NAME: ${{ github.ref_name }}
DRY_RUN_INPUT: ${{ github.event.inputs.dryRun }}
run: |
# Determine if this is force mode (manual dispatch) or auto mode (push)
if [[ "$EVENT_NAME" == "workflow_dispatch" ]]; then
echo "is_force_mode=true" >> $GITHUB_OUTPUT
echo "📦 Mode: Force publish (manual trigger)"
# Force mode always uses next tag and prerelease versioning
echo "npm_tag=next" >> $GITHUB_OUTPUT
echo "version_strategy=prerelease" >> $GITHUB_OUTPUT
echo "is_prerelease=true" >> $GITHUB_OUTPUT
# Dry run based on user input
if [[ "$DRY_RUN_INPUT" == "true" ]]; then
echo "is_dry_run=true" >> $GITHUB_OUTPUT
echo "🔍 Dry run mode enabled"
else
echo "is_dry_run=false" >> $GITHUB_OUTPUT
fi
else
echo "is_force_mode=false" >> $GITHUB_OUTPUT
echo "🚀 Mode: Auto publish (CI trigger)"
echo "is_dry_run=false" >> $GITHUB_OUTPUT
# Auto mode configuration based on branch
if [[ "$BRANCH_NAME" == "next" ]]; then
echo "npm_tag=next" >> $GITHUB_OUTPUT
echo "version_strategy=prerelease" >> $GITHUB_OUTPUT
echo "is_prerelease=true" >> $GITHUB_OUTPUT
echo "📦 Branch: next → npm tag: next, prerelease versioning"
else
echo "npm_tag=latest" >> $GITHUB_OUTPUT
echo "version_strategy=conventional" >> $GITHUB_OUTPUT
echo "is_prerelease=false" >> $GITHUB_OUTPUT
echo "📦 Branch: main → npm tag: latest, conventional versioning"
fi
fi
- name: Validate branch (force mode only)
if: github.event_name == 'workflow_dispatch'
env:
BRANCH_NAME: ${{ github.ref_name }}
run: |
if [[ "$BRANCH_NAME" != "next" ]]; then
echo "❌ ERROR: Force publish is only allowed on the 'next' branch"
echo "Current branch: $BRANCH_NAME"
echo "This workflow publishes with the 'next' npm tag and should only run on the next branch."
exit 1
fi
echo "✅ Branch validation passed - running on 'next' branch"
- name: Determine base SHA for affected detection
id: base-sha
if: github.event_name == 'push'
env:
BEFORE_SHA: ${{ github.event.before }}
run: |
BASE_SHA="$BEFORE_SHA"
# Handle edge case: first push to a new branch (null SHA)
if [[ "$BASE_SHA" == "0000000000000000000000000000000000000000" ]]; then
echo "First push to branch - using HEAD~1 as base"
echo "base=HEAD~1" >> $GITHUB_OUTPUT
else
echo "Using commit before push: $BASE_SHA"
echo "base=$BASE_SHA" >> $GITHUB_OUTPUT
fi
- name: Detect reusable workflow changes
id: workflow-changes
if: github.event_name == 'push'
env:
BASE_SHA: ${{ steps.base-sha.outputs.base }}
run: |
echo "🔍 Checking for reusable workflow changes..."
echo "Base SHA: $BASE_SHA"
echo "Head SHA: HEAD"
# Get list of changed files matching the reusable workflow pattern
CHANGED_WORKFLOWS=$(git diff --name-only "$BASE_SHA" HEAD -- '.github/workflows/_*.yml' 2>/dev/null || echo "")
if [ -n "$CHANGED_WORKFLOWS" ]; then
echo "✅ Found reusable workflow changes:"
echo "$CHANGED_WORKFLOWS" | while read -r file; do
echo " - $file"
done
# Output as JSON array for downstream consumption
CHANGED_JSON=$(echo "$CHANGED_WORKFLOWS" | jq -R . | jq -s -c .)
echo "has_changes=true" >> $GITHUB_OUTPUT
echo "changed_files=$CHANGED_JSON" >> $GITHUB_OUTPUT
echo "change_count=$(echo "$CHANGED_WORKFLOWS" | wc -l | tr -d ' ')" >> $GITHUB_OUTPUT
else
echo "ℹ️ No reusable workflow changes detected"
echo "has_changes=false" >> $GITHUB_OUTPUT
echo "changed_files=[]" >> $GITHUB_OUTPUT
echo "change_count=0" >> $GITHUB_OUTPUT
fi
- name: Resolve packages to publish
id: resolve
env:
EVENT_NAME: ${{ github.event_name }}
INPUT_PACKAGES: ${{ github.event.inputs.packages }}
BASE_SHA: ${{ steps.base-sha.outputs.base }}
run: |
# Get all release-configured packages from nx.json
echo "Scanning for release-configured packages..."
ALL_PACKAGES=$(npx nx show projects --json 2>/dev/null | jq -r '.[]' | while read project; do
PROJECT_ROOT=$(npx nx show project "$project" --json 2>/dev/null | jq -r '.root' || echo "")
if [ -n "$PROJECT_ROOT" ] && [ -f "$PROJECT_ROOT/package.json" ]; then
IS_PRIVATE=$(jq -r '.private // false' "$PROJECT_ROOT/package.json")
if [ "$IS_PRIVATE" != "true" ]; then
PACKAGE_NAME=$(jq -r '.name // ""' "$PROJECT_ROOT/package.json")
if [ -n "$PACKAGE_NAME" ]; then
# Check if this package is in the nx.json release.projects array
if jq -e --arg pkg "$PACKAGE_NAME" '.release.projects | index($pkg)' nx.json > /dev/null 2>&1; then
echo "$project:$PACKAGE_NAME"
fi
fi
fi
fi
done)
if [[ "$EVENT_NAME" == "workflow_dispatch" ]]; then
# FORCE MODE: Use user-specified packages
echo "🔧 Force mode: resolving user-specified packages"
echo "Input packages: \"$INPUT_PACKAGES\""
if [[ "$INPUT_PACKAGES" == "all" ]]; then
# Use all release-configured packages
SELECTED_PROJECTS=$(echo "$ALL_PACKAGES" | cut -d: -f1 | tr '\n' ',' | sed 's/,$//')
SELECTED_PACKAGES=$(echo "$ALL_PACKAGES" | cut -d: -f2 | tr '\n' ',' | sed 's/,$//')
else
# Parse comma-separated input and match to projects
SELECTED_PROJECTS=""
SELECTED_PACKAGES=""
IFS=',' read -ra INPUT_PKG_ARRAY <<< "$INPUT_PACKAGES"
for input_pkg in "${INPUT_PKG_ARRAY[@]}"; do
# Trim whitespace
input_pkg=$(echo "$input_pkg" | xargs)
# Find the matching project
MATCH=$(echo "$ALL_PACKAGES" | grep ":${input_pkg}$" || echo "")
if [ -z "$MATCH" ]; then
echo "❌ ERROR: Package '$input_pkg' not found in release configuration"
echo "Available packages:"
echo "$ALL_PACKAGES" | cut -d: -f2
exit 1
fi
PROJECT=$(echo "$MATCH" | cut -d: -f1)
PACKAGE=$(echo "$MATCH" | cut -d: -f2)
if [ -n "$SELECTED_PROJECTS" ]; then
SELECTED_PROJECTS="${SELECTED_PROJECTS},${PROJECT}"
SELECTED_PACKAGES="${SELECTED_PACKAGES},${PACKAGE}"
else
SELECTED_PROJECTS="$PROJECT"
SELECTED_PACKAGES="$PACKAGE"
fi
done
fi
else
# AUTO MODE: Use Nx affected detection
echo "🔍 Auto mode: detecting affected packages"
echo "Running: npx nx show projects --affected --base=$BASE_SHA --head=HEAD --type=lib --json"
# Capture output and check if it's valid JSON
RAW_OUTPUT=$(NODE_NO_WARNINGS=1 npx nx show projects --affected --base="$BASE_SHA" --head=HEAD --type=lib --json 2>&1 || echo "[]")
# Filter out npm warnings
AFFECTED_OUTPUT=$(echo "$RAW_OUTPUT" | grep -v '^npm warn' || echo "[]")
# Check if output is valid JSON
if echo "$AFFECTED_OUTPUT" | jq empty 2>/dev/null; then
AFFECTED_JSON="$AFFECTED_OUTPUT"
else
echo "⚠️ Nx output is not valid JSON. Using empty array as fallback"
AFFECTED_JSON="[]"
fi
# Filter to only publishable packages
SELECTED_PROJECTS=""
SELECTED_PACKAGES=""
for project in $(echo "$AFFECTED_JSON" | jq -r '.[]'); do
PROJECT_ROOT=$(npx nx show project "$project" --json 2>/dev/null | jq -r '.root' || echo "")
if [ -n "$PROJECT_ROOT" ] && [ -f "$PROJECT_ROOT/package.json" ]; then
IS_PRIVATE=$(jq -r '.private // false' "$PROJECT_ROOT/package.json")
if [ "$IS_PRIVATE" = "true" ]; then
echo " ⏭️ Skipping $project (private: true)"
else
PACKAGE_NAME=$(jq -r '.name // ""' "$PROJECT_ROOT/package.json")
if [ -n "$PACKAGE_NAME" ]; then
echo " ✅ Including $project → $PACKAGE_NAME (publishable)"
if [ -n "$SELECTED_PROJECTS" ]; then
SELECTED_PROJECTS="${SELECTED_PROJECTS},${project}"
SELECTED_PACKAGES="${SELECTED_PACKAGES},${PACKAGE_NAME}"
else
SELECTED_PROJECTS="$project"
SELECTED_PACKAGES="$PACKAGE_NAME"
fi
fi
fi
fi
done
fi
echo "Selected Nx projects: $SELECTED_PROJECTS"
echo "Selected npm packages: $SELECTED_PACKAGES"
# Count packages
if [ -z "$SELECTED_PACKAGES" ]; then
PACKAGE_COUNT=0
else
PACKAGE_COUNT=$(echo "$SELECTED_PACKAGES" | tr ',' '\n' | grep -c '.' || echo "0")
fi
echo "projects=$SELECTED_PROJECTS" >> $GITHUB_OUTPUT
echo "packages=$SELECTED_PACKAGES" >> $GITHUB_OUTPUT
echo "package_count=$PACKAGE_COUNT" >> $GITHUB_OUTPUT
if [ "$PACKAGE_COUNT" -eq "0" ]; then
echo "has_packages=false" >> $GITHUB_OUTPUT
echo "ℹ️ No packages to publish"
else
echo "has_packages=true" >> $GITHUB_OUTPUT
echo "✅ Found $PACKAGE_COUNT package(s) to publish"
fi
# ============================================================================
# PUBLISH: Build, version, and publish packages to npm
# ============================================================================
publish:
name: Build and publish packages
runs-on:
group: npm-deploy
environment: Production
needs: detect
if: needs.detect.outputs.has_packages == 'true'
permissions:
id-token: write
contents: write
packages: write
pull-requests: write
issues: write
outputs:
successful_packages: ${{ steps.publish.outputs.successful_packages }}
failed_packages: ${{ steps.publish.outputs.failed_packages }}
successful_count: ${{ steps.publish.outputs.successful_count }}
failed_count: ${{ steps.publish.outputs.failed_count }}
has_failures: ${{ steps.publish.outputs.has_failures }}
has_successes: ${{ steps.publish.outputs.has_successes }}
steps:
- uses: bullfrogsec/bullfrog@1831f79cce8ad602eef14d2163873f27081ebfb3 # v0.8.4
- name: Import GPG key
uses: crazy-max/ghaction-import-gpg@e89d40939c28e39f97cf32126055eeae86ba74ec # v6
with:
gpg_private_key: ${{ secrets.SERVICE_ACCOUNT_GPG_PRIVATE_KEY }}
git_user_signingkey: true
git_commit_gpgsign: true
git_tag_gpgsign: true
git_config_global: true
- name: Checkout repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
fetch-depth: 0
token: ${{ secrets.WORKFLOW_PAT }}
- name: Setup Node.js
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with:
node-version: ${{ vars.NODE_VERSION }}
registry-url: "https://registry.npmjs.org"
scope: "@uniswap"
- name: Install npm
run: npm install -g npm@${{ vars.NPM_VERSION }}
- name: Install dependencies
run: npm ci --prefer-offline --no-audit
- name: Verify package-lock.json unchanged
run: |
if ! git diff --exit-code package-lock.json; then
echo "❌ ERROR: npm ci modified package-lock.json!"
echo "This should not happen. Please investigate why npm ci is modifying the lock file."
git diff package-lock.json
exit 1
fi
echo "✅ package-lock.json unchanged after npm ci"
- name: Configure Git identity for service account
run: |
git config user.name "Uniswap Labs Service Account"
git config user.email "[email protected]"
- name: Verify GPG configuration
run: |
echo "GPG signing enabled: $(git config commit.gpgsign)"
echo "GPG signing key: $(git config user.signingkey)"
echo "Git user name: $(git config user.name)"
echo "Git user email: $(git config user.email)"
- name: Build packages
env:
INPUT_PROJECTS: ${{ needs.detect.outputs.projects }}
INPUT_BASE_SHA: ${{ needs.detect.outputs.base_sha }}
run: |
echo "Building packages: $INPUT_PROJECTS"
if [ -n "$INPUT_BASE_SHA" ]; then
echo "Using affected build with base: $INPUT_BASE_SHA"
npx nx affected --target=build --base="$INPUT_BASE_SHA" --head=HEAD
else
IFS=',' read -ra PROJECT_ARRAY <<< "$INPUT_PROJECTS"
for project in "${PROJECT_ARRAY[@]}"; do
echo "Building $project..."
npx nx build "$project"
done
fi
- name: Clean up orphaned tags (auto mode only)
if: github.event_name == 'push' && needs.detect.outputs.is_dry_run != 'true'
env:
INPUT_PACKAGES: ${{ needs.detect.outputs.packages }}
GITHUB_TOKEN: ${{ secrets.WORKFLOW_PAT }}
NODE_AUTH_TOKEN: ${{ secrets.NODE_AUTH_TOKEN }}
INPUT_BRANCH: ${{ github.ref_name }}
INPUT_NPM_TAG: ${{ needs.detect.outputs.npm_tag }}
run: |
# Configure npm authentication for viewing restricted packages
echo "//registry.npmjs.org/:_authToken=${NODE_AUTH_TOKEN}" > ~/.npmrc
echo "=========================================="
echo "Checking for orphaned Git tags"
echo "=========================================="
IFS=',' read -ra PACKAGES <<< "$INPUT_PACKAGES"
CLEANED_TAGS=()
for package in "${PACKAGES[@]}"; do
if [ -z "$package" ]; then continue; fi
echo "Checking package: $package"
PACKAGE_NAME=$(echo "$package" | sed 's/@[^/]*\///')
# Find highest stable tag (sort -V doesn't handle semver prereleases correctly)
# Filter to stable versions: @X.Y.Z (no hyphen after version = no prerelease suffix)
LATEST_TAG=$(git tag -l "${PACKAGE_NAME}@*" "${package}@*" 2>/dev/null | grep -E '@[0-9]+\.[0-9]+\.[0-9]+$' | sort -V | tail -n 1)
if [ -z "$LATEST_TAG" ]; then
echo " ℹ️ No stable tags found - skipping orphaned check"
continue
fi
VERSION="${LATEST_TAG##*@}"
echo " Latest stable tag: $LATEST_TAG (version: $VERSION)"
if npm view "${package}@${VERSION}" version --registry=https://registry.npmjs.org &>/dev/null; then
echo " ✅ Version $VERSION exists on npm - tag is valid"
else
echo " ⚠️ Version $VERSION NOT found on npm - tag is orphaned!"
echo " 🧹 Deleting orphaned tag: $LATEST_TAG"
git tag -d "$LATEST_TAG" 2>/dev/null && echo " ✅ Deleted local tag" || echo " ⚠️ Local tag already deleted"
git push --delete origin "$LATEST_TAG" 2>/dev/null && CLEANED_TAGS+=("$LATEST_TAG") && echo " ✅ Deleted remote tag" || echo " ⚠️ Remote tag already deleted"
fi
done
echo ""
echo "=========================================="
echo "Checking for missing Git tags"
echo "=========================================="
CREATED_TAGS=()
for package in "${PACKAGES[@]}"; do
if [ -z "$package" ]; then continue; fi
echo "Checking package: $package"
if [[ "$INPUT_NPM_TAG" == "next" ]]; then
LATEST_NPM=$(npm view "$package" dist-tags.next --registry=https://registry.npmjs.org 2>/dev/null || echo "")
else
LATEST_NPM=$(npm view "$package" version --registry=https://registry.npmjs.org 2>/dev/null || echo "")
fi
if [ -z "$LATEST_NPM" ]; then
echo " ℹ️ Package not on npm yet (will be first release)"
continue
fi
EXPECTED_TAG="${package}@${LATEST_NPM}"
echo " Latest npm version: $LATEST_NPM"
if git rev-parse "$EXPECTED_TAG" >/dev/null 2>&1; then
echo " ✅ Git tag exists: $EXPECTED_TAG (in sync)"
else
echo " 🔧 Git tag missing for npm version $LATEST_NPM"
echo " 🏷️ Creating tag: $EXPECTED_TAG"
git tag "$EXPECTED_TAG" HEAD 2>/dev/null && CREATED_TAGS+=("$EXPECTED_TAG") && echo " ✅ Created local tag" || echo " ⚠️ Failed to create tag"
fi
done
# Push created tags
for tag in "${CREATED_TAGS[@]}"; do
echo " 🏷️ Pushing tag: $tag"
git push origin "$tag" 2>/dev/null && echo " ✅ Pushed $tag" || echo " ⚠️ Failed to push $tag"
done
echo ""
echo "✅ Git/npm synchronization complete."
- name: Version and publish packages (dry run)
if: needs.detect.outputs.is_dry_run == 'true'
env:
INPUT_PROJECTS: ${{ needs.detect.outputs.projects }}
INPUT_PACKAGES: ${{ needs.detect.outputs.packages }}
INPUT_NPM_TAG: ${{ needs.detect.outputs.npm_tag }}
INPUT_VERSION_STRATEGY: ${{ needs.detect.outputs.version_strategy }}
INPUT_PREID: "next"
NODE_AUTH_TOKEN: ${{ secrets.NODE_AUTH_TOKEN }}
run: |
echo "//registry.npmjs.org/:_authToken=${NODE_AUTH_TOKEN}" > ~/.npmrc
echo "=========================================="
echo "DRY RUN - Publishing Preview"
echo "=========================================="
echo "Projects: $INPUT_PROJECTS"
echo "npm tag: $INPUT_NPM_TAG"
echo "Version strategy: $INPUT_VERSION_STRATEGY"
calculate_prerelease_version() {
local package="$1"
local preid="$2"
local latest_version
latest_version=$(npm view "$package" dist-tags.latest --registry=https://registry.npmjs.org 2>/dev/null || echo "")
if [ -z "$latest_version" ]; then
latest_version="0.0.0"
fi
local major minor patch
IFS='.' read -r major minor patch <<< "$latest_version"
patch="${patch%%-*}"
local base_version="${major}.${minor}.$((patch + 1))"
local npm_prerelease=-1
local npm_versions
npm_versions=$(npm view "$package" versions --json --registry=https://registry.npmjs.org 2>/dev/null || echo "[]")
if [ "$npm_versions" != "[]" ]; then
local highest_npm
highest_npm=$(echo "$npm_versions" | jq -r --arg base "$base_version" --arg preid "$preid" '
.[] |
select(startswith($base + "-" + $preid + ".")) |
split("-" + $preid + ".")[1] |
tonumber
' 2>/dev/null | sort -n | tail -1)
[ -n "$highest_npm" ] && npm_prerelease=$highest_npm
fi
local git_prerelease=-1
local git_tags
git_tags=$(git tag -l "${package}@${base_version}-${preid}.*" 2>/dev/null || echo "")
if [ -n "$git_tags" ]; then
local highest_git
highest_git=$(echo "$git_tags" | sed "s|${package}@${base_version}-${preid}\.||" | sort -n | tail -1)
[[ "$highest_git" =~ ^[0-9]+$ ]] && git_prerelease=$highest_git
fi
local max_prerelease=$(( npm_prerelease > git_prerelease ? npm_prerelease : git_prerelease ))
local new_version="${base_version}-${preid}.$((max_prerelease + 1))"
echo "$new_version"
}
# Calculate stable version for main branch releases (dry run version)
calculate_stable_version() {
local package="$1"
local latest_version
latest_version=$(npm view "$package" dist-tags.latest --registry=https://registry.npmjs.org 2>/dev/null || echo "")
if [ -z "$latest_version" ]; then
echo "0.0.1"
return
fi
local base_version="${latest_version%%-*}"
if [[ "$latest_version" == *"-"* ]]; then
local npm_versions
npm_versions=$(npm view "$package" versions --json --registry=https://registry.npmjs.org 2>/dev/null || echo "[]")
local base_exists_on_npm=false
if echo "$npm_versions" | jq -e --arg v "$base_version" 'index($v)' > /dev/null 2>&1; then
base_exists_on_npm=true
fi
local base_exists_as_tag=false
if git rev-parse "${package}@${base_version}" >/dev/null 2>&1; then
base_exists_as_tag=true
fi
if [ "$base_exists_on_npm" = false ] && [ "$base_exists_as_tag" = false ]; then
echo "$base_version"
return
fi
fi
local major minor patch
IFS='.' read -r major minor patch <<< "$base_version"
local npm_versions
npm_versions=$(npm view "$package" versions --json --registry=https://registry.npmjs.org 2>/dev/null || echo "[]")
local highest_stable_patch=$patch
if [ "$npm_versions" != "[]" ]; then
local highest_npm_stable
highest_npm_stable=$(echo "$npm_versions" | jq -r --arg maj "$major" --arg min "$minor" '
.[] |
select(test("^" + $maj + "\\." + $min + "\\.[0-9]+$")) |
split(".")[2] |
tonumber
' 2>/dev/null | sort -n | tail -1)
if [ -n "$highest_npm_stable" ]; then
highest_stable_patch=$highest_npm_stable
fi
fi
local git_tags
git_tags=$(git tag -l "${package}@${major}.${minor}.*" 2>/dev/null | grep -v '-' || echo "")
if [ -n "$git_tags" ]; then
local highest_git_patch
highest_git_patch=$(echo "$git_tags" | sed "s|${package}@${major}\.${minor}\.||" | sort -n | tail -1)
if [[ "$highest_git_patch" =~ ^[0-9]+$ ]] && [ "$highest_git_patch" -gt "$highest_stable_patch" ]; then
highest_stable_patch=$highest_git_patch
fi
fi
local new_version="${major}.${minor}.$((highest_stable_patch + 1))"
echo "$new_version"
}
IFS=',' read -ra PROJECT_ARRAY <<< "$INPUT_PROJECTS"
IFS=',' read -ra PACKAGE_ARRAY <<< "$INPUT_PACKAGES"
for i in "${!PROJECT_ARRAY[@]}"; do
project="${PROJECT_ARRAY[$i]}"
package="${PACKAGE_ARRAY[$i]}"
echo "-------------------------------------------"
echo "Processing: $project → $package"
echo "-------------------------------------------"
if [[ "$INPUT_VERSION_STRATEGY" == "prerelease" ]]; then
CALCULATED_VERSION=$(calculate_prerelease_version "$package" "$INPUT_PREID")
echo "📋 WOULD SET VERSION TO: $CALCULATED_VERSION"
echo "📋 WOULD CREATE TAG: ${package}@${CALCULATED_VERSION}"
else
CALCULATED_VERSION=$(calculate_stable_version "$package")
echo "📋 WOULD SET VERSION TO: $CALCULATED_VERSION"
echo "📋 WOULD CREATE TAG: ${package}@${CALCULATED_VERSION}"
fi
echo "📋 WOULD PUBLISH: $package with tag '$INPUT_NPM_TAG'"
echo ""
done
echo "=========================================="
echo "DRY RUN COMPLETE - No changes made"
echo "=========================================="
- name: Version, publish, and push packages
if: needs.detect.outputs.is_dry_run != 'true'
id: publish
env:
INPUT_PROJECTS: ${{ needs.detect.outputs.projects }}
INPUT_PACKAGES: ${{ needs.detect.outputs.packages }}
INPUT_NPM_TAG: ${{ needs.detect.outputs.npm_tag }}
INPUT_VERSION_STRATEGY: ${{ needs.detect.outputs.version_strategy }}
INPUT_PREID: "next"
INPUT_BRANCH: ${{ github.ref_name }}
NODE_AUTH_TOKEN: ${{ secrets.NODE_AUTH_TOKEN }}
GITHUB_TOKEN: ${{ secrets.WORKFLOW_PAT }}
run: |
echo "//registry.npmjs.org/:_authToken=${NODE_AUTH_TOKEN}" > ~/.npmrc
echo "Processing packages with tag: $INPUT_NPM_TAG"
echo "=========================================="
echo "ATOMIC PUBLISH STRATEGY"
echo "=========================================="
echo "For each package: version → publish → push (or revert on failure)"
echo ""
calculate_prerelease_version() {
local package="$1"
local preid="$2"
echo " Calculating prerelease version for $package with preid '$preid'..." >&2
local latest_version
latest_version=$(npm view "$package" dist-tags.latest --registry=https://registry.npmjs.org 2>/dev/null || echo "")
if [ -z "$latest_version" ]; then
latest_version="0.0.0"
echo " 📦 Package not yet published, using base 0.0.0" >&2
else
echo " 📦 Latest version on npm: $latest_version" >&2
fi
local major minor patch
IFS='.' read -r major minor patch <<< "$latest_version"
patch="${patch%%-*}"
local base_version="${major}.${minor}.$((patch + 1))"
echo " 📐 Base version: $base_version" >&2
local npm_prerelease=-1
local npm_versions
npm_versions=$(npm view "$package" versions --json --registry=https://registry.npmjs.org 2>/dev/null || echo "[]")
if [ "$npm_versions" != "[]" ]; then
local highest_npm
highest_npm=$(echo "$npm_versions" | jq -r --arg base "$base_version" --arg preid "$preid" '
.[] |
select(startswith($base + "-" + $preid + ".")) |
split("-" + $preid + ".")[1] |
tonumber
' 2>/dev/null | sort -n | tail -1)
if [ -n "$highest_npm" ]; then
npm_prerelease=$highest_npm
echo " 🔍 Highest npm prerelease: ${base_version}-${preid}.${npm_prerelease}" >&2
fi
fi
local git_prerelease=-1
local git_tags
git_tags=$(git tag -l "${package}@${base_version}-${preid}.*" 2>/dev/null || echo "")
if [ -n "$git_tags" ]; then
local highest_git
highest_git=$(echo "$git_tags" | sed "s|${package}@${base_version}-${preid}\.||" | sort -n | tail -1)
if [[ "$highest_git" =~ ^[0-9]+$ ]]; then
git_prerelease=$highest_git
echo " 🏷️ Highest git prerelease: ${base_version}-${preid}.${git_prerelease}" >&2
fi
fi
local max_prerelease=$(( npm_prerelease > git_prerelease ? npm_prerelease : git_prerelease ))
local new_version="${base_version}-${preid}.$((max_prerelease + 1))"
echo " ✨ New version: $new_version" >&2
echo "$new_version"
}
# Calculate stable version for main branch releases
# Strategy: Get latest from npm, strip prerelease suffix, check if exists, bump if needed
calculate_stable_version() {
local package="$1"
echo " Calculating stable version for $package..." >&2
# Get the latest version from npm (could be stable or prerelease due to past bugs)
local latest_version
latest_version=$(npm view "$package" dist-tags.latest --registry=https://registry.npmjs.org 2>/dev/null || echo "")
if [ -z "$latest_version" ]; then
# Package not yet published, start at 0.0.1
echo " 📦 Package not yet published, using 0.0.1" >&2
echo "0.0.1"
return
fi
echo " 📦 Latest version on npm: $latest_version" >&2
# Strip prerelease suffix to get base version
local base_version="${latest_version%%-*}"
echo " 📐 Base version (stripped prerelease): $base_version" >&2
# If the latest was a prerelease, check if the base version exists as stable
if [[ "$latest_version" == *"-"* ]]; then
echo " ℹ️ Latest is a prerelease, checking if base version $base_version exists..." >&2
# Check if base version exists on npm
local npm_versions
npm_versions=$(npm view "$package" versions --json --registry=https://registry.npmjs.org 2>/dev/null || echo "[]")
local base_exists_on_npm=false
if echo "$npm_versions" | jq -e --arg v "$base_version" 'index($v)' > /dev/null 2>&1; then
base_exists_on_npm=true
echo " 🔍 Base version $base_version exists on npm" >&2
fi
# Check if base version exists as git tag
local base_exists_as_tag=false
if git rev-parse "${package}@${base_version}" >/dev/null 2>&1; then
base_exists_as_tag=true
echo " 🏷️ Base version $base_version exists as git tag" >&2
fi
if [ "$base_exists_on_npm" = false ] && [ "$base_exists_as_tag" = false ]; then
# Base version doesn't exist, use it (graduate from prerelease)
echo " ✨ New version: $base_version (graduating from prerelease)" >&2
echo "$base_version"
return
fi
fi
# Base version exists or latest is stable, need to bump patch
local major minor patch
IFS='.' read -r major minor patch <<< "$base_version"
# Find the highest stable version on npm
local npm_versions
npm_versions=$(npm view "$package" versions --json --registry=https://registry.npmjs.org 2>/dev/null || echo "[]")
local highest_stable_patch=$patch
if [ "$npm_versions" != "[]" ]; then
local highest_npm_stable
highest_npm_stable=$(echo "$npm_versions" | jq -r --arg maj "$major" --arg min "$minor" '
.[] |
select(test("^" + $maj + "\\." + $min + "\\.[0-9]+$")) |
split(".")[2] |
tonumber
' 2>/dev/null | sort -n | tail -1)
if [ -n "$highest_npm_stable" ]; then
highest_stable_patch=$highest_npm_stable
echo " 🔍 Highest stable patch on npm: ${major}.${minor}.${highest_stable_patch}" >&2
fi
fi
# Also check git tags for stable versions
local git_tags
git_tags=$(git tag -l "${package}@${major}.${minor}.*" 2>/dev/null | grep -v '-' || echo "")
if [ -n "$git_tags" ]; then
local highest_git_patch
highest_git_patch=$(echo "$git_tags" | sed "s|${package}@${major}\.${minor}\.||" | sort -n | tail -1)
if [[ "$highest_git_patch" =~ ^[0-9]+$ ]] && [ "$highest_git_patch" -gt "$highest_stable_patch" ]; then
highest_stable_patch=$highest_git_patch
echo " 🏷️ Highest stable patch in git: ${major}.${minor}.${highest_stable_patch}" >&2
fi
fi
local new_version="${major}.${minor}.$((highest_stable_patch + 1))"
echo " ✨ New version: $new_version" >&2
echo "$new_version"
}
IFS=',' read -ra PROJECTS <<< "$INPUT_PROJECTS"
IFS=',' read -ra PACKAGES <<< "$INPUT_PACKAGES"
FAILED_PACKAGES=()
SUCCESS_PACKAGES=()
declare -A SUCCESS_VERSIONS # Track versions for each successful package
for i in "${!PROJECTS[@]}"; do
project="${PROJECTS[$i]}"
package="${PACKAGES[$i]}"
if [ -z "$project" ] || [ -z "$package" ]; then continue; fi
echo "=========================================="
echo "Processing: $project → $package"
echo "=========================================="
PACKAGE_NAME=$(echo "$package" | sed 's/@[^/]*\///')
PROJECT_ROOT=$(npx nx show project "$project" --json 2>/dev/null | jq -r '.root' || echo "")
if [ -z "$PROJECT_ROOT" ] || [ ! -f "$PROJECT_ROOT/package.json" ]; then
echo "❌ Could not find package.json for $project"
FAILED_PACKAGES+=("$package")
continue
fi
echo ""
echo "Step 1: Versioning package..."
if [[ "$INPUT_VERSION_STRATEGY" == "prerelease" ]]; then
# Prerelease versioning (next branch)
NEW_VERSION=$(calculate_prerelease_version "$package" "$INPUT_PREID")
if [ -z "$NEW_VERSION" ]; then
echo "❌ Failed to calculate version"
FAILED_PACKAGES+=("$package")
continue
fi
else
# Stable versioning (main branch) - use smart calculation instead of nx release version
NEW_VERSION=$(calculate_stable_version "$package")
if [ -z "$NEW_VERSION" ]; then
echo "❌ Failed to calculate version"
FAILED_PACKAGES+=("$package")
continue
fi
fi
echo " Setting version to: $NEW_VERSION"
jq --arg version "$NEW_VERSION" '.version = $version' "$PROJECT_ROOT/package.json" > "$PROJECT_ROOT/package.json.tmp"
mv "$PROJECT_ROOT/package.json.tmp" "$PROJECT_ROOT/package.json"
# Run sync-version target if it exists (updates version.ts and similar files)
if npx nx show project "$project" --json 2>/dev/null | jq -e '.targets["sync-version"]' > /dev/null 2>&1; then
echo " Running sync-version target..."
npx nx run "$project:sync-version" || echo " ⚠️ sync-version target failed, continuing..."
fi
# Update package-lock.json to reflect workspace dependency changes
echo " Updating package-lock.json..."
npm install --package-lock-only --ignore-scripts
# Stage all modified files in the project directory (handles version.ts, etc.)
git add "$PROJECT_ROOT"
# Include package-lock.json if it changed (check unstaged changes, not cached)
if ! git diff --quiet package-lock.json 2>/dev/null; then
git add package-lock.json
echo " Including package-lock.json in commit"
fi
# Only commit if there are staged changes (handles case where version is already correct)
if git diff --cached --quiet; then
echo " ℹ️ No changes to commit (version already at $NEW_VERSION)"
else
git commit -m "chore(release): $package@$NEW_VERSION"
fi
# Only create tag if it doesn't already exist
if git rev-parse "${package}@${NEW_VERSION}" >/dev/null 2>&1; then
echo " ℹ️ Tag ${package}@${NEW_VERSION} already exists, skipping tag creation"
else
git tag -m "Release ${package}@${NEW_VERSION}" "${package}@${NEW_VERSION}"
fi
echo "✅ Versioning complete"
echo "New version: $NEW_VERSION"
echo ""
echo "Step 2: Publishing to npm..."
if npm view "$package" version --registry=https://registry.npmjs.org &>/dev/null; then
NPM_VERSION=$(npm view "$package" version --registry=https://registry.npmjs.org 2>/dev/null || echo "unknown")
echo "📦 Status: EXISTING package (current: $NPM_VERSION)"
if npx nx release publish --projects="$project" --tag="$INPUT_NPM_TAG" --registry=https://registry.npmjs.org --verbose; then
PUBLISH_SUCCESS=true
else
PUBLISH_SUCCESS=false
fi
else
echo "🆕 Status: NEW package"
if npx nx release publish --projects="$project" --first-release --tag="$INPUT_NPM_TAG" --registry=https://registry.npmjs.org --verbose; then
PUBLISH_SUCCESS=true
else
PUBLISH_SUCCESS=false
fi
fi
if [ "$PUBLISH_SUCCESS" = true ]; then
echo "✅ Successfully published $package@$NEW_VERSION"
echo ""
echo "Step 3: Pushing commit and tag to remote..."
PUSH_FAILED=false
echo "📤 Pushing version commit to $INPUT_BRANCH..."
if git push origin "$INPUT_BRANCH"; then
echo "✅ Pushed version commit"
else
if git fetch origin "$INPUT_BRANCH" && git diff --quiet HEAD "origin/$INPUT_BRANCH"; then
echo "✅ Branch already up to date"
else
echo "❌ CRITICAL: Failed to push commit after npm publish!"
PUSH_FAILED=true
fi
fi
# Use the exact tag we created - don't re-query with sort -V (it doesn't follow semver)
PACKAGE_TAG="${package}@${NEW_VERSION}"
if git rev-parse "$PACKAGE_TAG" >/dev/null 2>&1; then
echo "🏷️ Pushing tag: $PACKAGE_TAG"
if git push origin "$PACKAGE_TAG"; then
echo "✅ Pushed tag"
else
if git ls-remote --tags origin | grep -q "refs/tags/$PACKAGE_TAG$"; then
echo "✅ Tag already exists on remote"
else
echo "❌ CRITICAL: Failed to push tag!"
PUSH_FAILED=true
fi
fi
fi
SUCCESS_PACKAGES+=("$package")
SUCCESS_VERSIONS["$package"]="$NEW_VERSION" # Track version for GitHub release
if [ "$PUSH_FAILED" = true ]; then
echo "⚠️ Package published but git sync incomplete"
else
echo "✅ Package fully published and synced!"
fi
else
echo "❌ Failed to publish $package"
echo ""
echo "Step 3: Reverting local changes..."
TAG_TO_DELETE=$(git tag -l "${PACKAGE_NAME}@*" "${package}@*" 2>/dev/null | grep "@${NEW_VERSION}$" | head -n 1)
[ -n "$TAG_TO_DELETE" ] && git tag -d "$TAG_TO_DELETE" 2>/dev/null
git reset --hard HEAD~1
FAILED_PACKAGES+=("$package")
echo "⏮️ Reverted local changes"
fi
echo ""
done
SUCCESS_JSON=$(printf '%s\n' "${SUCCESS_PACKAGES[@]}" | jq -R . | jq -s -c .)
FAILED_JSON=$(printf '%s\n' "${FAILED_PACKAGES[@]}" | jq -R . | jq -s -c .)
# Build versions JSON safely using jq: {"@scope/pkg": "1.2.3", ...}
# Note: -c flag ensures compact single-line output (required for GitHub Actions outputs)
VERSIONS_JSON="{}"
for pkg in "${SUCCESS_PACKAGES[@]}"; do
VERSIONS_JSON=$(echo "$VERSIONS_JSON" | jq -c --arg k "$pkg" --arg v "${SUCCESS_VERSIONS[$pkg]}" '. + {($k): $v}')
done
echo "successful_packages=$SUCCESS_JSON" >> $GITHUB_OUTPUT
echo "failed_packages=$FAILED_JSON" >> $GITHUB_OUTPUT
echo "successful_versions=$VERSIONS_JSON" >> $GITHUB_OUTPUT
echo "successful_count=${#SUCCESS_PACKAGES[@]}" >> $GITHUB_OUTPUT
echo "failed_count=${#FAILED_PACKAGES[@]}" >> $GITHUB_OUTPUT
echo "has_failures=$( [ ${#FAILED_PACKAGES[@]} -gt 0 ] && echo 'true' || echo 'false' )" >> $GITHUB_OUTPUT
echo "has_successes=$( [ ${#SUCCESS_PACKAGES[@]} -gt 0 ] && echo 'true' || echo 'false' )" >> $GITHUB_OUTPUT
echo "=========================================="
echo "Publishing Summary"
echo "=========================================="
echo "Successful: ${#SUCCESS_PACKAGES[@]} package(s)"
for pkg in "${SUCCESS_PACKAGES[@]}"; do echo " ✅ $pkg"; done
if [ ${#FAILED_PACKAGES[@]} -gt 0 ]; then
echo ""
echo "Failed: ${#FAILED_PACKAGES[@]} package(s)"
for pkg in "${FAILED_PACKAGES[@]}"; do echo " ❌ $pkg"; done
else
echo ""
echo "✅ All packages published successfully!"
fi
[ ${#SUCCESS_PACKAGES[@]} -eq 0 ] && exit 1 || exit 0
- name: Create GitHub releases
if: needs.detect.outputs.is_dry_run != 'true' && steps.publish.outputs.has_successes == 'true'
env:
SUCCESSFUL_PACKAGES: ${{ steps.publish.outputs.successful_packages }}
SUCCESSFUL_VERSIONS: ${{ steps.publish.outputs.successful_versions }}
INPUT_IS_PRERELEASE: ${{ needs.detect.outputs.is_prerelease }}
GITHUB_TOKEN: ${{ secrets.WORKFLOW_PAT }}
run: |
echo "Creating GitHub releases..."
readarray -t PACKAGES < <(echo "$SUCCESSFUL_PACKAGES" | jq -r '.[]')
for package in "${PACKAGES[@]}"; do
[ -z "$package" ] && continue
# Get version from passed data - don't re-query with sort -V (it doesn't follow semver)
VERSION=$(echo "$SUCCESSFUL_VERSIONS" | jq -r --arg pkg "$package" '.[$pkg] // empty')
if [ -z "$VERSION" ]; then
echo "⚠️ No version found for $package, skipping release"
continue
fi
TAG="${package}@${VERSION}"
if gh release view "$TAG" &>/dev/null; then
echo "✓ Release $TAG already exists"
else
echo "Creating release for $TAG"
if [ "$INPUT_IS_PRERELEASE" = "true" ]; then
gh release create "$TAG" --title "$package $VERSION" --verify-tag --prerelease --notes "Prerelease $VERSION of $package."
else
gh release create "$TAG" --title "$package $VERSION" --verify-tag --notes "Release $VERSION of $package."
fi
echo "✅ Created release for $TAG"
fi
done
# ============================================================================
# GENERATE CHANGELOG: AI-powered changelog (auto mode only)
# ============================================================================
generate-changelog:
name: Generate AI-powered changelog
needs: [detect, publish]
if: |
github.event_name == 'push' &&
needs.detect.outputs.is_dry_run != 'true' &&
needs.publish.outputs.has_successes == 'true'
uses: ./.github/workflows/_generate-changelog.yml
with:
from_ref: ${{ github.event.before }}
to_ref: ${{ github.sha }}
output_formats: "slack,markdown"
custom_prompt_text: |
# Release Changelog Generation Prompt
You are a changelog generator. Based on the following git changes, create a concise, human-readable changelog summary.
${{ needs.publish.outputs.has_failures == 'true' && format('**IMPORTANT**: This is a PARTIAL release. Some packages failed to publish.
**Successfully published packages**:
{0}
**Failed packages** (not published):
{1}
Focus your changelog ONLY on the successfully published packages. Add a note at the end about which packages failed to publish.
', join(fromJSON(needs.publish.outputs.successful_packages), ', '), join(fromJSON(needs.publish.outputs.failed_packages), ', ')) || format('All packages were successfully published.
**Published packages**:
{0}
', join(fromJSON(needs.publish.outputs.successful_packages), ', ')) }}
Focus on:
- What features were added
- What bugs were fixed
- What was changed or improved
Format requirements:
- Use bullet points (• or -) for each item, separated by a newline character
- Keep it to 3-10 items max
- Be concise and clear
- Do NOT include commit hashes unless specifically requested
- Group related changes together
Slack formatting requirements (IMPORTANT):
- DO NOT use markdown headers (no #, ##, ###)
- Use plain text for section titles followed by a colon (e.g., "Features:")
- Use _single asterisks_ for bold text (NOT double asterisks)
- Use _underscores_ for italic text
- Use simple bullet lists with • or - characters
- Keep formatting minimal and clean
- DO NOT use standard markdown links [text](url) - just use plain URLs or omit them
max_tokens: 1024
secrets:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
# ============================================================================
# NOTIFY RELEASE: Slack notification (auto mode only)
# ============================================================================
notify-release:
name: Notify release via Slack
needs: [detect, publish, generate-changelog]
if: |
github.event_name == 'push' &&
needs.detect.outputs.is_dry_run != 'true' &&
needs.publish.outputs.has_successes == 'true'
uses: ./.github/workflows/_notify-release.yml
with:
changelog_slack: ${{ needs.generate-changelog.outputs.changelog_slack }}
changelog_markdown: ${{ needs.generate-changelog.outputs.changelog_markdown }}
destinations: "slack,notion"
from_ref: ${{ github.event.before }}
to_ref: ${{ github.sha }}
branch: ${{ github.ref_name }}
release_title: ${{ needs.publish.outputs.has_failures == 'true' && format('⚠️ Partial Release - {0} ({1} succeeded, {2} failed)', github.ref_name, needs.publish.outputs.successful_count, needs.publish.outputs.failed_count) || '' }}
secrets:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
NOTION_API_KEY: ${{ secrets.NOTION_API_KEY }}
RELEASE_NOTES_NOTION_DATABASE_ID: ${{ secrets.RELEASE_NOTES_NOTION_DATABASE_ID }}
NODE_AUTH_TOKEN: ${{ secrets.NODE_AUTH_TOKEN }}
# ============================================================================
# SYNC NEXT: Sync next branch with main after main releases or workflow changes
# ============================================================================
# Runs when packages are published OR reusable workflows have changed.
# This ensures workflow updates are synced to next branch even when no
# packages need to be published.
# ============================================================================
sync-next:
name: Sync next branch with main
runs-on: ubuntu-24.04
needs: [detect, publish]
if: |
always() &&
github.ref_name == 'main' &&
github.event_name == 'push' &&
needs.detect.outputs.is_dry_run != 'true' &&
needs.detect.outputs.should_continue == 'true' &&
(needs.publish.result == 'success' || needs.publish.result == 'skipped')
permissions:
contents: write
steps:
- uses: bullfrogsec/bullfrog@1831f79cce8ad602eef14d2163873f27081ebfb3 # v0.8.4
- name: Checkout repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
fetch-depth: 0
token: ${{ secrets.WORKFLOW_PAT }}
- name: Import GPG key
uses: crazy-max/ghaction-import-gpg@e89d40939c28e39f97cf32126055eeae86ba74ec # v6
with:
gpg_private_key: ${{ secrets.SERVICE_ACCOUNT_GPG_PRIVATE_KEY }}
git_user_signingkey: true
git_commit_gpgsign: true
git_tag_gpgsign: true
git_config_global: true
- name: Configure Git identity for service account
run: |
git config user.name "Uniswap Labs Service Account"
git config user.email "[email protected]"
- name: Install Graphite CLI
run: |
npm install -g @withgraphite/graphite-cli
gt --version
- name: Sync next branch with main
env:
GITHUB_TOKEN: ${{ secrets.WORKFLOW_PAT }}
run: |
echo "Starting sync of next branch with main..."
git fetch origin
if ! git show-ref --verify --quiet refs/remotes/origin/next; then
echo "Next branch does not exist on remote. Skipping sync."
exit 0
fi
git checkout -B next origin/next
echo "Rebasing next onto main..."
if git rebase origin/main; then
echo "✅ Rebase successful!"
git commit --allow-empty -m "chore(sync): [skip ci] sync next with main"
git push origin next --force-with-lease
echo "✅ Successfully synced next branch with main"
echo "## 🔄 Branch Sync Complete" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "The \`next\` branch has been successfully rebased onto \`main\`." >> $GITHUB_STEP_SUMMARY
else
echo "❌ Rebase failed due to conflicts"
git rebase --abort
gh issue create \
--title "Manual intervention required: next branch sync failed" \
--body "The automatic sync of the \`next\` branch with \`main\` has failed due to conflicts.
## Action Required
Please manually resolve the conflicts by:
1. Checking out the \`next\` branch locally
2. Rebasing it onto \`main\`
3. Resolving any conflicts
4. Force pushing the resolved branch
## Commands
\`\`\`bash
git checkout next
git fetch origin
git rebase origin/main
# Resolve conflicts
git push origin next --force-with-lease
\`\`\`" \
--label "automated,needs-attention"
echo "## ⚠️ Branch Sync Failed" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "An issue has been created for manual intervention." >> $GITHUB_STEP_SUMMARY
exit 1
fi
- name: Alternative sync with Graphite (if available)
if: failure()
continue-on-error: true
run: |
echo "Attempting alternative sync with Graphite CLI..."
gt init --trunk main --no-interactive || true
gt sync --force --no-interactive && echo "✅ Synced using Graphite" || echo "⚠️ Graphite sync also failed"
# ============================================================================
# NOTIFY WORKFLOW CHANGES: Slack notification for workflow-only updates
# ============================================================================
# Runs when reusable workflows have changed but no packages were published.
# Sends a Slack notification to inform the team about workflow updates.
# ============================================================================
notify-workflow-changes:
name: Notify workflow changes via Slack
runs-on: ubuntu-24.04
needs: [detect, sync-next]
if: |
always() &&
github.ref_name == 'main' &&
github.event_name == 'push' &&
needs.detect.outputs.is_dry_run != 'true' &&
needs.detect.outputs.has_workflow_changes == 'true' &&
needs.detect.outputs.has_packages != 'true' &&
needs.sync-next.result == 'success'
steps:
- name: Send Slack notification for workflow changes
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
CHANGED_WORKFLOWS: ${{ needs.detect.outputs.changed_workflows }}
BRANCH_NAME: ${{ github.ref_name }}
COMMIT_SHA: ${{ github.sha }}
COMMIT_URL: ${{ github.server_url }}/${{ github.repository }}/commit/${{ github.sha }}
REPO_URL: ${{ github.server_url }}/${{ github.repository }}
run: |
echo "📢 Sending Slack notification for workflow changes..."
# Build the list of changed workflows
WORKFLOW_LIST=$(echo "$CHANGED_WORKFLOWS" | jq -r '.[] | "• " + (. | split("/") | last)')
# Create the Slack message payload
PAYLOAD=$(jq -n \
--arg branch "$BRANCH_NAME" \
--arg sha "${COMMIT_SHA:0:7}" \
--arg commit_url "$COMMIT_URL" \
--arg repo_url "$REPO_URL" \
--arg workflows "$WORKFLOW_LIST" \
'{
"blocks": [
{
"type": "header",
"text": {
"type": "plain_text",
"text": "🔄 Reusable Workflows Updated",
"emoji": true
}
},
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": ("*Branch:* `" + $branch + "`\n*Commit:* <" + $commit_url + "|" + $sha + ">")
}
},
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": ("*Changed Workflows:*\n" + $workflows)
}
},
{
"type": "context",
"elements": [
{
"type": "mrkdwn",
"text": ("These reusable workflow updates have been synced to the `next` branch. | <" + $repo_url + "/actions|View Actions>")
}
]
}
]
}')
# Send to Slack
if [ -n "$SLACK_WEBHOOK_URL" ]; then
curl -X POST -H 'Content-type: application/json' \
--data "$PAYLOAD" \
"$SLACK_WEBHOOK_URL"
echo ""
echo "✅ Slack notification sent"
else
echo "⚠️ SLACK_WEBHOOK_URL not configured, skipping notification"
fi
# Generate step summary
echo "## 🔄 Reusable Workflows Updated" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "The following reusable workflows were updated:" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "$CHANGED_WORKFLOWS" | jq -r '.[]' | while read -r workflow; do
echo "- \`$workflow\`" >> $GITHUB_STEP_SUMMARY
done
echo "" >> $GITHUB_STEP_SUMMARY
echo "These changes have been synced to the \`next\` branch." >> $GITHUB_STEP_SUMMARY
# ============================================================================
# SUMMARY: Generate workflow summary (force mode only)
# ============================================================================
summary:
name: Generate summary
runs-on: ubuntu-24.04
needs: [detect, publish]
if: github.event_name == 'workflow_dispatch' && always()
steps:
- name: Generate workflow summary
env:
DRY_RUN: ${{ needs.detect.outputs.is_dry_run }}
BRANCH_NAME: ${{ github.ref_name }}
PACKAGE_COUNT: ${{ needs.detect.outputs.package_count }}
SUCCESSFUL_COUNT: ${{ needs.publish.outputs.successful_count || '0' }}
FAILED_COUNT: ${{ needs.publish.outputs.failed_count || '0' }}
SUCCESSFUL_PACKAGES: ${{ needs.publish.outputs.successful_packages }}
FAILED_PACKAGES: ${{ needs.publish.outputs.failed_packages }}
run: |
echo "## 🚀 Force Publish Results" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
if [ "$DRY_RUN" = "true" ]; then
echo "**Mode:** Dry Run (no changes made)" >> $GITHUB_STEP_SUMMARY
else
echo "**Mode:** Live Publish" >> $GITHUB_STEP_SUMMARY
fi
echo "**Branch:** $BRANCH_NAME" >> $GITHUB_STEP_SUMMARY
echo "**Packages requested:** $PACKAGE_COUNT" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
if [ "$DRY_RUN" != "true" ]; then
echo "### Results" >> $GITHUB_STEP_SUMMARY
echo "- **Successful:** $SUCCESSFUL_COUNT" >> $GITHUB_STEP_SUMMARY
echo "- **Failed:** $FAILED_COUNT" >> $GITHUB_STEP_SUMMARY
if [ -n "$SUCCESSFUL_PACKAGES" ] && [ "$SUCCESSFUL_PACKAGES" != "null" ]; then
echo "" >> $GITHUB_STEP_SUMMARY
echo "#### Successfully Published" >> $GITHUB_STEP_SUMMARY
echo "$SUCCESSFUL_PACKAGES" | jq -r '.[]' 2>/dev/null | while read pkg; do
[ -n "$pkg" ] && echo "- ✅ $pkg" >> $GITHUB_STEP_SUMMARY
done
fi
if [ "$FAILED_COUNT" != "0" ] && [ -n "$FAILED_PACKAGES" ] && [ "$FAILED_PACKAGES" != "null" ]; then
echo "" >> $GITHUB_STEP_SUMMARY
echo "#### Failed" >> $GITHUB_STEP_SUMMARY
echo "$FAILED_PACKAGES" | jq -r '.[]' 2>/dev/null | while read pkg; do
[ -n "$pkg" ] && echo "- ❌ $pkg" >> $GITHUB_STEP_SUMMARY
done
fi
fi