Repository: google/github-release-retry

Issue:
New GitHub node id format is not supported
It seems that GitHub recently introduced a new format for their node ids. Specifically, release asset node ids used to just be a base64 encoded string. Newer node ids look like this: `"RA_[base64 encoded bytes]"`. It looks like the asset id is still encoded within the node id, but the format is different.

We use the ability to extract the REST v3 API "id" from the GraphQL v4 API "node id", even though this is undocumented.

Discussion:

Patch:
diff --git a/ci/check_headers.py b/ci/check_headers.py
index 4fa435e..507f057 100755
--- a/ci/check_headers.py
+++ b/ci/check_headers.py
@@ -88,7 +88,7 @@ def exclude_filename(f: str):
 def go():
     fail = False
     copyright_pattern = re.compile(
-        r"Copyright 20(18|19|20) The github-release-retry Project Authors"
+        r"Copyright 20(18|19|20|21) The github-release-retry Project Authors"
     )
 
     for (dirpath, dirnames, filenames) in os.walk(os.curdir):
diff --git a/dictionary.dic b/dictionary.dic
index 82c52d7..4784640 100644
--- a/dictionary.dic
+++ b/dictionary.dic
@@ -24,3 +24,5 @@ dirname
 testcase
 unprocessable
 S106
+endian
+exc
diff --git a/github_release_retry/github_release_retry.py b/github_release_retry/github_release_retry.py
index c54a7a9..22a7dd9 100755
--- a/github_release_retry/github_release_retry.py
+++ b/github_release_retry/github_release_retry.py
@@ -23,6 +23,7 @@ import json
 import os
 import sys
 import time
+import traceback
 import typing
 from dataclasses import dataclass
 from pathlib import Path
@@ -48,6 +49,61 @@ def log(message: str) -> None:
     print(message, file=sys.stderr)  # noqa: T001
 
 
+def log_exception(message: str) -> None:
+    log(message)
+    traceback.print_exc(file=sys.stderr)
+    log("")
+
+
+def log_response(response: requests.Response) -> None:
+    log(f"status_code: {response.status_code}")
+    if response.content:
+        try:
+            content = response.content.decode(encoding="utf-8", errors="ignore")
+            log(f"content: {content}")
+        except Exception:  # pylint: disable=broad-except;
+            log(f"content: {response.content!r}")
+    log("")
+
+
+def release_asset_node_id_to_asset_id(node_id: str) -> str:
+    """
+    Extracts and returns the asset id from the given Release Asset |node_id|.
+
+    The "id" returned from the GraphQL v4 API is called the "node_id" in the REST API v3.
+    We can get back to the REST "id" by decoding the "node_id" (it is base64 encoded)
+    and extracting the id number at the end, but this is undocumented and may change.
+
+    :param node_id: The Release Asset node_id.
+    :return: The extracted REST API v3 asset id.
+    """
+    # There is a new format and an old format.
+
+    if node_id.startswith("RA_"):
+        # New format: "RA_[base64 encoded bytes]".
+        # The last four bytes (big-endian, unsigned) of the base64 encoded bytes are the node id.
+
+        # Strip off the "RA_".
+        base64_string = node_id[3:]
+
+        asset_id = str(int.from_bytes(base64.b64decode(base64_string)[-4:], "big"))
+    else:
+        # Old format: just a base64 encoded string.
+        # Once decoded, the format is similar to "012:ReleaseAsset18381577".  # noqa: SC100
+        # The asset id part is 18381577.
+        node_id_decoded: str = base64.b64decode(node_id).decode(
+            encoding="utf-8", errors="ignore"
+        )
+        if "ReleaseAsset" not in node_id_decoded:
+            raise AssertionError(
+                f"Unrecognized node_id format: {node_id}. Decoded (base64) string: {node_id_decoded}."
+            )
+
+        asset_id = node_id_decoded.split("ReleaseAsset")[1]
+
+    return asset_id
+
+
 @dataclass
 class GithubResourceError(DataClassJsonMixin):
     resource: Optional[str] = None
@@ -144,7 +200,6 @@ class GithubApi(DataClassJsonMixin):
         )
 
     def upload_asset(self, file_path: Path, release: Release) -> requests.Response:
-
         if not release.upload_url:
             raise AssertionError("Need release object with upload_url.")
 
@@ -180,12 +235,17 @@ class GithubApi(DataClassJsonMixin):
         """
         Returns the asset id.
 
-        :returns the asset id or 0 if the asset was not found.
+        This relies on undocumented behavior; see release_asset_node_id_to_asset_id.
+
+        :returns the asset id or None if the asset was not found.
         """
+        if not release.tag_name:
+            raise AssertionError("Expected tag_name")
+
         # We get the asset id via GitHub's v4 GraphQL API, as this seems to be more reliable.
         # But most other operations still require using the REST v3 API.
 
-        log(f"Finding asset id of {file_name}.")
+        log(f"Finding asset id using v4 API of {file_name}.")
 
         query = f"""
 query {{
@@ -240,23 +300,36 @@ query {{
             return None
 
         try:
-            node_id = assets[0]["id"]
+            node_id: str = assets[0]["id"]
         except KeyError:
             raise UnexpectedResponseError(response)
 
-        # The id we get from the GraphQL API is called the "node_id" in the REST API v3.
-        # We can get back to the REST id by decoding the node_id (it is base64 encoded)
-        # and extracting the number at the end.
-        # Once decoded, the node_id is similar to "012:ReleaseAsset18381577".  # noqa: SC100
-        # The id part is 18381577.
+        return release_asset_node_id_to_asset_id(node_id)
 
-        node_id_decoded: str = base64.b64decode(node_id).decode(
-            encoding="utf-8", errors="ignore"
-        )
-
-        asset_id = node_id_decoded.split("ReleaseAsset")[1]
+    def verify_asset_size_and_state_via_v3_api(
+        self, file_name: str, file_size: int, tag_name: str, release: Optional[Release]
+    ) -> bool:
+        if not release:
+            log("Getting the current release again to check asset status.")
+            response = self.get_release_by_tag(tag_name)
+            if response.status_code != requests.codes.ok:
+                raise UnexpectedResponseError(response)
+            log("Decoding release info.")
+            try:
+                release = Release.from_json(response.content)
+            except json.JSONDecodeError:
+                raise UnexpectedResponseError(response)
 
-        return asset_id
+        if release.assets:
+            for asset in release.assets:
+                if (
+                    asset.name == file_name
+                    and asset.size == file_size
+                    and asset.state == "uploaded"
+                ):
+                    log("The asset has the correct size and state. Asset done.\n")
+                    return True
+        return False
 
 
 class MissingTokenError(Exception):
@@ -281,7 +354,7 @@ class ReachedRetryLimitError(Exception):
     pass
 
 
-def upload_file(  # pylint: disable=too-many-branches;
+def upload_file(  # pylint: disable=too-many-branches,too-many-nested-blocks,too-many-statements;
     g: GithubApi, release: Release, file_path: Path  # noqa: VNE001
 ) -> None:
 
@@ -292,53 +365,92 @@ def upload_file(  # pylint: disable=too-many-branches;
     retry_count = 0
     wait_time = 2
 
+    if not release.tag_name:
+        raise AssertionError("Expected tag_name")
+
     # Optimization:
-    # We don't trust the |release| object; assets that exist on the releases web page might be missing from this object.
-    # However, if the asset *does* exist in the |release| object, and has the correct size and state, then we can assume
-    # it has already been uploaded without making any further remote API calls.
-    if release.assets:
-        for asset in release.assets:
-            if (
-                asset.name == file_path.name
-                and asset.size == file_size
-                and asset.state == "uploaded"
-            ):
-                log("The asset has the correct size and state. Asset done.\n")
-                return
+    # The v3 API does not always show assets that are in a bad state, but if the asset *does* exist with the correct
+    # size and state, then we can assume the asset was successfully uploaded.
+    # We use the existing |release| object, which means we might be able to skip making any further remote API calls.
+    try:
+        if g.verify_asset_size_and_state_via_v3_api(
+            file_name=file_path.name,
+            file_size=file_size,
+            tag_name=release.tag_name,
+            release=release,
+        ):
+            return
+    except Exception:  # pylint: disable=broad-except;
+        log_exception(
+            "Ignoring exception that occurred when trying to check asset status with the v3 API."
+        )
 
     # Only exit the loop if we manage to verify that the asset has the expected size and state, or if we reach the retry
     # limit.
     while True:
-
-        existing_asset_id = g.find_asset_id_by_file_name(file_path.name, release)
-        if existing_asset_id:
-            log("Asset exists.")
-            log("Getting asset info.")
-            response = g.get_asset_by_id(existing_asset_id)
-            if response.status_code != requests.codes.ok:
-                raise UnexpectedResponseError(response)
-
-            log("Decoding asset info.")
-            try:
-                existing_asset = Asset.from_json(response.content)
-            except json.JSONDecodeError:
-                raise UnexpectedResponseError(response)
-
-            if existing_asset.size == file_size and existing_asset.state == "uploaded":
-                log("The asset has the correct size and state. Asset done.\n")
+        # We use try-except liberally so that we always at least try to blindly upload the asset (towards the end of the
+        # loop), because this may well succeed and then the asset checking code may also be more likely to succeed on
+        # subsequent iterations.
+
+        # Optimization:
+        # The v3 API does not always show assets that are in a bad state, but if the asset *does* exist with the
+        # correct size and state, then we can assume the asset was successfully uploaded, without relying on
+        # undocumented behavior.
+        # We pass release=None, which forces a fresh fetch of the Release object.
+        try:
+            if g.verify_asset_size_and_state_via_v3_api(
+                file_name=file_path.name,
+                file_size=file_size,
+                tag_name=release.tag_name,
+                release=None,
+            ):
                 return
+        except Exception:  # pylint: disable=broad-except;
+            log_exception(
+                "Ignoring exception that occurred when trying to check asset status with the v3 API."
+            )
 
-            log('The asset looks bad (wrong size or state was not "uploaded").')
+        # We now try to get the asset details via the v4 API.
+        # This allows us to delete the asset if it is in a bad state, but relies on undocumented behavior.
+        try:
+            existing_asset_id = g.find_asset_id_by_file_name(file_path.name, release)
+            if existing_asset_id:
+                log("Asset exists.")
+                log("Getting asset info.")
+                response = g.get_asset_by_id(existing_asset_id)
+                if response.status_code != requests.codes.ok:
+                    raise UnexpectedResponseError(response)
 
-            log("Deleting asset.")
+                log("Decoding asset info.")
+                try:
+                    existing_asset = Asset.from_json(response.content)
+                except json.JSONDecodeError:
+                    raise UnexpectedResponseError(response)
 
-            response = g.delete_asset(existing_asset_id)
-            if response.status_code != requests.codes.no_content:
-                log(f"Ignoring failed deletion: {response}")
-        else:
-            log("Asset does not exist.")
+                if (
+                    existing_asset.size == file_size
+                    and existing_asset.state == "uploaded"
+                ):
+                    log("The asset has the correct size and state. Asset done.\n")
+                    return
+
+                log('The asset looks bad (wrong size or state was not "uploaded").')
+
+                log("Deleting asset.")
+
+                response = g.delete_asset(existing_asset_id)
+                if response.status_code != requests.codes.no_content:
+                    log("Ignoring failed deletion.")
+                    log_response(response)
+            else:
+                log("Asset does not exist.")
+        except Exception:  # pylint: disable=broad-except;
+            log_exception(
+                "Ignoring exception that occurred when trying to check asset status with the v4 API."
+            )
 
-        # Asset does not exist or has now been deleted.
+        # Asset does not exist, has been deleted, or an error occurred.
+        # Upload the asset, regardless.
 
         if retry_count >= g.retry_limit:
             raise ReachedRetryLimitError("Reached upload retry limit.")
@@ -354,9 +466,10 @@ def upload_file(  # pylint: disable=too-many-branches;
         try:
             response = g.upload_asset(file_path, release)
             if response.status_code != requests.codes.created:
-                log(f"Ignoring failed upload: {response}")
-        except Exception as ex:  # pylint: disable=broad-except;
-            log(f"Ignoring upload exception: {ex}")
+                log("Ignoring failed upload.")
+                log_response(response)
+        except Exception:  # pylint: disable=broad-except;
+            log_exception("Ignoring upload exception.")
 
         # And now we loop.
 
@@ -406,9 +519,9 @@ def make_release(
                 if response.status_code != requests.codes.ok:
                     raise UnexpectedResponseError(response)
 
-        except UnexpectedResponseError as ex:
-            log(
-                f"Unexpected response when creating the release or getting the existing release info:\n{ex}..."
+        except UnexpectedResponseError:
+            log_exception(
+                "Unexpected response when creating the release or getting the existing release info."
             )
             # Note: GitHub will sometimes return a custom error for the Release resource with a message:
             # "Published releases must have a valid tag".


Test patch:
diff --git a/.github/workflows/build_and_test.yml b/.github/workflows/build_and_test.yml
index f7cdd58..0e58106 100644
--- a/.github/workflows/build_and_test.yml
+++ b/.github/workflows/build_and_test.yml
@@ -24,33 +24,18 @@ jobs:
       fail-fast: false
       matrix:
         os:
-          - ubuntu-16.04
+          - ubuntu-18.04
           - windows-latest
         python-version:
           - 3.6
     runs-on: ${{ matrix.os }}
     steps:
 
-      - name: get_actions
-        run: |
-          mkdir -p ./../.github/actions/
-          pushd ./../.github/actions/
-            git clone https://github.com/actions/setup-python.git
-            pushd setup-python/
-              git checkout v2
-            popd
-            git clone https://github.com/actions/checkout.git
-            pushd checkout/
-              git checkout v2
-            popd
-          popd
-        shell: bash
-
       - name: checkout
-        uses: ./../.github/actions/checkout
+        uses: actions/checkout@v2
 
       - name: setup_python
-        uses: ./../.github/actions/setup-python
+        uses: actions/setup-python@v2
         with:
           python-version: ${{ matrix.python-version }}
           architecture: x64
diff --git a/github_release_retry_tests/test_node_id_extraction.py b/github_release_retry_tests/test_node_id_extraction.py
new file mode 100644
index 0000000..2247475
--- /dev/null
+++ b/github_release_retry_tests/test_node_id_extraction.py
@@ -0,0 +1,57 @@
+# -*- coding: utf-8 -*-
+
+# Copyright 2021 The github-release-retry Project Authors
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import base64
+
+from github_release_retry import github_release_retry
+
+
+def test_release_asset_node_id_to_asset_id() -> None:
+
+    old_format_node_id = "MDEyOlJlbGVhc2VBc3NldDQyMTY0NTg2"
+    old_format_expected_asset_id = "42164586"
+
+    old_format_actual_asset_id = github_release_retry.release_asset_node_id_to_asset_id(
+        old_format_node_id
+    )
+
+    assert old_format_actual_asset_id == old_format_expected_asset_id
+
+    new_format_node_id = "RA_kwDODNhc0c4CrJ0q"
+    new_format_expected_asset_id = "44866858"
+
+    new_format_actual_asset_id = github_release_retry.release_asset_node_id_to_asset_id(
+        new_format_node_id
+    )
+
+    assert new_format_actual_asset_id == new_format_expected_asset_id
+
+
+def test_release_asset_node_id_to_asset_id_exception() -> None:
+    # The release_asset_node_id_to_asset_id(...) function expects a node id with "ReleaseAsset", not "ReleaseBadAsset".
+    node_id_decoded = b"012:ReleaseBadAsset18381577"
+    node_id_encoded = base64.b64encode(node_id_decoded).decode(
+        encoding="utf-8", errors="none"
+    )
+
+    try:
+        github_release_retry.release_asset_node_id_to_asset_id(node_id_encoded)
+    except AssertionError as error:
+        message = str(error)
+        assert "format" in message
+        return
+
+    raise AssertionError("Expected an exception")