Skip to content
Merged
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
61 changes: 49 additions & 12 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -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"

Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
190 changes: 186 additions & 4 deletions scripts/__tests__/release-workflow.test.js
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -8,18 +10,26 @@ 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 <type>` — pnpm forwards the `--` to
* commit-and-tag-version, whose parser then treats every following flag
* as a positional and silently falls back to auto-detection (and even
* 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");
Expand Down Expand Up @@ -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");
});
});
Loading