diff --git a/.github/actions/compute-next-version/action.yml b/.github/actions/compute-next-version/action.yml new file mode 100644 index 0000000000..2c14bf070b --- /dev/null +++ b/.github/actions/compute-next-version/action.yml @@ -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:-} -> ${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..2ee2617707 --- /dev/null +++ b/.github/workflows/npm-release-beta.yml @@ -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: + # + # + # - 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}" + # `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" 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..3826583971 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/npm-release-beta.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..44bf823601 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 `npm-release-beta` 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`, `src/management/version.ts`, 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 `npm-release-beta` workflow owns `.version`, `package.json` (version field), `src/management/version.ts`, 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.