Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down
18 changes: 18 additions & 0 deletions cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ interface ReleaseOptions {
versionpy?: boolean
versionrb?: boolean
mdfile?: string
preparedVersion?: boolean
}

if (process.env.NPM_TOKEN && !process.env.NODE_AUTH_TOKEN) {
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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 })
Expand Down
20 changes: 20 additions & 0 deletions src/analyze/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Analysis> => {
Expand All @@ -35,6 +36,24 @@ export const analyze = async (ctx: AppContext): Promise<Analysis> => {
`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(
Expand Down Expand Up @@ -76,5 +95,6 @@ export const analyze = async (ctx: AppContext): Promise<Analysis> => {
transition_method,
current_version,
next_version,
current_version_is_unreleased,
}
}
12 changes: 10 additions & 2 deletions src/app-context/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export type AppContext = {

current_method: "auto" | "package.json"
transition_method: "auto" | "simplegit"
prepared_version: boolean
release_methods: Array<ReleaseMethod>
readme_files: string[]
}
Expand All @@ -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) {
Expand Down Expand Up @@ -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)],
}
Expand Down
67 changes: 43 additions & 24 deletions src/release/configure-origin.ts
Original file line number Diff line number Diff line change
@@ -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)
}
29 changes: 20 additions & 9 deletions src/stage/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`
)
Expand All @@ -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
Expand Down