diff --git a/README.md b/README.md index 2de9789..ff95f40 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/release/push-to-main-via-api.ts b/src/release/push-to-main-via-api.ts new file mode 100644 index 0000000..81296ea --- /dev/null +++ b/src/release/push-to-main-via-api.ts @@ -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 + } + } +} diff --git a/src/release/push-to-main.ts b/src/release/push-to-main.ts index 14e94ba..4d927bd 100644 --- a/src/release/push-to-main.ts +++ b/src/release/push-to-main.ts @@ -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 }