diff --git a/README.md b/README.md index 2de9789..03bdd25 100644 --- a/README.md +++ b/README.md @@ -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/ @@ -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 ` 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 @@ -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 @@ -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/ @@ -114,6 +129,7 @@ jobs: - run: pver release env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + GH_TOKEN: ${{ secrets.GH_TOKEN }} ``` ## Analysis diff --git a/cli.ts b/cli.ts index 1bca6fb..94a8c4a 100755 --- a/cli.ts +++ b/cli.ts @@ -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", diff --git a/src/analyze/current/get-last-commit-altering-package-json-version.ts b/src/analyze/current/get-last-commit-altering-package-json-version.ts index 40ccd4b..dc0b30c 100644 --- a/src/analyze/current/get-last-commit-altering-package-json-version.ts +++ b/src/analyze/current/get-last-commit-altering-package-json-version.ts @@ -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 @@ -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" }) } diff --git a/src/app-context/index.ts b/src/app-context/index.ts index fb95831..6af9ec2 100644 --- a/src/app-context/index.ts +++ b/src/app-context/index.ts @@ -24,6 +24,8 @@ export type AppContext = { transition_method: "auto" | "simplegit" release_methods: Array readme_files: string[] + base_branch: string + push_branch?: string } export const getAppContext = async ({ @@ -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") @@ -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, } } diff --git a/src/release/configure-origin.ts b/src/release/configure-origin.ts index eb15c28..5070279 100644 --- a/src/release/configure-origin.ts +++ b/src/release/configure-origin.ts @@ -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) } } } diff --git a/src/release/get-branch-targets.ts b/src/release/get-branch-targets.ts new file mode 100644 index 0000000..1ed8d44 --- /dev/null +++ b/src/release/get-branch-targets.ts @@ -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 diff --git a/src/release/index.ts b/src/release/index.ts index 46078ef..538b966 100644 --- a/src/release/index.ts +++ b/src/release/index.ts @@ -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")) { @@ -26,4 +26,4 @@ export const release = async (ctx: AppContext) => { if (ctx.release_methods.includes("push-main")) { await pushToMain(ctx) } -} \ No newline at end of file +} diff --git a/src/release/push-to-main.ts b/src/release/push-to-main.ts index 14e94ba..74b4a6c 100644 --- a/src/release/push-to-main.ts +++ b/src/release/push-to-main.ts @@ -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}`]) } diff --git a/src/release/sync-with-remote.ts b/src/release/sync-with-remote.ts index 78a0641..6c1b8f2 100644 --- a/src/release/sync-with-remote.ts +++ b/src/release/sync-with-remote.ts @@ -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/ 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() @@ -33,4 +35,4 @@ export const syncWithRemote = async (ctx: AppContext) => { } throw e } -} \ No newline at end of file +} diff --git a/tests/protected-branch.test.ts b/tests/protected-branch.test.ts new file mode 100644 index 0000000..6257eee --- /dev/null +++ b/tests/protected-branch.test.ts @@ -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) { + 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 => + ({ + current_method: "auto", + transition_method: "auto", + release_methods: [], + readme_files: [], + base_branch: "main", + ...overrides, + }) as AppContext + +const withEnv = async ( + env: Record, + run: () => Promise +) => { + 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) +})