From cc7f87e00cb20be2eae442e2094c64306f0ff7d5 Mon Sep 17 00:00:00 2001 From: "ankitatripathi.mp@gmail.com" Date: Thu, 20 Aug 2026 16:43:05 +0530 Subject: [PATCH 1/2] ci: add automated beta SDK release track --- .../actions/compute-next-version/action.yml | 69 +++++ .github/workflows/beta-autorelease.yml | 280 ++++++++++++++++++ .github/workflows/ci.yml | 4 +- AGENTS.md | 49 +++ CONTRIBUTING.md | 72 +++++ 5 files changed, 472 insertions(+), 2 deletions(-) create mode 100644 .github/actions/compute-next-version/action.yml create mode 100644 .github/workflows/beta-autorelease.yml 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. From e377d0d6a6065ad610d74b6877a65da42cc9c327 Mon Sep 17 00:00:00 2001 From: "ankitatripathi.mp@gmail.com" Date: Fri, 4 Sep 2026 15:49:32 +0530 Subject: [PATCH 2/2] ci: route beta releases through release.yml to satisfy npm trusted publisher --- .github/workflows/beta-autorelease.yml | 280 ------------------------- .github/workflows/npm-release-beta.yml | 223 ++++++++++++++++++++ .github/workflows/release.yml | 61 +++++- 3 files changed, 281 insertions(+), 283 deletions(-) delete mode 100644 .github/workflows/beta-autorelease.yml create mode 100644 .github/workflows/npm-release-beta.yml diff --git a/.github/workflows/beta-autorelease.yml b/.github/workflows/beta-autorelease.yml deleted file mode 100644 index c123847c1a..0000000000 --- a/.github/workflows/beta-autorelease.yml +++ /dev/null @@ -1,280 +0,0 @@ -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/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: