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
75 changes: 75 additions & 0 deletions .github/actions/compute-next-version/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
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.
# `|| true` so that under `set -e`/`pipefail` an empty grep result
# (no matching tag) is a valid empty value handled by the check
# below, rather than a non-zero pipeline status that aborts the step.
LATEST_STABLE=$(git tag --list | grep -E "^v${CURRENT_MAJOR}\.[0-9]+\.[0-9]+$" | sort -V | tail -1 || true)
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.
# `|| true` so the first beta of a new minor (no existing beta
# tags) yields an empty value handled by `${LAST_N:-0}` below,
# rather than aborting the step under `set -e`/`pipefail`.
LAST_N=$(git tag --list | grep -E "^${BASE}-beta\.[0-9]+$" | sed -E 's/.*-beta\.//' | sort -n | tail -1 || true)
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
266 changes: 266 additions & 0 deletions .github/workflows/npm-release-beta.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,266 @@
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
env:
VERSION: ${{ steps.get_version.outputs.version }}
run: |
echo "::error::Tag ${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}"
# `npm version` rewrites package.json's version field without
# assuming any particular indentation (unlike a sed regex) and
# without creating a git tag/commit of its own.
npm version "${PKG_VERSION}" --no-git-tag-version --allow-same-version
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

# npm publish runs BEFORE the release commit/tag is created so the
# published artifacts carry the stamped version. RECOVERY: if this
# publish succeeds but a later step (commit/tag/release) fails, npm
# has the version but git has no tag. A re-run will recompute the
# same beta.N (tags are the source of truth) and then fail to
# republish because the version already exists on npm. To recover,
# manually create the missing tag at the intended commit (and the
# GitHub release) rather than re-running the workflow.
- name: Publish to npm with beta dist-tag
run: npm publish --provenance --tag beta

# Commit the stamped files back to the `beta` branch through the
# GitHub Git Data API rather than `git commit`/`git push`.
# Commits authored via the API with GITHUB_TOKEN are automatically
# signed with GitHub's key and show as Verified, without us having
# to manage a GPG/SSH signing key in CI. The push is allowed because
# the job runs in the `release` environment with `contents: write`.
- id: release_commit
name: Create and push signed release commit
shell: bash
env:
GH_TOKEN: ${{ secrets.github-token }}
REPO: ${{ github.repository }}
VERSION: ${{ steps.get_version.outputs.version }}
run: |
set -euo pipefail
BRANCH=beta
FILES=(.version package.json src/management/version.ts CHANGELOG.md)

# 1. Current tip of the branch and its tree.
BASE_SHA=$(gh api "repos/$REPO/git/refs/heads/$BRANCH" --jq '.object.sha')
BASE_TREE=$(gh api "repos/$REPO/git/commits/$BASE_SHA" --jq '.tree.sha')

# 2. Upload each stamped file as a blob, collect tree entries.
TREE=$(for f in "${FILES[@]}"; do
SHA=$(gh api "repos/$REPO/git/blobs" \
-f content="$(base64 -w0 "$f")" -f encoding=base64 --jq '.sha')
jq -nc --arg path "$f" --arg sha "$SHA" \
'{path:$path, mode:"100644", type:"blob", sha:$sha}'
done | jq -sc '.')

# 3. New tree on top of the current one.
NEW_TREE=$(jq -nc --arg base "$BASE_TREE" --argjson tree "$TREE" \
'{base_tree:$base, tree:$tree}' \
| gh api "repos/$REPO/git/trees" --input - --jq '.sha')

# 4. Commit — signed by GitHub because GH_TOKEN authenticates the call.
NEW_SHA=$(jq -nc --arg msg "Release $VERSION" --arg tree "$NEW_TREE" --arg parent "$BASE_SHA" \
'{message:$msg, tree:$tree, parents:[$parent]}' \
| gh api "repos/$REPO/git/commits" --input - --jq '.sha')

# 5. Move the branch to the new commit (the "push"). The tag
# itself is created by the release-create step below (via
# softprops/action-gh-release at this commit), so we do not
# create a tag ref here.
gh api -X PATCH "repos/$REPO/git/refs/heads/$BRANCH" -f sha="$NEW_SHA"

echo "SHA=$NEW_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"
Loading
Loading