Repository: oras-project/oras-py

Issue:
Pushing subfolder as OCI Artifact generate new blob even when no changes
I've been using the oras-py library to push the content of folder (that contains subfolders) into a OCI Artifact.
I'm using the following code:

```
application_files = [f'{application_absolute_path}/app-lib', f'{application_absolute_path}/springboot-site']

client.push(files=application_files,
            target="<target-registry>/repository/test-docker-oci/application:1.0", disable_path_validation=True)
```

The issue is, even without changing the content of app-lib folder nor springboot-site folder, new layers are created pointing to new blobs. 
We can't take advantages of previous published blobs.
I was digging into the code and looks like the library is creating a temporal tar.gz file with the content of the subfolder and the use a specific function to generate the disgest.

https://github.com/oras-project/oras-py/blob/main/oras/utils/fileio.py#L33
https://github.com/oras-project/oras-py/blob/main/oras/utils/fileio.py#L109

For one side, a different temporal tar.gz file is created each time, and also, that file contains the timestamp of the creation.
https://wiki.debian.org/ReproducibleBuilds/TimestampsInGzipHeaders

This behaviour is not reproducible using oras cli client. In that case, the digest of the blob for each subfolder is the same, if the content hasn't changed.

Hints from issue comments:
@vsoch: We would definitely want to mirror what the Go oras client does. If you'd like to open a PR to work on the issue I'd be happy to review it.@joseacl: I'd try to do it, thanks for the answer!@joseacl: Hi @vsoch PR is already done!

Solution patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 15c5166..1dec712 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -14,6 +14,7 @@ and **Merged pull requests**. Critical items to know are:
 The versions coincide with releases on pip. Only major versions will be released as tags on Github.
 
 ## [0.0.x](https://github.com/oras-project/oras-py/tree/main) (0.0.x)
+ - Make reproducible targz without mimetype (0.2.30)
  - don't include Content-Length header in upload_manifest (0.2.29)
  - propagate the tls_verify parameter to auth backends (0.2.28)
  - don't add an Authorization header is there is no token (0.2.27), closes issue [182](https://github.com/oras-project/oras-py/issues/182)
diff --git a/oras/utils/fileio.py b/oras/utils/fileio.py
index 9aab3ae..294f11e 100644
--- a/oras/utils/fileio.py
+++ b/oras/utils/fileio.py
@@ -3,6 +3,7 @@ __copyright__ = "Copyright The ORAS Authors."
 __license__ = "Apache-2.0"
 
 import errno
+import gzip
 import hashlib
 import io
 import json
@@ -26,13 +27,29 @@ class PathAndOptionalContent:
         self.content = content
 
 
+def reset(tarinfo):
+    """Helper to reset modification time for tar entries"""
+    tarinfo.mtime = 0
+    return tarinfo
+
+
 def make_targz(source_dir: str, dest_name: Optional[str] = None) -> str:
     """
-    Make a targz (compressed) archive from a source directory.
+    Make a reproducible (no mtime) targz (compressed) archive from a source directory.
     """
     dest_name = dest_name or get_tmpfile(suffix=".tar.gz")
-    with tarfile.open(dest_name, "w:gz") as tar:
-        tar.add(source_dir, arcname=os.path.basename(source_dir))
+
+    # os.O_WRONLY tells the computer you are only going to writo to the file, not read
+    # os.O_CREATE tells the computer to create the file if it doesn't exist
+    with os.fdopen(
+        os.open(dest_name, os.O_WRONLY | os.O_CREAT, 0o644), "wb"
+    ) as out_file:
+        with gzip.GzipFile(mode="wb", fileobj=out_file, mtime=0) as gzip_file:
+            with tarfile.open(fileobj=gzip_file, mode="w:") as tar_file:
+                tar_file.add(
+                    source_dir, filter=reset, arcname=os.path.basename(source_dir)
+                )
+
     return dest_name
 
 
diff --git a/oras/version.py b/oras/version.py
index ab072db..4b797fd 100644
--- a/oras/version.py
+++ b/oras/version.py
@@ -2,7 +2,7 @@ __author__ = "Vanessa Sochat"
 __copyright__ = "Copyright The ORAS Authors."
 __license__ = "Apache-2.0"
 
-__version__ = "0.2.29"
+__version__ = "0.2.30"
 AUTHOR = "Vanessa Sochat"
 EMAIL = "vsoch@users.noreply.github.com"
 NAME = "oras"


Test patch:
diff --git a/oras/tests/test_utils.py b/oras/tests/test_utils.py
index 440d381..cb5ac68 100644
--- a/oras/tests/test_utils.py
+++ b/oras/tests/test_utils.py
@@ -130,3 +130,19 @@ def test_split_path_and_content():
     path_content = utils.split_path_and_content(testref)
     assert path_content.path == "path/to/config.json"
     assert not path_content.content
+
+
+def test_make_targz_files_with_same_content_generates_same_hash(tmp_path):
+    tmp_file = str(tmp_path / "written_file.txt")
+    utils.write_file(tmp_file, "hello!")
+
+    tmp_tar_1 = str(tmp_path / "test1.tar.gz")
+    utils.make_targz(tmp_file, tmp_tar_1)
+
+    tmp_tar_2 = str(tmp_path / "test2.tar.gz")
+    utils.make_targz(tmp_file, tmp_tar_2)
+
+    hash_tar_1 = utils.get_file_hash(tmp_tar_1)
+    hash_tar_2 = utils.get_file_hash(tmp_tar_2)
+
+    assert hash_tar_1 == hash_tar_2