Merge pull request #82 from saphid/fix/release-draft-id

release.yml: read the new draft's id from the REST POST, not the lagging release list
This commit is contained in:
Alex Southwell authored and GitHub committed 2026-10-08 20:54:18 +11:00
commit b5c8d2f19d
3 files changed
+162 -15

No files matched your search

+13 -4
View File
@@ -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:
+43 -11
View File
@@ -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))
+106
View File
@@ -0,0 +1,106 @@
"""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": []}
# 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())
(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()