diff --git a/.github/scripts/open-release-pr.mjs b/.github/scripts/open-release-pr.mjs new file mode 100644 index 0000000..72cabac --- /dev/null +++ b/.github/scripts/open-release-pr.mjs @@ -0,0 +1,97 @@ +#!/usr/bin/env node +// Open a release PR and enable auto-merge — without the gh CLI, which is not +// installed on the self-hosted runner. Uses the REST API to create the PR and the GraphQL +// API to enable auto-merge. Relies only on Node's global fetch (Node >= 22). +// +// The PR targets BASE_BRANCH (default: develop) — the version bump lands on develop +// first and reaches main through the normal develop -> main promotion, never by a +// direct push or PR to main. +// +// Required env: GH_TOKEN, GITHUB_REPOSITORY (owner/repo), VERSION, RELEASE_BRANCH. +// Optional env: BASE_BRANCH (default "develop"). + +const token = process.env.GH_TOKEN; +const repo = process.env.GITHUB_REPOSITORY; +const version = process.env.VERSION; +const branch = process.env.RELEASE_BRANCH; +const baseBranch = process.env.BASE_BRANCH || 'develop'; + +for (const [k, v] of Object.entries({ GH_TOKEN: token, GITHUB_REPOSITORY: repo, VERSION: version, RELEASE_BRANCH: branch })) { + if (!v) { + console.error(`::error::Missing required env ${k}`); + process.exit(1); + } +} + +const [owner, name] = repo.split('/'); + +async function rest(path, init = {}) { + const res = await fetch(`https://api.github.com${path}`, { + ...init, + headers: { + Authorization: `Bearer ${token}`, + Accept: 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28', + 'Content-Type': 'application/json', + ...(init.headers ?? {}), + }, + }); + const body = await res.json().catch(() => ({})); + return { status: res.status, body }; +} + +async function graphql(query, variables) { + const res = await fetch('https://api.github.com/graphql', { + method: 'POST', + headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ query, variables }), + }); + return res.json(); +} + +const prBody = + `Automated version bump for v${version}. Tag \`v${version}\` and the npm publish already ` + + `completed; merging this lands the bumped package.json on \`${baseBranch}\`, from where it ` + + `is promoted to main through the regular ${baseBranch} -> main merge.`; + +// Create the PR — or reuse an existing one for this branch (idempotent on re-run). +let { status, body } = await rest(`/repos/${owner}/${name}/pulls`, { + method: 'POST', + body: JSON.stringify({ title: `release: v${version}`, head: branch, base: baseBranch, body: prBody }), +}); + +let pr; +if (status === 201) { + pr = body; +} else { + // 422 typically means a PR already exists for this head — find and reuse it. + const existing = await rest(`/repos/${owner}/${name}/pulls?head=${owner}:${branch}&state=open`); + if (existing.status === 200 && Array.isArray(existing.body) && existing.body.length > 0) { + pr = existing.body[0]; + console.log(`Reusing existing PR #${pr.number}`); + } else { + console.error(`::error::Failed to create PR (HTTP ${status}): ${JSON.stringify(body)}`); + process.exit(1); + } +} + +console.log(`PR #${pr.number}: ${pr.html_url}`); + +// Enable auto-merge (squash) so it lands once required checks/approvals pass. +const result = await graphql( + `mutation($id: ID!) { + enablePullRequestAutoMerge(input: { pullRequestId: $id, mergeMethod: SQUASH }) { + pullRequest { number } + } + }`, + { id: pr.node_id }, +); + +if (result.errors?.length) { + console.log( + `::warning::Could not enable auto-merge for PR #${pr.number}: ${result.errors.map((e) => e.message).join('; ')}. ` + + `Ensure "Allow auto-merge" is enabled in repo settings. The PR is open for manual merge.`, + ); +} else { + console.log(`Auto-merge (squash) enabled for PR #${pr.number}.`); +} diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 6dcb97c..687aa0a 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -52,6 +52,11 @@ jobs: git config user.email "github-actions[bot]@users.noreply.github.com" npm version ${{ inputs.version }} --no-git-tag-version VERSION=$(node -p "require('./package.json').version") + echo "VERSION=${VERSION}" >> "$GITHUB_ENV" + echo "RELEASE_BRANCH=release/v${VERSION}" >> "$GITHUB_ENV" + # Never touch main directly: commit the bump to a release branch; a PR + # into develop is opened below, and develop is promoted to main manually. + git switch -c "release/v${VERSION}" git add package.json git commit -m "release: v${VERSION}" git tag "v${VERSION}" @@ -66,8 +71,15 @@ jobs: env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - - name: Push version commit and tag + - name: Push release branch and tag, open auto-merge PR into develop if: ${{ inputs.dry-run != true }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + BASE_BRANCH: develop run: | - git push origin main + git push origin "${RELEASE_BRANCH}" git push origin --tags + # gh CLI is not installed on the self-hosted runner — open the PR + enable + # auto-merge via the GitHub API (Node global fetch) instead. The PR targets + # develop; main only receives the bump via the manual develop -> main merge. + node .github/scripts/open-release-pr.mjs diff --git a/package.json b/package.json index 32c9b23..38fcb7c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "devglide", - "version": "0.2.22", + "version": "0.2.35", "description": "AI workflow toolkit with MCP servers for kanban, shell, testing, workflows, and more — built for Claude Code", "license": "MIT", "author": "Daniel Kutyla",