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/beta-autorelease.yml b/.github/workflows/beta-autorelease.yml new file mode 100644 index 0000000000..c123847c1a --- /dev/null +++ b/.github/workflows/beta-autorelease.yml @@ -0,0 +1,280 @@ +name: Beta Auto-Release + +# +# Publishes a beta prerelease 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 with the `beta` dist-tag, +# the commit is tagged, and a GitHub prerelease is created. +# +# Triggering on `pull_request: closed` (merged) rather than `push` means: +# - the bot's own "Release ..." commit cannot re-trigger the workflow, so no +# infinite-loop guard is needed (a PR merges exactly once); +# - two PRs merging in quick succession each get their own run with their own +# computed version, instead of racing on the same `push` event. +# All inflow to `beta` goes through PRs, so nothing is missed. +# +# This runs on the `beta` branch, so CHANGELOG.md here is the beta track's +# own changelog and never collides with the stable changelog on `master`. +# +# `workflow_dispatch` is kept only as a manual backup; it is not used in the +# normal hands-off flow. +# +# Security notes: +# - This uses `pull_request` (NOT `pull_request_target`). 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, which do have the needed access. +# - The workflow never executes checked-out PR code; it only reads the merge +# commit message and stamps files. Untrusted text is handled via shell +# variables/`env:`, never interpolated into `run:` via `${{ }}`. +# - Permissions default to none and are granted minimally at the job level. +# + +on: + pull_request: + branches: [beta] + types: [closed] + workflow_dispatch: + +# Serialize releases so concurrent merges cut versions one at a time and the +# push-back to `beta` never races. +concurrency: + group: beta-release + cancel-in-progress: false + +# Least privilege: grant nothing by default; the job opts into exactly what it +# needs (pushing the release commit/tag, creating the GitHub release, and +# publishing to npm with provenance). +permissions: {} + +jobs: + beta-release: + # Run for a genuinely merged PR into beta, or a manual dispatch on beta. + if: >- + github.repository == 'auth0/node-auth0' && + (github.event.pull_request.merged == true || github.event_name == 'workflow_dispatch') + runs-on: ubuntu-latest + environment: release + permissions: + contents: write + id-token: write # For publishing to npm using --provenance + + steps: + # Checkout the code + - 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: refuse to proceed if the computed tag already + # exists, so a re-run can never overwrite a published release or + # create a duplicate release commit. + - 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 the release notes from the structured squash-commit message + # of the merged regeneration PR. The combined beta PR mixes + # stable-mirrored and beta-only changes in one squash commit, so the + # author marks them at merge time: + # + # + # - feat: add Sandbox preview API (Beta) + # + # + # - feat: add tenant security headers + # + # + # We render a self-contained entry with a "Beta" section and a + # "Stable (from master)" section. If the markers are absent we fall + # back to the raw commit subject so the release never produces empty + # notes. + - 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 '' '') + + # Use an unpredictable heredoc delimiter so untrusted + # 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 + # No structured markers: fall back to the commit subject. + 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" + env: + VERSION: ${{ steps.get_version.outputs.version }} + + # Stamp the version into .version, package.json, + # src/management/version.ts, and prepend a CHANGELOG.md entry. + # This only edits files on disk; the commit is created separately + # through the GitHub API (later step) so GitHub signs it server-side. + - 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 + + # src/management/version.ts embeds the version at runtime + 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 package.json version. + # If publish fails we have not yet created a release commit, keeping + # the beta branch clean. + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22.23.1 + 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 + run: yarn build + + - name: Validate package + run: yarn lint:package + + - name: Publish to npm with beta dist-tag + run: npm publish --provenance --tag beta + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + + # Create the release commit through the GitHub API. Commits created + # via the API are signed by GitHub's web-flow key server-side, so + # the release commit shows as "Verified", with no GPG key to manage. + # The commit is built from blobs/tree/commit and the `beta` ref is + # fast-forwarded to it. + - id: stamp + name: Create signed release commit + uses: actions/github-script@v7 + env: + VERSION: ${{ steps.get_version.outputs.version }} + with: + script: | + const fs = require('fs'); + const { owner, repo } = context.repo; + const branch = 'beta'; + const version = process.env.VERSION; + const files = ['.version', 'package.json', 'src/management/version.ts', 'CHANGELOG.md']; + + // Current tip of beta = parent of the new commit. + const ref = await github.rest.git.getRef({ + owner, repo, ref: `heads/${branch}`, + }); + const parentSha = ref.data.object.sha; + const parentCommit = await github.rest.git.getCommit({ + owner, repo, commit_sha: parentSha, + }); + + // Upload each changed file as a blob and assemble a tree. + const tree = []; + for (const path of files) { + const blob = await github.rest.git.createBlob({ + owner, repo, + content: fs.readFileSync(path, 'utf8'), + encoding: 'utf-8', + }); + tree.push({ path, mode: '100644', type: 'blob', sha: blob.data.sha }); + } + const newTree = await github.rest.git.createTree({ + owner, repo, base_tree: parentCommit.data.tree.sha, tree, + }); + + // Create the commit (GitHub signs this) and move beta to it. + const commit = await github.rest.git.createCommit({ + owner, repo, + message: `Release ${version}`, + tree: newTree.data.sha, + parents: [parentSha], + }); + await github.rest.git.updateRef({ + owner, repo, ref: `heads/${branch}`, sha: commit.data.sha, + }); + + core.setOutput('SHA', commit.data.sha); + core.notice(`Created signed release commit ${commit.data.sha} for ${version}`); + + # Create the GitHub prerelease. action-gh-release creates the tag at + # the signed commit, so the tag points at the verified release commit. + - 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.stamp.outputs.SHA }} + prerelease: "true" 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/AGENTS.md b/AGENTS.md index d29e25cacc..6530b4efb1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -471,3 +471,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.