From 0304575e5b4236517f3d8a826cdd59ae09749150 Mon Sep 17 00:00:00 2001 From: saphid <4596216+saphid@users.noreply.github.com> Date: Thu, 8 Oct 2026 20:28:07 +1100 Subject: [PATCH 1/2] release.yml: create the draft through the REST API so its id comes straight back v0.4.2's first run created the draft with gh release create, then looked its id up in the release list, which didn't show the fresh draft yet; the PATCH went to releases/ (404) and the builds were skipped. Now the draft is POSTed and the id read from the response, after checking the tag exists on GitHub (what --verify-tag guarded). A rerun still reuses the draft found by tag_name, and an empty id stops the job instead of PATCHing releases/. tests/test_release_workflow.py runs the step against tests/fakegh, which now answers POST releases and applies --jq through jq when installed. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/release.yml | 17 ++++-- tests/fakegh/gh | 54 +++++++++++++---- tests/test_release_workflow.py | 103 +++++++++++++++++++++++++++++++++ 3 files changed, 159 insertions(+), 15 deletions(-) create mode 100644 tests/test_release_workflow.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fabd7a7..29cfd85 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -16,8 +16,12 @@ permissions: jobs: # One job makes the draft, before the builds: three matrix jobs each running # "view || create" could race and make duplicate drafts. The draft is tied to - # the pushed tag (--verify-tag, then tag_name set explicitly), so - # scripts/publish-release.sh and `gh release upload` find it by tag. + # the pushed tag (the tag must already exist on GitHub, then tag_name is set + # explicitly), so scripts/publish-release.sh and `gh release upload` find it + # by tag. It is created with the REST API so the id comes straight back: the + # release list can lag a fresh draft (v0.4.2's first run found nothing and + # PATCHed releases/ with an empty id). tests/test_release_workflow.py runs + # this step against tests/fakegh. draft: if: startsWith(github.ref, 'refs/tags/') runs-on: ubuntu-latest @@ -28,11 +32,16 @@ jobs: GH_REPO: ${{ github.repository }} run: | tag="${GITHUB_REF_NAME}" + # A rerun finds the draft an earlier attempt made. id=$(gh api --paginate "repos/$GH_REPO/releases?per_page=100" --jq ".[] | select(.tag_name == \"$tag\") | .id" | head -n 1) if [ -z "$id" ]; then - gh release create "$tag" --draft --verify-tag --title "Frame Control ${tag#v}" --notes "" - id=$(gh api --paginate "repos/$GH_REPO/releases?per_page=100" --jq ".[] | select(.draft and .name == \"Frame Control ${tag#v}\") | .id" | head -n 1) + # Given a missing tag, GitHub would create it on the default branch. + gh api "repos/$GH_REPO/git/ref/tags/$tag" >/dev/null \ + || { echo "tag $tag isn't on GitHub" >&2; exit 1; } + id=$(gh api -X POST "repos/$GH_REPO/releases" -f tag_name="$tag" -f name="Frame Control ${tag#v}" \ + -F draft=true -f body="" --jq .id) fi + [ -n "$id" ] || { echo "no release id for $tag; not PATCHing releases/" >&2; exit 1; } gh api -X PATCH "repos/$GH_REPO/releases/$id" -f tag_name="$tag" --jq '"release " + (.id|tostring) + " tag_name " + .tag_name' build: diff --git a/tests/fakegh/gh b/tests/fakegh/gh index cbac489..ec76aff 100755 --- a/tests/fakegh/gh +++ b/tests/fakegh/gh @@ -1,11 +1,17 @@ #!/usr/bin/env python3 -"""A stand-in for the GitHub CLI's `gh api`, for tests/test_publish_release.py. Serves -the releases in $FAKEGH_RELEASES (a JSON list, drafts included) and the tags in -$FAKEGH_TAGS (space-separated); an upload's body is written to $FAKEGH_UPLOAD. -Every call is appended to $FAKEGH_LOG as a JSON line. Anything else fails, so the -script under test can't fall back to `gh release ...` (GraphQL) unnoticed.""" +"""A stand-in for the GitHub CLI's `gh api`, for tests/test_publish_release.py and +tests/test_release_workflow.py. Serves the releases in $FAKEGH_RELEASES (a JSON list, +drafts included) and the tags in $FAKEGH_TAGS (space-separated); an upload's body is +written to $FAKEGH_UPLOAD. Every call is appended to $FAKEGH_LOG as a JSON line. +Anything else fails, so the script under test can't fall back to `gh release ...` +(GraphQL) unnoticed. POST .../releases (release.yml's draft step) answers as release +$FAKEGH_NEW_ID (default 9001) without adding it to the listing, like GitHub's list +lagging a fresh draft. With jq installed, --jq filters a response the way gh does +(raw strings, compact JSON).""" import json import os +import shutil +import subprocess import sys args = sys.argv[1:] @@ -14,7 +20,7 @@ with open(os.environ["FAKEGH_LOG"], "a") as f: if not args or args[0] != "api": sys.exit("fakegh: only `gh api` is supported: %r" % args) -method, endpoint, fields, inp, i = "GET", None, {}, None, 1 +method, endpoint, fields, inp, jq, i = "GET", None, {}, None, None, 1 while i < len(args): a = args[i] if a == "-X": @@ -23,7 +29,9 @@ while i < len(args): k, _, v = args[i + 1].partition("="); fields[k] = v; i += 2 elif a == "--input": inp = args[i + 1]; i += 2 - elif a in ("-H", "--jq"): + elif a == "--jq": + jq = args[i + 1]; i += 2 + elif a == "-H": i += 2 elif a.startswith("-"): i += 1 @@ -34,14 +42,34 @@ while i < len(args): releases = json.load(open(os.environ["FAKEGH_RELEASES"])) repo = "repos/saphid/frame-control/" + + +def answer(response, fallback): + """Print response through --jq when jq is here, else the fixed fallback text.""" + if jq and shutil.which("jq"): + out = subprocess.run(["jq", "-r", "-c", jq], input=json.dumps(response), + stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True) + sys.stdout.write(out.stdout) + if out.returncode: + sys.exit("fakegh: jq failed: " + out.stderr) + else: + print(fallback) + + if method == "GET" and endpoint.startswith(repo + "git/ref/tags/"): tag = endpoint.rsplit("/", 1)[1] if tag not in os.environ.get("FAKEGH_TAGS", "").split(): sys.exit("gh: Not Found (HTTP 404)") print(json.dumps({"ref": "refs/tags/" + tag})) elif method == "GET" and endpoint.startswith(repo + "releases?"): - for r in releases: # what --jq '.[]' prints - print(json.dumps(r)) + # The fallback is what --jq '.[]' prints. + answer(releases, "\n".join(json.dumps(r) for r in releases)) +elif method == "POST" and endpoint == repo + "releases": + new = {"id": int(os.environ.get("FAKEGH_NEW_ID", "9001")), "tag_name": fields.get("tag_name"), + "name": fields.get("name"), "draft": fields.get("draft") == "true", + "body": fields.get("body"), "assets": [], + "html_url": "https://github.com/saphid/frame-control/releases/tag/untagged-be29e900f7855d794136"} + answer(new, json.dumps(new)) elif method == "DELETE" and endpoint.startswith(repo + "releases/assets/"): pass elif method == "POST" and endpoint.startswith("https://uploads.github.com/" + repo + "releases/"): @@ -49,7 +77,11 @@ elif method == "POST" and endpoint.startswith("https://uploads.github.com/" + re dst.write(src.read()) print("{}") elif method == "PATCH" and endpoint.startswith(repo + "releases/"): - print("published %s at https://github.com/saphid/frame-control/releases/tag/%s" - % (fields.get("tag_name"), fields.get("tag_name"))) + rid = endpoint[len(repo + "releases/"):] + if not rid.isdigit(): # what GitHub said to v0.4.2's PATCH of releases/ (an empty id) + sys.exit("gh: Not Found (HTTP 404)") + page = "https://github.com/saphid/frame-control/releases/tag/%s" % fields.get("tag_name") + answer({"id": int(rid), "tag_name": fields.get("tag_name"), "html_url": page}, + "published %s at %s" % (fields.get("tag_name"), page)) else: sys.exit("fakegh: unhandled %s %s" % (method, endpoint)) diff --git a/tests/test_release_workflow.py b/tests/test_release_workflow.py new file mode 100644 index 0000000..6c99769 --- /dev/null +++ b/tests/test_release_workflow.py @@ -0,0 +1,103 @@ +"""The "Create the draft release" step of .github/workflows/release.yml, taken out of +the workflow and run with bash (as Actions runs it) against a stand-in gh +(tests/fakegh/gh): a new draft's id comes straight from the POST even when the +release list doesn't show it yet (v0.4.2's first run), a rerun reuses the draft +found by tag_name, and a tag that isn't on GitHub stops it. Nothing here talks to +GitHub. + +Run: python3 -m unittest discover -s tests +""" +import sandbox # noqa: F401 (first: keeps tests off real data and services) +import json +import os +import shutil +import subprocess +import tempfile +import textwrap +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +WORKFLOW = ROOT / ".github" / "workflows" / "release.yml" +FAKEGH = ROOT / "tests" / "fakegh" +STEP = "- name: Create the draft release" + + +def step_script(): + """The step's `run: |` block, dedented.""" + lines = WORKFLOW.read_text().splitlines() + i = next(n for n, line in enumerate(lines) if line.strip() == STEP) + while lines[i].strip() != "run: |": + i += 1 + indent = len(lines[i]) - len(lines[i].lstrip()) + body = [] + for line in lines[i + 1:]: + if line.strip() and len(line) - len(line.lstrip()) <= indent: + break + body.append(line) + return textwrap.dedent("\n".join(body)).strip() + "\n" + + +def release(rid, tag_name, name="Frame Control 9.8.7", draft=True): + return {"id": rid, "tag_name": tag_name, "name": name, "draft": draft, "assets": []} + + +@unittest.skipUnless(shutil.which("bash") and shutil.which("jq"), "needs bash and jq (for gh --jq)") +class DraftStep(unittest.TestCase): + def run_step(self, releases, tags="v9.8.7", tag="v9.8.7"): + d = Path(tempfile.mkdtemp()) + (d / "step.sh").write_text(step_script()) + (d / "releases.json").write_text(json.dumps(releases)) + env = dict(os.environ, PATH="%s:%s" % (FAKEGH, os.environ["PATH"]), + FAKEGH_RELEASES=str(d / "releases.json"), FAKEGH_TAGS=tags, + FAKEGH_LOG=str(d / "log"), GITHUB_REF_NAME=tag, GH_REPO="saphid/frame-control") + p = subprocess.run(["bash", "--noprofile", "--norc", "-eo", "pipefail", str(d / "step.sh")], + env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE, + universal_newlines=True, timeout=30) + log = [json.loads(line) for line in (d / "log").read_text().splitlines()] if (d / "log").exists() else [] + return p, log + + def posts(self, log): + return [c for c in log if c[1:4] == ["-X", "POST", "repos/saphid/frame-control/releases"]] + + def patches(self, log): + return [c for c in log if c[1:3] == ["-X", "PATCH"]] + + def test_new_draft_uses_the_id_the_post_returns(self): + # The list doesn't show the fresh draft (it never does in the fake): v0.4.2's case. + p, log = self.run_step([]) + self.assertEqual(p.returncode, 0, p.stderr + p.stdout) + [post] = self.posts(log) + for f in ("tag_name=v9.8.7", "name=Frame Control 9.8.7", "draft=true"): + self.assertIn(f, post) + self.assertIn(["api", "repos/saphid/frame-control/git/ref/tags/v9.8.7"], log) + [patch] = self.patches(log) + self.assertEqual(patch[3], "repos/saphid/frame-control/releases/9001") + self.assertIn("tag_name=v9.8.7", patch) + self.assertIn("release 9001 tag_name v9.8.7", p.stdout) + self.assertTrue(all(c[0] == "api" for c in log)) # never `gh release ...` + + def test_a_rerun_reuses_the_draft_with_the_tag(self): + p, log = self.run_step([release(7, "v9.8.6", "Frame Control 9.8.6"), release(42, "v9.8.7")]) + self.assertEqual(p.returncode, 0, p.stderr + p.stdout) + self.assertEqual(self.posts(log), []) + [patch] = self.patches(log) + self.assertEqual(patch[3], "repos/saphid/frame-control/releases/42") + + def test_other_releases_alone_mean_a_new_draft(self): + p, log = self.run_step([release(7, "v9.8.6", "Frame Control 9.8.6", draft=False), + release(8, "v9.8.70", "Frame Control 9.8.70")]) + self.assertEqual(p.returncode, 0, p.stderr + p.stdout) + self.assertEqual(len(self.posts(log)), 1) + self.assertEqual(self.patches(log)[0][3], "repos/saphid/frame-control/releases/9001") + + def test_stops_when_the_tag_is_not_on_github(self): + p, log = self.run_step([], tags="") + self.assertNotEqual(p.returncode, 0) + self.assertIn("tag v9.8.7 isn't on GitHub", p.stderr) + self.assertEqual(self.posts(log), []) + self.assertEqual(self.patches(log), []) + + +if __name__ == "__main__": + unittest.main() From 4a489e4606e06096e33892fb69f14d7beeb9e600 Mon Sep 17 00:00:00 2001 From: saphid <4596216+saphid@users.noreply.github.com> Date: Thu, 8 Oct 2026 20:46:55 +1100 Subject: [PATCH 2/2] tests: skip the release draft step test on Windows There `bash` is the WSL launcher (no distribution on GitHub's runners), so the step couldn't run; it only ever runs on ubuntu-latest. Co-Authored-By: Claude Opus 5.5 (1M context) --- tests/test_release_workflow.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/test_release_workflow.py b/tests/test_release_workflow.py index 6c99769..f911746 100644 --- a/tests/test_release_workflow.py +++ b/tests/test_release_workflow.py @@ -42,7 +42,10 @@ def release(rid, tag_name, name="Frame Control 9.8.7", draft=True): return {"id": rid, "tag_name": tag_name, "name": name, "draft": draft, "assets": []} -@unittest.skipUnless(shutil.which("bash") and shutil.which("jq"), "needs bash and jq (for gh --jq)") +# The step only runs on ubuntu-latest. On Windows `bash` is often the WSL launcher +# (with no distribution on GitHub's runners), not a bash that can run tests/fakegh. +@unittest.skipUnless(os.name != "nt" and shutil.which("bash") and shutil.which("jq"), + "needs a POSIX bash and jq (for gh --jq); the step runs on ubuntu only") class DraftStep(unittest.TestCase): def run_step(self, releases, tags="v9.8.7", tag="v9.8.7"): d = Path(tempfile.mkdtemp())