From 2ba1c5667d67c242d190d6f6588f951fae47adf9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Thu, 12 Nov 2026 11:48:06 -0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix(publish-github-release):=20o?= =?UTF-8?q?mit=20release=20target?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GitHub checks a release target's workflow files against the default branch tip and refuses GITHUB_TOKEN, which lacks the workflows permission, once a later merge changed a workflow. simplint 0.11.0 hit this when a CI change merged while its release built. The tag already exists, so drop the target and stop if the tag is missing or points at another commit. --- .../+release-on-existing-tag.bugfix.md | 4 ++ docs/release-publish.md | 2 +- docs/troubleshooting.md | 5 ++ publish-github-release/action.yml | 14 ++++-- tests/publish_github_release/test_publish.py | 46 ++++++++++++++++++- 5 files changed, 66 insertions(+), 5 deletions(-) create mode 100644 changelog.d/+release-on-existing-tag.bugfix.md diff --git a/changelog.d/+release-on-existing-tag.bugfix.md b/changelog.d/+release-on-existing-tag.bugfix.md new file mode 100644 index 0000000..397aeac --- /dev/null +++ b/changelog.d/+release-on-existing-tag.bugfix.md @@ -0,0 +1,4 @@ +`publish-github-release` creates the draft on the existing tag without naming a target commit. GitHub compared the +target's workflow files with the default branch tip and refused the job's token with +`Resource not accessible by integration` whenever a pull request that changed a workflow merged while the release was +building. The Action now fails before creating the draft if the tag is missing or points at another commit. diff --git a/docs/release-publish.md b/docs/release-publish.md index fdfc845..857d73f 100644 --- a/docs/release-publish.md +++ b/docs/release-publish.md @@ -372,7 +372,7 @@ needs no checkout or tool installation. Enable immutable releases in the reposit | Input | Default | Purpose | | --------------------------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `tag` **(required)** | | Tag of the Release, in the caller repository. | -| `commit` **(required)** | | The tag's commit, used as target when the Action creates the Release. | +| `commit` **(required)** | | The tag's commit. The Action stops before creating the Release if the tag is missing or names another commit. | | `title` **(required)** | | Release title. | | `notes-file` **(required)** | | The exact rendered notes file. | | `assets-directory` | empty | Assets to attach. Each filename and SHA256 is compared with GitHub's asset metadata; missing draft assets are uploaded, extra or changed ones rejected. After publication it verifies GitHub's release attestation for each. | diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index dabc654..d62dfa8 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -87,6 +87,11 @@ A publish call failed, and reading the Release back did not show the expected st resumes the draft. A published Release with a different asset set needs manual inspection, because published releases are not rewritten. +**`tag must exist and point at , found ...`** + +The Action creates the Release on an existing tag, and the tag is missing or names another commit than `commit`. Push +the tag before publishing and pass the commit it points at, such as `github.sha` in a workflow triggered by the tag. + **`release asset attestation could not be verified`** GitHub has no release attestation for a local asset. Check that immutable releases are enabled for the repository. diff --git a/publish-github-release/action.yml b/publish-github-release/action.yml index da5d8db..3a534df 100644 --- a/publish-github-release/action.yml +++ b/publish-github-release/action.yml @@ -117,10 +117,18 @@ runs: # shellcheck disable=SC1091 # GitHub supplies this Action path at runtime. source "$GITHUB_ACTION_PATH/retry.sh" if jq -e 'length == 0' "$RUNNER_TEMP/release-existing.json" >/dev/null; then - jq -n --arg tag "$TAG" --arg commit "$COMMIT" --arg title "$TITLE" \ + # The request names no target: GitHub checks a target's workflow files against the default branch tip and + # refuses a GITHUB_TOKEN, which never holds the workflows permission, once a later merge changed one. The tag + # must then exist, or publishing would create it at the default branch tip. + tagged=$(gh_api "repos/$GH_REPO/commits/refs/tags/$TAG" --jq .sha) || tagged="" + if [[ "$tagged" != "$COMMIT" ]]; then + echo "::error::tag $TAG must exist and point at $COMMIT, found ${tagged:-no commit}" >&2 + exit 1 + fi + jq -n --arg tag "$TAG" --arg title "$TITLE" \ --rawfile body "$RUNNER_TEMP/release-body.md" --argjson prerelease "$PRERELEASE" \ - '{tag_name: $tag, target_commitish: $commit, name: $title, body: $body, - draft: true, prerelease: $prerelease}' >"$RUNNER_TEMP/release-request.json" + '{tag_name: $tag, name: $title, body: $body, draft: true, prerelease: $prerelease}' \ + >"$RUNNER_TEMP/release-request.json" # Not retried: if GitHub stored the draft before answering 5xx, a second POST could create another. gh api -X POST "repos/$GH_REPO/releases" --input "$RUNNER_TEMP/release-request.json" \ >"$RUNNER_TEMP/release-created.json" diff --git a/tests/publish_github_release/test_publish.py b/tests/publish_github_release/test_publish.py index 25ea3de..cb78e8a 100644 --- a/tests/publish_github_release/test_publish.py +++ b/tests/publish_github_release/test_publish.py @@ -51,6 +51,12 @@ def _expect_generated_notes(server: HTTPServer) -> None: ) +def _expect_tag(server: HTTPServer, commit: str) -> None: + server.expect_ordered_request( + "/api/v3/repos/tox-dev/fixture/commits/refs/tags/0.1.0", method="GET" + ).respond_with_json({"sha": commit}) + + def _release_requests(server: HTTPServer) -> list[Request]: return [request for request, _ in server.log if request.path != _GENERATE_NOTES] @@ -317,6 +323,7 @@ def test_missing_release_is_created_then_optionally_published( prefix = "/api/v3/repos/tox-dev/fixture" server.expect_ordered_request(f"{prefix}/releases", query_string="per_page=100", method="GET").respond_with_json([]) _expect_generated_notes(server) + _expect_tag(server, commit) server.expect_ordered_request(f"{prefix}/releases", method="POST").respond_with_json(draft, status=201) server.expect_ordered_request(f"{prefix}/releases/42", method="GET").respond_with_json(draft) if mode != "stage": @@ -326,6 +333,7 @@ def test_missing_release_is_created_then_optionally_published( result = _request(repository, tmp_path, action_scripts, notes, commit, environment | release_cli, mode=mode) assert result.returncode == 0, result.stderr assert [request.method for request in _release_requests(server)] == [ + "GET", "GET", "POST", "GET", @@ -335,7 +343,6 @@ def test_missing_release_is_created_then_optionally_published( request = next(request for request in _release_requests(server) if request.method == "POST") assert request.get_json() == { "tag_name": "0.1.0", - "target_commitish": commit, "name": "0.1.0", "body": notes.read_text(), "draft": True, @@ -758,6 +765,7 @@ def test_missing_release_explains_created_draft( prefix = "/api/v3/repos/tox-dev/fixture" server.expect_ordered_request(f"{prefix}/releases", query_string="per_page=100", method="GET").respond_with_json([]) _expect_generated_notes(server) + _expect_tag(server, commit) server.expect_ordered_request(f"{prefix}/releases", method="POST").respond_with_json(draft, status=201) server.expect_ordered_request(f"{prefix}/releases/42", method="GET").respond_with_json(draft) @@ -806,6 +814,7 @@ def test_new_release_credits_first_time_contributors_above_the_links( "**Full Changelog**: https://github.com/tox-dev/fixture/compare/0.0.9...0.1.0\n" } ) + _expect_tag(server, commit) server.expect_ordered_request(f"{prefix}/releases", method="POST").respond_with_json(draft, status=201) server.expect_ordered_request(f"{prefix}/releases/42", method="GET").respond_with_json(draft) @@ -842,6 +851,7 @@ def test_server_error_on_generated_notes_is_retried( body, status=status, mimetype=mimetype ) _expect_generated_notes(server) + _expect_tag(server, commit) server.expect_ordered_request(f"{prefix}/releases", method="POST").respond_with_json( _release(notes, draft=True), status=201 ) @@ -871,6 +881,39 @@ def test_client_error_on_generated_notes_is_not_retried( assert len(_requests(server, "POST", _GENERATE_NOTES)) == 1 +@pytest.mark.parametrize( + ("status", "body", "found"), + [ + pytest.param(200, {"sha": "0" * 40}, "0" * 40, id="other-commit"), + pytest.param(422, {"message": "No commit found for SHA: refs/tags/0.1.0"}, "no commit", id="missing"), + ], +) +def test_draft_creation_requires_the_tag_on_the_commit( + repository: Path, + tmp_path: Path, + action_scripts: Path, + github_api: tuple[HTTPServer, dict[str, str]], + release_cli: dict[str, str], + status: int, + body: dict[str, str], + found: str, +) -> None: + server, environment = github_api + notes, commit = _notes_and_commit(repository, tmp_path) + prefix = "/api/v3/repos/tox-dev/fixture" + server.expect_ordered_request(f"{prefix}/releases", query_string="per_page=100", method="GET").respond_with_json([]) + _expect_generated_notes(server) + server.expect_ordered_request(f"{prefix}/commits/refs/tags/0.1.0", method="GET").respond_with_json( + body, status=status + ) + + result = _request(repository, tmp_path, action_scripts, notes, commit, environment | release_cli, mode="stage") + + assert result.returncode != 0 + assert f"::error::tag 0.1.0 must exist and point at {commit}, found {found}" in result.stderr + assert not _requests(server, "POST", f"{prefix}/releases") + + def test_draft_creation_is_not_retried( repository: Path, tmp_path: Path, @@ -883,6 +926,7 @@ def test_draft_creation_is_not_retried( prefix = "/api/v3/repos/tox-dev/fixture" server.expect_ordered_request(f"{prefix}/releases", query_string="per_page=100", method="GET").respond_with_json([]) _expect_generated_notes(server) + _expect_tag(server, commit) server.expect_ordered_request(f"{prefix}/releases", method="POST").respond_with_data(status=502) result = _request(repository, tmp_path, action_scripts, notes, commit, environment | release_cli, mode="stage") assert result.returncode != 0