This is an automated email from the ASF dual-hosted git repository.

mchades pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/gravitino.git


The following commit(s) were added to refs/heads/main by this push:
     new 1a28d6ff4c [#10478] feat(release): add gravitino-release skill and fix 
state-dir path bug (#10479)
1a28d6ff4c is described below

commit 1a28d6ff4c7c44e0a34c965b2f5c305a092475ef
Author: Jerry Shao <[email protected]>
AuthorDate: Mon Apr 27 10:05:21 2026 +0800

    [#10478] feat(release): add gravitino-release skill and fix state-dir path 
bug (#10479)
    
    ### What changes were proposed in this pull request?
    
    - Adds `dev/release/gravitino-release/SKILL.md`: a Claude Code skill
    that acts as an interactive release manager, guiding through all 7
    stages (tag, build, docs, publish, docker, finalize, release-note) with
    state tracking, mock mode, dry-run support, preflight checks, and
    credential collection.
    - Adds `dev/release/mock/do-release.sh` and
    `dev/release/mock/publish-docker.sh`: mock scripts that simulate all
    stages without real operations, for safe end-to-end testing of the
    skill.
    - Fixes `STATE_DIR` path bug in `do-release.sh`, `mock/do-release.sh`,
    and `publish-docker.sh`: the default `.release-state` was a relative
    path, causing state files to be written to the caller's working
    directory instead of alongside the scripts. Fixed to use
    `$SELF/.release-state`.
    - Fixes log file path bug in `mock/do-release.sh`: log files also used a
    relative path; fixed to use `$SELF/${STEP}.log`.
    
    ### Why are the changes needed?
    
    Fix: #10478
    
    The release process is complex and multi-staged. Without structured
    tooling, a release manager must remember which stages have run, which
    credentials are needed, and how to recover from failures. The skill
    automates this orchestration. The state-dir bug caused state files to be
    scattered in unpredictable locations depending on where scripts were
    invoked from, breaking resume-after-failure behavior.
    
    ### Does this PR introduce _any_ user-facing change?
    
    No API or property key changes. The skill and mock scripts are new
    developer tooling only.
    
    ### How was this patch tested?
    
    Tested end-to-end using mock mode (`dev/release/mock/do-release.sh`)
    with all 7 stages, including failure simulation via `MOCK_FAIL_STAGE`,
    dry-run mode, and resume-from-partial-state scenarios.
    
    ---------
    
    Co-authored-by: Claude Sonnet 4.6 <[email protected]>
---
 agent-skills/gravitino-release/README.md | 100 +++++
 agent-skills/gravitino-release/SKILL.md  | 626 +++++++++++++++++++++++++++++++
 dev/release/do-release.sh                | 100 ++++-
 dev/release/mock/do-release.sh           | 240 ++++++++++++
 dev/release/mock/publish-docker.sh       | 191 ++++++++++
 dev/release/publish-docker.sh            |  39 ++
 6 files changed, 1279 insertions(+), 17 deletions(-)

diff --git a/agent-skills/gravitino-release/README.md 
b/agent-skills/gravitino-release/README.md
new file mode 100644
index 0000000000..8c5590628e
--- /dev/null
+++ b/agent-skills/gravitino-release/README.md
@@ -0,0 +1,100 @@
+<!--
+  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.
+-->
+
+# gravitino-release skill
+
+A Claude Code skill that guides you through the full Apache Gravitino release 
pipeline, stage by stage.
+
+## What it does
+
+The skill orchestrates the 8-stage release process:
+
+| # | Stage | Description |
+|---|-------|-------------|
+| 1 | **tag** | Creates git tag and bumps version |
+| 2 | **build** | Builds and uploads artifacts to ASF SVN staging |
+| 3 | **docs** | Builds Javadoc and Python Sphinx docs |
+| 4 | **publish** | Publishes Maven artifacts to Apache Nexus |
+| 5 | **docker** | Publishes RC Docker images via GitHub Actions |
+| 6 | **finalize** | Promotes artifacts to release (irreversible) |
+| 7 | **docker-final** | Publishes final Docker images |
+| 8 | **release-note** | Generates a Markdown release note draft |
+
+Long-running stages (build, docs, publish, finalize) run in the background so 
you can keep chatting while they complete.
+
+## Installation
+
+Copy the skill directory to Claude Code's skills folder:
+
+```bash
+# Global — available in all projects
+cp -r agent-skills/gravitino-release ~/.claude/skills/
+
+# Project-local — available only inside this repo
+cp -r agent-skills/gravitino-release .claude/skills/
+```
+
+## Prerequisites
+
+- **`gh` CLI** installed and authenticated (`gh auth login`)
+- Apache committer credentials (`ASF_USERNAME`, `ASF_PASSWORD`)
+- GPG key set up for signing (`GPG_KEY`, `GPG_PASSPHRASE`)
+- PyPI API token (`PYPI_API_TOKEN`) for Python package publishing
+- Docker Hub credentials and GitHub token for Docker stages
+
+The skill will prompt you for any missing credentials before each stage runs.
+
+## Usage
+
+Invoke the skill from Claude Code:
+
+```
+/gravitino-release branch-1.2
+```
+
+At startup the skill will:
+1. Verify `gh` is installed and authenticated
+2. Ask for the release branch and RC number, then confirm the derived version 
and tag
+3. Sparse-clone `dev/release/` from the release branch (no full local clone 
needed)
+4. Show the current release state (which stages are done)
+5. Ask what you want to do next
+
+### Mock mode
+
+Test the full pipeline without making any real changes:
+
+```
+/gravitino-release mock
+```
+
+Mock scripts validate all credentials and simulate each stage, writing real 
`.done` state files so you can test resume, retry, and failure scenarios.
+
+Set `MOCK_STAGE_DELAY=<seconds>` to simulate long-running stages (e.g. `export 
MOCK_STAGE_DELAY=180`).
+
+### Checking status mid-run
+
+While a long stage is running in the background, just ask:
+
+> "How's the build going?"
+
+Claude will tail the log and report back. You can also use `/loop` to poll 
automatically:
+
+```
+/loop 10m check stage build status
+```
+
+The release scripts themselves live in `dev/release/` inside a fresh clone of 
`apache/gravitino` — they are downloaded automatically when you start the skill.
diff --git a/agent-skills/gravitino-release/SKILL.md 
b/agent-skills/gravitino-release/SKILL.md
new file mode 100644
index 0000000000..d9fd67c12c
--- /dev/null
+++ b/agent-skills/gravitino-release/SKILL.md
@@ -0,0 +1,626 @@
+<!--
+  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: gravitino-release
+description: Apache Gravitino release manager — guides through the full staged 
release pipeline stage by stage
+argument-hint: "[branch] (e.g. branch-1.2 | mock | status)"
+allowed-tools: Bash
+---
+
+# Apache Gravitino Release Manager
+
+You are the Apache Gravitino release manager agent. Your role is to guide the 
user through the Gravitino release process, one stage at a time, using the 
scripts in `dev/release/`.
+
+## Scripts Reference
+
+| Script | Purpose |
+|--------|---------|
+| `do-release.sh` | Main orchestrator — runs tag, package, docs, publish, 
finalize stages |
+| `release-tag.sh` | Creates git tag and bumps version (invoked by 
do-release.sh) |
+| `release-build.sh` | Builds, publishes, and finalizes artifacts (invoked by 
do-release.sh) |
+| `publish-docker.sh` | Publishes Docker images via GitHub Actions (run 
separately) |
+| `check-license.sh` | Verifies all files referenced in LICENSE exist |
+| `release-util.sh` | Shared utility functions (sourced by other scripts, not 
run directly) |
+| `mock/do-release.sh` | Mock version of do-release.sh for testing (no real 
operations) |
+| `mock/publish-docker.sh` | Mock version of publish-docker.sh for testing (no 
real operations) |
+
+## Release Stages (in order)
+
+| # | Stage | `-s` flag | State file |
+|---|-------|-----------|------------|
+| 1 | **tag** | `tag` | `.release-state/{TAG}/tag.done` |
+| 2 | **build** | `build` | `.release-state/{TAG}/build.done` |
+| 3 | **docs** | `docs` | `.release-state/{TAG}/docs.done` |
+| 4 | **publish** | `publish` | `.release-state/{TAG}/publish.done` |
+| 5 | **docker** | _(separate script)_ | `.release-state/{TAG}/docker.done` |
+| 6 | **finalize** | `finalize` | `.release-state/{TAG}/finalize.done` |
+| 7 | **docker-final** | _(separate script)_ | 
`.release-state/{TAG}/docker-final.done` |
+| 8 | **release-note** | _(agent-generated)_ | _(no state file — always 
re-runnable)_ |
+
+> `{TAG}` is the release candidate tag, e.g. `v1.2.0-rc1`.
+
+## Directory Layout
+
+After setup, the working directory structure is always:
+
+```
+~/gravitino-release-v{VERSION}-rc{RC}/       ← WORK_DIR (sparse clone of 
apache/gravitino)
+└── dev/
+    └── release/                             ← hardcoded path, never changes 
in the repo
+        ├── do-release.sh
+        ├── publish-docker.sh
+        ├── release-build.sh
+        ├── release-tag.sh
+        ├── release-util.sh
+        ├── check-license.sh
+        ├── mock/
+        │   ├── do-release.sh
+        │   └── publish-docker.sh
+        └── .release-state/
+            └── v{VERSION}-rc{RC}/
+                ├── tag.done
+                ├── package.done
+                └── ...
+```
+
+```bash
+WORK_DIR="$HOME/gravitino-release-v${RELEASE_VERSION}-rc${RC_COUNT}"
+RELEASE_SCRIPTS_DIR="$WORK_DIR/dev/release"
+```
+
+`dev/release/` is a fixed path in the Gravitino repository and never changes. 
All script invocations use the full path, e.g. 
`"$RELEASE_SCRIPTS_DIR/do-release.sh"`.
+
+## State Tracking
+
+State lives in `$RELEASE_SCRIPTS_DIR/.release-state/{RELEASE_TAG}/`.
+
+- **Before a stage**: the script checks for the `.done` file. If found, it 
prints the completion info and exits — no re-run.
+- **After success**: the script writes the `.done` file with a timestamp and 
verification details.
+- **To re-run a stage**: the user must manually delete the `.done` file.
+- Override the state directory with the `RELEASE_STATE_DIR` environment 
variable.
+
+---
+
+## Session Start Protocol
+
+### Step 0 — Check prerequisites
+
+Verify `gh` is installed:
+```bash
+gh --version
+```
+If not found:
+> "`gh` (GitHub CLI) is required. Install it from https://cli.github.com, then 
run `gh auth login`."
+> Stop here.
+
+Verify `gh` is authenticated:
+```bash
+gh auth status
+```
+If not authenticated:
+> "Please run `gh auth login` first."
+> Stop here.
+
+---
+
+### Step 1 — Collect release parameters
+
+Ask the user for:
+- **Branch**: e.g. `branch-1.2`
+- **RC number**: e.g. `1`
+
+Auto-detect the release version from the branch:
+```bash
+gh api "repos/apache/gravitino/contents/gradle.properties?ref=${GIT_BRANCH}" \
+  --jq '.content' | base64 -d | grep '^version' | cut -d'=' -f2 | tr -d ' '
+```
+
+Confirm with the user:
+```
+Branch:          branch-1.2
+Release version: 1.2.0
+RC number:       1
+Release tag:     v1.2.0-rc1
+
+Is this correct? [y/N]
+```
+
+---
+
+### Step 2 — Set up working directory
+
+Derive paths from the confirmed release parameters:
+```bash
+WORK_DIR="$HOME/gravitino-release-v${RELEASE_VERSION}-rc${RC_COUNT}"
+RELEASE_SCRIPTS_DIR="$WORK_DIR/dev/release"
+```
+
+Check if `$WORK_DIR` already exists:
+```bash
+ls "$WORK_DIR" 2>/dev/null
+```
+
+If it exists, ask:
+> "Found existing scripts at `$WORK_DIR`. Use this or re-download? 
[use/download]"
+
+If it does not exist (or user chose re-download), sparse-clone just 
`dev/release/`:
+```bash
+gh repo clone apache/gravitino "$WORK_DIR" -- \
+  --depth 1 \
+  --branch "${GIT_BRANCH}" \
+  --filter=blob:none \
+  --sparse
+
+cd "$WORK_DIR"
+git sparse-checkout set dev/release
+git checkout
+```
+
+---
+
+### Step 3 — Select mode
+
+Ask before reading state, because mock mode uses a different scripts directory 
and therefore a different state directory:
+
+```
+Mock mode? [y/N]
+Dry run mode? [y/N]
+```
+
+If the user selects mock mode, set `RELEASE_SCRIPTS_DIR` to the mock 
subdirectory **now**, before any state check:
+```bash
+RELEASE_SCRIPTS_DIR="$WORK_DIR/dev/release/mock"
+```
+
+---
+
+### Step 4 — Read current state
+
+Using the `RELEASE_SCRIPTS_DIR` set in Step 3, check 
`.release-state/v${RELEASE_VERSION}-rc${RC_COUNT}/` for `.done` files and show 
the user:
+
+```
+Release state for v1.2.0-rc1:
+  ✓ tag          (completed 2026-03-19T10:00:00Z)
+  ✓ build        (completed 2026-03-19T12:30:00Z)
+  ○ docs         (pending)
+  ○ publish      (pending)
+  ○ docker       (pending)
+  ○ finalize      (pending)
+  ○ docker-final  (pending)
+  ↻ release-note  (always re-runnable — no state file)
+```
+
+`release-note` has no `.done` file and must always be shown as `↻ re-runnable` 
regardless of what other stages have completed.
+
+If no state directory exists: "No stages completed yet for v{VERSION}-rc{RC}."
+
+---
+
+### Step 5 — Ask what to do
+
+```
+What would you like to do?
+  1. Run next pending stage
+  2. Run a specific stage
+  3. Run all remaining stages sequentially
+  4. Show current state only
+```
+
+---
+
+### Step 6 — Collect secrets
+
+Check each required environment variable before running any stage. If unset, 
ask the user for the value and `export` it. **Never display, log, or echo 
secret values.**
+
+| Variable | Required for stages | Notes |
+|----------|---------------------|-------|
+| `ASF_USERNAME` | tag, package, publish, finalize | Apache committer username 
|
+| `ASF_PASSWORD` | tag, package, publish, finalize | Apache committer password 
|
+| `GPG_KEY` | package, publish | GPG key ID (typically `[email protected]`) |
+| `GPG_PASSPHRASE` | package, publish | GPG key passphrase |
+| `PYPI_API_TOKEN` | tag, package, finalize | PyPI API token (starts with 
`pypi-`) |
+| `GH_TOKEN` | docker, docker-final | GitHub token with `repo`+`workflow` 
scope |
+| `DOCKER_USERNAME` | docker, docker-final | Docker Hub username |
+| `PUBLISH_DOCKER_TOKEN` | docker, docker-final | Workflow authorization token 
(matched against repo secret) |
+
+Collect only what is needed for the stage(s) about to run. This applies 
equally in mock mode — all credentials are validated by the mock scripts 
exactly as in the real scripts.
+
+---
+
+## Preflight Checks
+
+Run these checks **before the tag stage only**. Present results clearly, then 
ask whether to proceed. All checks are **advisory** — never block automatically.
+
+### 1. Open PRs targeting the release branch
+
+```bash
+gh pr list \
+  --repo apache/gravitino \
+  --base "${GIT_BRANCH}" \
+  --state open \
+  --json number,title,author,url \
+  --jq '.[] | "#\(.number)  \(.title)  by \(.author.login)  \(.url)"'
+```
+
+If any are found:
+> "Found N open PR(s) targeting {GIT_BRANCH}. Any commits merged after tagging 
will miss this release."
+> Show the list, then ask: "Proceed anyway? [y/N]"
+
+### 2. Recent CI status on the release branch
+
+```bash
+gh run list \
+  --repo apache/gravitino \
+  --branch "${GIT_BRANCH}" \
+  --limit 5 \
+  --json status,conclusion,name,url \
+  --jq '.[] | "\(.conclusion // .status)  \(.name)  \(.url)"'
+```
+
+If any recent runs show `failure` or `cancelled`:
+> "Recent CI run(s) on {GIT_BRANCH} did not pass. A green branch is 
recommended before tagging."
+> Ask: "Proceed anyway? [y/N]"
+
+### Preflight summary
+
+```
+Preflight results for v1.2.0-rc1 (branch-1.2):
+  Open PRs  (base branch-1.2):   1  ⚠
+  Recent CI (branch-1.2):        ✓  (last 5 runs passed)
+
+Proceed to tag? [y/N]
+```
+
+---
+
+## Stage Details
+
+### Execution model for long-running stages
+
+Stages **build**, **docs**, **publish**, and **finalize** typically take 30–90 
minutes. Launch them in the background so Claude remains **fully responsive** 
to user input while the stage runs — the user can ask questions, check status, 
or do other work during this time.
+
+#### Launching
+
+```bash
+# Launch in background — stdout+stderr go to the stage log, PID saved to a file
+nohup "$RELEASE_SCRIPTS_DIR/do-release.sh" -s <stage> -y -b "$GIT_BRANCH" -r 
"$RC_COUNT" \
+  > "$RELEASE_SCRIPTS_DIR/<stage>.log" 2>&1 &
+echo $! > "$RELEASE_SCRIPTS_DIR/<stage>.pid"
+echo "Stage <stage> running in background (PID $(cat 
"$RELEASE_SCRIPTS_DIR/<stage>.pid")). You can keep chatting."
+```
+
+After launching, Claude notifies the user and returns to the conversation 
immediately.
+
+#### Checking status (on demand)
+
+Claude runs this whenever the user asks (e.g. "how's the build going?") or 
proactively after a natural pause:
+
+```bash
+STAGE_PID=$(cat "$RELEASE_SCRIPTS_DIR/<stage>.pid" 2>/dev/null)
+if kill -0 "$STAGE_PID" 2>/dev/null; then
+  echo "Still running. Recent log:"
+  tail -10 "$RELEASE_SCRIPTS_DIR/<stage>.log"
+elif [ -f "$STATE_DIR/<stage>.done" ]; then
+  echo "Completed successfully:"
+  cat "$STATE_DIR/<stage>.done"
+else
+  echo "Process exited but no .done file — FAILED. Last log:"
+  tail -30 "$RELEASE_SCRIPTS_DIR/<stage>.log"
+fi
+```
+
+#### Auto-polling (optional)
+
+Periodically ask the agent to check status (e.g. "any update on the build?") — 
it will run the status check above and report back. In **Claude Code** you can 
also use the built-in `/loop` skill to automate this:
+```
+/loop 10m check stage <stage> status
+```
+Use `1m` for mock mode (with `MOCK_STAGE_DELAY=180`), `10m` for real runs. 
`/loop` is Claude Code only and not available in other tools.
+
+**Mock mode tip:** Set `MOCK_STAGE_DELAY=<secs>` to simulate a long-running 
stage:
+```bash
+export MOCK_STAGE_DELAY=180   # stage sleeps 3 minutes before completing
+```
+
+**Short stages** (tag, docker, docker-final) complete in under 5 minutes and 
are run synchronously — no backgrounding needed.
+
+---
+
+### Stage 1: tag
+
+**Command:**
+```bash
+"$RELEASE_SCRIPTS_DIR/do-release.sh" -s tag -y -b "$GIT_BRANCH" -r "$RC_COUNT"
+```
+
+**Required env vars:** `ASF_USERNAME`, `ASF_PASSWORD`, `GPG_KEY`, 
`GPG_PASSPHRASE`, `PYPI_API_TOKEN`
+
+**What it does:**
+- Clones the repo from gitbox.apache.org
+- Updates version in: `gradle.properties`, `clients/client-python/setup.py`, 
`clients/filesystem-fuse/Cargo.toml`, all three Helm charts (`Chart.yaml` + 
`values.yaml`), `mcp-server/pyproject.toml`
+- Commits and creates git tag `v{VERSION}-rc{RC}`
+- Bumps all files to next SNAPSHOT version, commits and pushes both
+
+**Log:** `$RELEASE_SCRIPTS_DIR/tag.log`
+**State file includes:** Remote verification that the tag exists on 
github.com/apache/gravitino
+
+**Dry run:** Keeps a local `gravitino-tag/` clone for inspection instead of 
pushing.
+
+---
+
+### Stage 2: build
+
+**Command:**
+```bash
+"$RELEASE_SCRIPTS_DIR/do-release.sh" -s build -y -b "$GIT_BRANCH" -r 
"$RC_COUNT"
+```
+
+**Required env vars:** `ASF_USERNAME`, `ASF_PASSWORD`, `GPG_KEY`, 
`GPG_PASSPHRASE`, `PYPI_API_TOKEN`
+
+**What it does:**
+- Clones from the release tag
+- Builds source tarball (`gravitino-{VERSION}-src.tar.gz`)
+- Builds binary tarballs: server, iceberg-rest-server, lance-rest-server, 
trino connectors
+- Builds Python client package
+- GPG-signs all artifacts; generates SHA512 checksums
+- Uploads Python RC package to PyPI as `{VERSION}rc{RC}`
+- Uploads all artifacts to ASF SVN staging:
+  `https://dist.apache.org/repos/dist/dev/gravitino/v{VERSION}-rc{RC}/`
+
+**Log:** `$RELEASE_SCRIPTS_DIR/build.log`
+
+---
+
+### Stage 3: docs
+
+**Command:**
+```bash
+"$RELEASE_SCRIPTS_DIR/do-release.sh" -s docs -y -b "$GIT_BRANCH" -r "$RC_COUNT"
+```
+
+**Required env vars:** _(none — docs build is fully local)_
+
+**What it does:**
+- Builds Javadoc from `clients/client-java` → `gravitino-{VERSION}-javadoc/`
+- Builds Python Sphinx docs from `clients/client-python` → 
`gravitino-{VERSION}-pydoc/`
+- Output is local only; nothing is published in this stage
+
+**Log:** `$RELEASE_SCRIPTS_DIR/docs.log`
+
+---
+
+### Stage 4: publish (Maven/Nexus)
+
+**Command:**
+```bash
+"$RELEASE_SCRIPTS_DIR/do-release.sh" -s publish -y -b "$GIT_BRANCH" -r 
"$RC_COUNT"
+```
+
+**Required env vars:** `ASF_USERNAME`, `ASF_PASSWORD`, `GPG_KEY`, 
`GPG_PASSPHRASE`
+
+**What it does:**
+- Creates an Apache Nexus staging repository
+- Builds Maven artifacts for Scala 2.12 and Scala 2.13
+- Signs each artifact and uploads to Nexus
+- Closes the staging repository (making it available for the vote)
+
+**Log:** `$RELEASE_SCRIPTS_DIR/publish.log`
+
+---
+
+### Stage 5: docker
+
+**Command:**
+```bash
+"$RELEASE_SCRIPTS_DIR/publish-docker.sh" "v${RELEASE_VERSION}-rc${RC_COUNT}" \
+  --docker-version "${RELEASE_VERSION}-rc${RC_COUNT}" \
+  --trino-version "${TRINO_VERSION:-478}"
+```
+
+**Required env vars:** `GH_TOKEN` (or active `gh auth login`), 
`DOCKER_USERNAME`, `PUBLISH_DOCKER_TOKEN`
+
+**What it does:** Triggers GitHub Actions `docker-image.yml` workflow for each 
image:
+- `apache/gravitino:{VERSION}-rc{RC}`
+- `apache/gravitino-iceberg-rest:{VERSION}-rc{RC}`
+- `apache/gravitino-lance-rest:{VERSION}-rc{RC}`
+- `apache/gravitino-mcp-server:{VERSION}-rc{RC}`
+- `apache/gravitino-playground:trino-{TRINO_VER}-gravitino-{VERSION}-rc{RC}`
+
+**Default Trino version:** `478` — ask the user if they want a different 
version before running.
+
+**Always offer `--dry-run` first** to preview the workflow dispatch commands 
without triggering anything.
+
+**Monitor progress:** 
https://github.com/apache/gravitino/actions/workflows/docker-image.yml
+
+---
+
+### Stage 6: finalize
+
+> ⚠️ **Irreversible.** Always get explicit user confirmation before running.
+
+**Confirmation prompt:**
+```
+finalize will permanently:
+  - Create git tag v{VERSION} (no rc suffix)
+  - Upload final Python package to PyPI (without rc suffix)
+  - Move SVN artifacts: dev/gravitino/v{VERSION}-rc{RC} → 
release/gravitino/{VERSION}
+  - Update the public KEYS file
+
+This cannot be undone. Type 'yes' to proceed:
+```
+
+**Command:**
+```bash
+"$RELEASE_SCRIPTS_DIR/do-release.sh" -s finalize -y -b "$GIT_BRANCH" -r 
"$RC_COUNT"
+```
+
+**Required env vars:** `ASF_USERNAME`, `ASF_PASSWORD`, `GPG_KEY`, 
`GPG_PASSPHRASE`, `PYPI_API_TOKEN`
+
+**Log:** `$RELEASE_SCRIPTS_DIR/finalize.log`
+**State file includes:** Verification that final tag `v{VERSION}` (no rc 
suffix) exists on github.com
+
+---
+
+### Stage 7: docker-final
+
+**Command:**
+```bash
+RELEASE_STATE_DIR="$RELEASE_SCRIPTS_DIR/.release-state/$RELEASE_TAG" \
+"$RELEASE_SCRIPTS_DIR/publish-docker.sh" "v${RELEASE_VERSION}" \
+  --docker-version "${RELEASE_VERSION}" \
+  --trino-version "${TRINO_VERSION:-478}" \
+  --state-key "docker-final"
+```
+
+**Required env vars:** `GH_TOKEN` (or active `gh auth login`), 
`DOCKER_USERNAME`, `PUBLISH_DOCKER_TOKEN`
+
+**Optional env vars:** `TRINO_VERSION` — Trino version for the playground 
image (default: `478`). Ask the user before running if they want a different 
version.
+
+**What it does:** Triggers GitHub Actions `docker-image.yml` for each final 
(non-RC) image:
+- `apache/gravitino:{VERSION}`
+- `apache/gravitino-iceberg-rest:{VERSION}`
+- `apache/gravitino-lance-rest:{VERSION}`
+- `apache/gravitino-mcp-server:{VERSION}`
+- `apache/gravitino-playground:trino-{TRINO_VER}-gravitino-{VERSION}`
+
+The `--state-key docker-final` flag causes `publish-docker.sh` to write 
`docker-final.done` (distinct from the RC `docker.done` written in stage 5). 
`RELEASE_STATE_DIR` is set explicitly so both state files land in the same 
`.release-state/{RC_TAG}/` directory.
+
+**Always offer `--dry-run` first** before triggering any real workflow 
dispatches.
+
+**Monitor progress:** 
https://github.com/apache/gravitino/actions/workflows/docker-image.yml
+
+---
+
+### Stage 8: release-note
+
+**Required env vars:** _(none — read-only GitHub API calls via `gh`)_
+
+**What it does:**
+1. Fetches all issues labelled with the release version (label format: 
`{VERSION}`, e.g. `1.2.1`):
+   ```bash
+   gh issue list --repo apache/gravitino \
+     --label "${RELEASE_VERSION}" \
+     --state all \
+     --limit 500 \
+     --json number,title,assignees,labels,url
+   ```
+2. Asks the user to describe 3–5 key features/themes for the **Highlights** 
section before generating the draft.
+3. Generates a Markdown draft with the following sections in order:
+   - **Highlights** — a short paragraph per highlight based on user input
+   - **New Features** (`type:feature`, `feature`)
+   - **Bug Fixes** (`type:bug`, `bug`)
+   - **Improvements** (`type:improvement`, `improvement`, `enhancement`)
+   - **Documentation** (`documentation`, `docs`)
+   - **Build / CI** (`build`, `ci`)
+   - **Other** (no matching label)
+   - **Credits** — deduplicated list of all issue assignees sorted 
case-insensitively by GitHub login, formatted as `@login`
+4. Displays the draft for review, then saves to:
+   `$RELEASE_SCRIPTS_DIR/gravitino-{VERSION}-release-notes.md`
+
+---
+
+## Error Handling
+
+When a script fails:
+1. Read the corresponding `.log` file and show the user the last 30 lines
+2. Do **not** retry automatically — explain what failed and ask the user how 
to proceed
+3. The `.done` file will not have been written (only written on success)
+4. After the user fixes the issue, they can re-run the same stage
+
+| Symptom | Likely cause | Action |
+|---------|-------------|--------|
+| `svn: E170013` | Wrong ASF password or expired auth | Re-export 
`ASF_PASSWORD` and retry |
+| `gpg: signing failed` | Wrong passphrase or key not in keyring | Check `gpg 
--list-secret-keys` |
+| `twine upload` 403 | Expired `PYPI_API_TOKEN` | Regenerate token on pypi.org 
|
+| Nexus upload 401 | Wrong ASF credentials | Re-export `ASF_USERNAME` / 
`ASF_PASSWORD` |
+| Tag not found after creation | GitHub sync lag | Wait ~1 minute, check 
github.com manually |
+| `docker.done` not written | Bad `GH_TOKEN` or `PUBLISH_DOCKER_TOKEN` | Check 
`gh auth status` and token scopes |
+
+---
+
+## Testing
+
+To test the skill without any real build, publish, or git operations, use the 
mock scripts in `dev/release/mock/`.
+
+The mock scripts:
+- Accept **identical flags and env vars** as the real scripts
+- **Validate all credentials** exactly as the real scripts do — 
`ASF_PASSWORD`, `GPG_PASSPHRASE`, `PYPI_API_TOKEN`, `GH_TOKEN`, 
`DOCKER_USERNAME`, and `PUBLISH_DOCKER_TOKEN` are all required and checked
+- Use the real `release-util.sh` for state management — guard checks and 
`.done` files behave exactly as in production
+- Print what each stage *would* do instead of executing it
+- Support `MOCK_FAIL_STAGE=<stage>` to simulate a failure at any stage and 
test error handling
+
+### Invoking mock mode
+
+When the user requests mock mode at Step 4 of the Session Start Protocol, set 
`RELEASE_SCRIPTS_DIR` to the `mock/` subdirectory:
+
+```bash
+RELEASE_SCRIPTS_DIR="$WORK_DIR/dev/release/mock"
+```
+
+All stage commands then become e.g. `"$RELEASE_SCRIPTS_DIR/do-release.sh" -s 
tag -y ...`
+Everything else — credential collection, preflight checks, state reading — is 
identical to a real run.
+
+### Test scenarios
+
+| Scenario | How to trigger |
+|----------|---------------|
+| Full happy path (all 6 stages) | Run all stages in mock mode; verify all 
`.done` files are written |
+| Resume from partial state | Pre-create some `.done` files; verify skill 
skips those stages |
+| Stage already done | Run a stage twice; verify second run is blocked and 
shows completion info |
+| Stage failure + retry | Set `MOCK_FAIL_STAGE=build`; verify error is shown 
and log is tailed; fix and re-run |
+| Finalize confirmation guard | Attempt finalize without typing `yes`; verify 
skill refuses |
+| Dry run mode | Run with `-n`; verify no `.done` files are written |
+| Missing secret | Unset `ASF_PASSWORD`; verify skill prompts before running |
+| Preflight checks | `gh` calls run against the real repo (read-only — safe in 
mock mode) |
+
+---
+
+## Rules
+
+1. **Never display or log secrets** — mask passwords, passphrases, and tokens 
in all output.
+2. **Always use `-y` mode** — `do-release.sh` requires interactive input 
without it; an agent cannot respond to prompts.
+3. **Show state at the start of every session** — the user must always know 
what has been done.
+4. **Guard finalize with explicit confirmation** — it affects public 
infrastructure and is irreversible.
+5. **Respect `.done` files** — never delete or bypass them without the user's 
explicit instruction.
+6. **Always offer `--dry-run`** before any publishing step the user hasn't run 
before in this session.
+7. **Run preflight before tag** — always run the two preflight checks before 
the tag stage starts.
+
+---
+
+## Installation
+
+Claude Code, GitHub Copilot CLI, and OpenClaw all manage skills as directories 
containing a `SKILL.md` file.
+The skill is packaged as `agent-skills/gravitino-release/` in the Gravitino 
repo — copy the whole directory to install.
+
+> Claude Code and GitHub Copilot CLI share the same `.claude/` directory.
+
+```bash
+# Claude Code / GitHub Copilot CLI — global (available in all projects)
+cp -r agent-skills/gravitino-release ~/.claude/skills/
+
+# Claude Code / GitHub Copilot CLI — project-local (available only inside this 
repo)
+cp -r agent-skills/gravitino-release .claude/skills/
+
+# OpenClaw — global
+cp -r agent-skills/gravitino-release ~/.openclaw/workspace/skills/
+
+# OpenClaw — project-local
+cp -r agent-skills/gravitino-release .openclaw/workspace/skills/
+```
+
+Invoke with `/gravitino-release`. No local clone of the Gravitino repository 
is needed — the skill downloads the release scripts automatically via `gh`.
diff --git a/dev/release/do-release.sh b/dev/release/do-release.sh
index 963513e71a..f75b214a69 100755
--- a/dev/release/do-release.sh
+++ b/dev/release/do-release.sh
@@ -139,44 +139,110 @@ function should_build {
   [ -z "$RELEASE_STEP" ] || [ "$WHAT" = "$RELEASE_STEP" ]
 }
 
-if should_build "tag" && [ $SKIP_TAG = 0 ]; then
-  run_silent "Creating release tag $RELEASE_TAG..." "tag.log" \
-    "$SELF/release-tag.sh"
-  echo "It may take some time for the tag to be synchronized to github."
-  if is_force; then
-    echo "Force mode: skipping wait."
+# ---------------------------------------------------------------------------
+# Stage state tracking
+# Each completed stage writes a marker file to STATE_DIR so that re-runs can
+# detect what has already been done and skip those stages automatically.
+# Override the directory with RELEASE_STATE_DIR env var if needed.
+# ---------------------------------------------------------------------------
+STATE_DIR="${RELEASE_STATE_DIR:-$SELF/.release-state}/${RELEASE_TAG}"
+mkdir -p "$STATE_DIR"
+
+function stage_file { echo "$STATE_DIR/$1.done"; }
+
+function is_stage_done { [ -f "$(stage_file "$1")" ]; }
+
+function mark_stage_done {
+  local STEP="$1" INFO="${2:-}"
+  {
+    echo "completed_at=$(date -u '+%Y-%m-%dT%H:%M:%SZ')"
+    echo "release=$RELEASE_TAG"
+    [ -n "$INFO" ] && echo "info=$INFO"
+  } > "$(stage_file "$STEP")"
+  echo "Stage '$STEP' marked as done. State file: $(stage_file "$STEP")"
+}
+
+# Returns 0 (proceed) if stage is NOT done; returns 1 (skip) and prints a
+# message if the stage is already complete.
+function check_stage_guard {
+  local STEP="$1"
+  if is_stage_done "$STEP"; then
+    echo ""
+    echo "=== Stage '$STEP' is already complete ==="
+    cat "$(stage_file "$STEP")"
+    echo "To re-run this stage, delete: $(stage_file "$STEP")"
+    echo ""
+    return 1
+  fi
+  return 0
+}
+
+if should_build "tag"; then
+  if [ $SKIP_TAG = 1 ]; then
+    echo "Tag $RELEASE_TAG already exists on remote. Skipping tag creation."
+    is_stage_done "tag" || mark_stage_done "tag" "Tag $RELEASE_TAG already 
existed on remote"
   else
-    echo "Press enter when you've verified that the new tag ($RELEASE_TAG) is 
available."
-    read
+    check_stage_guard "tag" && {
+      run_silent "Creating release tag $RELEASE_TAG..." "tag.log" \
+        "$SELF/release-tag.sh"
+      if is_force; then
+        echo "Force mode: skipping wait for tag sync."
+      else
+        echo "It may take some time for the tag to be synchronized to github."
+        echo "Press enter when you've verified that the new tag ($RELEASE_TAG) 
is available."
+        read
+      fi
+      if check_for_tag "$RELEASE_TAG"; then
+        mark_stage_done "tag" "Tag $RELEASE_TAG verified on 
github.com/apache/gravitino"
+      else
+        echo "WARNING: Tag $RELEASE_TAG not found on remote after creation. 
Stage not marked as done."
+      fi
+    }
   fi
 else
-  echo "Skipping tag creation for $RELEASE_TAG."
+  echo "Skipping tag stage."
 fi
 
 if should_build "build"; then
-  run_silent "Building Gravitino..." "build.log" \
-    "$SELF/release-build.sh" package
+  check_stage_guard "build" && {
+    run_silent "Building Gravitino..." "build.log" \
+      "$SELF/release-build.sh" package
+    mark_stage_done "build" "Artifacts built and uploaded to ASF SVN staging"
+  }
 else
   echo "Skipping build step."
 fi
 
 if should_build "docs"; then
-  run_silent "Building documentation..." "docs.log" \
-    "$SELF/release-build.sh" docs
+  check_stage_guard "docs" && {
+    run_silent "Building documentation..." "docs.log" \
+      "$SELF/release-build.sh" docs
+    mark_stage_done "docs" "Javadoc and Python docs built locally"
+  }
 else
   echo "Skipping docs step."
 fi
 
 if should_build "publish"; then
-  run_silent "Publishing release" "publish.log" \
-    "$SELF/release-build.sh" publish-release
+  check_stage_guard "publish" && {
+    run_silent "Publishing release" "publish.log" \
+      "$SELF/release-build.sh" publish-release
+    mark_stage_done "publish" "Maven artifacts uploaded to Apache Nexus 
staging"
+  }
 else
   echo "Skipping publish step."
 fi
 
 if [ "$RELEASE_STEP" = "finalize" ]; then
-  run_silent "Finalizing release" "finalize.log" \
-    "$SELF/release-build.sh" finalize
+  check_stage_guard "finalize" && {
+    run_silent "Finalizing release" "finalize.log" \
+      "$SELF/release-build.sh" finalize
+    if check_for_tag "v$RELEASE_VERSION"; then
+      mark_stage_done "finalize" "Release v$RELEASE_VERSION promoted: PyPI 
uploaded, SVN moved to release, KEYS updated"
+    else
+      mark_stage_done "finalize" "Finalize script completed (verify 
v$RELEASE_VERSION tag manually)"
+    fi
+  }
 fi
 
 echo "Release build and publish completed"
diff --git a/dev/release/mock/do-release.sh b/dev/release/mock/do-release.sh
new file mode 100755
index 0000000000..fc86014156
--- /dev/null
+++ b/dev/release/mock/do-release.sh
@@ -0,0 +1,240 @@
+#!/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.
+#
+
+# Mock version of do-release.sh for testing the gravitino-release skill.
+#
+# Accepts identical flags and env vars as the real do-release.sh but replaces
+# all build/publish/git operations with printed simulations. State tracking
+# (mark_stage_done / check_stage_guard) runs for real so the skill's
+# orchestration logic is fully exercised.
+#
+# Extra env vars for testing:
+#   MOCK_FAIL_STAGE=<stage>   Simulate a failure at the given stage name.
+#                             Valid values: tag, build, docs, publish, finalize
+#   MOCK_STAGE_DELAY=<secs>   Seconds to sleep per stage (default: 1).
+
+set -euo pipefail
+
+SELF=$(cd "$(dirname "$0")" && pwd)
+REAL_SCRIPTS_DIR="$SELF/.."
+
+# ---------------------------------------------------------------------------
+# Parse identical flags as the real do-release.sh
+# ---------------------------------------------------------------------------
+while getopts ":b:s:p:t:r:nyh" opt; do
+  case $opt in
+    b) GIT_BRANCH=$OPTARG ;;
+    n) DRY_RUN=1 ;;
+    s) RELEASE_STEP=$OPTARG ;;
+    p) GPG_PASSPHRASE=$OPTARG ;;
+    t) ASF_PASSWORD=$OPTARG ;;
+    r) RC_COUNT=$OPTARG ;;
+    y) FORCE=1 ;;
+    h)
+      echo "Usage: $0 [options]  (MOCK — no real operations run)"
+      echo ""
+      echo "Accepts identical flags and env vars as do-release.sh."
+      echo "All credentials are validated exactly as in the real script."
+      echo ""
+      echo "Extra env vars for testing:"
+      echo "  MOCK_FAIL_STAGE=<stage>   Simulate failure at: tag, build, docs, 
publish, finalize"
+      echo "  MOCK_STAGE_DELAY=<secs>   Sleep duration per stage (default: 1)"
+      exit 0
+      ;;
+    :) echo "Option -$OPTARG requires an argument." >&2; exit 1 ;;
+    \?) echo "Invalid option: -$OPTARG" >&2; exit 1 ;;
+  esac
+done
+
+export RUNNING_IN_DOCKER=${RUNNING_IN_DOCKER:-0}
+export DRY_RUN=${DRY_RUN:-0}
+export FORCE=${FORCE:-0}
+export RC_COUNT=${RC_COUNT:-0}
+export RELEASE_STEP=${RELEASE_STEP:-}
+export GIT_BRANCH=${GIT_BRANCH:-}
+export RELEASE_VERSION=${RELEASE_VERSION:-}
+export RELEASE_TAG=${RELEASE_TAG:-}
+export ASF_PASSWORD=${ASF_PASSWORD:-}
+export GPG_PASSPHRASE=${GPG_PASSPHRASE:-}
+
+if [[ "${RC_COUNT}" != "0" ]] && ! [[ "${RC_COUNT}" =~ ^[1-9][0-9]*$ ]]; then
+  echo "Error: RC number must be a positive integer, got: '${RC_COUNT}'" >&2
+  exit 1
+fi
+
+if [ -n "${RELEASE_STEP}" ]; then
+  case "${RELEASE_STEP}" in
+    tag|build|docs|publish|finalize) ;;
+    *) echo "Error: invalid release step '${RELEASE_STEP}'. Valid steps: tag, 
build, docs, publish, finalize" >&2; exit 1 ;;
+  esac
+fi
+
+# Source real release-util.sh for get_release_info, is_dry_run, is_force, etc.
+. "$REAL_SCRIPTS_DIR/release-util.sh"
+
+# Validate PYPI_API_TOKEN — same check as the real script.
+if ! is_dry_run; then
+  if [[ -z "${PYPI_API_TOKEN:-}" ]]; then
+    echo 'The environment variable PYPI_API_TOKEN is not set. Exiting.'
+    exit 1
+  fi
+fi
+
+# Collect release parameters the same way as the real script.
+# In -y (force) mode this reads from env vars with no prompts.
+# Credentials (ASF_PASSWORD, GPG_PASSPHRASE) are collected inside 
get_release_info.
+SKIP_TAG=0
+get_release_info
+
+function should_build {
+  local WHAT=$1
+  [ -z "$RELEASE_STEP" ] || [ "$WHAT" = "$RELEASE_STEP" ]
+}
+
+# ---------------------------------------------------------------------------
+# State tracking (identical to real do-release.sh)
+# ---------------------------------------------------------------------------
+STATE_DIR="${RELEASE_STATE_DIR:-$SELF/.release-state}/${RELEASE_TAG}"
+mkdir -p "$STATE_DIR"
+
+function stage_file { echo "$STATE_DIR/$1.done"; }
+
+function is_stage_done { [ -f "$(stage_file "$1")" ]; }
+
+function mark_stage_done {
+  local STEP="$1" INFO="${2:-}"
+  {
+    echo "completed_at=$(date -u '+%Y-%m-%dT%H:%M:%SZ')"
+    echo "release=$RELEASE_TAG"
+    echo "mode=MOCK"
+    [ -n "$INFO" ] && echo "info=$INFO"
+  } > "$(stage_file "$STEP")"
+  echo "Stage '$STEP' marked as done (MOCK). State file: $(stage_file "$STEP")"
+}
+
+function check_stage_guard {
+  local STEP="$1"
+  if is_stage_done "$STEP"; then
+    echo ""
+    echo "=== Stage '$STEP' is already complete ==="
+    cat "$(stage_file "$STEP")"
+    echo "To re-run this stage, delete: $(stage_file "$STEP")"
+    echo ""
+    return 1
+  fi
+  return 0
+}
+
+# ---------------------------------------------------------------------------
+# Mock stage runner
+# Prints what the real stage would do, sleeps briefly, then either
+# writes a .done file (success) or exits non-zero (if MOCK_FAIL_STAGE matches).
+# ---------------------------------------------------------------------------
+function mock_stage {
+  local STEP="$1"
+  local LOG_FILE="$SELF/${STEP}.log"
+
+  echo "========================"
+  echo "= [MOCK] Stage: $STEP"
+  echo "= Release: $RELEASE_TAG  Branch: $GIT_BRANCH  DryRun: $DRY_RUN"
+
+  case "$STEP" in
+    tag)
+      echo "= Would: clone repo, update versions, commit, create tag 
$RELEASE_TAG, bump to next SNAPSHOT, push"
+      ;;
+    build)
+      echo "= Would: clone from $RELEASE_TAG, build source+binary tarballs, 
sign, checksum,"
+      echo "=        upload Python RC to PyPI, upload artifacts to ASF SVN 
staging"
+      ;;
+    docs)
+      echo "= Would: build Javadoc and Python Sphinx docs locally"
+      ;;
+    publish)
+      echo "= Would: create Nexus staging repo, build Maven artifacts (Scala 
2.12 + 2.13),"
+      echo "=        sign, upload to Nexus, close staging repo"
+      ;;
+    finalize)
+      echo "= Would: create tag v$RELEASE_VERSION, upload final Python package 
to PyPI,"
+      echo "=        move SVN dev → release, update KEYS"
+      ;;
+  esac
+
+  sleep "${MOCK_STAGE_DELAY:-1}"
+
+  if [ "${MOCK_FAIL_STAGE:-}" = "$STEP" ]; then
+    echo "[MOCK] Simulated failure injected for stage '$STEP'" | tee 
"$LOG_FILE" >&2
+    echo "Error: MOCK_FAIL_STAGE=$STEP triggered a non-zero exit." >> 
"$LOG_FILE"
+    exit 1
+  fi
+
+  echo "[MOCK] Stage '$STEP' completed successfully." | tee "$LOG_FILE"
+}
+
+# ---------------------------------------------------------------------------
+# Stage execution (mirrors real do-release.sh structure exactly)
+# ---------------------------------------------------------------------------
+if should_build "tag"; then
+  if [ $SKIP_TAG = 1 ]; then
+    echo "Tag $RELEASE_TAG already exists on remote. Skipping tag creation."
+    is_stage_done "tag" || mark_stage_done "tag" "MOCK: tag $RELEASE_TAG 
already existed on remote"
+  else
+    check_stage_guard "tag" && {
+      mock_stage "tag"
+      mark_stage_done "tag" "MOCK: tag $RELEASE_TAG simulated (not pushed to 
remote)"
+    }
+  fi
+else
+  echo "Skipping tag stage."
+fi
+
+if should_build "build"; then
+  check_stage_guard "build" && {
+    mock_stage "build"
+    mark_stage_done "build" "MOCK: artifacts build and SVN staging upload 
simulated"
+  }
+else
+  echo "Skipping build step."
+fi
+
+if should_build "docs"; then
+  check_stage_guard "docs" && {
+    mock_stage "docs"
+    mark_stage_done "docs" "MOCK: Javadoc and Python docs build simulated"
+  }
+else
+  echo "Skipping docs step."
+fi
+
+if should_build "publish"; then
+  check_stage_guard "publish" && {
+    mock_stage "publish"
+    mark_stage_done "publish" "MOCK: Maven artifacts upload to Nexus simulated"
+  }
+else
+  echo "Skipping publish step."
+fi
+
+if [ "$RELEASE_STEP" = "finalize" ]; then
+  check_stage_guard "finalize" && {
+    mock_stage "finalize"
+    mark_stage_done "finalize" "MOCK: PyPI upload, SVN promotion, KEYS update 
simulated"
+  }
+fi
+
+echo "Mock release completed."
diff --git a/dev/release/mock/publish-docker.sh 
b/dev/release/mock/publish-docker.sh
new file mode 100755
index 0000000000..c51a49e0e2
--- /dev/null
+++ b/dev/release/mock/publish-docker.sh
@@ -0,0 +1,191 @@
+#!/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.
+#
+
+# Mock version of publish-docker.sh for testing the gravitino-release skill.
+#
+# Accepts identical arguments and env vars as the real publish-docker.sh but
+# prints simulated workflow dispatch commands instead of triggering them.
+# State tracking runs for real so the skill's guard logic is exercised.
+#
+# Extra env vars for testing:
+#   MOCK_FAIL_STAGE=docker    Simulate a failure during docker stage.
+
+set -e
+
+SELF=$(cd "$(dirname "$0")" && pwd)
+
+# Parse arguments — identical to real publish-docker.sh
+DRY_RUN=false
+INPUT_TAG=""
+DOCKER_VERSION=""
+TRINO_VER="478"
+STATE_KEY="docker"
+
+while [[ $# -gt 0 ]]; do
+  case "$1" in
+    --dry-run)
+      DRY_RUN=true
+      shift
+      ;;
+    --state-key)
+      if [[ -z "${2:-}" || "$2" == -* ]]; then
+        echo "ERROR: --state-key requires a non-empty value." >&2
+        exit 1
+      fi
+      STATE_KEY="$2"
+      shift 2
+      ;;
+    --docker-version)
+      if [[ -z "${2:-}" || "$2" == -* ]]; then
+        echo "ERROR: --docker-version requires a non-empty value." >&2
+        exit 1
+      fi
+      DOCKER_VERSION="$2"
+      shift 2
+      ;;
+    --trino-version)
+      if [[ -z "${2:-}" || "$2" == -* ]]; then
+        echo "ERROR: --trino-version requires a non-empty value." >&2
+        exit 1
+      fi
+      TRINO_VER="$2"
+      shift 2
+      ;;
+    -h|--help)
+      cat << 'EOF'
+Usage: publish-docker.sh <tag|branch> --docker-version <version> 
[--trino-version <version>] [--dry-run]
+(MOCK VERSION — no workflows are actually triggered)
+
+Accepts identical arguments as the real publish-docker.sh.
+
+Extra env vars for testing:
+  MOCK_FAIL_STAGE=docker   Simulate a failure during the docker stage.
+EOF
+      exit 0
+      ;;
+    *)
+      if [[ -z "$INPUT_TAG" ]]; then
+        INPUT_TAG="$1"
+      else
+        echo "ERROR: Unknown argument: $1" >&2
+        exit 1
+      fi
+      shift
+      ;;
+  esac
+done
+
+if [[ -z "$INPUT_TAG" ]]; then
+  echo "ERROR: Missing tag/branch argument" >&2
+  exit 1
+fi
+
+if [[ -z "$DOCKER_VERSION" ]]; then
+  echo "ERROR: Missing --docker-version argument" >&2
+  exit 1
+fi
+
+# ---------------------------------------------------------------------------
+# State tracking — identical convention as real publish-docker.sh
+# ---------------------------------------------------------------------------
+DOCKER_STATE_DIR="${RELEASE_STATE_DIR:-$SELF/.release-state}/${INPUT_TAG}"
+DOCKER_STATE_FILE="${DOCKER_STATE_DIR}/${STATE_KEY}.done"
+
+if [[ "$DRY_RUN" == "false" ]] && [[ -f "$DOCKER_STATE_FILE" ]]; then
+  echo ""
+  echo "=== Stage '${STATE_KEY}' is already complete ==="
+  cat "$DOCKER_STATE_FILE"
+  echo "To re-run, delete: $DOCKER_STATE_FILE"
+  echo ""
+  exit 0
+fi
+
+TRINO_VERSION="${TRINO_VER}-gravitino-${DOCKER_VERSION}"
+
+echo "[MOCK] Skipping remote tag/branch verification for: $INPUT_TAG"
+
+# Validate credentials — same checks as the real script.
+if [[ "$DRY_RUN" == "false" ]]; then
+  if [[ -z "${GH_TOKEN:-}" ]] && ! gh auth status > /dev/null 2>&1; then
+    echo "ERROR: GH_TOKEN is not set and gh auth is not configured."
+    echo "Either export GH_TOKEN or run: gh auth login"
+    exit 1
+  fi
+  if [[ -z "${DOCKER_USERNAME:-}" ]]; then
+    echo "ERROR: DOCKER_USERNAME environment variable not set"
+    exit 1
+  fi
+  if [[ -z "${PUBLISH_DOCKER_TOKEN:-}" ]]; then
+    echo "ERROR: PUBLISH_DOCKER_TOKEN environment variable not set"
+    exit 1
+  fi
+  echo "Username: ${DOCKER_USERNAME}"
+fi
+echo ""
+
+if [[ "$DRY_RUN" == "true" ]]; then
+  echo "=== [MOCK DRY RUN] Preview Gravitino Docker Image Build ==="
+else
+  echo "=== [MOCK] Simulating Gravitino Docker Image Build ==="
+fi
+echo "Input:          ${INPUT_TAG}"
+echo "Docker Version: ${DOCKER_VERSION}"
+echo "Trino Version:  ${TRINO_VERSION}"
+echo ""
+
+declare -a images=(
+  "gravitino"
+  "gravitino-iceberg-rest-server"
+  "gravitino-lance-rest-server"
+  "gravitino-mcp-server"
+)
+
+echo "=== [MOCK] Simulated Workflow Dispatches ==="
+
+for img in "${images[@]}"; do
+  echo "[MOCK] gh workflow run docker-image.yml -R apache/gravitino --ref 
${INPUT_TAG} -f image=${img} -f version=${DOCKER_VERSION}"
+  sleep 0.2
+done
+
+echo "[MOCK] gh workflow run docker-image.yml -R apache/gravitino --ref 
${INPUT_TAG} -f image=gravitino-playground:trino -f version=${TRINO_VERSION}"
+
+echo ""
+
+if [[ "$DRY_RUN" == "true" ]]; then
+  echo "=== [MOCK DRY RUN] Preview Complete ==="
+else
+  if [ "${MOCK_FAIL_STAGE:-}" = "docker" ]; then
+    echo "[MOCK] Simulated failure injected for stage 'docker'" >&2
+    exit 1
+  fi
+  echo "=== [MOCK] All workflows simulated ==="
+fi
+
+# Mark stage as done regardless of dry-run so the skill can track completion.
+mkdir -p "$DOCKER_STATE_DIR"
+{
+  echo "completed_at=$(date -u '+%Y-%m-%dT%H:%M:%SZ')"
+  echo "release=$INPUT_TAG"
+  echo "docker_version=$DOCKER_VERSION"
+  echo "trino_version=$TRINO_VERSION"
+  echo "mode=MOCK"
+  [[ "$DRY_RUN" == "true" ]] && echo "dry_run=true"
+  echo "info=MOCK: all Docker image workflow dispatches simulated"
+} > "$DOCKER_STATE_FILE"
+echo "Stage '${STATE_KEY}' marked as done (MOCK). State file: 
$DOCKER_STATE_FILE"
diff --git a/dev/release/publish-docker.sh b/dev/release/publish-docker.sh
index b607bd5fd2..48250f2eca 100755
--- a/dev/release/publish-docker.sh
+++ b/dev/release/publish-docker.sh
@@ -54,6 +54,8 @@
 
 set -e
 
+SELF=$(cd "$(dirname "$0")" && pwd)
+
 # Check required commands
 for cmd in git gh; do
   if ! command -v "$cmd" > /dev/null 2>&1; then
@@ -67,6 +69,7 @@ DRY_RUN=false
 INPUT_TAG=""
 DOCKER_VERSION=""
 TRINO_VER="478"
+STATE_KEY="docker"
 
 while [[ $# -gt 0 ]]; do
   case "$1" in
@@ -74,6 +77,14 @@ while [[ $# -gt 0 ]]; do
       DRY_RUN=true
       shift
       ;;
+    --state-key)
+      if [[ -z "${2:-}" || "$2" == -* ]]; then
+        echo "ERROR: --state-key requires a non-empty value." >&2
+        exit 1
+      fi
+      STATE_KEY="$2"
+      shift 2
+      ;;
     --docker-version)
       if [[ -z "${2:-}" || "$2" == -* ]]; then
         echo "ERROR: --docker-version requires a non-empty value." >&2
@@ -153,6 +164,22 @@ fi
 
 echo "Verified: $INPUT_TAG exists"
 
+# ---------------------------------------------------------------------------
+# Stage state tracking: reuse the same .release-state/{TAG}/ convention as
+# do-release.sh so all stage markers live together.
+# ---------------------------------------------------------------------------
+DOCKER_STATE_DIR="${RELEASE_STATE_DIR:-$SELF/.release-state}/${INPUT_TAG}"
+DOCKER_STATE_FILE="${DOCKER_STATE_DIR}/${STATE_KEY}.done"
+
+if [[ "$DRY_RUN" == "false" ]] && [[ -f "$DOCKER_STATE_FILE" ]]; then
+  echo ""
+  echo "=== Stage '${STATE_KEY}' is already complete ==="
+  cat "$DOCKER_STATE_FILE"
+  echo "To re-run, delete: $DOCKER_STATE_FILE"
+  echo ""
+  exit 0
+fi
+
 # Trino special version
 TRINO_VERSION="${TRINO_VER}-gravitino-${DOCKER_VERSION}"
 
@@ -233,3 +260,15 @@ else
   echo "=== All workflows triggered ==="
   echo "View progress: 
https://github.com/apache/gravitino/actions/workflows/docker-image.yml";
 fi
+
+# Mark stage as done regardless of dry-run so the skill can track completion.
+mkdir -p "$DOCKER_STATE_DIR"
+{
+  echo "completed_at=$(date -u '+%Y-%m-%dT%H:%M:%SZ')"
+  echo "release=$INPUT_TAG"
+  echo "docker_version=$DOCKER_VERSION"
+  echo "trino_version=$TRINO_VERSION"
+  [[ "$DRY_RUN" == "true" ]] && echo "dry_run=true"
+  echo "info=All Docker image workflows dispatched to apache/gravitino"
+} > "$DOCKER_STATE_FILE"
+echo "Stage '${STATE_KEY}' marked as done. State file: $DOCKER_STATE_FILE"

Reply via email to