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
46 changes: 46 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,52 @@ jobs:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
```

### Using with Protected Branches

If your `main` branch is protected, pver automatically handles this by falling back to the GitHub API. To use this feature:

1. **Ensure your workflow has write permissions**:

```yml
name: Publish to npm
on:
push:
branches:
- main

permissions:
contents: write # Required for updating files via API

jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
with:
token: ${{ secrets.GITHUB_TOKEN }} # Use GITHUB_TOKEN
- uses: actions/setup-node@v3
with:
node-version: 20
registry-url: https://registry.npmjs.org/
- run: npm install -g pver
- run: npm ci
- run: pver release
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Ensure GITHUB_TOKEN is available
```

2. **Configure branch protection** (optional):
- Go to Settings → Branches → Branch protection rules
- Add a rule for `main`
- Enable "Allow GitHub Actions to bypass branch protection" (recommended)

Even without this setting, pver will work because it uses the GitHub API
to update files directly, which can bypass protection rules when using
`GITHUB_TOKEN` from GitHub Actions.

**How it works**: When pver detects that a regular `git push` is blocked due to branch protection, it automatically falls back to using the GitHub REST API to update files. This allows the release process to complete even on protected branches.

## Analysis

Analysis has two steps. Getting the `current_version` and using the `transition_method` to
Expand Down
165 changes: 165 additions & 0 deletions src/release/push-to-main-via-api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
import simpleGit, { SimpleGit } from "simple-git"
import { AppContext } from "../app-context"
import { configureOrigin } from "./configure-origin"
import fs from "fs/promises"
import path from "path"

interface GitHubFile {
path: string
content: string
sha?: string
}

/**
* Push changes to main branch using GitHub API.
* This method works even when the branch is protected because GitHub Actions
* GITHUB_TOKEN can update files via API even on protected branches.
*/
export const pushToMainViaApi = async (ctx: AppContext) => {
console.log("Pushing to main via GitHub API (for protected branch support)")

const token = process.env.GITHUB_TOKEN
if (!token) {
throw new Error("GITHUB_TOKEN is required for API-based push")
}

// Get repository info from environment or git remote
const git: SimpleGit = simpleGit(ctx.current_directory)
const remoteUrlRaw = await git.remote(["get-url", "origin"])
const remoteUrl = typeof remoteUrlRaw === "string" ? remoteUrlRaw : ""

// Parse owner/repo from remote URL
let owner: string, repo: string
const httpsMatch = remoteUrl.match(/https:\/\/github\.com\/([^\/]+)\/([^\/\n]+)/)
const sshMatch = remoteUrl.match(/git@github\.com:([^\/]+)\/([^\/\n]+)/)

if (httpsMatch) {
owner = httpsMatch[1]
repo = httpsMatch[2].replace(/\.git$/, "")
} else if (sshMatch) {
owner = sshMatch[1]
repo = sshMatch[2].replace(/\.git$/, "")
} else {
throw new Error(`Could not parse owner/repo from remote URL: ${remoteUrl}`)
}

console.log(`Target repository: ${owner}/${repo}`)

// Get the current branch name
const branch = process.env.GITHUB_REF_NAME ??
(process.env.GITHUB_REF ? process.env.GITHUB_REF.replace("refs/heads/", "") : "main")

console.log(`Target branch: ${branch}`)

// Get list of changed files
const status = await git.status()
const changedFiles = [...status.staged, ...status.modified, ...status.created]

if (changedFiles.length === 0) {
console.log("No files to commit")
return
}

console.log(`Files to update: ${changedFiles.join(", ")}`)

// Get the latest commit message
const log = await git.log(["-1"])
const commitMessage = log.latest?.message ?? "Update files via pver"

// For each changed file, update via GitHub API
for (const filePath of changedFiles) {
const fullPath = path.join(ctx.current_directory ?? ".", filePath)

try {
const content = await fs.readFile(fullPath, "utf-8")
const base64Content = Buffer.from(content).toString("base64")

// Get current file SHA (if exists)
let sha: string | undefined
try {
const response = await fetch(
`https://api.github.com/repos/${owner}/${repo}/contents/${filePath}?ref=${branch}`,
{
headers: {
"Authorization": `Bearer ${token}`,
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
},
}
)
if (response.ok) {
const data = await response.json() as { sha: string }
sha = data.sha
}
} catch (e) {
// File doesn't exist yet, that's fine
}

// Update or create the file
const updateResponse = await fetch(
`https://api.github.com/repos/${owner}/${repo}/contents/${filePath}`,
{
method: "PUT",
headers: {
"Authorization": `Bearer ${token}`,
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
"Content-Type": "application/json",
},
body: JSON.stringify({
message: commitMessage,
content: base64Content,
sha,
branch,
}),
}
)

if (!updateResponse.ok) {
const error = await updateResponse.text()
throw new Error(`Failed to update ${filePath}: ${updateResponse.status} ${error}`)
}

console.log(`✓ Updated ${filePath}`)
} catch (e) {
console.error(`Failed to update ${filePath}:`, e)
throw e
}
}

console.log("Successfully pushed all files via GitHub API")
}

/**
* Try git push first, fallback to GitHub API if branch is protected.
*/
export const pushToMainWithFallback = async (ctx: AppContext) => {
console.log("Pushing to main (with protected branch fallback)")

let git: SimpleGit = simpleGit(ctx.current_directory)
await configureOrigin(git)

const branch = process.env.GITHUB_REF ?? "main"

try {
// Try regular git push first
await git.push(["origin", `HEAD:${branch}`])
console.log("Successfully pushed via git")
} catch (error: any) {
const errorMessage = error.message || String(error)

// Check if the error is due to branch protection
if (
errorMessage.includes("protected") ||
errorMessage.includes("required status checks") ||
errorMessage.includes("required pull request reviews") ||
errorMessage.includes("403")
) {
console.log("Branch is protected, falling back to GitHub API method")
await pushToMainViaApi(ctx)
} else {
// Re-throw if it's a different error
throw error
}
}
}
25 changes: 20 additions & 5 deletions src/release/push-to-main.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,27 @@
import simpleGit, { SimpleGit } from "simple-git"
import { AppContext } from "../app-context"
import { configureOrigin } from "./configure-origin"
import { pushToMainViaApi, pushToMainWithFallback } from "./push-to-main-via-api"

/**
* Push to main branch with automatic fallback to GitHub API for protected branches.
*
* This function first attempts a regular git push. If the push fails due to
* branch protection rules, it automatically falls back to using the GitHub API
* to update files, which can bypass branch protection when using GITHUB_TOKEN
* from GitHub Actions.
*
* To enable this behavior, ensure:
* 1. GITHUB_TOKEN is set (automatically available in GitHub Actions)
* 2. Your workflow has appropriate permissions (contents: write)
* 3. Branch protection allows GitHub Actions to bypass (optional but recommended)
*/
export const pushToMain = async (ctx: AppContext) => {
console.log("Pushing to main")
let git: SimpleGit = simpleGit(ctx.current_directory)

await configureOrigin(git)

await git.push(["origin", `HEAD:${process.env.GITHUB_REF ?? "main"}`])

// Use fallback method which handles protected branches automatically
await pushToMainWithFallback(ctx)
}

// Export additional methods for direct use
export { pushToMainViaApi, pushToMainWithFallback }