Repository: apache/iceberg-go
Pull request #149 (2024-09-15 16:44:40)

Test: Add coverage for Output
@zeroshade 
1. I've figured out how to run tests with pterm, but there are a few questions I need your input on.

```
var buf bytes.Buffer
pterm.SetDefaultOutput(&buf)

text{}.DescribeTable(table)

// compare buf.String() with expected result
```
or
```
pterm.SetDefaultOutput(os.Stdout)

text{}.DescribeTable(table)

// Output:
// Expected output
```
2. Also we need to disable colors. Otherwise output has ANSI codes, making comparison impossible.
```
pterm.DisableColor()
```

3. Not sure if we can reuse `Examples`.
https://github.com/golang/go/issues/26460
```
The trailing spaces cannot be handled by the // Output: of an Example
```
In our case, `Metadata location    | ` has whitespace after the pipe `|`, which is getting removed.
```
// Output:
// Table format version | 2
// Metadata location    |
// ...
```
And also `Examples` do not provide any diff when actual result doesn't match expected one.
So, for this matter I used 3rd party `github.com/MarvinJWendt/testza`, but maybe there is a better approach.

I've pushed some working version as an example of what I have so far. I'd appreciate some feedback before I proceed with covering all scenarios.

alex-kar: @zeroshade 
1. I've figured out how to run tests with pterm, but there are a few questions I need your input on.

```
var buf bytes.Buffer
pterm.SetDefaultOutput(&buf)

text{}.DescribeTable(table)

// compare buf.String() with expected result
```

2. Also we need to disable colors. Otherwise output has ANSI codes, making comparison impossible.
```
pterm.DisableColor()
```

3. Not sure if we can reuse `Examples`.
https://github.com/golang/go/issues/26460
```
The trailing spaces cannot be handled by the // Output: of an Example
```
In our case, `Metadata location    | ` has whitespace after the pipe `|`, which is getting removed.
```
// Output:
// Table format version | 2
// Metadata location    |
// ...
```
And also `Examples` do not provide any diff when actual result doesn't match expected one.
So, for this matter I used 3rd party `github.com/MarvinJWendt/testza`, but maybe there is a better approach.

I've pushed some working version as an example of what I have so far. I'd appreciate some feedback before I proceed with covering all scenarios.
zeroshade: This is awesome for a find, thanks for this! As for your questions:

I personally prefer this one: 

```go
pterm.SetDefaultOutput(os.Stdout)

text{}.DescribeTable(table)

// Output:
// Expected output
```

But given the 3rd part of this, it can definitely make more sense to do the first option if that's easier to implement.

> 2. Also we need to disable colors. Otherwise output has ANSI codes, making comparison impossible.

Can we only disable the colors for the tests? I personally don't want to lose the colors for regular output and use of the CLI, perhaps we make an option for disabling colors or a global var that the test can set to disable it?

> In our case, Metadata location    |  has whitespace after the pipe |, which is getting removed.

Should we modify the output to not print trailing whitespaces?

> And also Examples do not provide any diff when actual result doesn't match expected one.

That's fair, we just get a "want: ...." and "got: ...." which would make diffing difficult. In light of this, it might make sense to go with the example you've provided here that writes to a buffer and we compare manually so that we can output the diff instead.
alex-kar: @zeroshade 
```
Can we only disable the colors for the tests? I personally don't want to lose the colors for regular output and use of the CLI, perhaps we make an option for disabling colors or a global var that the test can set to disable it?
```
It's something that `pterm` provides us out of the box. We just tern global variable to disable colors in test only.
Please, see in my test example
```
pterm.SetDefaultOutput(&buf)
pterm.DisableColor()

text{}.DescribeTable(table)
```
zeroshade: @alex-kar oh perfect! I missed that. Nice!
alex-kar: Also I tried to write something like "parameterized" test to provide different test cases, but not sure if arguments are readable.
Here is the idea:
```
var testArgs = []struct {
	meta     string
	expected string
}{
	{`<metadata json 1>`, `<expecte output 1>`},
	{`<metadata json 2>`, `<expecte output 2>`},
	{`<metadata json 3>`, `<expecte output 3>`},
}

func TestDescribeTable(t *testing.T) {
	var buf bytes.Buffer
	pterm.SetDefaultOutput(&buf)
	pterm.DisableColor()
	for _, tt := range testArgs {
		meta, _ := table.ParseMetadataBytes([]byte(tt.meta))
		table := table.New([]string{"t"}, meta, "", nil)

		text{}.DescribeTable(table)

		testza.AssertEqual(t, tt.expected, buf.String())
	}
}
```
But because "metadata json" and "expected output" are quite long multiline strings, it make arguments less readable.

@zeroshade WDYT? Is it better to just write separate tests for each case or go with different parameters for the same test?
However, if we use parameters, we won't be able to reuse `Examples`.
alex-kar: @zeroshade Please review tests to cover `output`.
I added two parameters: one with minimal table metadata and another with all fields set. For comparison, I used `github.com/stretchr/testify/assert`, which is already in the project and provides a nice diff view.

Patch:
diff --git a/.github/workflows/go-ci.yml b/.github/workflows/go-ci.yml
index e82a3ed..3f90c29 100644
--- a/.github/workflows/go-ci.yml
+++ b/.github/workflows/go-ci.yml
@@ -42,7 +42,7 @@ jobs:
         go: [ '1.21', '1.22' ]
         os: [ 'ubuntu-latest', 'windows-latest', 'macos-latest' ]
     steps:
-    - uses: actions/checkout@v3
+    - uses: actions/checkout@v4
     - name: Install Go
       uses: actions/setup-go@v4
       with:
diff --git a/.github/workflows/rc.yml b/.github/workflows/rc.yml
new file mode 100644
index 0000000..5a407d8
--- /dev/null
+++ b/.github/workflows/rc.yml
@@ -0,0 +1,127 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you 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
+#
+#   http://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.
+
+name: RC
+on:
+  push:
+    branches:
+      - '**'
+      - '!dependabot/**'
+    tags:
+      - '*-rc*'
+  pull_request:
+
+concurrency:
+  group: ${{ github.repository }}-${{ github.head_ref || github.sha }}-${{ github.workflow }}
+  cancel-in-progress: true
+permissions:
+  contents: read
+
+jobs:
+  archive:
+    name: Archive
+    runs-on: ubuntu-latest
+    timeout-minutes: 5
+    steps:
+      - name: Checkout
+        uses: actions/checkout@v4
+      - name: Prepare for tag
+        if: github.ref_type == 'tag'
+        run: |
+          version=${GITHUB_REF_NAME%-rc}
+          version=${version#v}
+          rc=${GITHUB_REF_NAME#*-rc}
+          echo "VERSION=${version}" >> ${GITHUB_ENV}
+          echo "RC=${rc}" >> ${GITHUB_ENV}
+      - name: Prepare for branch
+        if: github.ref_type == 'branch'
+        run: |
+          rc=100
+          echo "VERSION=${version}" >> ${GITHUB_ENV}
+          echo "RC=${rc}" >> ${GITHUB_ENV}
+      - name: Archive
+        run: |
+          id="apache-iceberg-go-${VERSION}"
+          tar_gz="${id}.tar.gz"
+          echo "TAR_GZ=${tar_gz}" >> ${GITHUB_ENV}
+          git archive HEAD --prefix "${id}/" --output "${tar_gz}"
+          sha256sum "${tar_gz}" > "${tar_gz}.sha256"
+          sha512sum "${tar_gz}" > "${tar_gz}.sha512"
+      - name: Audit
+        run: |
+          dev/release/run_rat.sh "${TAR_GZ}"
+      - uses: actions/upload-artifact@v4
+        with:
+          name: archive
+          path: |
+            apache-iceberg-go-*
+  
+  verify:
+    name: Verify
+    needs:
+      - archive
+    runs-on: ${{ matrix.os }}
+    strategy:
+      fail-fast: false
+      matrix:
+        os:
+          - macos-latest
+          - ubuntu-latest
+    steps:
+      - name: Checkout
+        uses: actions/checkout@v4
+      - uses: actions/download-artifact@v4
+        with:
+          name: archive
+      - name: Verify
+        run: |
+          tar_gz=$(echo apache-iceberg-go-*.tar.gz)
+          version=${tar_gz#apache-iceberg-go-}
+          version=${version%.tar.gz}
+          if [ "${GITHUB_REF_TYPE}" = "tag" ]; then
+            rc="${GITHUB_REF_NAME#*-rc}"
+          else
+            rc=100
+          fi
+          VERIFY_DEFAULT=0 dev/release/verify_rc.sh "${version}" "${rc}"
+        env:
+          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+  upload:
+    name: upload
+    if: github.ref_type == 'tag'
+    needs:
+      - verify
+    runs-on: ubuntu-latest
+    permissions:
+      contents: write
+    steps:
+      - name: Checkout
+        uses: actions/checkout@v4
+      - uses: actions/download-artifact@v4
+        with:
+          name: archive
+      - name: Upload
+        run: |
+          # TODO: Add support for release notes
+          gh release create ${GITHUB_REF_NAME} \
+            --prerelease \
+            --title "Apache Iceberg Go ${GITHUB_REF_NAME}" \
+            --verify-tag \
+            apache-iceberg-go-*.tar.gz \
+            apache-iceberg-go-*.tar.gz.sha*
+        env:
+          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
diff --git a/.gitignore b/.gitignore
index 8b398c6..99c3976 100644
--- a/.gitignore
+++ b/.gitignore
@@ -49,4 +49,10 @@ lib/
 
 # local catalog environment via docker
 dev/notebooks
-dev/warehouse
\ No newline at end of file
+dev/warehouse
+
+/apache-iceberg-go-*.tar.gz
+/apache-iceberg-go-*.tar.gz.asc
+/dev/release/apache-rat-*.jar
+/dev/release/filtered_rat.txt
+/dev/release/rat.xml
diff --git a/dev/.rat-excludes b/dev/.rat-excludes
deleted file mode 100644
index e947260..0000000
--- a/dev/.rat-excludes
+++ /dev/null
@@ -1,8 +0,0 @@
-.gitignore
-.rat-excludes
-LICENSE
-NOTICE
-go.sum
-build
-rat-results.txt
-operation_string.go
\ No newline at end of file
diff --git a/dev/check-license b/dev/check-license
index 23d22f7..7ba2d9a 100755
--- a/dev/check-license
+++ b/dev/check-license
@@ -65,7 +65,7 @@ mkdir -p "$FWDIR"/lib
 }
 
 mkdir -p build
-$java_cmd -jar "$rat_jar" -E "$FWDIR"/dev/.rat-excludes -d "$FWDIR" > build/rat-results.txt
+$java_cmd -jar "$rat_jar" -E "$FWDIR"/dev/release/rat_exclude_files.txt -d "$FWDIR" > build/rat-results.txt
 
 if [ $? -ne 0 ]; then
    echo "RAT exited abnormally"
diff --git a/dev/release/README.md b/dev/release/README.md
new file mode 100644
index 0000000..1f9285f
--- /dev/null
+++ b/dev/release/README.md
@@ -0,0 +1,105 @@
+<!---
+  Licensed to the Apache Software Foundation (ASF) under one
+  or more contributor license agreements.  See the NOTICE file
+  distributed with this work for additional information
+  regarding copyright ownership.  The ASF licenses this file
+  to you 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
+    http://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.
+-->
+
+# Release
+
+## Overview
+
+    1. Test the revision to be released
+    2. Prepare RC and vote (detailed later)
+    3. Publish (detailed later)
+
+### Prepare RC and vote
+
+Run `dev/release/release_rc.sh` on a working copy of 
+`git@github.com:apache/iceberg-go` not from your fork:
+
+```console
+$ git clone git@github.com:apache/iceberg-go.git
+$ dev/release/release_rc.sh ${VERSION} ${RC}
+(Send a vote email to dev@iceberg.apache.org.
+ You can use a draft shown by release_rc.sh for the email.)
+```
+
+Here is an example to release RC1:
+
+```console
+$ GH_TOKEN=${YOUR_GITHUB_TOKEN} dev/release/release_rc.sh 1.0.0 1
+```
+
+The arguments of `release_rc.sh` are the version and the RC number. If RC1 has a problem, we'll increment the RC number such as RC2, RC3 and so on.
+
+Requirements to run `release_rc.sh`:
+
+    * You must be an Apache Iceberg committer or PMC member
+    * You must prepare your PGP key for signing
+
+If you don't have a PGP key, https://infra.apache.org/release-signing.html#generate
+may be helpful.
+
+Your PGP key must be registered to the following:
+
+    * https://dist.apache.org/repos/dist/dev/iceberg/KEYS
+    * https://dist.apache.org/repos/dist/release/iceberg/KEYS
+
+See the header comment of them for how to add a PGP key.
+
+Apache Iceberg committers can update them by Subversion client with their ASF account.
+e.g.:
+
+```console
+$ svn co https://dist.apache.org/repos/dist/dev/iceberg
+$ cd iceberg
+$ editor KEYS
+$ svn ci KEYS
+```
+
+### Publish
+
+We need to do the following to publish a new release:
+
+    * Publish to apache.org
+
+Run `dev/release/release.sh` to publish to apache.org:
+
+```console
+$ GH_TOKEN=${YOUR_GITHUB_TOKEN} dev/release/release.sh ${VERSION} ${RC}
+```
+
+Add the release to ASF's report database via [Apache Committee Report Helper](https://reporter.apache.org/addrelease.html?iceberg)
+
+### Verify
+
+We have a script for verifying a RC.
+
+You must install the following to run the script:
+
+    * `curl`
+    * `gpg`
+    * `shasum` or `sha256sum`/`sha512sum`
+    * `tar`
+
+You don't need to have Go installed, if it isn't on the system the latest Go will be
+automatically downloaded and used only for verification.
+
+To verify a RC, run the following:
+
+```console
+$ dev/release/verify_rc.sh ${VERSION} ${RC}
+```
+
+If the verification is successful, the message `RC looks good!` is shown.
diff --git a/dev/release/check_rat_report.py b/dev/release/check_rat_report.py
new file mode 100755
index 0000000..c45baa0
--- /dev/null
+++ b/dev/release/check_rat_report.py
@@ -0,0 +1,59 @@
+#!/usr/bin/env python3
+#
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you 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
+#
+#   http://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 fnmatch
+import re
+import sys
+import xml.etree.ElementTree as ET
+
+if len(sys.argv) != 3:
+    sys.stderr.write("Usage: %s exclude_globs.lst rat_report.xml\n" %
+                     sys.argv[0])
+    sys.exit(1)
+
+exclude_globs_filename = sys.argv[1]
+xml_filename = sys.argv[2]
+
+globs = [line.strip() for line in open(exclude_globs_filename, "r")]
+
+tree = ET.parse(xml_filename)
+root = tree.getroot()
+resources = root.findall('resource')
+
+all_ok = True
+for r in resources:
+    approvals = r.findall('license-approval')
+    if not approvals or approvals[0].attrib['name'] == 'true':
+        continue
+    clean_name = re.sub('^[^/]+/', '', r.attrib['name'])
+    excluded = False
+    for g in globs:
+        if fnmatch.fnmatch(clean_name, g):
+            excluded = True
+            break
+    if not excluded:
+        sys.stdout.write("NOT APPROVED: %s (%s): %s\n" % (
+            clean_name, r.attrib['name'], approvals[0].attrib['name']))
+        all_ok = False
+
+if not all_ok:
+    sys.exit(1)
+
+print('OK')
+sys.exit(0)
diff --git a/dev/release/rat_exclude_files.txt b/dev/release/rat_exclude_files.txt
new file mode 100644
index 0000000..08ab050
--- /dev/null
+++ b/dev/release/rat_exclude_files.txt
@@ -0,0 +1,24 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you 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
+#
+#   http://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.
+
+.gitignore
+LICENSE
+NOTICE
+go.sum
+build
+rat-results.txt
+operation_string.go
\ No newline at end of file
diff --git a/dev/release/release.sh b/dev/release/release.sh
new file mode 100755
index 0000000..d67264e
--- /dev/null
+++ b/dev/release/release.sh
@@ -0,0 +1,83 @@
+#!/usr/bin/env bash
+#
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you 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
+#
+#   http://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.
+
+set -eu
+
+if [ "$#" -ne 2 ]; then
+  echo "Usage: $0 <version> <rc>"
+  echo " e.g.: $0 1.0.0 1"
+  exit 1
+fi
+
+version=$1
+rc=$2
+
+git_origin_url="$(git remote get-url origin)"
+repository="${git_origin_url#*github.com?}"
+repository="${repository%.git}"
+if [ "${git_origin_url}" != "git@github.com:apache/iceberg-go.git" ]; then
+  echo "This script must be ran with a working copy of apache/iceberg-go."
+  echo "The origin's URL: ${git_origin_url}"
+  exit 1
+fi
+
+tag="v${version}"
+rc_tag="${tag}-rc${rc}"
+echo "Tagging for release: ${tag}"
+git tag "${tag}" "${rc_tag}"
+git push origin "${tag}"
+
+dist_url="https://dist.apache.org/repos/dist/release/iceberg"
+dist_dir="dev/release/dist"
+echo "Checking out ${dist_url}"
+rm -rf "${dist_dir}"
+svn co --depth=empty "${dist_url}" "${dist_dir}"
+gh release download "${rc_tag}" \
+  --repo "${repository}" \
+  --dir "${dist_dir}" \
+  --skip-existing
+
+release_id="apache-iceberg-go-${version}"
+echo "Uploading to release/"
+pushd "${dist_dir}"
+svn add .
+svn ci -m "Apache Iceberg Go ${version}"
+popd
+rm -rf "${dist_dir}"
+
+echo "Keep only the latest versions"
+old_releases=$(
+  svn ls https://dist.apache.org/repos/dist/release/iceberg/ |
+  grep -E '^apache-iceberg-go-' |
+  sort --version-sort --reverse |
+  tail -n +2
+)
+for old_release_version in ${old_releases}; do
+  echo "Remove old release ${old_release_version}"
+  svn \
+    delete \
+    -m "Remove old Apache Iceberg Go release: ${old_release_version}" \
+    "https://dist.apache.org/repos/dist/release/iceberg/${old_release_version}"
+done
+
+echo "Success! The release is available here:"
+echo "  https://dist.apache.org/repos/dist/release/iceberg/${release_id}"
+echo
+echo "Add this release to ASF's report database:"
+echo "  https://reporter.apache.org/addrelease.html?iceberg"
diff --git a/dev/release/release_rc.sh b/dev/release/release_rc.sh
new file mode 100755
index 0000000..3c93f84
--- /dev/null
+++ b/dev/release/release_rc.sh
@@ -0,0 +1,140 @@
+#!/usr/bin/env bash
+#
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you 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
+#
+#   http://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.
+
+set -eu
+
+SOURCE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+SOURCE_TOP_DIR="$(cd "${SOURCE_DIR}/../../" && pwd)"
+
+if [ "$#" -ne 1 ]; then
+  echo "Usage: $0 <version> <rc>"
+  echo " e.g.: $0 1.0.0 1"
+  exit 1
+fi
+
+# TODO: possibly use go-semantic-release to auto generate the version?
+version=$1
+rc=$2
+
+: "${RELEASE_DEFAULT:=1}"
+: "${RELEASE_PULL:=${RELEASE_DEFAULT}}"
+: "${RELEASE_PUSH_TAG:=${RELEASE_DEFAULT}}"
+: "${RELEASE_SIGN:=${RELEASE_DEFAULT}}"
+: "${RELEASE_UPLOAD:=${RELEASE_DEFAULT}}"
+
+
+cd "${SOURCE_TOP_DIR}"
+
+if [ "${RELEASE_PULL}" -gt 0 ] || [ "${RELEASE_PUSH_TAG}" -gt 0 ]; then
+  git_origin_url="$(git remote get-url origin)"
+  if [ "${git_origin_url}" != "git@github.com:apache/iceberg-go.git" ]; then
+    echo "This script must be ran with working copy of apache/iceberg-go."
+    echo "The origin's URL: ${git_origin_url}"
+    exit 1
+  fi
+fi
+
+if [ "${RELEASE_PULL}" -gt 0 ]; then
+  echo "Ensure using the latest commit"
+  git checkout main
+  git pull --rebase --prune
+fi
+
+rc_tag="v${version}-rc${rc}"
+if [ "${RELEASE_PUSH_TAG}" -gt 0 ]; then
+  echo "Tagging for RC: ${rc_tag}"
+  git tag -a -m "${version} RC${rc}" "${rc_tag}"
+  git push origin "${rc_tag}"
+fi
+
+rc_hash="$(git rev-list --max-count=1 "${rc_tag}")"
+
+id="apache-iceberg-go-${version}"
+tar_gz="${id}.tar.gz"
+
+if [ "${RELEASE_SIGN}" -gt 0 ]; then
+  git_origin_url="$(git remote get-url origin)"
+  repository="${git_origin_url#*github.com?}"
+  repository="${repository%.git}"
+
+  echo "Looking for GitHub Actions workflow on ${repository}:${rc_tag}"
+  run_id=""
+  while [ -z "${run_id}" ]; do
+    echo "Waiting for run to start..."
+    run_id=$(gh run list \
+      --repo "${repository}" \
+      --workflow=rc.yml \
+      --json 'databaseId,event,headBranch,status' \
+      --jq ".[] | select(.event == \"push\" and .headBranch == \"${rc_tag}\") | .databaseId")
+    sleep 1
+  done
+
+  echo "Found GitHub Actions workflow with ID: ${run_id}"
+  gh run watch --repo "${repository}" --exit-status "${run_id}"
+
+  echo "Downloading .tar.gz from GitHub Releases"
+  gh release download "${rc_tag}" \
+    --dir . \
+    --pattern "${tar_gz}" \
+    --repo "${repository}" \
+    --skip-existing
+
+  echo "Signing tar.gz and creating checksums"
+  gpg --armor --output "${tar_gz}.asc" --detach-sig "${tar_gz}"
+fi
+
+if [ "${RELEASE_UPLOAD}" -gt 0 ]; then
+  echo "Uploading signature"
+  gh release upload "${rc_tag}" \
+    --clobber \
+    --repo "${repository}" \
+    "${tar_gz}.asc"
+fi
+
+echo "Draft email for dev@iceberg.apache.org mailing list"
+echo ""
+echo "---------------------------------------------------------"
+cat <<MAIL
+To: dev@iceberg.apache.org
+Subject: [VOTE][Go] Release Apache Iceberg Go ${version} RC${rc}
+
+Hi,
+
+I would like to propose the following release candidate (RC${rc}) of
+Apache Iceberg Go version ${version}.
+
+This release candidate is based on commit:
+${rc_hash} [1]
+
+The source release rc${rc} is hosted at [2].
+
+Please download, verify checksums and signatures, run the unit tests,
+and vote on the release. See [3] for how to validate a release candidate.
+
+The vote will be open for at least 72 hours.
+
+[ ] +1 Release this as Apache Iceberg Go ${version}
+[ ] +0
+[ ] -1 Do not release this as Apache Iceberg Go ${version} because...
+
+[1]: https://github.com/apache/iceberg-go/tree/${rc_hash}
+[2]: https://github.com/apache/iceberg-go/releases/${rc_tag}
+[3]: https://github.com/apache/iceberg-go/blob/main/dev/release/README.md#verify
+MAIL
+echo "---------------------------------------------------------"
diff --git a/dev/release/run_rat.sh b/dev/release/run_rat.sh
new file mode 100755
index 0000000..2104369
--- /dev/null
+++ b/dev/release/run_rat.sh
@@ -0,0 +1,54 @@
+#!/usr/bin/env bash
+#
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you 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
+#
+#   http://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.
+
+set -eu
+
+RELEASE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+
+RAT_VERSION=0.16.1
+
+RAT_JAR="${RELEASE_DIR}/apache-rat-${RAT_VERSION}.jar"
+if [ ! -f "${RAT_JAR}" ]; then
+  curl \
+    --fail \
+    --output "${RAT_JAR}" \
+    --show-error \
+    --silent \
+    https://repo1.maven.org/maven2/org/apache/rat/apache-rat/${RAT_VERSION}/apache-rat-${RAT_VERSION}.jar
+fi
+
+RAT_XML="${RELEASE_DIR}/rat.xml"
+java \
+  -jar "${RAT_JAR}" \
+  --out "${RAT_XML}" \
+  --xml \
+  "$1"
+FILTERED_RAT_TXT="${RELEASE_DIR}/filtered_rat.txt"
+if ${PYTHON:-python3} \
+  "${RELEASE_DIR}/check_rat_report.py" \
+  "${RELEASE_DIR}/rat_exclude_files.txt" \
+  "${RAT_XML}" > \
+  "${FILTERED_RAT_TXT}"; then
+  echo "No unapproved licenses"
+else
+  cat "${FILTERED_RAT_TXT}"
+  N_UNAPPROVED=$(grep -c "NOT APPROVED" "${FILTERED_RAT_TXT}")
+  echo "${N_UNAPPROVED} unapproved licenses. Check Rat report: ${RAT_XML}"
+  exit 1
+fi
diff --git a/dev/release/verify_rc.sh b/dev/release/verify_rc.sh
new file mode 100755
index 0000000..a84af66
--- /dev/null
+++ b/dev/release/verify_rc.sh
@@ -0,0 +1,206 @@
+#!/usr/bin/env bash
+#
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you 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
+#
+#   http://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.
+
+set -eu
+
+SOURCE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+TOP_SOURCE_DIR="$(dirname "$(dirname "${SOURCE_DIR}")")"
+
+if [ "$#" -ne 2 ]; then
+  echo "Usage: $0 <version> <rc>"
+  echo " e.g.: $0 18.0.0 1"
+  exit 1
+fi
+
+set -o pipefail
+set -x
+
+VERSION="$1"
+RC="$2"
+
+ICEBERG_DIST_BASE_URL="https://dist.apache.org/repos/dist/dev/iceberg"
+DOWNLOAD_RC_BASE_URL="https://github.com/apache/iceberg-go/releases/download/v${VERSION}-rc${RC}"
+ARCHIVE_BASE_NAME="apache-iceberg-go-${VERSION}"
+
+: "${VERIFY_DEFAULT:=1}"
+: "${VERIFY_DOWNLOAD:=${VERIFY_DEFAULT}}"
+: "${VERIFY_FORCE_USE_GO_BINARY:=0}"
+: "${VERIFY_SIGN:=${VERIFY_DEFAULT}}"
+
+VERIFY_SUCCESS=no
+
+setup_tmpdir() {
+  cleanup() {
+    go clean -modcache || :
+    if [ "${VERIFY_SUCCESS}" = "yes" ]; then
+      rm -rf "${VERIFY_TMPDIR}"
+    else
+      echo "Failed to verify release candidate. See ${VERIFY_TMPDIR} for details."
+    fi
+  }
+
+  if [ -z "${VERIFY_TMPDIR:-}" ]; then
+    VERIFY_TMPDIR="$(mktemp -d -t "$1.XXXXX")"
+    trap cleanup EXIT
+  else
+    mkdir -p "${VERIFY_TMPDIR}"
+  fi
+}
+
+download() {
+  curl \
+    --fail \
+    --location \
+    --remote-name \
+    --show-error \
+    --silent \
+    "$1"
+}
+
+download_rc_file() {
+  if [ "${VERIFY_DOWNLOAD}" -gt 0 ]; then
+    download "${DOWNLOAD_RC_BASE_URL}/$1"
+  else
+    cp "${TOP_SOURCE_DIR}/$1" "$1"
+  fi
+}
+
+import_gpg_keys() {
+  if [ "${VERIFY_SIGN}" -gt 0 ]; then
+    download "${ICEBERG_DIST_BASE_URL}/KEYS"
+    gpg --import KEYS
+  fi
+}
+
+if type shasum >/dev/null 2>&1; then
+  sha256_verify="shasum -a 256 -c"
+  sha512_verify="shasum -a 512 -c"
+else
+  sha256_verify="sha256sum -c"
+  sha512_verify="sha512sum -c"
+fi
+
+fetch_archive() {
+  download_rc_file "${ARCHIVE_BASE_NAME}.tar.gz"
+  if [ "${VERIFY_SIGN}" -gt 0 ]; then
+    download_rc_file "${ARCHIVE_BASE_NAME}.tar.gz.asc"
+    gpg --verify "${ARCHIVE_BASE_NAME}.tar.gz.asc" "${ARCHIVE_BASE_NAME}.tar.gz"
+  fi
+  download_rc_file "${ARCHIVE_BASE_NAME}.tar.gz.sha256"
+  ${sha256_verify} "${ARCHIVE_BASE_NAME}.tar.gz.sha256"
+  download_rc_file "${ARCHIVE_BASE_NAME}.tar.gz.sha512"
+  ${sha512_verify} "${ARCHIVE_BASE_NAME}.tar.gz.sha512"
+}
+
+ensure_source_directory() {
+  tar xf "${ARCHIVE_BASE_NAME}".tar.gz
+}
+
+latest_go_version() {
+  local -a options
+  options=(
+    --fail
+    --location
+    --show-error
+    --silent
+  )
+  if [ -n "${GITHUB_TOKEN:-}" ]; then
+    options+=("--header" "Authorization: Bearer ${GITHUB_TOKEN}")
+  fi
+  curl \
+    "${options[@]}" \
+    https://api.github.com/repos/golang/go/git/matching-refs/tags/go |
+    grep -o '"ref": "refs/tags/go.*"' |
+    tail -n 1 |
+    sed \
+      -e 's,^"ref": "refs/tags/go,,g' \
+      -e 's/"$//g'
+}
+
+ensure_go() {
+  if [ "${VERIFY_FORCE_USE_GO_BINARY}" -le 0 ]; then
+    if go version; then
+      GOPATH="${VERIFY_TMPDIR}/gopath"
+      export GOPATH
+      mkdir -p "${GOPATH}"
+      return
+    fi
+  fi
+
+ local go_version
+  go_version=$(latest_go_version)
+  local go_os
+  go_os="$(uname)"
+  case "${go_os}" in
+  Darwin)
+    go_os="darwin"
+    ;;
+  Linux)
+    go_os="linux"
+    ;;
+  esac
+  local go_arch
+  go_arch="$(arch)"
+  case "${go_arch}" in
+  i386 | x86_64)
+    go_arch="amd64"
+    ;;
+  aarch64)
+    go_arch="arm64"
+    ;;
+  esac
+  local go_binary_tar_gz
+  go_binary_tar_gz="go${go_version}.${go_os}-${go_arch}.tar.gz"
+  local go_binary_url
+  go_binary_url="https://go.dev/dl/${go_binary_tar_gz}"
+  curl \
+    --fail \
+    --location \
+    --output "${go_binary_tar_gz}" \
+    --show-error \
+    --silent \
+    "${go_binary_url}"
+  tar xf "${go_binary_tar_gz}"
+  GOROOT="$(pwd)/go"
+  export GOROOT
+  GOPATH="$(pwd)/gopath"
+  export GOPATH
+  mkdir -p "${GOPATH}"
+  PATH="${GOROOT}/bin:${GOPATH}/bin:${PATH}"
+}
+
+test_source_distribution() {
+  go test -v ./...
+  # TODO: run integration tests
+}
+
+setup_tmpdir "iceberg-go-${VERSION}-${RC}"
+echo "Working in sandbox ${VERIFY_TMPDIR}"
+cd "${VERIFY_TMPDIR}"
+
+import_gpg_keys
+fetch_archive
+ensure_source_directory
+ensure_go
+pushd "${ARCHIVE_BASE_NAME}"
+test_source_distribution
+popd
+
+VERIFY_SUCCESS=yes
+echo "RC looks good!"
diff --git a/go.mod b/go.mod
index 4ed0447..20b2df9 100644
--- a/go.mod
+++ b/go.mod
@@ -21,12 +21,12 @@ go 1.21
 
 require (
 	github.com/apache/arrow/go/v16 v16.1.0
-	github.com/aws/aws-sdk-go-v2 v1.30.5
+	github.com/aws/aws-sdk-go-v2 v1.31.0
 	github.com/aws/aws-sdk-go-v2/config v1.27.33
-	github.com/aws/aws-sdk-go-v2/credentials v1.17.32
-	github.com/aws/aws-sdk-go-v2/service/glue v1.95.0
+	github.com/aws/aws-sdk-go-v2/credentials v1.17.37
+	github.com/aws/aws-sdk-go-v2/service/glue v1.99.2
 	github.com/aws/aws-sdk-go-v2/service/s3 v1.61.2
-	github.com/aws/smithy-go v1.20.4
+	github.com/aws/smithy-go v1.21.0
 	github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815
 	github.com/google/uuid v1.6.0
 	github.com/hamba/avro/v2 v2.23.0
@@ -42,18 +42,18 @@ require (
 	atomicgo.dev/keyboard v0.2.9 // indirect
 	atomicgo.dev/schedule v0.1.0 // indirect
 	github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.4 // indirect
-	github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.13 // indirect
-	github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.17 // indirect
-	github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.17 // indirect
+	github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.14 // indirect
+	github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.18 // indirect
+	github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.18 // indirect
 	github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1 // indirect
 	github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.17 // indirect
-	github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.4 // indirect
+	github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.5 // indirect
 	github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.3.19 // indirect
-	github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.19 // indirect
+	github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.20 // indirect
 	github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.17.17 // indirect
-	github.com/aws/aws-sdk-go-v2/service/sso v1.22.7 // indirect
-	github.com/aws/aws-sdk-go-v2/service/ssooidc v1.26.7 // indirect
-	github.com/aws/aws-sdk-go-v2/service/sts v1.30.7 // indirect
+	github.com/aws/aws-sdk-go-v2/service/sso v1.23.3 // indirect
+	github.com/aws/aws-sdk-go-v2/service/ssooidc v1.27.3 // indirect
+	github.com/aws/aws-sdk-go-v2/service/sts v1.31.3 // indirect
 	github.com/containerd/console v1.0.3 // indirect
 	github.com/davecgh/go-spew v1.1.1 // indirect
 	github.com/goccy/go-json v0.10.2 // indirect
diff --git a/go.sum b/go.sum
index 35add03..5ea65b2 100644
--- a/go.sum
+++ b/go.sum
@@ -18,44 +18,44 @@ github.com/MarvinJWendt/testza v0.5.2/go.mod h1:xu53QFE5sCdjtMCKk8YMQ2MnymimEctc
 github.com/apache/arrow/go/v16 v16.1.0 h1:dwgfOya6s03CzH9JrjCBx6bkVb4yPD4ma3haj9p7FXI=
 github.com/apache/arrow/go/v16 v16.1.0/go.mod h1:9wnc9mn6vEDTRIm4+27pEjQpRKuTvBaessPoEXQzxWA=
 github.com/atomicgo/cursor v0.0.1/go.mod h1:cBON2QmmrysudxNBFthvMtN32r3jxVRIvzkUiF/RuIk=
-github.com/aws/aws-sdk-go-v2 v1.30.5 h1:mWSRTwQAb0aLE17dSzztCVJWI9+cRMgqebndjwDyK0g=
-github.com/aws/aws-sdk-go-v2 v1.30.5/go.mod h1:CT+ZPWXbYrci8chcARI3OmI/qgd+f6WtuLOoaIA8PR0=
+github.com/aws/aws-sdk-go-v2 v1.31.0 h1:3V05LbxTSItI5kUqNwhJrrrY1BAXxXt0sN0l72QmG5U=
+github.com/aws/aws-sdk-go-v2 v1.31.0/go.mod h1:ztolYtaEUtdpf9Wftr31CJfLVjOnD/CVRkKOOYgF8hA=
 github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.4 h1:70PVAiL15/aBMh5LThwgXdSQorVr91L127ttckI9QQU=
 github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.4/go.mod h1:/MQxMqci8tlqDH+pjmoLu1i0tbWCUP1hhyMRuFxpQCw=
 github.com/aws/aws-sdk-go-v2/config v1.27.33 h1:Nof9o/MsmH4oa0s2q9a0k7tMz5x/Yj5k06lDODWz3BU=
 github.com/aws/aws-sdk-go-v2/config v1.27.33/go.mod h1:kEqdYzRb8dd8Sy2pOdEbExTTF5v7ozEXX0McgPE7xks=
-github.com/aws/aws-sdk-go-v2/credentials v1.17.32 h1:7Cxhp/BnT2RcGy4VisJ9miUPecY+lyE9I8JvcZofn9I=
-github.com/aws/aws-sdk-go-v2/credentials v1.17.32/go.mod h1:P5/QMF3/DCHbXGEGkdbilXHsyTBX5D3HSwcrSc9p20I=
-github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.13 h1:pfQ2sqNpMVK6xz2RbqLEL0GH87JOwSxPV2rzm8Zsb74=
-github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.13/go.mod h1:NG7RXPUlqfsCLLFfi0+IpKN4sCB9D9fw/qTaSB+xRoU=
-github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.17 h1:pI7Bzt0BJtYA0N/JEC6B8fJ4RBrEMi1LBrkMdFYNSnQ=
-github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.17/go.mod h1:Dh5zzJYMtxfIjYW+/evjQ8uj2OyR/ve2KROHGHlSFqE=
-github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.17 h1:Mqr/V5gvrhA2gvgnF42Zh5iMiQNcOYthFYwCyrnuWlc=
-github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.17/go.mod h1:aLJpZlCmjE+V+KtN1q1uyZkfnUWpQGpbsn89XPKyzfU=
+github.com/aws/aws-sdk-go-v2/credentials v1.17.37 h1:G2aOH01yW8X373JK419THj5QVqu9vKEwxSEsGxihoW0=
+github.com/aws/aws-sdk-go-v2/credentials v1.17.37/go.mod h1:0ecCjlb7htYCptRD45lXJ6aJDQac6D2NlKGpZqyTG6A=
+github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.14 h1:C/d03NAmh8C4BZXhuRNboF/DqhBkBCeDiJDcaqIT5pA=
+github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.14/go.mod h1:7I0Ju7p9mCIdlrfS+JCgqcYD0VXz/N4yozsox+0o078=
+github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.18 h1:kYQ3H1u0ANr9KEKlGs/jTLrBFPo8P8NaH/w7A01NeeM=
+github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.18/go.mod h1:r506HmK5JDUh9+Mw4CfGJGSSoqIiLCndAuqXuhbv67Y=
+github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.18 h1:Z7IdFUONvTcvS7YuhtVxN99v2cCoHRXOS4mTr0B/pUc=
+github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.18/go.mod h1:DkKMmksZVVyat+Y+r1dEOgJEfUeA7UngIHWeKsi0yNc=
 github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1 h1:VaRN3TlFdd6KxX1x3ILT5ynH6HvKgqdiXoTxAF4HQcQ=
 github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1/go.mod h1:FbtygfRFze9usAadmnGJNc8KsP346kEe+y2/oyhGAGc=
 github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.17 h1:Roo69qTpfu8OlJ2Tb7pAYVuF0CpuUMB0IYWwYP/4DZM=
 github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.17/go.mod h1:NcWPxQzGM1USQggaTVwz6VpqMZPX1CvDJLDh6jnOCa4=
-github.com/aws/aws-sdk-go-v2/service/glue v1.95.0 h1:3kShOn09X5x7WS4p6TmZXg5tkDmpbZtEd4C37Qfx9SM=
-github.com/aws/aws-sdk-go-v2/service/glue v1.95.0/go.mod h1:CLJUKbfv3FrzdDeaD/MpYl7GmA2SfQbC5ZesWlOLMWU=
-github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.4 h1:KypMCbLPPHEmf9DgMGw51jMj77VfGPAN2Kv4cfhlfgI=
-github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.4/go.mod h1:Vz1JQXliGcQktFTN/LN6uGppAIRoLBR2bMvIMP0gOjc=
+github.com/aws/aws-sdk-go-v2/service/glue v1.99.2 h1:pfX1qky3shRS3rgxkdXKtiNWtMUeGgOEFXEqIdTQYHQ=
+github.com/aws/aws-sdk-go-v2/service/glue v1.99.2/go.mod h1:rCyUHLWGaSR9/oQgj2nGKRmPqFwtq3qxL14LkuQdadA=
+github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.5 h1:QFASJGfT8wMXtuP3D5CRmMjARHv9ZmzFUMJznHDOY3w=
+github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.5/go.mod h1:QdZ3OmoIjSX+8D1OPAzPxDfjXASbBMDsz9qvtyIhtik=
 github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.3.19 h1:FLMkfEiRjhgeDTCjjLoc3URo/TBkgeQbocA78lfkzSI=
 github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.3.19/go.mod h1:Vx+GucNSsdhaxs3aZIKfSUjKVGsxN25nX2SRcdhuw08=
-github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.19 h1:rfprUlsdzgl7ZL2KlXiUAoJnI/VxfHCvDFr2QDFj6u4=
-github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.19/go.mod h1:SCWkEdRq8/7EK60NcvvQ6NXKuTcchAD4ROAsC37VEZE=
+github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.20 h1:Xbwbmk44URTiHNx6PNo0ujDE6ERlsCKJD3u1zfnzAPg=
+github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.20/go.mod h1:oAfOFzUB14ltPZj1rWwRc3d/6OgD76R8KlvU3EqM9Fg=
 github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.17.17 h1:u+EfGmksnJc/x5tq3A+OD7LrMbSSR/5TrKLvkdy/fhY=
 github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.17.17/go.mod h1:VaMx6302JHax2vHJWgRo+5n9zvbacs3bLU/23DNQrTY=
 github.com/aws/aws-sdk-go-v2/service/s3 v1.61.2 h1:Kp6PWAlXwP1UvIflkIP6MFZYBNDCa4mFCGtxrpICVOg=
 github.com/aws/aws-sdk-go-v2/service/s3 v1.61.2/go.mod h1:5FmD/Dqq57gP+XwaUnd5WFPipAuzrf0HmupX27Gvjvc=
-github.com/aws/aws-sdk-go-v2/service/sso v1.22.7 h1:pIaGg+08llrP7Q5aiz9ICWbY8cqhTkyy+0SHvfzQpTc=
-github.com/aws/aws-sdk-go-v2/service/sso v1.22.7/go.mod h1:eEygMHnTKH/3kNp9Jr1n3PdejuSNcgwLe1dWgQtO0VQ=
-github.com/aws/aws-sdk-go-v2/service/ssooidc v1.26.7 h1:/Cfdu0XV3mONYKaOt1Gr0k1KvQzkzPyiKUdlWJqy+J4=
-github.com/aws/aws-sdk-go-v2/service/ssooidc v1.26.7/go.mod h1:bCbAxKDqNvkHxRaIMnyVPXPo+OaPRwvmgzMxbz1VKSA=
-github.com/aws/aws-sdk-go-v2/service/sts v1.30.7 h1:NKTa1eqZYw8tiHSRGpP0VtTdub/8KNk8sDkNPFaOKDE=
-github.com/aws/aws-sdk-go-v2/service/sts v1.30.7/go.mod h1:NXi1dIAGteSaRLqYgarlhP/Ij0cFT+qmCwiJqWh/U5o=
-github.com/aws/smithy-go v1.20.4 h1:2HK1zBdPgRbjFOHlfeQZfpC4r72MOb9bZkiFwggKO+4=
-github.com/aws/smithy-go v1.20.4/go.mod h1:irrKGvNn1InZwb2d7fkIRNucdfwR8R+Ts3wxYa/cJHg=
+github.com/aws/aws-sdk-go-v2/service/sso v1.23.3 h1:rs4JCczF805+FDv2tRhZ1NU0RB2H6ryAvsWPanAr72Y=
+github.com/aws/aws-sdk-go-v2/service/sso v1.23.3/go.mod h1:XRlMvmad0ZNL+75C5FYdMvbbLkd6qiqz6foR1nA1PXY=
+github.com/aws/aws-sdk-go-v2/service/ssooidc v1.27.3 h1:S7EPdMVZod8BGKQQPTBK+FcX9g7bKR7c4+HxWqHP7Vg=
+github.com/aws/aws-sdk-go-v2/service/ssooidc v1.27.3/go.mod h1:FnvDM4sfa+isJ3kDXIzAB9GAwVSzFzSy97uZ3IsHo4E=
+github.com/aws/aws-sdk-go-v2/service/sts v1.31.3 h1:VzudTFrDCIDakXtemR7l6Qzt2+JYsVqo2MxBPt5k8T8=
+github.com/aws/aws-sdk-go-v2/service/sts v1.31.3/go.mod h1:yMWe0F+XG0DkRZK5ODZhG7BEFYhLXi2dqGsv6tX0cgI=
+github.com/aws/smithy-go v1.21.0 h1:H7L8dtDRk0P1Qm6y0ji7MCYMQObJ5R9CRpyPhRUkLYA=
+github.com/aws/smithy-go v1.21.0/go.mod h1:irrKGvNn1InZwb2d7fkIRNucdfwR8R+Ts3wxYa/cJHg=
 github.com/containerd/console v1.0.3 h1:lIr7SlA5PxZyMV30bDW0MGbiOPXwc63yRuCP0ARubLw=
 github.com/containerd/console v1.0.3/go.mod h1:7LqA/THxQ86k76b8c/EMSiaJ3h1eZkMkXar0TQ1gf3U=
 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=


Test patch:
diff --git a/cmd/iceberg/output_test.go b/cmd/iceberg/output_test.go
new file mode 100644
index 0000000..5907660
--- /dev/null
+++ b/cmd/iceberg/output_test.go
@@ -0,0 +1,195 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you 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
+//
+//   http://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.
+
+package main
+
+import (
+	"bytes"
+	"testing"
+
+	"github.com/apache/iceberg-go/table"
+	"github.com/pterm/pterm"
+	"github.com/stretchr/testify/assert"
+)
+
+var testArgs = []struct {
+	meta     string
+	expected string
+}{
+	{`{
+    "format-version": 2,
+    "table-uuid": "9c12d441-03fe-4693-9a96-a0705ddf69c1",
+    "location": "s3://bucket/test/location",
+    "last-sequence-number": 0,
+    "last-updated-ms": 1602638573590,
+    "last-column-id": 3,
+    "current-schema-id": 0,
+    "schemas": [
+        {"type": "struct", "schema-id": 0, "fields": [{"id": 1, "name": "x", "required": true, "type": "long"}]},
+        {
+            "type": "struct",
+            "fields": [
+                {"id": 1, "name": "x", "required": true, "type": "long"}
+            ]
+        }
+    ],
+    "default-spec-id": 0,
+    "partition-specs": [{"spec-id": 0, "fields": []}],
+    "last-partition-id": 1000,
+    "default-sort-order-id": 0,
+    "sort-orders": [
+        {
+            "order-id": 0,
+            "fields": [ ]
+        }
+    ],
+    "properties": {"read.split.target.size": "134217728"},
+    "current-snapshot-id": -1,
+    "snapshots": [ ],
+    "snapshot-log": [ ],
+    "metadata-log": [ ],
+    "refs": { }
+}`, 
+`Table format version | 2
+Metadata location    | 
+Table UUID           | 9c12d441-03fe-4693-9a96-a0705ddf69c1
+Last updated         | 1602638573590
+Sort Order           | 0: []
+Partition Spec       | []
+
+Current Schema, id=0
+└──1: x: required long
+
+Current Snapshot | 
+
+Snapshots
+
+Properties
+key                    | value
+----------------------------------
+read.split.target.size | 134217728
+
+`},
+	{`{
+    "format-version": 2,
+    "table-uuid": "9c12d441-03fe-4693-9a96-a0705ddf69c1",
+    "location": "s3://bucket/test/location",
+    "last-sequence-number": 34,
+    "last-updated-ms": 1602638573590,
+    "last-column-id": 3,
+    "current-schema-id": 1,
+    "schemas": [
+        {"type": "struct", "schema-id": 0, "fields": [{"id": 1, "name": "x", "required": true, "type": "long"}]},
+        {
+            "type": "struct",
+            "schema-id": 1,
+            "identifier-field-ids": [1, 2],
+            "fields": [
+                {"id": 1, "name": "x", "required": true, "type": "long"},
+                {"id": 2, "name": "y", "required": true, "type": "long", "doc": "comment"},
+                {"id": 3, "name": "z", "required": true, "type": "long"}
+            ]
+        }
+    ],
+    "default-spec-id": 0,
+    "partition-specs": [{"spec-id": 0, "fields": [{"name": "x", "transform": "identity", "source-id": 1, "field-id": 1000}]}],
+    "last-partition-id": 1000,
+    "default-sort-order-id": 3,
+    "sort-orders": [
+        {
+            "order-id": 3,
+            "fields": [
+                {"transform": "identity", "source-id": 2, "direction": "asc", "null-order": "nulls-first"},
+                {"transform": "bucket[4]", "source-id": 3, "direction": "desc", "null-order": "nulls-last"}
+            ]
+        }
+    ],
+    "properties": {"read.split.target.size": "134217728"},
+    "current-snapshot-id": 3055729675574597004,
+    "snapshots": [
+        {
+            "snapshot-id": 3051729675574597004,
+            "timestamp-ms": 1515100955770,
+            "sequence-number": 0,
+            "summary": {"operation": "append"},
+            "manifest-list": "s3://a/b/1.avro",
+	    "schema-id": 1
+        },
+        {
+            "snapshot-id": 3055729675574597004,
+            "parent-snapshot-id": 3051729675574597004,
+            "timestamp-ms": 1555100955770,
+            "sequence-number": 1,
+            "summary": {"operation": "append"},
+            "manifest-list": "s3://a/b/2.avro",
+            "schema-id": 1
+        }
+    ],
+    "snapshot-log": [
+        {"snapshot-id": 3051729675574597004, "timestamp-ms": 1515100955770},
+        {"snapshot-id": 3055729675574597004, "timestamp-ms": 1555100955770}
+    ],
+    "metadata-log": [{"metadata-file": "s3://bucket/.../v1.json", "timestamp-ms": 1515100}],
+    "refs": {"test": {"snapshot-id": 3051729675574597004, "type": "tag", "max-ref-age-ms": 10000000}}
+}`, 
+`Table format version | 2
+Metadata location    | 
+Table UUID           | 9c12d441-03fe-4693-9a96-a0705ddf69c1
+Last updated         | 1602638573590
+Sort Order           | 3: [
+                     | 2 asc nulls-first
+                     | bucket[4](3) desc nulls-last
+                     | ]
+Partition Spec       | [
+                     | 	1000: x: identity(1)
+                     | ]
+
+Current Schema, id=1
+├──1: x: required long
+├──2: y: required long (comment)
+└──3: z: required long
+
+Current Snapshot | append, {}: id=3055729675574597004, parent_id=3051729675574597004, schema_id=1, sequence_number=1, timestamp_ms=1555100955770, manifest_list=s3://a/b/2.avro
+
+Snapshots
+├──Snapshot 3051729675574597004, schema 1: s3://a/b/1.avro
+└──Snapshot 3055729675574597004, schema 1: s3://a/b/2.avro
+
+Properties
+key                    | value
+----------------------------------
+read.split.target.size | 134217728
+
+`},
+}
+
+
+func TestDescribeTable(t *testing.T) {
+	var buf bytes.Buffer
+	pterm.SetDefaultOutput(&buf)
+	pterm.DisableColor()
+
+	for _, tt := range testArgs {
+		meta, _ := table.ParseMetadataBytes([]byte(tt.meta))
+		table := table.New([]string{"t"}, meta, "", nil)
+		buf.Reset()
+
+		text{}.DescribeTable(table)
+
+		assert.Equal(t, tt.expected, buf.String())
+	}
+}