diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1381b7a..137fc82 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -20,11 +20,18 @@ name: Release # created with GITHUB_TOKEN, and GitHub never fires # `on: release` for an event that token created. # -# CI on the release PR: pull requests opened with GITHUB_TOKEN do not trigger -# `pull_request` workflows, so the required checks would never run. Add a -# RELEASE_TOKEN repository secret (fine-grained PAT with contents + pull -# requests: write) and the PR is opened as that user, which triggers CI as -# normal. Without it, close and reopen the PR once to kick off the checks. +# Opening the release PR (issue #144): with a RELEASE_TOKEN repository secret +# (fine-grained PAT with contents + pull requests: write) the PR is opened as +# that user and CI runs on it as normal. Without it the job falls back to +# GITHUB_TOKEN, which has two limits: +# - It may open a PR only while the repository setting "Allow GitHub Actions +# to create and approve pull requests" (Settings > Actions > General > +# Workflow permissions) is on. With it off, GitHub's default, `gh pr create` +# is refused. The branch is pushed by then, so the step does not fail: it +# writes the compare link, the PR body and the exact `gh pr create` command +# to the step summary and ends with a warning. +# - A PR it did open starts no `pull_request` workflow, so the required +# checks never run until someone closes and reopens the PR once. on: workflow_dispatch: @@ -138,6 +145,8 @@ jobs: HAS_RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN != '' }} run: | set -euo pipefail + TMP="${RUNNER_TEMP:-/tmp}" + TITLE="chore(release): $TAG" { echo "Release **$TAG** prepared by the Release workflow (\`$RELEASE_TYPE\` bump)." echo @@ -146,18 +155,46 @@ jobs: echo "## Release notes" echo cat RELEASE_NOTES.md - } > /tmp/release-pr-body.md + } > "$TMP/release-pr-body.md" EXISTING=$(gh pr list --head "$BRANCH" --base main --state open --json url --jq '.[0].url // empty') if [ -n "$EXISTING" ]; then - gh pr edit "$EXISTING" --title "chore(release): $TAG" --body-file /tmp/release-pr-body.md + gh pr edit "$EXISTING" --title "$TITLE" --body-file "$TMP/release-pr-body.md" URL="$EXISTING" + elif URL=$(gh pr create --base main --head "$BRANCH" --title "$TITLE" --body-file "$TMP/release-pr-body.md" 2>"$TMP/pr-create.err"); then + : else - URL=$(gh pr create \ - --base main \ - --head "$BRANCH" \ - --title "chore(release): $TAG" \ - --body-file /tmp/release-pr-body.md) + # Refused (issue #144). Without RELEASE_TOKEN this runs as GITHUB_TOKEN, which may + # open a PR only while "Allow GitHub Actions to create and approve pull requests" + # is on. The branch is already pushed, so hand over to a human instead of failing. + ERROR=$(tr -d '\r' < "$TMP/pr-create.err") + COMPARE="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/compare/main...$BRANCH?expand=1&title=chore%28release%29%3A%20$TAG" + { + echo "## Release PR not opened" + echo + echo "Branch \`$BRANCH\` is pushed, but \`gh pr create\` failed:" + echo + echo '```' + echo "$ERROR" + echo '```' + echo + case "$ERROR" in + *"not permitted to create or approve pull requests"*) + echo "The step ran with \`GITHUB_TOKEN\` and the repository setting **Allow GitHub Actions to create and approve pull requests** (Settings > Actions > General > Workflow permissions) is off. To let the workflow open the PR itself next time, either add a \`RELEASE_TOKEN\` repository secret (fine-grained PAT with Contents and Pull requests: write; the PR is then opened as that user and CI starts on its own) or turn that setting on (the PR then still needs one close and reopen before CI runs)." + echo + ;; + esac + echo "Open the PR by hand from the [compare view]($COMPARE), pasting the body below, or with the GitHub CLI:" + echo + echo '```sh' + echo "gh pr create --repo $GITHUB_REPOSITORY --base main --head $BRANCH \\" + echo " --title \"$TITLE\" --body-file - <<'PR_BODY'" + cat "$TMP/release-pr-body.md" + echo "PR_BODY" + echo '```' + } >> "$GITHUB_STEP_SUMMARY" + echo "::warning::Could not open the release PR ($(echo "$ERROR" | head -n 1)). Branch $BRANCH is pushed; the step summary has the compare link and a gh pr create command." + exit 0 fi echo "Release PR: $URL" >> "$GITHUB_STEP_SUMMARY" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a281939..b448200 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -114,7 +114,7 @@ Releases are cut from the **Release** GitHub Actions workflow ([`.github/workflo 2. **Publish.** Review and merge the release PR like any other. The push to `main` re-runs the workflow, which sees a `package.json` version with no matching tag, tags the merge commit `vX.Y.Z`, and creates the GitHub Release from that version's `CHANGELOG.md` section (via [`scripts/extract-release-notes.js`](scripts/extract-release-notes.js)). Re-running on a version that is already tagged is a no-op. 3. **Installers.** The same run then calls [`gui-release.yml`](.github/workflows/gui-release.yml), which builds the desktop GUI installers on macOS, Windows and Linux runners, smoke-tests each build, and attaches the files to the new Release. If one platform fails, fix it and run **GUI Release Build** by hand from the Actions tab with the tag; the upload replaces any asset that is already there. The release is created with `GITHUB_TOKEN`, so an `on: release` trigger would never fire; that is why the installer build is called from the same run. -> **CI on the release PR.** Pull requests opened with the default `GITHUB_TOKEN` do not trigger `pull_request` workflows, so the required checks never start. Add a `RELEASE_TOKEN` repository secret (a fine-grained personal access token with *Contents* and *Pull requests* write access) and the workflow opens the PR as that user, which runs CI normally. Without it, close and reopen the release PR once to trigger the checks. +> **Opening the release PR.** With a `RELEASE_TOKEN` repository secret (a fine-grained personal access token with *Contents* and *Pull requests* write access) the workflow opens the PR as that user and CI runs on it normally. Without it the workflow falls back to `GITHUB_TOKEN`, which may only open a pull request while the repository setting **Allow GitHub Actions to create and approve pull requests** (Settings > Actions > General > Workflow permissions) is on. With that setting off (GitHub's default) the step does not fail: the branch is already pushed, so the run ends with a warning and its step summary carries the compare link, the PR body and the exact `gh pr create` command to open the PR by hand. A PR that `GITHUB_TOKEN` did open starts no `pull_request` workflow, so close and reopen it once to run the required checks. The same commands are available locally for previews and emergencies: diff --git a/scripts/__tests__/release-workflow.test.js b/scripts/__tests__/release-workflow.test.js index db184c4..1ac95e8 100644 --- a/scripts/__tests__/release-workflow.test.js +++ b/scripts/__tests__/release-workflow.test.js @@ -1,5 +1,7 @@ import { describe, it, expect } from "vitest"; -import { readFileSync } from "fs"; +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "fs"; +import { spawnSync } from "child_process"; +import { tmpdir } from "os"; import { join, dirname } from "path"; import { fileURLToPath } from "url"; @@ -8,11 +10,11 @@ const repoRoot = join(__dirname, "..", ".."); const WORKFLOW = join(repoRoot, ".github", "workflows", "release.yml"); /** - * Regression guards for .github/workflows/release.yml (issue #121). + * Regression guards for .github/workflows/release.yml (issues #121 and #144). * * The workflow is plain YAML with inline bash; there is no YAML parser in the - * dev dependencies, so these are textual invariants over the file. Each one - * pins a specific failure mode observed while cutting v2.0.0: + * dev dependencies, so the first two groups are textual invariants over the + * file. Each one pins a specific failure mode observed while cutting v2.0.0: * * 1. `pnpm release -- --release-as ` — pnpm forwards the `--` to * commit-and-tag-version, whose parser then treats every following flag @@ -20,6 +22,14 @@ const WORKFLOW = join(repoRoot, ".github", "workflows", "release.yml"); * ignores --dry-run). * 2. `git push … main` from GITHUB_TOKEN is rejected by the branch ruleset * (GH013), after the tag ref has already been accepted. + * + * The third group executes the "Open release PR" step's script against a stub + * `gh`, for the failure seen while cutting v2.1.0 (issue #144): + * + * 3. With the repository setting "Allow GitHub Actions to create and approve + * pull requests" off and no RELEASE_TOKEN, `gh pr create` is refused + * outright. The branch is already pushed by then, so the step must hand + * the human the compare link and the exact command instead of failing red. */ const source = readFileSync(WORKFLOW, "utf8"); @@ -74,3 +84,175 @@ describe("release.yml — no direct push to the protected branch (issue #121, bu expect(source).toMatch(/secrets\.RELEASE_TOKEN \|\| secrets\.GITHUB_TOKEN/); }); }); + +/** + * The bash of the step called `name` (its `run: |` block, de-indented), so the + * exact script the runner executes can be run here against a stub `gh`. + */ +function stepScript(name) { + const all = source.split("\n"); + const start = all.findIndex((l) => l.trim() === `- name: ${name}`); + if (start === -1) return null; + let runIndex = -1; + for (let i = start + 1; i < all.length; i++) { + if (/^\s*- (name|uses):/.test(all[i])) break; + if (/^\s*run: \|\s*$/.test(all[i])) { + runIndex = i; + break; + } + } + if (runIndex === -1) return null; + const keyIndent = all[runIndex].match(/^ */)[0].length; + const body = []; + for (const line of all.slice(runIndex + 1)) { + if (line.trim() !== "" && line.match(/^ */)[0].length <= keyIndent) break; + body.push(line); + } + const contentIndent = body.find((l) => l.trim() !== "").match(/^ */)[0].length; + return body.map((l) => l.slice(contentIndent)).join("\n"); +} + +const REPO = "PMDevSolutions/Aurelius"; +const CREATED_PR = `https://github.com/${REPO}/pull/150`; +const REFUSED = + "pull request create failed: GraphQL: GitHub Actions is not permitted to create or approve pull requests (createPullRequest)"; +const RELEASE_NOTES = + "### Features\n\n* **gui:** a feature ([abc1234](https://github.com/PMDevSolutions/Aurelius/commit/abc1234))\n"; + +// Stand-in for the GitHub CLI: records every call, answers from GH_STUB_* variables. +const GH_STUB = `#!/usr/bin/env bash +printf '%s\\n' "$*" >> "$GH_STUB_LOG" +case "$1 $2" in + "pr list") printf '%s' "\${GH_STUB_EXISTING_PR:-}" ;; + "pr edit") exit 0 ;; + "pr create") + if [ -n "\${GH_STUB_CREATE_ERROR:-}" ]; then + echo "\${GH_STUB_CREATE_ERROR}" >&2 + exit 1 + fi + echo "${CREATED_PR}" + ;; + *) + echo "unexpected gh call: $*" >&2 + exit 99 + ;; +esac +`; + +/** Runs the "Open release PR" step as the runner would, with `gh` stubbed. */ +function runOpenReleasePr(env = {}) { + const dir = mkdtempSync(join(tmpdir(), "aurelius-release-pr-")); + const bin = join(dir, "bin"); + mkdirSync(bin); + writeFileSync(join(bin, "gh"), GH_STUB, { mode: 0o755 }); + writeFileSync(join(dir, "RELEASE_NOTES.md"), RELEASE_NOTES); + const summary = join(dir, "step-summary.md"); + writeFileSync(summary, ""); + const log = join(dir, "gh-calls.log"); + writeFileSync(log, ""); + const script = join(dir, "open-release-pr.sh"); + writeFileSync(script, stepScript("Open release PR")); + try { + const result = spawnSync("bash", [script], { + cwd: dir, + encoding: "utf8", + env: { + PATH: `${bin}:${process.env.PATH}`, + HOME: dir, + GH_TOKEN: "stub-token", + GH_STUB_LOG: log, + GITHUB_STEP_SUMMARY: summary, + GITHUB_REPOSITORY: REPO, + GITHUB_SERVER_URL: "https://github.com", + RUNNER_TEMP: dir, + BRANCH: "release/v9.9.9", + VERSION: "9.9.9", + TAG: "v9.9.9", + RELEASE_TYPE: "auto", + HAS_RELEASE_TOKEN: "false", + ...env, + }, + }); + return { + status: result.status, + stdout: result.stdout, + stderr: result.stderr, + summary: readFileSync(summary, "utf8"), + calls: readFileSync(log, "utf8").split("\n").filter(Boolean), + }; + } finally { + rmSync(dir, { recursive: true, force: true }); + } +} + +describe("release.yml — opening the release PR (issue #144, bug 3)", () => { + const script = stepScript("Open release PR"); + + it("is valid bash that takes every input from env, never from a ${{ }} expression", () => { + expect(script).not.toBeNull(); + expect(script).not.toMatch(/\$\{\{/); + const syntax = spawnSync("bash", ["-n"], { input: script, encoding: "utf8" }); + expect(syntax.stderr).toBe(""); + expect(syntax.status).toBe(0); + }); + + it("opens the PR from the pushed branch against main and records its URL in the summary", () => { + const run = runOpenReleasePr(); + expect(run.status).toBe(0); + expect(run.calls).toContainEqual( + expect.stringMatching( + /^pr create .*--base main .*--head release\/v9\.9\.9 .*--title chore\(release\): v9\.9\.9/, + ), + ); + expect(run.summary).toContain(`Release PR: ${CREATED_PR}`); + }); + + it("refreshes an already-open release PR instead of opening a second one", () => { + const existing = `https://github.com/${REPO}/pull/149`; + const run = runOpenReleasePr({ GH_STUB_EXISTING_PR: existing }); + expect(run.status).toBe(0); + expect(run.calls.some((c) => c.startsWith("pr create"))).toBe(false); + expect(run.calls).toContainEqual(expect.stringMatching(`^pr edit ${existing} `)); + expect(run.summary).toContain(`Release PR: ${existing}`); + }); + + it("notes that a PR opened with GITHUB_TOKEN starts no CI, unless RELEASE_TOKEN was used", () => { + expect(runOpenReleasePr().stdout).toMatch(/::notice::.*close and reopen/i); + expect(runOpenReleasePr({ HAS_RELEASE_TOKEN: "true" }).stdout).not.toMatch(/::notice::/); + }); + + it("exits 0 with a warning when Actions may not create pull requests: the branch is pushed", () => { + const run = runOpenReleasePr({ GH_STUB_CREATE_ERROR: REFUSED }); + expect(run.status).toBe(0); + expect(run.stdout).toMatch(/^::warning::.*release\/v9\.9\.9/m); + // There is no PR to close and reopen. + expect(run.stdout).not.toMatch(/::notice::/); + }); + + it("then puts the compare link, the exact gh pr create command and the PR body in the summary", () => { + const { summary } = runOpenReleasePr({ GH_STUB_CREATE_ERROR: REFUSED }); + expect(summary).toContain(`https://github.com/${REPO}/compare/main...release/v9.9.9`); + expect(summary).toMatch(/gh pr create[^\n]*--repo PMDevSolutions\/Aurelius/); + expect(summary).toMatch(/gh pr create[\s\S]*--base main/); + expect(summary).toMatch(/gh pr create[\s\S]*--head release\/v9\.9\.9/); + expect(summary).toMatch(/gh pr create[\s\S]*--title "chore\(release\): v9\.9\.9"/); + expect(summary).toContain("Merging this PR tags the merge commit as `v9.9.9`"); + expect(summary).toContain(RELEASE_NOTES.trim()); + expect(summary).toContain(REFUSED); + }); + + it("names both permanent fixes when the refusal is the Actions permission", () => { + const { summary } = runOpenReleasePr({ GH_STUB_CREATE_ERROR: REFUSED }); + expect(summary).toContain("RELEASE_TOKEN"); + expect(summary).toContain("Allow GitHub Actions to create and approve pull requests"); + }); + + it("degrades the same way for any other gh pr create failure, without the permission advice", () => { + const error = "HTTP 502: Bad Gateway (https://api.github.com/graphql)"; + const run = runOpenReleasePr({ GH_STUB_CREATE_ERROR: error }); + expect(run.status).toBe(0); + expect(run.summary).toContain(error); + expect(run.summary).toContain(`https://github.com/${REPO}/compare/main...release/v9.9.9`); + expect(run.summary).not.toContain("RELEASE_TOKEN"); + }); +});