Skip to content

Release

Release #65

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
## Block telemetry from CI — same rationale as ci.yml. The release
## workflow runs pytest before publishing; without this flag every tag
## push would fire one or more synthetic events.
env:
GODOT_AI_DISABLE_TELEMETRY: "true"
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/` + `godot-ai-LICENSE.txt` 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.
#
# The second top-level entry is namespaced (`godot-ai-LICENSE.txt`)
# rather than a bare `LICENSE`. A bare `LICENSE` lands at
# `res://LICENSE` on install and silently overwrites the user's own
# project LICENSE file — issue #450. The namespaced name preserves
# the AssetLib trick without clobbering anything in a typical project.
# The canonical addon-scoped license still ships at
# `addons/godot_ai/LICENSE` for in-tree consumers.
run: |
mkdir -p staging/addons
cp -r plugin/addons/godot_ai staging/addons/
cp LICENSE staging/godot-ai-LICENSE.txt
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/ godot-ai-LICENSE.txt
- 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" != "addons godot-ai-LICENSE.txt" ]; then
echo "ERROR: expected top-level entries 'addons godot-ai-LICENSE.txt' 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 'godot-ai-LICENSE.txt'; then
echo "ERROR: godot-ai-LICENSE.txt not present at zip root" >&2
exit 1
fi
# Guard against the issue-#450 regression: a bare `LICENSE` at zip
# root would silently overwrite the installing project's own
# LICENSE on AssetLib install.
if unzip -Z1 godot-ai-plugin.zip | grep -qx 'LICENSE'; then
echo "ERROR: zip contains bare 'LICENSE' at root — would clobber user project LICENSE on install (see #450)" >&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/... + godot-ai-LICENSE.txt (multi-top)"
- name: Create GitHub Release
uses: softprops/action-gh-release@v3
with:
files: godot-ai-plugin.zip
generate_release_notes: true
- name: Post changelog to Discord
# Posts the release's auto-generated notes to the #changelog channel
# via a channel-scoped webhook. Skips cleanly when the webhook secret
# isn't configured, so releases never break if it's unset/removed.
#
# The post is silent: flags:4096 is Discord's SUPPRESS_NOTIFICATIONS
# (the @silent mechanism) — the message lands in the channel and marks
# it unread, but triggers NO push/desktop notification for anyone,
# regardless of their per-channel notification settings. The generated
# notes never contain @everyone/@here, and allowed_mentions.parse:[] is
# belt-and-suspenders against any @mention in the notes ever pinging.
env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_CHANGELOG_WEBHOOK }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
# Strip any whitespace/newlines from the secret. `gh secret set`
# captures a trailing newline if the value was piped/pasted with
# one, which makes curl reject the URL with "(3) Malformed input to
# a URL function". A webhook URL contains no internal whitespace, so
# stripping all whitespace is safe and fixes contaminated secrets
# regardless of how they were set.
DISCORD_WEBHOOK=$(printf '%s' "$DISCORD_WEBHOOK" | tr -d '[:space:]')
if [ -z "$DISCORD_WEBHOOK" ]; then
echo "DISCORD_CHANGELOG_WEBHOOK not set — skipping changelog post."
exit 0
fi
tag="$GITHUB_REF_NAME"
name=$(gh release view "$tag" --json name --jq '.name')
url=$(gh release view "$tag" --json url --jq '.url')
notes=$(gh release view "$tag" --json body --jq '.body')
# Discord embed description caps at 4096 chars; leave headroom and
# append a link line so the full notes are always one click away.
desc=$(printf '%s' "$notes" | head -c 3900)
payload=$(jq -n \
--arg title "$name" \
--arg url "$url" \
--arg desc "$desc" \
'{
username: "Godot AI",
allowed_mentions: { parse: [] },
flags: 4096,
embeds: [ {
title: $title,
url: $url,
description: ($desc + "\n\n[View full release →](" + $url + ")"),
color: 5793266
} ]
}')
# The changelog post is non-essential: the release already shipped
# before this step. A Discord outage or a bad webhook must not turn
# a successful release red — log a warning annotation and exit 0.
if curl -fsS --retry 3 --retry-delay 2 -H "Content-Type: application/json" \
-X POST -d "$payload" "$DISCORD_WEBHOOK"; then
echo "Posted changelog for $tag to Discord."
else
echo "::warning::Discord changelog post for $tag failed (release itself is unaffected)."
fi