Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions .github/actions/compute-next-version/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
name: Compute the next version for a release track

#
# Computes the next version for either the stable or beta track from the
# existing git tags, which are treated as the single source of truth.
#
# - stable: next minor after the latest stable tag (vX.Y.0 -> vX.(Y+1).0)
# - beta: vX.(Y+1).0-beta.N, where the base is the next minor after the
# latest stable tag and N auto-increments from existing beta tags.
#
# The current major line is read from the .version file so that legacy tags
# from older majors (e.g. v5.*) are never treated as candidates.
#
# Because the beta base is always derived from the latest stable tag, the
# moment a stable release is tagged the next beta computation rolls forward
# automatically. No shared state file is needed.
#

inputs:
track:
description: 'Release track: "stable" or "beta".'
required: true

outputs:
version:
value: ${{ steps.compute.outputs.VERSION }}

runs:
using: composite

steps:
- id: compute
shell: bash
run: |
set -euo pipefail
git fetch --tags --quiet

# Determine the current major from the .version file so we never
# pick up tags from a previous major line (e.g. v5.*).
CURRENT_MAJOR=$(head -1 .version | sed -E 's/^v([0-9]+)\..*/\1/')

# Only consider clean stable tags on the current major line
# (vMAJOR.MINOR.PATCH with no prerelease suffix). `sort -V` orders
# by semver so double-digit minors sort correctly.
LATEST_STABLE=$(git tag --list | grep -E "^v${CURRENT_MAJOR}\.[0-9]+\.[0-9]+$" | sort -V | tail -1)
if [ -z "${LATEST_STABLE}" ]; then
echo "::error::No stable v${CURRENT_MAJOR}.MINOR.PATCH tag found; cannot compute next version." >&2
exit 1
fi

BASE=$(echo "${LATEST_STABLE}" | awk -F. '{printf "%s.%d.0", $1, $2+1}')

if [ "${TRACK}" = "stable" ]; then
VERSION="${BASE}"
echo "::notice::compute-next-version (stable): latest_stable=${LATEST_STABLE} -> ${VERSION}"
elif [ "${TRACK}" = "beta" ]; then
# Only beta tags whose base is exactly BASE, with a numeric suffix.
LAST_N=$(git tag --list | grep -E "^${BASE}-beta\.[0-9]+$" | sed -E 's/.*-beta\.//' | sort -n | tail -1)
N=$(( ${LAST_N:-0} + 1 ))
VERSION="${BASE}-beta.${N}"
echo "::notice::compute-next-version (beta): latest_stable=${LATEST_STABLE} base=${BASE} last_beta_n=${LAST_N:-<none>} -> ${VERSION}"
else
echo "::error::Unknown track '${TRACK}'. Expected 'stable' or 'beta'." >&2
exit 1
fi

echo "VERSION=${VERSION}" >> "$GITHUB_OUTPUT"
env:
TRACK: ${{ inputs.track }}
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@ name: CI

on:
push:
branches: [master, v5]
branches: [master, v5, beta]
pull_request:
branches: [master, v5]
branches: [master, v5, beta]

jobs:
lint:
Expand Down
223 changes: 223 additions & 0 deletions .github/workflows/npm-release-beta.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
name: Publish beta release to npm

#
# Reusable workflow that publishes a beta prerelease to npm (dist-tag `beta`).
# It is called by `release.yml` whenever a PR is merged into the `beta` branch
# (every fern-bot regeneration PR or human PR). The version is computed from
# the existing git tags by the compute-next-version action, the version files
# are stamped, a CHANGELOG.md entry is generated from the merged squash-commit
# message, the package is built and published to npm, a release commit is
# tagged, and a GitHub prerelease is created.
#
# Routing through `release.yml` (rather than triggering directly) means a
# single entry workflow is the npm trusted publisher for both the stable and
# beta tracks, matching npm's one-trusted-publisher-per-package limit.
#
# This runs against the `beta` branch, so CHANGELOG.md here is the beta
# track's own changelog and never collides with the stable changelog on
# `master`.
#
# Security notes:
# - Reached via `pull_request` (NOT `pull_request_target`) in the caller.
# PRs from forks run with a read-only token and no access to secrets, so
# a malicious fork PR cannot reach the release credentials. All real inflow
# (fern-bot, org members) comes from same-repo branches.
# - The workflow never executes checked-out PR source beyond the repo's own
# build; it only reads the merge commit message and stamps files. Untrusted
# text is handled via shell variables/`env:`, never interpolated into `run:`
# via `${{ }}`.
#

on:
workflow_call:
inputs:
node-version:
required: true
type: string
require-build:
default: true
type: string
secrets:
github-token:
required: true

# Least privilege: the job opts into only what it needs.
permissions: {}

jobs:
beta-release:
# Guard against forks running the release logic. The caller (release.yml)
# already gates on the merge/dispatch event and the target branch.
if: github.repository == 'auth0/node-auth0'
runs-on: ubuntu-latest
environment: release
permissions:
contents: write # for pushing the release commit and tag
id-token: write # for publishing to npm using --provenance

steps:
# Checkout the full history so compute-next-version can inspect all
# existing git tags.
- uses: actions/checkout@v4
with:
fetch-depth: 0
ref: beta

# Compute the next beta version from the existing git tags.
- id: get_version
uses: ./.github/actions/compute-next-version
with:
track: beta

# Defense in depth: abort if the tag already exists so a re-run
# can never overwrite a published release.
- id: tag_exists
uses: ./.github/actions/tag-exists
with:
tag: ${{ steps.get_version.outputs.version }}
token: ${{ secrets.github-token }}

- if: steps.tag_exists.outputs.exists == 'true'
shell: bash
run: |
echo "::error::Tag ${{ steps.get_version.outputs.version }} already exists; aborting to avoid overwriting a published release."
exit 1

# Build release notes from the structured squash-commit message of
# the merged PR. The author marks beta-only vs. stable-mirrored
# changes at merge time using HTML comment markers:
#
# <!-- BETA -->
# - feat: add Sandbox preview API (Beta)
# <!-- /BETA -->
# <!-- STABLE -->
# - feat: add tenant security headers
# <!-- /STABLE -->
#
# If the markers are absent we fall back to the raw commit subject.
- id: notes
name: Generate release notes
shell: bash
run: |
MSG=$(git log -1 --pretty='%B' HEAD)

extract() { # $1=open marker $2=close marker
printf '%s\n' "${MSG}" | awk -v o="$1" -v c="$2" '
$0 ~ o {grab=1; next}
$0 ~ c {grab=0}
grab {print}
' | sed '/^[[:space:]]*$/d'
}

BETA_SECTION=$(extract '<!-- BETA -->' '<!-- /BETA -->')
STABLE_SECTION=$(extract '<!-- STABLE -->' '<!-- /STABLE -->')

# Unpredictable delimiter so commit-message content cannot
# forge the terminator and inject extra step outputs.
DELIM="RELEASE_NOTES_$(openssl rand -hex 16)"
{
echo "RELEASE_NOTES<<${DELIM}"
if [ -z "${BETA_SECTION}" ] && [ -z "${STABLE_SECTION}" ]; then
echo "**Beta**"
echo "- $(printf '%s\n' "${MSG}" | head -1)"
else
echo "**Beta**"
if [ -n "${BETA_SECTION}" ]; then
echo "${BETA_SECTION}"
else
echo "- No beta-only changes in this release."
fi
echo ""
echo "**Stable (from master)**"
if [ -n "${STABLE_SECTION}" ]; then
echo "${STABLE_SECTION}"
else
echo "- No stable changes in this release."
fi
fi
echo "${DELIM}"
} >> "$GITHUB_OUTPUT"

# Stamp .version, package.json, src/management/version.ts, and
# prepend a CHANGELOG.md entry. Files are only edited on disk here;
# the git commit is created in a later step.
- name: Stamp version files and changelog
shell: bash
run: |
# .version stores the full vX.Y.Z-beta.N string
echo "${VERSION}" > .version

# package.json and version.ts use the bare version without
# the leading 'v' (npm semver convention)
PKG_VERSION="${VERSION#v}"
sed -i -E 's/^( "version": ")[^"]*(",)$/\1'"${PKG_VERSION}"'\2/' package.json
sed -i -E 's/export const SDK_VERSION = "[^"]*";/export const SDK_VERSION = "'"${PKG_VERSION}"'";/' src/management/version.ts

DATE=$(date -u +%Y-%m-%d)
[ -f CHANGELOG.md ] || printf '# Change Log\n\n' > CHANGELOG.md
{
head -2 CHANGELOG.md
echo "## [${VERSION}](https://github.com/auth0/node-auth0/tree/${VERSION}) (${DATE})"
echo ""
echo "${RELEASE_NOTES}"
echo ""
tail -n +3 CHANGELOG.md
} > CHANGELOG.md.tmp
mv CHANGELOG.md.tmp CHANGELOG.md
env:
VERSION: ${{ steps.get_version.outputs.version }}
RELEASE_NOTES: ${{ steps.notes.outputs.RELEASE_NOTES }}

# Build and publish to npm BEFORE creating the release commit so
# that the built artifacts carry the stamped version. If publish
# fails we have not yet created a release commit.
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ inputs.node-version }}
cache: yarn
registry-url: https://registry.npmjs.org

- name: Install dependencies
run: yarn install --frozen-lockfile

- name: Update npm to latest
run: npm install -g npm@^11

- name: Build package
if: inputs.require-build == 'true'
run: yarn build

- name: Validate package
run: yarn lint:package

- name: Publish to npm with beta dist-tag
run: npm publish --provenance --tag beta

# Commit the stamped files, tag the commit, and push both back to
# the `beta` branch. The push is allowed because the job runs in
# the `release` environment with `contents: write`.
- id: release_commit
name: Create and push release commit
shell: bash
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add .version package.json src/management/version.ts CHANGELOG.md
git commit -m "Release ${{ steps.get_version.outputs.version }}"
git tag -a "${{ steps.get_version.outputs.version }}" \
-m "Release ${{ steps.get_version.outputs.version }}"
RELEASE_SHA=$(git rev-parse HEAD)
git push origin "${{ steps.get_version.outputs.version }}"
git push origin HEAD:beta
echo "SHA=${RELEASE_SHA}" >> "$GITHUB_OUTPUT"

# Create the GitHub prerelease on the tag.
- uses: ./.github/actions/release-create
with:
token: ${{ secrets.github-token }}
name: ${{ steps.get_version.outputs.version }}
body: ${{ steps.notes.outputs.RELEASE_NOTES }}
tag: ${{ steps.get_version.outputs.version }}
commit: ${{ steps.release_commit.outputs.SHA }}
prerelease: "true"
61 changes: 58 additions & 3 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -1,31 +1,86 @@
name: Create GitHub Release

#
# Single entry workflow for both release tracks. It is the one npm trusted
# publisher for this package (npm allows only one per package), so every
# publish for either track flows through this file:
#
# - stable: a `release/*` branch merged into `master` -> npm `latest`
# - beta: any PR merged into `beta` -> npm `beta`
#
# The actual publish logic lives in the reusable workflows this file calls
# (`npm-release.yml` for stable, `npm-release-beta.yml` for beta). npm
# validates the calling workflow for OIDC trusted publishing, so routing both
# tracks through this caller lets a single trusted-publisher registration
# cover both.
#
# `pull_request` workflows are read from the base branch of the PR, so this
# file must exist on both `master` and `beta`.
#

on:
pull_request:
types:
- closed
branches: [master, beta]
workflow_dispatch:
inputs:
track:
description: "Release track to run for a manual dispatch."
required: true
type: choice
options:
- stable
- beta
default: stable

permissions:
contents: write
id-token: write # For publishing to npm using --provenance

# Serialize releases per track so concurrent merges cut versions one at a time
# and the push-back to the release branch never races.
concurrency:
group: release-${{ github.event.pull_request.base.ref || inputs.track }}
cancel-in-progress: false

### TODO: Replace instances of './.github/workflows/' w/ `auth0/dx-sdk-actions/workflows/` and append `@latest` after the common `dx-sdk-actions` repo is made public.
### TODO: Also remove `get-prerelease`, `get-release-notes`, `get-version`, `npm-publish`, `release-create`, and `tag-exists` actions from this repo's .github/actions folder once the repo is public.
### TODO: Also remove `npm-release` workflow from this repo's .github/workflows folder once the repo is public.
### TODO: Also remove `npm-release` and `npm-release-beta` workflows from this repo's .github/workflows folder once the repo is public.

jobs:
release:
# Stable: a `release/*` branch merged into `master`, or a manual stable dispatch.
stable:
if: >-
(github.event_name == 'workflow_dispatch' && inputs.track == 'stable') ||
(github.event_name == 'pull_request' &&
github.event.pull_request.merged == true &&
github.event.pull_request.base.ref == 'master' &&
startsWith(github.event.pull_request.head.ref, 'release/'))
uses: ./.github/workflows/npm-release.yml
with:
node-version: 22.23.1
require-build: true
secrets:
github-token: ${{ secrets.GITHUB_TOKEN }}

# Beta: any PR merged into `beta`, or a manual beta dispatch.
beta:
if: >-
(github.event_name == 'workflow_dispatch' && inputs.track == 'beta') ||
(github.event_name == 'pull_request' &&
github.event.pull_request.merged == true &&
github.event.pull_request.base.ref == 'beta')
uses: ./.github/workflows/npm-release-beta.yml
with:
node-version: 22.23.1
require-build: true
secrets:
github-token: ${{ secrets.GITHUB_TOKEN }}

deploy-docs:
name: Deploy Documentation
needs: release
needs: stable
if: github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && github.event.pull_request.merged && startsWith(github.event.pull_request.head.ref, 'release/'))
runs-on: ubuntu-latest
permissions:
Expand Down
Loading
Loading