Files
remove-ai-watermarks/.github/workflows/verify-release.yml
T
Victor Kuznetsov de03748849 Read the flat single-artifact layout in release verification
download-artifact@v8 extracts the only pattern-matched artifact directly
into the target path, without the artifact-name directory the resolve
step assumed, so every workflow_run verification died at
"No distributed release identity found" before checking a single
surface. The directory-name attempt check now applies only when the
artifact directory exists; a flat identity is still pinned by the
run-id and attempt-eligibility asserts. The workflow_run path of
5c6588b had never executed: v0.38.0's run failed on later checks and
the surfaces were confirmed by a manual dispatch.

The new test drives the real workflow script against the flat layout
and reproduced the production failure before the fix.

pre-commit: 1) maintain.sh - exit 2 (uv-secure on the documented
lightning/accelerate blocks, unchanged lock; core checks separately:
C2PA sync current, Ruff clean with no rewrites, Pyright src/ 0 errors,
2360 passed 3 skipped); 2) /simplify - single-pass four angles, clean;
3) docs sync - grepped distributed-release over docs/: the one mention
stays accurate; 4) CLAUDE.md - improver grade A earlier this session,
revise: no additions
2026-09-08 20:30:59 -07:00

302 lines
15 KiB
YAML

name: Verify release surfaces
# Post-release verification: after distribute.yml fans a release out to
# Homebrew, the HF Space, and the ComfyUI Registry, confirm every published
# surface actually landed and works. This workflow is the automated twin of
# the manual checklist in docs/release-and-distribution.md ("Release
# verification"), not a replacement for the library's own test suite (which
# already ran in test.yml and publish.yml).
on:
workflow_dispatch:
inputs:
version:
description: "Version to verify (no leading v). Blank = latest on PyPI."
required: false
workflow_run:
workflows: ["Distribute on release"]
types: [completed]
# Read the triggering run's release identity while keeping all permissions read-only.
permissions:
contents: read
actions: read
jobs:
verify:
# Only run on a successful distribute (not on manual dispatch, where the
# version input or PyPI-latest provides the target).
if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }}
runs-on: ubuntu-latest
timeout-minutes: 25
env:
# The Space id appears in the Hub API, the raw-file URL and the app host,
# spelled three different ways. Name it once so a move cannot half-land.
SPACE_REPO: wiltodelta/remove-ai-watermarks
SPACE_URL: https://wiltodelta-remove-ai-watermarks.hf.space
steps:
- name: Download distributed release identity
if: ${{ github.event_name == 'workflow_run' }}
uses: actions/download-artifact@v8
with:
pattern: distributed-release-*
path: distribution-identities
run-id: ${{ github.event.workflow_run.id }}
github-token: ${{ github.token }}
- name: Resolve target version
id: v
env:
EVENT_NAME: ${{ github.event_name }}
INPUT_VERSION: ${{ github.event.inputs.version }}
DISTRIBUTION_RUN_ID: ${{ github.event.workflow_run.id }}
DISTRIBUTION_RUN_ATTEMPT: ${{ github.event.workflow_run.run_attempt }}
run: |
set -euo pipefail
if [ "$EVENT_NAME" = "workflow_run" ]; then
v=$(python3 - <<'PYTHON'
import json, os
from pathlib import Path
identities = []
for path in Path("distribution-identities").rglob("release-identity.json"):
item = json.loads(path.read_text(encoding="utf-8"))
# download-artifact@v8 extracts the only matched artifact directly
# into the path, with no artifact-name directory; several matches
# each get one, so the attempt is name-checked only in that layout.
if path.parent.name.startswith('distributed-release-'):
assert path.parent.name == 'distributed-release-' + item['run_attempt'], "Artifact attempt mismatch"
identities.append(item)
assert identities, "No distributed release identity found"
assert all(item["run_id"] == os.environ["DISTRIBUTION_RUN_ID"] for item in identities), "Distribution run mismatch"
# Rerunning only failed jobs can reuse resolve from an earlier attempt.
eligible = [item for item in identities if
int(item["run_attempt"]) <= int(os.environ["DISTRIBUTION_RUN_ATTEMPT"])]
assert eligible, "No identity exists for this distribution attempt"
identity = max(eligible, key=lambda item: int(item["run_attempt"]))
print(identity["version"])
PYTHON
)
elif [ -n "$INPUT_VERSION" ]; then
v="$INPUT_VERSION"
else
v=$(curl -fsSL https://pypi.org/pypi/remove-ai-watermarks/json \
| python3 -c "import sys,json;print(json.load(sys.stdin)['info']['version'])")
fi
# Versions become URL components and step outputs. Reject malformed identities.
[[ "$v" =~ ^[0-9]+\.[0-9]+\.[0-9]+([a-zA-Z0-9.+-]*)$ ]]
echo "version=$v" >> "$GITHUB_OUTPUT"
echo "Verifying release $v"
# -- PyPI ---------------------------------------------------------------
- name: Verify PyPI
env:
VERSION: ${{ steps.v.outputs.version }}
run: |
set -euo pipefail
echo "Checking PyPI for $VERSION..."
rel=$(curl -fsSL "https://pypi.org/pypi/remove-ai-watermarks/$VERSION/json")
echo "$rel" | python3 -c "
import json, sys
d = json.load(sys.stdin)
assert d['info']['version'] == '$VERSION', f\"PyPI version mismatch: {d['info']['version']}\"
assert d['urls'], 'No files on PyPI'
has_wheel = any(u['packagetype'] == 'bdist_wheel' for u in d['urls'])
has_sdist = any(u['packagetype'] == 'sdist' for u in d['urls'])
assert has_wheel, 'No wheel on PyPI'
assert has_sdist, 'No sdist on PyPI'
print(f' PyPI: {len(d[\"urls\"])} files (wheel + sdist) for $VERSION')
"
# -- Clean install from PyPI ---------------------------------------------
- name: Install uv
run: curl -LsSf https://astral.sh/uv/install.sh | sh
- name: Verify clean install
env:
VERSION: ${{ steps.v.outputs.version }}
run: |
set -euo pipefail
echo "Installing remove-ai-watermarks==$VERSION in a clean environment..."
export PATH="$HOME/.local/bin:$PATH"
result=$(uvx --from "remove-ai-watermarks==$VERSION" remove-ai-watermarks --version 2>&1) \
|| { echo "::error::Clean install failed: $result"; exit 1; }
echo " Clean install: $result"
# -- Homebrew --------------------------------------------------------------
- name: Verify Homebrew formula
env:
VERSION: ${{ steps.v.outputs.version }}
run: |
set -euo pipefail
echo "Checking Homebrew tap formula..."
formula=$(curl -fsSL \
"https://raw.githubusercontent.com/wiltodelta/homebrew-tap/main/Formula/remove-ai-watermarks.rb")
echo "$formula" | grep -q "remove_ai_watermarks-$VERSION.tar.gz" \
|| { echo "::error::Homebrew formula does not reference $VERSION"; \
echo "Formula URL line:"; echo "$formula" | grep '^url'; exit 1; }
echo " Homebrew: formula points to $VERSION"
# -- ComfyUI ------------------------------------------------------------------
- name: Verify ComfyUI registry node requires this release
env:
VERSION: ${{ steps.v.outputs.version }}
run: |
set -euo pipefail
echo "Checking the ComfyUI Registry node..."
# Read the published versions directly. Do NOT proxy through the sync
# workflow's run status: the node repo runs that workflow on a daily
# schedule too, so a green nightly says nothing about THIS release.
#
# And read the versions list, not `latest_version` on the node object.
# The registry reviews a new upload before activating it, and
# `latest_version` reports only the active one -- so for the whole
# review window a correctly published version looks identical to one
# that was never uploaded. Those are opposite situations: one is a
# queue nobody here controls, the other is a broken release.
python3 -m pip install --quiet packaging
versions=$(curl -fsSL "https://api.comfy.org/nodes/remove-ai-watermarks/versions")
status=$(printf '%s' "$versions" | VERSION="$VERSION" python3 -c "
import json, os, sys
version = os.environ['VERSION']
from packaging.requirements import InvalidRequirement, Requirement
from packaging.utils import canonicalize_name
from packaging.version import Version
target = Version(version)
def requires_release(value):
try:
requirement = Requirement(value)
except InvalidRequirement:
return False
return (
canonicalize_name(requirement.name) == 'remove-ai-watermarks'
and requirement.url is None
and requirement.marker is None
and target in requirement.specifier
and any(s.operator == '>=' and Version(s.version) == target for s in requirement.specifier)
)
matches = [
v for v in json.load(sys.stdin)
if any(requires_release(d) for d in (v.get('dependencies') or []))
]
if not matches:
sys.exit(f'::error::No ComfyUI node version requires {version}; the sync never published one')
# Sort rather than trusting the response order: the endpoint happens to
# return newest-first today and nothing documents that it must.
newest = max(matches, key=lambda v: v.get('createdAt') or '')
print(f\"{newest.get('version')} {newest.get('status')}\")
")
node_version=${status%% *}
node_status=${status##* }
echo " ComfyUI: node $node_version requires $VERSION (status $node_status)"
case "$node_status" in
NodeVersionStatusActive)
echo "- ComfyUI: registry node $node_version requires $VERSION and is live" >> "$GITHUB_STEP_SUMMARY"
;;
NodeVersionStatusPending)
# Published and waiting on the registry's own review. Say exactly
# that in the summary rather than reporting it as live, and do not
# fail: an external moderation queue going red on every release
# trains everyone to ignore this workflow.
echo "::notice::ComfyUI node $node_version is published and awaiting registry review."
echo "- ComfyUI: registry node $node_version requires $VERSION, awaiting registry activation" >> "$GITHUB_STEP_SUMMARY"
;;
*)
echo "::error::ComfyUI node $node_version is $node_status"
exit 1
;;
esac
# -- HF Space -------------------------------------------------------------------
- name: Verify the HF Space serves this release
env:
VERSION: ${{ steps.v.outputs.version }}
run: |
set -euo pipefail
echo "Checking the HF Space pin..."
# The Space pin bump is a MANUAL step (see docs/release-and-distribution.md):
# distribute.yml only factory-rebuilds whatever is already pinned. Failing
# here is the point -- a demo left on an older release is an incomplete
# release, and only this check can say so.
reqs=$(curl -fsSL "https://huggingface.co/spaces/$SPACE_REPO/raw/main/requirements.txt")
pinned=$(printf '%s\n' "$reqs" | sed -n 's/^remove-ai-watermarks==\([0-9][0-9a-zA-Z.]*\).*/\1/p' | head -1)
if [ -z "$pinned" ]; then
echo "::error::Space requirements.txt has no remove-ai-watermarks== pin"
exit 1
fi
if [ "$pinned" != "$VERSION" ]; then
echo "::error::HF Space is pinned to $pinned, not $VERSION."
echo " Bump remove-ai-watermarks in wiltodelta/raiw-hf-space (pyproject.toml,"
echo " uv lock, re-exported requirements.txt) and push; sync-to-hf.yml mirrors it."
exit 1
fi
echo " HF Space: requirements pin is $pinned"
- name: Verify the HF Space is running and answering
env:
VERSION: ${{ steps.v.outputs.version }}
run: |
set -euo pipefail
# A factory rebuild leaves the Space BUILDING for a while; wait for the
# runtime stage the API reports rather than for a bare HTTP 200, which a
# build page also returns.
for attempt in $(seq 1 20); do
stage=$(curl -fsSL "https://huggingface.co/api/spaces/$SPACE_REPO" \
| python3 -c "import json,sys;print((json.load(sys.stdin).get('runtime') or {}).get('stage',''))")
echo " attempt $attempt: runtime stage=$stage"
[ "$stage" = "RUNNING" ] && break
case "$stage" in
BUILD_ERROR|RUNTIME_ERROR|CONFIG_ERROR)
echo "::error::HF Space runtime stage is $stage"; exit 1 ;;
esac
sleep 15
done
if [ "$stage" != "RUNNING" ]; then
echo "::error::HF Space never reached RUNNING (last stage: $stage)"
exit 1
fi
# Alive is not the same as working. Drive the two CPU surfaces the demo
# exists for, against the Space's own committed example, and require a
# real verdict rather than any HTTP 200.
export PATH="$HOME/.local/bin:$PATH"
curl -fsSL -o /tmp/example.png \
"https://huggingface.co/spaces/$SPACE_REPO/resolve/main/examples/doubao-aigc.png"
uv run --no-project --with 'gradio_client>=1,<2' python - <<'PYEOF'
import os
import sys
from gradio_client import Client, handle_file
# The committed example is a 5 MB PNG, and httpx's default 5 s write
# timeout aborts that upload on a slow link before the Space ever sees
# it -- which would read as a broken demo rather than a slow one.
client = Client(os.environ["SPACE_URL"], verbose=False, httpx_kwargs={"timeout": 300})
report = client.predict(path=handle_file("/tmp/example.png"), api_name="/run_identify")
if "Verdict:" not in report:
sys.exit(f"::error::/run_identify returned no verdict: {report[:200]!r}")
print(f" HF Space identify: {report.splitlines()[0]}")
_image, _file, info = client.predict(
path=handle_file("/tmp/example.png"),
sensitivity_label="auto (recommended)",
api_name="/run_visible",
)
if "Removed:" not in info:
sys.exit(f"::error::/run_visible removed nothing on the Doubao example: {info!r}")
print(f" HF Space visible: {info.splitlines()[0]}")
PYEOF
echo "- HF Space: pinned to $VERSION, RUNNING, identify + visible answered" >> "$GITHUB_STEP_SUMMARY"
- name: Summary
env:
VERSION: ${{ steps.v.outputs.version }}
run: |
# Every check above appends its own line to the step summary as it passes,
# and every one of them fails the job instead of warning. So this heading
# can only be reached when all of them actually held -- an unconditional
# "verified" block printed here once claimed surfaces nobody had checked.
sed -i "1i ## Release $VERSION verified" "$GITHUB_STEP_SUMMARY"
cat "$GITHUB_STEP_SUMMARY"