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..f911746 --- /dev/null +++ b/tests/test_release_workflow.py @@ -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()