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
119 changes: 113 additions & 6 deletions .github/workflows/reusable-tag-on-release-merge.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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@<tag>
# 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
Expand All @@ -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
Expand All @@ -43,21 +78,93 @@ 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:
app-id: ${{ secrets.PYDEVICES_APP_ID }}
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
10 changes: 10 additions & 0 deletions docs/publishing-automation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
150 changes: 150 additions & 0 deletions tests/test_tag_on_release_merge.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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()
Loading