diff --git a/.github/RELEASING.md b/.github/RELEASING.md index ddc79b32..51d94889 100644 --- a/.github/RELEASING.md +++ b/.github/RELEASING.md @@ -12,18 +12,13 @@ 1. Choose a new version (e.g. 1.2.3), making sure to follow semver. Note that all packages in this repository use the same version number. -2. Make sure you are on the latest main, and create a new git branch. -3. Set the new version across all packages within the monorepo with the following - command: `npm run setversion 1.2.3` -4. Commit, push, and open a pull request with the title "Release 1.2.3". -5. Edit the PR description with release notes. See the section below for details. -6. Make sure CI passed on your PR and ask a maintainer for review. -7. After approval, run the following command to publish to npmjs.com: `npm run release`. -8. Merge your PR. -9. Create a new release in the GitHub UI - - Choose "v1.2.3" as a tag and as the release title. - - Copy and paste the release notes from the PR description. - - Check the checkbox “Create a discussion for this release”. +2. Trigger the prepare-release workflow that will create a release PR. + +- Note: If releasing for a hotfix of a major version that is behind the current main branch, make sure to create an appropriate branch (e.g. release/v1.x) before running the workflow with the branch name set as the base_branch. + +3. Edit the PR description with release notes. See the section below for details. +4. Make sure CI passed on your PR and ask a maintainer for review. +5. After approval, merge your PR. ## Release notes diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml new file mode 100644 index 00000000..9b9790ce --- /dev/null +++ b/.github/workflows/prepare-release.yml @@ -0,0 +1,91 @@ +name: Prepare Release + +on: + workflow_dispatch: + inputs: + version: + description: "Version to release (e.g. 1.2.3)" + required: true + type: string + base_branch: + description: | + Base branch for release (release/v1.x, or main). Specifying + a base branch other than main will require that release branch to + exist. Select something like release/v1.x to create a release + for a hotfix of a major version that is behind the current main + branch. + required: false + default: "main" + type: string + +jobs: + prepare-release: + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + ref: ${{ inputs.base_branch }} + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version-file: ".nvmrc" + + - name: Install dependencies + run: npm ci + + - name: Create release branch + run: | + git config --global user.name 'github-actions[bot]' + git config --global user.email 'github-actions[bot]@users.noreply.github.com' + git checkout -b "release/prep-release-${{ inputs.version }}" + + - name: Get current workspace version + id: workspace_version + run: | + VERSION=$(npm run getversion --silent) + echo "version=$VERSION" >> $GITHUB_OUTPUT + + - name: Set version and run build + run: | + npm run setversion ${{ inputs.version }} + + - name: Commit version changes + run: | + git add . + git commit -s -m "Release ${{ inputs.version }}" + git push --set-upstream origin "release/prep-release-${{ inputs.version }}" + + - name: Get release notes + id: release_notes + run: | + RELEASE_NOTES=$( + gh api \ + --method POST \ + -H "Accept: application/vnd.github+json" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + /repos/${{ github.repository }}/releases/generate-notes \ + -f 'tag_name=v${{ inputs.version }}' -f 'target_commitish=${{ inputs.base_branch }}' -f 'previous_tag_name=v${{ steps.workspace_version.outputs.version }}' \ + --jq ".body" \ + ) + echo "notes<> $GITHUB_OUTPUT + echo "$RELEASE_NOTES" >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Create pull request + run: | + gh pr create \ + --title "Release ${{ inputs.version }}" \ + --body "${{ steps.release_notes.outputs.notes }}" \ + --base "${{ inputs.base_branch }}" + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml new file mode 100644 index 00000000..5ffb3048 --- /dev/null +++ b/.github/workflows/publish-release.yml @@ -0,0 +1,63 @@ +name: Publish Release + +on: + pull_request: + types: [closed] + branches: + - main + - "release/**" + +jobs: + publish-release: + runs-on: ubuntu-latest + # Only run if PR was merged and branch name starts with release/prep-release- + if: github.event.pull_request.merged == true && startsWith(github.event.pull_request.head.ref, 'release/prep-release-') + permissions: + id-token: write # Required for OIDC + contents: write + pull-requests: write + issues: write + + steps: + - name: Checkout base branch + uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.base.ref }} + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version-file: ".nvmrc" + + - name: Install dependencies + run: npm ci + + - name: Get current workspace version + id: workspace_version + run: | + VERSION=$(npm run getversion --silent) + echo "version=$VERSION" >> $GITHUB_OUTPUT + + - name: Get updated release notes from PR + id: pr_notes + run: | + RELEASE_NOTES=$(gh pr view ${{ github.event.pull_request.number }} --json body | jq -r ".body") + echo "notes<> $GITHUB_OUTPUT + echo "$RELEASE_NOTES" >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Publish to npm + run: npm run release + + - name: Publish GitHub release + run: | + gh release create v${{ steps.workspace_version.outputs.version }} \ + --title "Release v${{ steps.workspace_version.outputs.version }}" \ + --notes "${{ steps.pr_notes.outputs.notes }}" + # --discussion-category "Announcements" ## Enable if discussions are enabled + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/cspell.config.json b/cspell.config.json index 5d030eeb..550e4c1e 100644 --- a/cspell.config.json +++ b/cspell.config.json @@ -40,6 +40,7 @@ "oneof", "typesafe", "setversion", + "getversion", "postsetversion", "postgenerate", "npmjs" diff --git a/package.json b/package.json index 09c49c77..5ba91566 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "all": "turbo run --ui tui build format test lint attw license-header", "clean": "git clean -Xdf", "setversion": "node scripts/set-workspace-version.js", + "getversion": "node scripts/find-workspace-version.js", "postsetversion": "npm run all", "release": "node scripts/release.js", "prerelease": "npm run all", diff --git a/scripts/find-workspace-version.js b/scripts/find-workspace-version.js new file mode 100755 index 00000000..121d5742 --- /dev/null +++ b/scripts/find-workspace-version.js @@ -0,0 +1,17 @@ +// Copyright 2021-2023 The Connect Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { findWorkspaceVersion } from "./utils.js"; + +process.stdout.write(`${findWorkspaceVersion("packages")}\n`); diff --git a/scripts/release.js b/scripts/release.js index 1b136c65..162377e0 100644 --- a/scripts/release.js +++ b/scripts/release.js @@ -12,20 +12,16 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { readdirSync, readFileSync } from "fs"; -import { join } from "path"; -import { existsSync } from "node:fs"; import { execSync } from "node:child_process"; +import { findWorkspaceVersion } from "./utils.js"; /* * Publish connect-query * * Recommended procedure: - * 1. Set a new version with `npm run setversion 1.2.3` - * 2. Commit and push all changes to a PR, wait for approval. - * 3. Login with `npm login` - * 4. Publish to npmjs.com with `npm run release` - * 5. Merge PR and create a release on GitHub + * 1. Trigger the prepare-release workflow with the version you want to release. + * 2. Reviews release notes in the created PR, wait for approval. + * 3. Merge the PR. */ const tag = determinePublishTag(findWorkspaceVersion("packages")); @@ -79,35 +75,3 @@ function determinePublishTag(version) { throw new Error(`Unable to determine publish tag from version ${version}`); } } - -/** - * @param {string} packagesDir - * @returns {string} - */ -function findWorkspaceVersion(packagesDir) { - let version = undefined; - for (const entry of readdirSync(packagesDir, { withFileTypes: true })) { - if (!entry.isDirectory()) { - continue; - } - const path = join(packagesDir, entry.name, "package.json"); - if (existsSync(path)) { - const pkg = JSON.parse(readFileSync(path, "utf-8")); - if (pkg.private === true) { - continue; - } - if (!pkg.version) { - throw new Error(`${path} is missing "version"`); - } - if (version === undefined) { - version = pkg.version; - } else if (version !== pkg.version) { - throw new Error(`${path} has unexpected version ${pkg.version}`); - } - } - } - if (version === undefined) { - throw new Error(`unable to find workspace version`); - } - return version; -} diff --git a/scripts/set-workspace-version.js b/scripts/set-workspace-version.js index deb647b5..78814ad5 100755 --- a/scripts/set-workspace-version.js +++ b/scripts/set-workspace-version.js @@ -18,7 +18,11 @@ import { readFileSync, writeFileSync, existsSync, globSync } from "node:fs"; import { dirname, join } from "node:path"; -if (process.argv.length !== 3 || !/^\d+\.\d+\.\d+$/.test(process.argv[2])) { +// Ensures that a valid semver version is provided +// See https://semver.org/#is-there-a-suggested-regular-expression-regex-to-check-a-semver-string +const versionRegex = + /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/; +if (process.argv.length !== 3 || !versionRegex.test(process.argv[2])) { process.stderr.write( [ `USAGE: ${process.argv[1]} `, @@ -28,6 +32,12 @@ if (process.argv.length !== 3 || !/^\d+\.\d+\.\d+$/.test(process.argv[2])) { "If a package depends on another package from the workspace, the", "dependency version is updated as well.", "", + ...(versionRegex.test(process.argv[2]) + ? [] + : [ + "Version provided is not a valid semver version.", + "Please provide a version in the format MAJOR.MINOR.PATCH[-PRERELEASE+BUILD].", + ]), ].join("\n"), ); process.exit(1); diff --git a/scripts/utils.js b/scripts/utils.js new file mode 100644 index 00000000..4969090e --- /dev/null +++ b/scripts/utils.js @@ -0,0 +1,50 @@ +// Copyright 2021-2023 The Connect Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { readdirSync, readFileSync, existsSync } from "node:fs"; +import { join } from "node:path"; + +/** + * Retrieves the workspace version from the package directory. + * + * @param {string} packagesDir + * @returns {string} + */ +export function findWorkspaceVersion(packagesDir) { + let version = undefined; + for (const entry of readdirSync(packagesDir, { withFileTypes: true })) { + if (!entry.isDirectory()) { + continue; + } + const path = join(packagesDir, entry.name, "package.json"); + if (existsSync(path)) { + const pkg = JSON.parse(readFileSync(path, "utf-8")); + if (pkg.private === true) { + continue; + } + if (!pkg.version) { + throw new Error(`${path} is missing "version"`); + } + if (version === undefined) { + version = pkg.version; + } else if (version !== pkg.version) { + throw new Error(`${path} has unexpected version ${pkg.version}`); + } + } + } + if (version === undefined) { + throw new Error(`unable to find workspace version`); + } + return version; +}