Skip to content

chore(release): publish #1

chore(release): publish

chore(release): publish #1

name: Release Workflow
on:
push:
branches:
- main
tags:
# Matches the Nx releaseTag pattern in nx.json: "{version}-{projectName}"
- '*-*'
workflow_dispatch:
inputs:
dist-tag:
description: "npm dist-tag to use (e.g. latest | next | canary)"
required: false
type: string
default: next
dry-run:
description: "Run release steps without making changes (no git push, no publish)"
required: false
type: boolean
default: false
release-group:
description: "Optional Nx project pattern to scope the release (empty = default behavior)"
required: false
type: string
default: ""
concurrency:
# Avoid overlapping publishes on the same ref/branch
group: nx-release-${{ github.ref }}
cancel-in-progress: false
permissions:
contents: write # needed to push version commits and tags
id-token: write # required for npm provenance / trusted publishing (OIDC)
jobs:
release:
name: Version and Publish (gated by environment)
if: ${{ github.actor != 'github-actions[bot]' }}
runs-on: ubuntu-latest
environment:
name: ${{ (github.event_name == 'workflow_dispatch' && inputs.dry-run) && 'npm-publish-dry-run' || 'npm-publish' }}
env:
# Optional: provide Nx Cloud token if used in this repo
NX_CLOUD_ACCESS_TOKEN: ${{ secrets.NX_CLOUD_ACCESS_TOKEN }}
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
with:
egress-policy: audit
- name: Checkout repository (full history for tagging)
uses: actions/checkout@v6.0.0
with:
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '24'
registry-url: 'https://registry.npmjs.org'
cache: 'npm'
- name: Update npm (required for OIDC trusted publishing)
run: |
npm install -g npm@^11.5.1
npm --version
- name: Install dependencies
run: npm ci
- name: Repo setup
run: npm run setup
- name: Resolve release context
id: ctx
shell: bash
run: |
set -euo pipefail
if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then
dist_tag="${{ inputs['dist-tag'] }}"
scope="${{ inputs['release-group'] }}"
dry_run="${{ inputs['dry-run'] }}"
mode="dispatch"
elif [[ "${GITHUB_REF}" == refs/tags/* ]]; then
dist_tag=""
scope=""
dry_run="false"
mode="tag"
else
dist_tag="next"
scope=""
dry_run="false"
mode="main"
fi
echo "mode=${mode}" >> "$GITHUB_OUTPUT"
echo "dist_tag=${dist_tag}" >> "$GITHUB_OUTPUT"
echo "scope=${scope}" >> "$GITHUB_OUTPUT"
echo "dry_run=${dry_run}" >> "$GITHUB_OUTPUT"
- name: Determine affected release projects (main)
id: affected
if: ${{ steps.ctx.outputs.mode == 'main' }}
shell: bash
run: |
set -euo pipefail
base='${{ github.event.before }}'
head='${{ github.sha }}'
# Handle edge cases where base commit doesn't exist (first push, force-push, etc.)
# Use HEAD~1 as fallback, or just compare against HEAD if no parent exists
if [[ "$base" == "0000000000000000000000000000000000000000" ]] || ! git cat-file -e "$base" 2>/dev/null; then
echo "Base commit not available, falling back to HEAD~1"
base="HEAD~1"
# If HEAD~1 doesn't exist (first commit), use empty tree
if ! git cat-file -e "$base" 2>/dev/null; then
base="$(git hash-object -t tree /dev/null)"
fi
fi
# Only consider libs under packages/* and exclude items configured as non-releaseable.
affected_json=$(npx nx show projects --affected --base "$base" --head "$head" --type lib --projects "packages/*" --exclude "ui-mobile-base,types-minimal,winter-tc,types,types-ios,types-android" --json)
affected_list=$(printf '%s' "$affected_json" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{const a=JSON.parse(s||"[]");process.stdout.write(a.join(","));});')
affected_count=$(printf '%s' "$affected_json" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{const a=JSON.parse(s||"[]");process.stdout.write(String(a.length));});')
echo "projects=${affected_list}" >> "$GITHUB_OUTPUT"
echo "count=${affected_count}" >> "$GITHUB_OUTPUT"
- name: Determine tag release project and dist-tag (tags)
id: taginfo
if: ${{ steps.ctx.outputs.mode == 'tag' }}
shell: bash
run: |
set -euo pipefail
tag_name="${GITHUB_REF_NAME}"
# Find the project by matching the tag suffix against known releaseable packages.
projects=$(npx nx show projects --projects "packages/*" --type lib --exclude "ui-mobile-base,types-minimal,winter-tc,types,types-ios,types-android" --sep ' ')
best_match=""
best_len=0
for p in $projects; do
suffix="-${p}"
if [[ "$tag_name" == *"$suffix" ]]; then
if (( ${#p} > best_len )); then
best_match="$p"
best_len=${#p}
fi
fi
done
if [[ -z "$best_match" ]]; then
echo "Could not determine project from tag '$tag_name'. Expected '{version}-{projectName}'." >&2
exit 1
fi
version_part="${tag_name%-$best_match}"
if [[ "$version_part" == *-* ]]; then
dist_tag="next"
else
dist_tag="latest"
fi
echo "project=${best_match}" >> "$GITHUB_OUTPUT"
echo "version=${version_part}" >> "$GITHUB_OUTPUT"
echo "dist_tag=${dist_tag}" >> "$GITHUB_OUTPUT"
- name: Configure git user for automated commits
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
# VERSION: updates versions and creates git tags following nx.json releaseTag.pattern.
- name: nx release version (main)
if: ${{ steps.ctx.outputs.mode == 'main' && steps.affected.outputs.count != '0' }}
shell: bash
run: |
set -euo pipefail
npx nx release version prerelease \
--preid next \
--projects "${{ steps.affected.outputs.projects }}" \
--git-commit \
--git-push \
--verbose
- name: nx release version (main, no-op)
if: ${{ steps.ctx.outputs.mode == 'main' && steps.affected.outputs.count == '0' }}
run: echo "No affected release projects on main; skipping version + publish."
- name: nx release version (dispatch)
if: ${{ steps.ctx.outputs.mode == 'dispatch' && !inputs.dry-run }}
shell: bash
run: |
set -euo pipefail
scope="${{ steps.ctx.outputs.scope }}"
if [[ -n "$scope" ]]; then
projects_arg=(--projects "$scope")
else
projects_arg=()
fi
npx nx release version prerelease \
--preid "${{ steps.ctx.outputs.dist_tag }}" \
"${projects_arg[@]}" \
--git-commit \
--git-push \
--verbose
- name: nx release version (dispatch, dry-run)
if: ${{ steps.ctx.outputs.mode == 'dispatch' && inputs.dry-run }}
shell: bash
run: |
set -euo pipefail
scope="${{ steps.ctx.outputs.scope }}"
if [[ -n "$scope" ]]; then
projects_arg=(--projects "$scope")
else
projects_arg=()
fi
npx nx release version prerelease \
--preid "${{ steps.ctx.outputs.dist_tag }}" \
"${projects_arg[@]}" \
--verbose \
--dry-run
# BUILD: Ensure projects are built before publishing
- name: Build affected projects (main)
if: ${{ steps.ctx.outputs.mode == 'main' && steps.affected.outputs.count != '0' }}
run: npx nx run-many -t build --projects "${{ steps.affected.outputs.projects }}" --verbose
- name: Build projects (dispatch)
if: ${{ steps.ctx.outputs.mode == 'dispatch' }}
shell: bash
run: |
set -euo pipefail
scope="${{ steps.ctx.outputs.scope }}"
if [[ -n "$scope" ]]; then
npx nx run-many -t build --projects "$scope" --verbose
else
npx nx run-many -t build --all --verbose
fi
# PUBLISH: OIDC trusted publishing (default). Avoid any lingering token auth.
- name: nx release publish (OIDC, main)
if: ${{ steps.ctx.outputs.mode == 'main' && steps.affected.outputs.count != '0' && vars.USE_NPM_TOKEN != 'true' }}
shell: bash
env:
NPM_CONFIG_PROVENANCE: true
NODE_AUTH_TOKEN: ""
run: |
set -euo pipefail
unset NODE_AUTH_TOKEN
rm -f ~/.npmrc || true
if [[ -n "${NPM_CONFIG_USERCONFIG:-}" ]]; then
rm -f "$NPM_CONFIG_USERCONFIG" || true
fi
npx nx release publish \
--projects "${{ steps.affected.outputs.projects }}" \
--tag "${{ steps.ctx.outputs.dist_tag }}" \
--access public \
--verbose
- name: nx release publish (OIDC, dispatch)
if: ${{ steps.ctx.outputs.mode == 'dispatch' && steps.ctx.outputs.dry_run != 'true' && vars.USE_NPM_TOKEN != 'true' }}
shell: bash
env:
NPM_CONFIG_PROVENANCE: true
NODE_AUTH_TOKEN: ""
run: |
set -euo pipefail
unset NODE_AUTH_TOKEN
rm -f ~/.npmrc || true
if [[ -n "${NPM_CONFIG_USERCONFIG:-}" ]]; then
rm -f "$NPM_CONFIG_USERCONFIG" || true
fi
scope="${{ steps.ctx.outputs.scope }}"
if [[ -n "$scope" ]]; then
projects_arg="--projects $scope"
else
projects_arg=""
fi
npx nx release publish \
$projects_arg \
--tag "${{ steps.ctx.outputs.dist_tag }}" \
--access public \
--verbose
- name: nx release publish (OIDC, dispatch dry-run)
if: ${{ steps.ctx.outputs.mode == 'dispatch' && inputs.dry-run && vars.USE_NPM_TOKEN != 'true' }}
shell: bash
env:
NPM_CONFIG_PROVENANCE: true
NODE_AUTH_TOKEN: ""
run: |
set -euo pipefail
unset NODE_AUTH_TOKEN
rm -f ~/.npmrc || true
if [[ -n "${NPM_CONFIG_USERCONFIG:-}" ]]; then
rm -f "$NPM_CONFIG_USERCONFIG" || true
fi
scope="${{ steps.ctx.outputs.scope }}"
if [[ -n "$scope" ]]; then
projects_arg="--projects $scope"
else
projects_arg=""
fi
npx nx release publish \
$projects_arg \
--tag "${{ steps.ctx.outputs.dist_tag }}" \
--access public \
--verbose \
--dry-run
# PUBLISH: token fallback (only when explicitly enabled via repo/environment variable USE_NPM_TOKEN=true).
- name: nx release publish (token, main)
if: ${{ steps.ctx.outputs.mode == 'main' && steps.affected.outputs.count != '0' && vars.USE_NPM_TOKEN == 'true' }}
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_PUBLISH_TOKEN }}
NPM_CONFIG_PROVENANCE: true
run: |
npx nx release publish --projects "${{ steps.affected.outputs.projects }}" --tag "${{ steps.ctx.outputs.dist_tag }}" --access public --verbose
- name: nx release publish (token, dispatch)
if: ${{ steps.ctx.outputs.mode == 'dispatch' && steps.ctx.outputs.dry_run != 'true' && vars.USE_NPM_TOKEN == 'true' }}
shell: bash
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_PUBLISH_TOKEN }}
NPM_CONFIG_PROVENANCE: true
run: |
set -euo pipefail
scope="${{ steps.ctx.outputs.scope }}"
if [[ -n "$scope" ]]; then
projects_arg="--projects $scope"
else
projects_arg=""
fi
npx nx release publish $projects_arg --tag "${{ steps.ctx.outputs.dist_tag }}" --access public --verbose
- name: nx release publish (token, dispatch dry-run)
if: ${{ steps.ctx.outputs.mode == 'dispatch' && inputs.dry-run && vars.USE_NPM_TOKEN == 'true' }}
shell: bash
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_PUBLISH_TOKEN }}
NPM_CONFIG_PROVENANCE: true
run: |
set -euo pipefail
scope="${{ steps.ctx.outputs.scope }}"
if [[ -n "$scope" ]]; then
projects_arg="--projects $scope"
else
projects_arg=""
fi
npx nx release publish $projects_arg --tag "${{ steps.ctx.outputs.dist_tag }}" --access public --verbose --dry-run
# Tag-triggered publishing: publish the single package referenced by the tag.
- name: Build project before publish (tag)
if: ${{ steps.ctx.outputs.mode == 'tag' }}
run: npx nx build "${{ steps.taginfo.outputs.project }}" --verbose
- name: nx release publish (tag)
if: ${{ steps.ctx.outputs.mode == 'tag' && vars.USE_NPM_TOKEN != 'true' }}
shell: bash
env:
NPM_CONFIG_PROVENANCE: true
NODE_AUTH_TOKEN: ""
run: |
set -euo pipefail
unset NODE_AUTH_TOKEN
rm -f ~/.npmrc || true
if [[ -n "${NPM_CONFIG_USERCONFIG:-}" ]]; then
rm -f "$NPM_CONFIG_USERCONFIG" || true
fi
npx nx release publish \
--projects "${{ steps.taginfo.outputs.project }}" \
--tag "${{ steps.taginfo.outputs.dist_tag }}" \
--access public \
--verbose
- name: nx release publish (tag, token)
if: ${{ steps.ctx.outputs.mode == 'tag' && vars.USE_NPM_TOKEN == 'true' }}
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_PUBLISH_TOKEN }}
NPM_CONFIG_PROVENANCE: true
run: |
npx nx release publish --projects "${{ steps.taginfo.outputs.project }}" --tag "${{ steps.taginfo.outputs.dist_tag }}" --access public --verbose
- name: Summary
if: always()
run: |
echo "Nx Release completed."
echo "- mode: ${{ steps.ctx.outputs.mode }}"
echo "- dist-tag: ${{ steps.ctx.outputs.mode == 'tag' && steps.taginfo.outputs.dist_tag || steps.ctx.outputs.dist_tag }}"
echo "- scope: '${{ steps.ctx.outputs.scope }}'"
echo "- dry-run: ${{ steps.ctx.outputs.dry_run }}"
echo "- use-token: ${{ vars.USE_NPM_TOKEN == 'true' }}"