diff --git a/.github/actions/compute-next-version/action.yml b/.github/actions/compute-next-version/action.yml new file mode 100644 index 0000000000..595bc3d8ff --- /dev/null +++ b/.github/actions/compute-next-version/action.yml @@ -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:-} -> ${VERSION}" + else + echo "::error::Unknown track '${TRACK}'. Expected 'stable' or 'beta'." >&2 + exit 1 + fi + + echo "VERSION=${VERSION}" >> "$GITHUB_OUTPUT" + env: + TRACK: ${{ inputs.track }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1b418981c1..9ab9778ae4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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: diff --git a/.github/workflows/npm-release-beta.yml b/.github/workflows/npm-release-beta.yml new file mode 100644 index 0000000000..2b3caeead2 --- /dev/null +++ b/.github/workflows/npm-release-beta.yml @@ -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: + # + # + # - feat: add Sandbox preview API (Beta) + # + # + # - feat: add tenant security headers + # + # + # 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 '' '') + STABLE_SECTION=$(extract '' '') + + # 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" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 20fd8b2d41..15e6b166e5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,21 +1,62 @@ 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 @@ -23,9 +64,23 @@ jobs: 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: diff --git a/AGENTS.md b/AGENTS.md index e601251a7f..05fba27fa5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -449,3 +449,52 @@ AUTH0_CLIENT_ID=your-test-client-id AUTH0_CLIENT_SECRET=your-test-client-secret AUTH0_M2M_TOKEN=your-machine-to-machine-token ``` + +## Beta Track Releases + +> Applies only when working on the `beta` branch. + +This SDK ships two tracks from one npm package (`auth0`): + +- **Stable** (`master`): EA/GA endpoints only. Released by a maintainer via a `release/*` branch. +- **Beta** (`beta`): a superset of stable plus beta-only endpoints. Released automatically when a PR is merged into `beta`. + +The `beta` branch is **regenerated** from the stable spec plus the beta-only spec files; it is never produced by merging `master` into `beta`. It receives one combined regeneration PR (stable + beta) as a single squash commit. The beta-only versus stable-mirrored split cannot be detected from code or file paths, so it must be recorded in the squash commit message. + +### Versioning + +Beta = the next stable minor + `-beta.N`, derived from git tags. Latest stable `v6.3.0` → beta `v6.4.0-beta.N`; `-beta.N` auto-increments; once stable `v6.4.0` ships, beta rolls to `v6.5.0-beta.1`. No state file. + +### When merging a beta regeneration PR + +Squash and merge with a commit message that marks each group: + +``` +Regenerate SDK (stable + beta) (#) + + +- feat: add `Management.Sandbox` preview API (Beta) + + + +- feat: add tenant security headers configuration + +``` + +- Beta-only changes go inside the `BETA` markers; stable-mirrored changes go inside the `STABLE` markers. +- The `` markers are HTML comments and stay invisible in GitHub's rendered view. +- Omitting a section renders a "No ... changes in this release." note; omitting both falls back to the raw commit subject. Always prefer the structured form. +- Everything reaches `beta` through a PR. Never push directly to `beta` (a direct push will not trigger a release). + +### Do not hand-edit release files on `beta` + +The Beta Auto-Release workflow (`.github/workflows/beta-autorelease.yml`) owns versioning. When a PR is merged into `beta` it computes the next `vX.Y.0-beta.N`, aborts if that tag already exists, stamps `.version`, `package.json`, `src/management/version.ts`, and `CHANGELOG.md`, creates the release commit **through the GitHub API** (so it is signed/Verified, no GPG key), publishes to npm with `--tag beta`, tags it, and publishes a GitHub prerelease. Never manually bump these files on `beta`. + +### Hand-written code + +Code outside of the Fern-generated directories is hand-written and is **not** regenerated on `beta`. A fix on `master` does not reach `beta` automatically, so hand-written changes must be PR'd to **both** `master` and `beta`. + +### Important notes for agents on the `beta` branch + +- Never hand-edit `.version`, `package.json` (version field), `src/management/version.ts`, or `CHANGELOG.md`; mark beta vs. stable changes in the squash commit message instead (see above). +- Do not merge `master` into `beta`; the branch is always regenerated from scratch. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index da24ad166b..b3116d5260 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -133,6 +133,78 @@ If you have questions or run into issues: For questions about the Fern code generator itself, see the [Fern documentation](https://buildwithfern.com) or [Fern repository](https://github.com/fern-api/fern). +## Beta Track Releases + +> Applies only when working on the `beta` branch. + +This SDK ships two tracks from one npm package (`auth0`): + +- **Stable** (`master`): EA/GA endpoints only. Released by a maintainer via a `release/*` branch. +- **Beta** (`beta`): a superset of stable plus beta-only endpoints. Released automatically when a PR is merged into `beta`. + +The `beta` branch is **regenerated** from the stable spec plus the beta-only spec files; it is never produced by merging `master` into `beta`. It receives one combined regeneration PR (stable + beta) as a single squash commit. + +### Versioning + +Beta uses the next stable minor + `-beta.N`, derived from git tags: + +``` +Latest stable: v6.3.0 → Next beta: v6.4.0-beta.1 + Subsequent betas: v6.4.0-beta.2, v6.4.0-beta.3, ... + Once v6.4.0 ships: v6.5.0-beta.1 +``` + +Because `v6.4.0-beta.N` sorts before `v6.4.0` in semver, consumers running `npm install auth0` will never receive a beta version unless they explicitly pin it: + +```sh +# Stable (default) +npm install auth0 + +# Beta (explicit prerelease pin) +npm install auth0@beta +# or a specific version +npm install auth0@6.4.0-beta.1 +``` + +### When merging a beta regeneration PR + +Squash and merge with a commit message that marks each group: + +``` +Regenerate SDK (stable + beta) (#) + + +- feat: add `Management.Sandbox` preview API (Beta) + + + +- feat: add tenant security headers configuration + +``` + +- Beta-only changes go inside the `BETA` markers; stable-mirrored changes go inside the `STABLE` markers. +- The `` markers are HTML comments and stay invisible in GitHub's rendered view. +- Omitting a section renders a "No ... changes in this release." note; omitting both falls back to the raw commit subject. Always prefer the structured form. +- Everything reaches `beta` through a PR. Never push directly to `beta` — a direct push will not trigger a release. + +### What happens automatically after a PR is merged into beta + +1. The `beta-autorelease` workflow computes the next `vX.Y.0-beta.N` from git tags. +2. It aborts if that tag already exists (prevents overwriting a published release). +3. It parses the `` / `` sections from the squash commit message. +4. It stamps `.version`, `package.json`, and `CHANGELOG.md` on disk. +5. It builds the package and publishes to npm with `--tag beta --provenance`. +6. It creates a signed release commit via the GitHub API (shows as **Verified**, no GPG key required). +7. It tags the commit and publishes a GitHub prerelease. + +### Do not hand-edit release files on beta + +The `beta-autorelease` workflow owns `.version`, `package.json` (version field), and `CHANGELOG.md` on the `beta` branch. Never manually bump these files on `beta`. + +### Hand-written code + +Code outside of the Fern-generated directories is hand-written and is **not** regenerated on `beta`. A fix on `master` does not reach `beta` automatically, so hand-written changes must be PR'd to **both** `master` and `beta`. + ## License By contributing to this project, you agree that your contributions will be licensed under the same license as the project.