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.

Discussion:
@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