Skip to content

Release

Release #50

Workflow file for this run

name: Release
on:
push:
tags:
- "v*"
# Dispatched explicitly by bump-and-release.yml — see that workflow's
# "Trigger release workflow" step for why the tag push alone isn't enough.
workflow_dispatch:
permissions:
contents: write
jobs:
# Publish the Python server package to PyPI FIRST so that by the time the
# plugin ZIP is released and users self-update, the matching Python package
# is already available on PyPI for `uvx --from godot-ai~=<version> godot-ai`
# to resolve.
#
# Uses PyPI Trusted Publishing (OIDC) — no API tokens / secrets required.
# Requires a one-time pending-publisher setup on PyPI (see README or the
# PR description).
publish-pypi:
runs-on: ubuntu-latest
permissions:
id-token: write # required for PyPI OIDC
steps:
- uses: actions/checkout@v6
- name: Verify ref is a version tag
# Guard against a manual workflow_dispatch on a branch ref (e.g.
# `main`), which would otherwise fall through and fail the version
# check with a confusing "tag (main) does not match..." message.
run: |
if [[ ! "$GITHUB_REF_NAME" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "ERROR: release.yml must run against a version tag (vX.Y.Z), got: $GITHUB_REF_NAME" >&2
echo "If dispatching manually, pick a v* tag in the ref dropdown." >&2
exit 1
fi
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: "3.13"
- name: Install build tools
run: python -m pip install --upgrade build
- name: Verify package version matches tag
run: |
tag="${GITHUB_REF_NAME#v}"
pkg=$(python -c "import tomllib; print(tomllib.loads(open('pyproject.toml','rb').read().decode())['project']['version'])")
if [ "$tag" != "$pkg" ]; then
echo "ERROR: tag ($tag) does not match pyproject.toml version ($pkg)" >&2
exit 1
fi
echo "Tag and package version match: $tag"
- name: Build sdist + wheel
run: python -m build
- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
with:
# Idempotent re-runs: if this version is already on PyPI (e.g. a
# previous run published but a downstream job failed), skip the
# upload instead of erroring with "File already exists" — which
# would block the build-plugin-zip job that depends on this one.
skip-existing: true
# Build the Godot plugin ZIP and attach it to the GitHub Release.
# Runs after publish-pypi so the Python package is live on PyPI before the
# plugin ZIP download becomes available — prevents a racing user from
# installing the plugin and having uvx fail to resolve the Python package.
build-plugin-zip:
runs-on: ubuntu-latest
needs: publish-pypi
steps:
- uses: actions/checkout@v6
- name: Build plugin ZIP
# Ship `addons/` + `LICENSE` at the zip's top level. The multi-top
# shape matters: Godot's AssetLib install dialog defaults "Ignore
# asset root" to CHECKED whenever it detects a single top-level
# folder, which would strip `addons/` and drop `godot_ai/` at
# res:// — outside the plugin path and outside the addons-warning
# exclusion. With two top-level entries, Godot doesn't offer the
# strip option, so files land as-archived at res://addons/godot_ai/
# regardless of any user-toggled preference.
run: |
mkdir -p staging/addons
cp -r plugin/addons/godot_ai staging/addons/
cp LICENSE staging/
cd staging
# `-D` strips zero-byte directory entries. The 2.2.x/2.3.0 self-
# update runner has an over-strict safety check that flags the
# bare `addons/godot_ai/` directory entry as unsafe and aborts
# the extract. Stripping directory entries lets those installs
# successfully self-update to a fixed runner.
zip -D -r ../godot-ai-plugin.zip addons/ LICENSE
- name: Verify zip structure
run: |
# unzip -Z1 lists only entry names, one per line, no header/footer
tops=$(unzip -Z1 godot-ai-plugin.zip | awk -F/ '{print $1}' | sort -u | tr '\n' ' ' | sed 's/ $//')
if [ "$tops" != "LICENSE addons" ]; then
echo "ERROR: expected top-level entries 'LICENSE addons' in zip, got:" >&2
echo " $tops" >&2
exit 1
fi
if ! unzip -Z1 godot-ai-plugin.zip | grep -qx 'addons/godot_ai/plugin.cfg'; then
echo "ERROR: plugin.cfg not at expected path addons/godot_ai/plugin.cfg" >&2
exit 1
fi
if ! unzip -Z1 godot-ai-plugin.zip | grep -qx 'LICENSE'; then
echo "ERROR: LICENSE not present at zip root" >&2
exit 1
fi
# Hard-fail if the zip contains zero-byte directory entries (paths
# ending in `/`). The 2.2.x/2.3.0 self-update runner has an over-
# strict `_is_safe_zip_addon_file` guard that rejects any path
# ending in `/`, so any release zip emitted without `zip -D` will
# abort the extract on those installs and strand them. This guard
# is the rescue mechanism's CI invariant — see PR #281, issue #283.
if unzip -Z1 godot-ai-plugin.zip | grep -qE '/$'; then
echo "ERROR: zip contains directory entries (forgot \`zip -D\`?):" >&2
unzip -Z1 godot-ai-plugin.zip | grep -E '/$' >&2
exit 1
fi
# Guard against macOS iCloud-sync duplicates (filenames like `foo 2.gd`)
# sneaking in via a contaminated checkout. CI should never hit this
# on a fresh actions/checkout, but a manual build from a synced
# working tree could. Fail loudly if any are present.
if unzip -Z1 godot-ai-plugin.zip | grep -q ' 2\.'; then
echo "ERROR: zip contains iCloud-duplicate files (pattern ' 2.'):" >&2
unzip -Z1 godot-ai-plugin.zip | grep ' 2\.' >&2
exit 1
fi
echo "Zip structure verified: addons/godot_ai/... + LICENSE (multi-top)"
- name: Create GitHub Release
uses: softprops/action-gh-release@v3
with:
files: godot-ai-plugin.zip
generate_release_notes: true