diff --git a/.github/workflows/reusable-tag-on-release-merge.yml b/.github/workflows/reusable-tag-on-release-merge.yml index 09f4975..21ecf8b 100644 --- a/.github/workflows/reusable-tag-on-release-merge.yml +++ b/.github/workflows/reusable-tag-on-release-merge.yml @@ -2,10 +2,43 @@ # changed), create the vX.Y.Z tag and GitHub Release with the App token -- # required so the created release triggers the publish workflow (a plain # GITHUB_TOKEN release does not trigger downstream workflows). +# +# The manual path is the recovery for a push event GitHub never delivered (a +# merge that ran no workflow at all). A caller that also triggers on +# `workflow_dispatch` gets it by passing its dispatch input through: +# +# on: +# push: { branches: [main], paths: [VERSION] } +# workflow_dispatch: +# inputs: +# release: { description: "Version or merge SHA; blank = VERSION as it stands", required: false, default: "" } +# dry-run: { type: boolean, default: false } +# jobs: +# tag: +# uses: PyDevices/.github/.github/workflows/reusable-tag-on-release-merge.yml@ +# with: { release: "${{ inputs.release }}", dry-run: "${{ inputs.dry-run == true }}" } +# secrets: inherit +# +# A manual run tags the commit that brought VERSION to the version being +# tagged, never an arbitrary one, and applies the same version grammar as the +# push path. Callers that don't dispatch are unchanged. name: Reusable tag on release merge on: - workflow_call: {} + workflow_call: + inputs: + release: + description: >- + Manual runs only. The version (X.Y.Z[{a|b|rc}N|.devN]) or the merge + SHA to tag. Blank tags VERSION as it stands on the default branch. + type: string + required: false + default: "" + dry-run: + description: Manual runs only. Say what would be tagged, tag nothing. + type: boolean + required: false + default: false permissions: contents: read @@ -16,10 +49,12 @@ jobs: steps: - uses: actions/checkout@v7 with: - fetch-depth: 2 + # A manual run walks VERSION's history and needs the tags. + fetch-depth: ${{ github.event_name == 'workflow_dispatch' && '0' || '2' }} - name: Detect a VERSION change id: detect + if: github.event_name != 'workflow_dispatch' run: | if git diff --quiet HEAD^ HEAD -- VERSION 2>/dev/null; then echo "changed=false" >> "$GITHUB_OUTPUT"; exit 0 @@ -43,8 +78,79 @@ jobs: echo "changed=true" >> "$GITHUB_OUTPUT" echo "version=$V" >> "$GITHUB_OUTPUT" + - name: Resolve a manual release + id: manual + if: github.event_name == 'workflow_dispatch' + env: + RELEASE: ${{ inputs.release }} + DRY_RUN: ${{ inputs.dry-run }} + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + run: | + set -eu + GRAMMAR='^[0-9]+\.[0-9]+\.[0-9]+((a|b|rc)[0-9]+|\.dev[0-9]+)?$' + if [ "$GITHUB_REF" != "refs/heads/$DEFAULT_BRANCH" ]; then + echo "::error::Run this on $DEFAULT_BRANCH, not $GITHUB_REF; refusing to tag." + exit 1 + fi + R=$(printf %s "$RELEASE" | tr -d '[:space:]') + WANT_SHA="" + if [ -z "$R" ]; then + V=$(tr -d '[:space:]' < VERSION) + elif [[ "$R" =~ $GRAMMAR ]]; then + V=$R + elif [[ "$R" =~ ^[0-9a-fA-F]{7,40}$ ]]; then + WANT_SHA=$(git rev-parse -q --verify "$R^{commit}") || { + echo "::error::No commit '$R' in this repository; refusing to tag."; exit 1; } + if ! git merge-base --is-ancestor "$WANT_SHA" HEAD; then + echo "::error::$WANT_SHA is not on $DEFAULT_BRANCH; refusing to tag." + exit 1 + fi + V=$(git show "$WANT_SHA:VERSION" | tr -d '[:space:]') + else + echo "::error::'$R' is neither a release version nor a commit SHA; refusing to tag." + exit 1 + fi + # Same grammar as the push path: a placeholder is not a release. + if [[ ! "$V" =~ $GRAMMAR ]]; then + echo "::error::VERSION is '$V', not a release version X.Y.Z[{a|b|rc}N|.devN]; refusing to tag." + exit 1 + fi + if git rev-parse -q --verify "refs/tags/v$V" >/dev/null; then + echo "Tag v$V already exists; nothing to do." + echo "changed=false" >> "$GITHUB_OUTPUT"; exit 0 + fi + # The commit on the default branch that brought VERSION to $V (the + # oldest of a run of commits that leave it there): what the push + # run would have tagged. + SHA="" + for C in $(git log --first-parent --format=%H -- VERSION); do + if [ "$(git show "$C:VERSION" | tr -d '[:space:]')" = "$V" ]; then + SHA=$C + elif [ -n "$SHA" ]; then + break + fi + done + if [ -z "$SHA" ]; then + echo "::error::No commit on $DEFAULT_BRANCH sets VERSION to $V; refusing to tag." + exit 1 + fi + if [ -n "$WANT_SHA" ] && [ "$WANT_SHA" != "$SHA" ]; then + echo "::error::$WANT_SHA is not the commit that set VERSION to $V; that is $SHA; refusing to tag." + exit 1 + fi + if [ "$DRY_RUN" = "true" ]; then + echo "Dry run: would tag v$V at $SHA. Nothing was tagged." + echo "changed=false" >> "$GITHUB_OUTPUT"; exit 0 + fi + echo "Tagging v$V at $SHA" + { + echo "changed=true" + echo "version=$V" + echo "sha=$SHA" + } >> "$GITHUB_OUTPUT" + - name: Mint App token - if: steps.detect.outputs.changed == 'true' + if: steps.detect.outputs.changed == 'true' || steps.manual.outputs.changed == 'true' id: app-token uses: actions/create-github-app-token@v3 with: @@ -52,12 +158,13 @@ jobs: private-key: ${{ secrets.PYDEVICES_APP_PRIVATE_KEY }} - name: Create tag and Release - if: steps.detect.outputs.changed == 'true' + if: steps.detect.outputs.changed == 'true' || steps.manual.outputs.changed == 'true' env: GH_TOKEN: ${{ steps.app-token.outputs.token }} - V: ${{ steps.detect.outputs.version }} + V: ${{ steps.detect.outputs.version || steps.manual.outputs.version }} + TARGET: ${{ steps.manual.outputs.sha || github.sha }} run: | PRERELEASE="" [[ "$V" =~ (a|b|rc)[0-9]+$|\.dev[0-9]+$ ]] && PRERELEASE="--prerelease" - gh release create "v$V" --target "$GITHUB_SHA" \ + gh release create "v$V" --target "$TARGET" \ --title "v$V" --generate-notes $PRERELEASE diff --git a/docs/publishing-automation.md b/docs/publishing-automation.md index 09424bd..51dd5f9 100644 --- a/docs/publishing-automation.md +++ b/docs/publishing-automation.md @@ -240,6 +240,16 @@ The App token is required here, not incidental: **a Release created with the default `GITHUB_TOKEN` does not trigger `on: release` workflows**, so the publish chain below would silently never fire. +If GitHub never delivers the push event for a merge (it has happened: a merge +that ran no workflow at all), run the repository's tag workflow by hand from +`main`. A caller that also triggers on `workflow_dispatch` and passes its +`release` and `dry-run` inputs through (the shape is in the header comment of +`reusable-tag-on-release-merge.yml`) gets a manual path that tags the commit +that brought `VERSION` to the version, checks the same version grammar as the +push path, and does nothing if the tag exists. Leave `release` blank to tag +`VERSION` as it stands, or give a version or that merge's SHA. Set `dry-run` to +see what it would tag without tagging anything. + ### 4. Publish release packages runs from the Release event `reusable-publish-release-packages.yml` resolves and validates the `vX.Y.Z` diff --git a/tests/test_tag_on_release_merge.py b/tests/test_tag_on_release_merge.py index cfb3321..746949b 100644 --- a/tests/test_tag_on_release_merge.py +++ b/tests/test_tag_on_release_merge.py @@ -28,6 +28,16 @@ def detect_step_shell() -> str: return textwrap.dedent(block[begin:end]) +def manual_step_shell() -> str: + """The `run:` body of the Resolve a manual release step.""" + text = TAG_WORKFLOW.read_text(encoding="utf-8") + start = text.index("- name: Resolve a manual release") + block = text[start:] + begin = block.index("run: |\n") + len("run: |\n") + end = block.index("\n\n - name:", begin) + return textwrap.dedent(block[begin:end]) + + def git(cwd: Path, *args: str) -> None: subprocess.run(["git", "-C", str(cwd), *args], check=True, capture_output=True) @@ -174,5 +184,145 @@ def test_an_already_tagged_version_is_a_no_op(self): self.assertIn("changed=false", output.read_text(encoding="utf-8")) +class ManualReleaseTests(unittest.TestCase): + """The workflow_dispatch path: recovery for a merge event GitHub lost.""" + + def run_manual(self, history, release="", *, dry_run="false", + ref="refs/heads/main", tags=(), setup=None): + """`history` is a list of VERSION contents, one commit each (None means + a commit that leaves VERSION alone). Returns (result, shas).""" + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + git(root, "init", "-q", "-b", "main") + git(root, "config", "user.email", "test@example.invalid") + git(root, "config", "user.name", "Test") + shas = [] + for number, version in enumerate(history): + if version is None: + (root / "other.txt").write_text(f"{number}\n", encoding="utf-8") + git(root, "add", "other.txt") + else: + (root / "VERSION").write_text(version + "\n", encoding="utf-8") + git(root, "add", "VERSION") + git(root, "commit", "-q", "-m", f"commit {number}") + shas.append(subprocess.run( + ["git", "-C", str(root), "rev-parse", "HEAD"], + check=True, capture_output=True, text=True).stdout.strip()) + for tag in tags: + git(root, "tag", tag) + if setup: + setup(root, shas) + script = root / "manual.sh" + script.write_text(manual_step_shell(), encoding="utf-8") + output = root / "github_output" + output.touch() + release = release(shas) if callable(release) else release + result = subprocess.run( + ["bash", "-e", str(script)], + cwd=root, + env={"PATH": "/usr/bin:/bin", "GITHUB_OUTPUT": str(output), + "GITHUB_REF": ref, "DEFAULT_BRANCH": "main", + "RELEASE": release, "DRY_RUN": dry_run}, + capture_output=True, text=True, check=False) + result.github_output = output.read_text(encoding="utf-8") # type: ignore[attr-defined] + return result, shas + + def assert_tags(self, result, version, sha): + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertIn("changed=true", result.github_output) + self.assertIn(f"version={version}", result.github_output) + self.assertIn(f"sha={sha}", result.github_output) + + def assert_refused(self, result): + self.assertEqual(result.returncode, 1, result.stdout + result.stderr) + self.assertIn("refusing to tag", result.stdout + result.stderr) + self.assertNotIn("changed=true", result.github_output) + + def test_blank_tags_the_commit_that_set_the_current_version(self): + result, shas = self.run_manual(["0.1.0", None, "0.2.0", None, None]) + self.assert_tags(result, "0.2.0", shas[2]) + + def test_a_named_version_finds_its_own_commit(self): + result, shas = self.run_manual( + ["0.1.0", "0.2.0", None, "0.3.0"], release="0.2.0") + self.assert_tags(result, "0.2.0", shas[1]) + + def test_a_run_of_commits_at_one_version_tags_the_first(self): + result, shas = self.run_manual(["0.1.0", "0.2.0", "0.2.0\n", None]) + self.assert_tags(result, "0.2.0", shas[1]) + + def test_a_merge_sha_works(self): + result, shas = self.run_manual( + ["0.1.0", "0.2.0", "0.3.0"], release=lambda s: s[1][:10]) + self.assert_tags(result, "0.2.0", shas[1]) + + def test_a_sha_that_did_not_set_the_version_is_refused(self): + result, shas = self.run_manual( + ["0.1.0", "0.2.0", None, None], release=lambda s: s[3]) + self.assert_refused(result) + self.assertIn(f"that is {shas[1]}", result.stdout + result.stderr) + + def test_a_placeholder_version_is_refused(self): + self.assert_refused(self.run_manual(["0.1.0", "0.0.0-PLACEHOLDER"])[0]) + + def test_a_named_version_that_was_never_set_is_refused(self): + self.assert_refused(self.run_manual(["0.1.0", "0.2.0"], release="0.9.0")[0]) + + def test_a_version_not_matching_the_grammar_is_refused(self): + self.assert_refused(self.run_manual(["0.1.0", "0.2.0"], release="v0.2.0")[0]) + + def test_a_sha_whose_version_is_a_placeholder_is_refused(self): + result, _ = self.run_manual( + ["0.1.0", "junk", "0.2.0"], release=lambda s: s[1]) + self.assert_refused(result) + + def test_an_unknown_sha_is_refused(self): + self.assert_refused( + self.run_manual(["0.1.0", "0.2.0"], release="deadbeef")[0]) + + def test_a_sha_off_the_default_branch_is_refused(self): + def branch(root, shas): + git(root, "checkout", "-q", "-b", "side", shas[0]) + (root / "VERSION").write_text("0.9.0\n", encoding="utf-8") + git(root, "commit", "-q", "-am", "side") + side = subprocess.run(["git", "-C", str(root), "rev-parse", "HEAD"], + check=True, capture_output=True, text=True).stdout.strip() + git(root, "checkout", "-q", "main") + shas.append(side) + result, _ = self.run_manual( + ["0.1.0", "0.2.0"], release=lambda s: s[2], setup=branch) + self.assert_refused(result) + + def test_a_branch_other_than_the_default_is_refused(self): + result, _ = self.run_manual(["0.1.0", "0.2.0"], ref="refs/heads/feature") + self.assert_refused(result) + + def test_an_existing_tag_is_a_no_op(self): + result, _ = self.run_manual(["0.1.0", "0.2.0"], tags=["v0.2.0"]) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertIn("changed=false", result.github_output) + self.assertNotIn("changed=true", result.github_output) + + def test_a_dry_run_says_what_it_would_tag_and_tags_nothing(self): + result, shas = self.run_manual(["0.1.0", "0.2.0"], dry_run="true") + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertIn(f"would tag v0.2.0 at {shas[1]}", result.stdout) + self.assertNotIn("changed=true", result.github_output) + + +class WorkflowShapeTests(unittest.TestCase): + def test_the_push_path_is_not_taken_on_a_manual_run(self): + text = TAG_WORKFLOW.read_text(encoding="utf-8") + detect = text[text.index("- name: Detect a VERSION change"):] + self.assertIn("if: github.event_name != 'workflow_dispatch'", + detect[:detect.index("run:")]) + + def test_inputs_are_optional_so_existing_callers_keep_working(self): + text = TAG_WORKFLOW.read_text(encoding="utf-8") + inputs = text[text.index("workflow_call:"):text.index("permissions:")] + self.assertEqual(inputs.count("required: false"), 2) + self.assertNotIn("required: true", inputs) + + if __name__ == "__main__": unittest.main()