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
24 changes: 20 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,12 +35,14 @@ on:
push:
branches:
- main
permissions:
contents: write
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
registry-url: https://registry.npmjs.org/
Expand All @@ -49,8 +51,16 @@ jobs:
- run: pver release
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
GH_TOKEN: ${{ secrets.GH_TOKEN }}
```

If `main` is protected, keep `contents: write` on the workflow and either:

- allow GitHub Actions to bypass branch protection, then use a tokenized HTTPS remote via `GH_TOKEN` or `GITHUB_TOKEN`, or
- set `--base-branch main --push-branch <release-branch>` and merge that branch through a PR instead of pushing to `main` directly.

If you use a PAT in `GH_TOKEN`, it needs `repo` and `workflow` scopes.

## Usage

```bash
Expand Down Expand Up @@ -81,6 +91,9 @@ pver release increment --git
pver release announce --git
pver release bigrelease --git

# Protected main branch workflow: sync from main, push release commits elsewhere
pver release --push-main --git --package-json --base-branch main --push-branch release

# Automatically compute the latest Pragmatic Version using the
# git history, tag the current commit and stage the changes locally
pver stage --git
Expand All @@ -100,12 +113,14 @@ on:
push:
branches:
- main
permissions:
contents: write
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
registry-url: https://registry.npmjs.org/
Expand All @@ -114,6 +129,7 @@ jobs:
- run: pver release
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
GH_TOKEN: ${{ secrets.GH_TOKEN }}
```

## Analysis
Expand Down
9 changes: 9 additions & 0 deletions cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,15 @@ yargs(hideBin(process.argv))
describe: "Disable pushing to main branch (overrides --push-main)",
type: "boolean",
})
.option("base-branch", {
describe: "Base branch to sync from before creating release commits",
type: "string",
default: "main",
})
.option("push-branch", {
describe: "Branch to push release commits to",
type: "string",
})
.option("readme", {
describe: "Increment version in README files",
type: "boolean",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { AppContext } from "../../app-context"
import simpleGit from "simple-git"
import { getBaseBranch } from "../../release/get-branch-targets"

export const getLastCommitAlteringPackageJsonVersion = async (
ctx: AppContext
Expand All @@ -12,8 +13,8 @@ export const getLastCommitAlteringPackageJsonVersion = async (
// Only one commit in the log usually means that we're on a github action
// with the checkout depth set to 1 (the default). Let's pull in some
// commits
// Fetch history of main branch TODO detect main branch
await git.fetch(["origin", "main", "--depth=30"])
// Fetch history of the base branch so shallow protected-branch workflows work
await git.fetch(["origin", getBaseBranch(ctx), "--depth=30"])
log = await git.log({ file: "package.json" })
}

Expand Down
13 changes: 13 additions & 0 deletions src/app-context/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ export type AppContext = {
transition_method: "auto" | "simplegit"
release_methods: Array<ReleaseMethod>
readme_files: string[]
base_branch: string
push_branch?: string
}

export const getAppContext = async ({
Expand Down Expand Up @@ -57,6 +59,15 @@ export const getAppContext = async ({
readme_files.push("README.md", "README.txt", "readme.md", "readme.txt")
}

const base_branch =
typeof argv.baseBranch === "string" && argv.baseBranch.trim().length > 0
? argv.baseBranch.trim()
: "main"
const push_branch =
typeof argv.pushBranch === "string" && argv.pushBranch.trim().length > 0
? argv.pushBranch.trim()
: undefined

if (has_git_dir === undefined) {
has_git_dir = await fs
.stat(".git")
Expand Down Expand Up @@ -85,5 +96,7 @@ export const getAppContext = async ({
transition_method: argv.transition ?? "auto",
release_methods: [...new Set(release_methods)],
readme_files: [...new Set(readme_files)],
base_branch,
push_branch,
}
}
35 changes: 16 additions & 19 deletions src/release/configure-origin.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,23 @@
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"])
const github_token = process.env.GH_TOKEN?.trim() || process.env.GITHUB_TOKEN?.trim()

// 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 (github_token) {
const remote_url = (await git.remote(["get-url", "origin"])).trim()

// Normalize SSH remotes to HTTPS before injecting the token.
const https_remote_url = remote_url
.replace(/^git@github\.com:/, "https://github.com/")
.replace(/^ssh:\/\/git@github\.com\//, "https://github.com/")
.replace(/^https:\/\/(?:oauth2:[^@]+@)?github\.com\//, "https://github.com/")

const tokenized_remote_url = https_remote_url.replace(
"https://github.com/",
`https://oauth2:${github_token}@github.com/`
)

if (!remote_url.includes("oauth2:")) {
const new_url = remote_url!
.replace(
"https://",
`https://oauth2:${process.env.GITHUB_TOKEN.trim()}@`
)
.replace(/\n/g, "")
if (tokenized_remote_url !== remote_url) {
await git.removeRemote("origin")
await git.addRemote("origin", new_url)
await git.addRemote("origin", tokenized_remote_url)
}
}
}
6 changes: 6 additions & 0 deletions src/release/get-branch-targets.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { AppContext } from "../app-context"

export const getBaseBranch = (ctx: AppContext) => ctx.base_branch

export const getPushBranch = (ctx: AppContext) =>
ctx.push_branch?.trim() || ctx.base_branch
4 changes: 2 additions & 2 deletions src/release/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ export const release = async (ctx: AppContext) => {

console.log(`Release methods enabled: ${ctx.release_methods.join(",")}`)

// Always sync with remote to establish origin/main reference for workflows
// Always sync with remote to establish the tracked base branch reference for workflows
await syncWithRemote(ctx)

if (ctx.release_methods.includes("git")) {
Expand All @@ -26,4 +26,4 @@ export const release = async (ctx: AppContext) => {
if (ctx.release_methods.includes("push-main")) {
await pushToMain(ctx)
}
}
}
6 changes: 4 additions & 2 deletions src/release/push-to-main.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import simpleGit, { SimpleGit } from "simple-git"
import { AppContext } from "../app-context"
import { configureOrigin } from "./configure-origin"
import { getPushBranch } from "./get-branch-targets"

export const pushToMain = async (ctx: AppContext) => {
console.log("Pushing to main")
const push_branch = getPushBranch(ctx)
console.log(`Pushing to ${push_branch}`)
let git: SimpleGit = simpleGit(ctx.current_directory)

await configureOrigin(git)

await git.push(["origin", `HEAD:${process.env.GITHUB_REF ?? "main"}`])
await git.push(["origin", `HEAD:${push_branch}`])
}
12 changes: 7 additions & 5 deletions src/release/sync-with-remote.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,19 @@
import simpleGit, { SimpleGit } from "simple-git"
import { AppContext } from "../app-context"
import { configureOrigin } from "./configure-origin"
import { getBaseBranch } from "./get-branch-targets"

export const syncWithRemote = async (ctx: AppContext) => {
console.log("Syncing with remote main")
const base_branch = getBaseBranch(ctx)
console.log(`Syncing with remote ${base_branch}`)
let git: SimpleGit = simpleGit(ctx.current_directory)

await configureOrigin(git)

// Pull main and rebase to sync with remote
// This establishes the origin/main reference needed for workflows
// Pull the base branch and rebase to sync with remote
// This establishes the origin/<base_branch> reference needed for workflows
try {
await git.pull("origin", "main", ["--rebase"])
await git.pull("origin", base_branch, ["--rebase"])
} catch (e: any) {
if (e.toString().includes("unstaged changes")) {
const status = await git.status()
Expand All @@ -33,4 +35,4 @@ export const syncWithRemote = async (ctx: AppContext) => {
}
throw e
}
}
}
107 changes: 107 additions & 0 deletions tests/protected-branch.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import assert from "node:assert/strict"
import { AppContext } from "../src/app-context"
import { configureOrigin } from "../src/release/configure-origin"
import { getBaseBranch, getPushBranch } from "../src/release/get-branch-targets"

class FakeGit {
public removed = false
public added: Array<[string, string]> = []

constructor(private remoteUrl: string) {}

async remote(args: Array<string>) {
if (args[0] !== "get-url" || args[1] !== "origin") {
throw new Error(`unexpected remote command: ${args.join(" ")}`)
}
return this.remoteUrl
}

async removeRemote(name: string) {
assert.equal(name, "origin")
this.removed = true
}

async addRemote(name: string, url: string) {
assert.equal(name, "origin")
this.added.push([name, url])
this.remoteUrl = url
}
}

const makeContext = (overrides: Partial<AppContext> = {}): AppContext =>
({
current_method: "auto",
transition_method: "auto",
release_methods: [],
readme_files: [],
base_branch: "main",
...overrides,
}) as AppContext

const withEnv = async (
env: Record<string, string | undefined>,
run: () => Promise<void>
) => {
const original = {
GH_TOKEN: process.env.GH_TOKEN,
GITHUB_TOKEN: process.env.GITHUB_TOKEN,
}

if (env.GH_TOKEN === undefined) {
delete process.env.GH_TOKEN
} else {
process.env.GH_TOKEN = env.GH_TOKEN
}

if (env.GITHUB_TOKEN === undefined) {
delete process.env.GITHUB_TOKEN
} else {
process.env.GITHUB_TOKEN = env.GITHUB_TOKEN
}

try {
await run()
} finally {
if (original.GH_TOKEN === undefined) {
delete process.env.GH_TOKEN
} else {
process.env.GH_TOKEN = original.GH_TOKEN
}

if (original.GITHUB_TOKEN === undefined) {
delete process.env.GITHUB_TOKEN
} else {
process.env.GITHUB_TOKEN = original.GITHUB_TOKEN
}
}
}

async function main() {
const base_context = makeContext({ push_branch: "release/protected" })
assert.equal(getBaseBranch(base_context), "main")
assert.equal(getPushBranch(base_context), "release/protected")
assert.equal(getPushBranch(makeContext()), "main")

await withEnv({ GH_TOKEN: "gh_secret" }, async () => {
const git = new FakeGit("git@github.com:example/repo.git")
await configureOrigin(git)
assert.equal(git.removed, true)
assert.deepEqual(git.added, [
["origin", "https://oauth2:gh_secret@github.com/example/repo.git"],
])
})

await withEnv({ GITHUB_TOKEN: "github_secret" }, async () => {
const git = new FakeGit("https://github.com/example/repo.git")
await configureOrigin(git)
assert.equal(git.removed, true)
assert.deepEqual(git.added, [
["origin", "https://oauth2:github_secret@github.com/example/repo.git"],
])
})
}

main().catch((error) => {
console.error(error)
process.exit(1)
})