diff --git a/README.md b/README.md index 2de9789..5751313 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,7 @@ pver release --git --package-json --readme pver release --git --npm --readme # Also available: --pyproject, --setuppy, --versionpy, --versionrb, +# --prepared-version, # --mdfile somefile.md # Use --package-json when you only need the local version bump without an npm publish. @@ -85,6 +86,8 @@ pver release bigrelease --git # git history, tag the current commit and stage the changes locally pver stage --git pver stage increment --git --npm +pver stage --package-json --readme +pver stage --package-json --mdfile CHANGELOG.md # Setup a Github repository to automatically release with pragmatic versions pver setup github @@ -100,11 +103,15 @@ on: push: branches: - main +permissions: + contents: write jobs: publish: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 + with: + fetch-depth: 0 - uses: actions/setup-node@v3 with: node-version: 20 @@ -116,6 +123,71 @@ jobs: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} ``` +### Protected Branches + +`pver release` normally creates a version commit and pushes it back to `main`. +That push is rejected when `main` is protected unless the token used by the +workflow is allowed to bypass the branch protection rule. + +There are two supported protected-branch patterns: + +#### Option A: allow a release token to push the version commit + +Use a fine-grained PAT or GitHub App installation token that has `contents: write` +and is allowed to bypass the `main` branch protection rule. Store it as a secret +named `PVER_GITHUB_TOKEN`, pass it to `actions/checkout`, and expose it to +`pver release`: + +```yml +name: Publish to npm +on: + push: + branches: + - main +permissions: + contents: write +jobs: + publish: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ secrets.PVER_GITHUB_TOKEN }} + - uses: actions/setup-node@v4 + with: + node-version: 20 + registry-url: https://registry.npmjs.org/ + - run: npm ci + - run: npx pver release --git --package-json --push-main + env: + PVER_GITHUB_TOKEN: ${{ secrets.PVER_GITHUB_TOKEN }} + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} +``` + +`PVER_GITHUB_TOKEN` takes priority over the default `GITHUB_TOKEN` when `pver` +configures the `origin` remote before pushing. This lets protected repositories +keep the default GitHub Actions token restricted while giving the release job an +explicit, auditable bypass token. + +#### Option B: do not push to `main` from the release job + +If your branch protection requires all changes to arrive through pull requests, +prepare the version bump in a normal PR, then publish from `main` without pushing +a new commit back to the protected branch: + +```bash +pver stage --git --package-json --readme +# open a PR with the version bump, merge it, then run: +pver release --git --npm --no-push-main --prepared-version +``` + +With `--prepared-version`, when `package.json` already contains a version that +has no matching git tag yet, `pver release` publishes that version as-is instead +of incrementing again. + +Use this pattern when no bot or app is allowed to bypass branch protection. + ## Analysis Analysis has two steps. Getting the `current_version` and using the `transition_method` to diff --git a/cli.ts b/cli.ts index 1bca6fb..203379f 100755 --- a/cli.ts +++ b/cli.ts @@ -15,6 +15,7 @@ interface ReleaseOptions { versionpy?: boolean versionrb?: boolean mdfile?: string + preparedVersion?: boolean } if (process.env.NPM_TOKEN && !process.env.NODE_AUTH_TOKEN) { @@ -84,6 +85,11 @@ yargs(hideBin(process.argv)) describe: "Disable pushing to main branch (overrides --push-main)", type: "boolean", }) + .option("prepared-version", { + describe: + "Release the version already committed in package.json when it has no matching git tag", + type: "boolean", + }) .option("readme", { describe: "Increment version in README files", type: "boolean", @@ -115,6 +121,18 @@ yargs(hideBin(process.argv)) describe: "Stage version in package.json", type: "boolean", }) + .option("package-json", { + describe: "Stage version in package.json without publishing to npm", + type: "boolean", + }) + .option("readme", { + describe: "Stage version in README files", + type: "boolean", + }) + .option("mdfile", { + describe: "Specific markdown file(s) to update while staging", + type: "array", + }) }, async (argv) => { const ctx = await getAppContext({ argv }) diff --git a/src/analyze/index.ts b/src/analyze/index.ts index 721fbc7..cd5749f 100644 --- a/src/analyze/index.ts +++ b/src/analyze/index.ts @@ -10,6 +10,7 @@ export type Analysis = { transition_method: string current_version: string next_version: string + current_version_is_unreleased: boolean } export const analyze = async (ctx: AppContext): Promise => { @@ -35,6 +36,24 @@ export const analyze = async (ctx: AppContext): Promise => { `Couldn't find current version using --current-method: ${ctx.current_method}[${current_method}]` ) + const current_version_is_unreleased = + ctx.prepared_version && + !(await checkIfGitTagExistsForVersion(ctx, current_version)) + + if (current_version_is_unreleased) { + console.log( + `Current package version ${current_version} was marked as prepared and has no matching git tag, releasing it as-is` + ) + + return { + current_method, + transition_method, + current_version, + next_version: current_version, + current_version_is_unreleased, + } + } + let next_version if (transition_method === "simplegit") { next_version = await getSimpleGitTransitions( @@ -76,5 +95,6 @@ export const analyze = async (ctx: AppContext): Promise => { transition_method, current_version, next_version, + current_version_is_unreleased, } } diff --git a/src/app-context/index.ts b/src/app-context/index.ts index fb95831..53a4ba7 100644 --- a/src/app-context/index.ts +++ b/src/app-context/index.ts @@ -22,6 +22,7 @@ export type AppContext = { current_method: "auto" | "package.json" transition_method: "auto" | "simplegit" + prepared_version: boolean release_methods: Array readme_files: string[] } @@ -40,11 +41,12 @@ export const getAppContext = async ({ if (argv.git) release_methods.push("git") if (argv.npm) release_methods.push("npm") - if (argv.pushMain) release_methods.push("push-main") + if (argv.pushMain && !argv.noPushMain) release_methods.push("push-main") if (argv.readme) release_methods.push("readme") if (argv.packageJson) release_methods.push("package-json") if (argv.mdfile) { + release_methods.push("readme") const mdfiles = Array.isArray(argv.mdfile) ? argv.mdfile : [argv.mdfile] for (const file of mdfiles) { if (typeof file === "string" && file.trim().length > 0) { @@ -72,17 +74,23 @@ export const getAppContext = async ({ // automatically determine release methods if (has_git_dir) { release_methods.push("git") - if (argv.pushMain !== false) { + if (!argv.noPushMain) { release_methods.push("push-main") } } if (has_package_json) release_methods.push("npm") } + if (argv.noPushMain) { + const push_main_index = release_methods.indexOf("push-main") + if (push_main_index !== -1) release_methods.splice(push_main_index, 1) + } + return { current_directory: process.cwd(), current_method: argv.current ?? "auto", transition_method: argv.transition ?? "auto", + prepared_version: Boolean(argv.preparedVersion), release_methods: [...new Set(release_methods)], readme_files: [...new Set(readme_files)], } diff --git a/src/release/configure-origin.ts b/src/release/configure-origin.ts index eb15c28..cff2a26 100644 --- a/src/release/configure-origin.ts +++ b/src/release/configure-origin.ts @@ -1,26 +1,45 @@ -export const configureOrigin = async (git: any) => { - if (process.env.GITHUB_TOKEN) { - // This doesn't work- it's possible the checkout command needs to use the - // github token (in the workflow yaml) - const remote_url = await git.remote(["get-url", "origin"]) - - // if the remote is in the form of git@github.com:foo/bar.git, convert it to - // https://github.com/foo/bar.git - if (remote_url.includes("git@")) { - const new_url = remote_url!.replace("git@", "https://") - await git.removeRemote("origin") - await git.addRemote("origin", new_url) - } - - if (!remote_url.includes("oauth2:")) { - const new_url = remote_url! - .replace( - "https://", - `https://oauth2:${process.env.GITHUB_TOKEN.trim()}@` - ) - .replace(/\n/g, "") - await git.removeRemote("origin") - await git.addRemote("origin", new_url) - } +const getGithubToken = () => + process.env.PVER_GITHUB_TOKEN?.trim() || process.env.GITHUB_TOKEN?.trim() + +const normalizeGithubRemoteUrl = (remote_url: string) => { + if (remote_url.startsWith("git@github.com:")) { + return remote_url.replace("git@github.com:", "https://github.com/") + } + + if (remote_url.startsWith("ssh://git@github.com/")) { + return remote_url.replace("ssh://git@github.com/", "https://github.com/") + } + + return remote_url +} + +const addTokenToGithubRemoteUrl = (remote_url: string, token: string) => { + if ( + !remote_url.startsWith("https://github.com/") && + !remote_url.startsWith("https://oauth2:") && + !remote_url.includes("@github.com/") + ) { + return remote_url } + + return remote_url.replace( + /^https:\/\/(?:[^/@]+@)?github\.com\//, + `https://oauth2:${token}@github.com/` + ) +} + +export const configureOrigin = async (git: any) => { + const github_token = getGithubToken() + if (!github_token) return + + const remote_url = (await git.remote(["get-url", "origin"])).trim() + const new_url = addTokenToGithubRemoteUrl( + normalizeGithubRemoteUrl(remote_url), + github_token + ) + + if (new_url === remote_url) return + + await git.removeRemote("origin") + await git.addRemote("origin", new_url) } diff --git a/src/stage/index.ts b/src/stage/index.ts index adac7d0..cd64a92 100644 --- a/src/stage/index.ts +++ b/src/stage/index.ts @@ -10,7 +10,10 @@ export const stage = async (ctx: AppContext) => { const analysis = await analyze(ctx) console.log(`current version: ${analysis.current_version}`) console.log(`next version: ${analysis.next_version}`) - if (analysis.current_version === analysis.next_version) { + if ( + analysis.current_version === analysis.next_version && + !analysis.current_version_is_unreleased + ) { throw new Error( `Next version is the same as current version, not releasing. Check the transition method if you expected a version bump` ) @@ -30,17 +33,25 @@ export const stage = async (ctx: AppContext) => { ctx.release_methods.includes("npm") || ctx.release_methods.includes("package-json") ) { - await updatePackageJson(analysis.next_version, ctx) - files_to_add.push("package.json") + if (analysis.current_version_is_unreleased) { + console.log("package.json already contains the unreleased target version") + } else { + await updatePackageJson(analysis.next_version, ctx) + files_to_add.push("package.json") + } } if (ctx.release_methods.includes("readme")) { - const readme_files = await updateReadme( - analysis.current_version, - analysis.next_version, - ctx - ) - files_to_add.push(...readme_files) + if (analysis.current_version_is_unreleased) { + console.log("README version updates are already expected to be committed") + } else { + const readme_files = await updateReadme( + analysis.current_version, + analysis.next_version, + ctx + ) + files_to_add.push(...readme_files) + } } // Always commit changes if there are files to add, regardless of push-main setting