2026-09-16 10:34:38 +02:00
|
|
|
#!/usr/bin/env bash
|
|
|
|
|
# Publish a release with the plugin zip attached, on whichever forge is running
|
|
|
|
|
# this, and then verify the result the way a shop will actually see it.
|
|
|
|
|
#
|
|
|
|
|
# Needs: TAG, GITHUB_REPOSITORY, GITHUB_API_URL, GITHUB_SERVER_URL, GH_TOKEN.
|
|
|
|
|
#
|
|
|
|
|
# The verification at the end is the part worth keeping. The update feed is
|
|
|
|
|
# read anonymously by every shop, and the two ways it breaks — a private
|
|
|
|
|
# repository and a release with no zip attached — both leave a release page
|
|
|
|
|
# that looks perfectly fine to whoever is logged in. Nobody would notice for
|
|
|
|
|
# months; what they would notice is that updates stopped arriving.
|
|
|
|
|
set -euo pipefail
|
|
|
|
|
|
|
|
|
|
cd "$(dirname "$0")/.."
|
|
|
|
|
. ci/lib.sh
|
|
|
|
|
ci_load_conf
|
|
|
|
|
|
|
|
|
|
: "${TAG:?TAG is not set}"
|
|
|
|
|
: "${GH_TOKEN:?GH_TOKEN is not set}"
|
|
|
|
|
: "${GITHUB_REPOSITORY:?}"
|
|
|
|
|
: "${GITHUB_API_URL:?}"
|
|
|
|
|
|
|
|
|
|
zip="build/$PLUGIN_SLUG.zip"
|
|
|
|
|
[ -f "$zip" ] || { echo "$zip does not exist — run ci/build-zip.sh first." >&2; exit 1; }
|
|
|
|
|
|
|
|
|
|
owner="${GITHUB_REPOSITORY%%/*}"
|
|
|
|
|
repo="${GITHUB_REPOSITORY#*/}"
|
|
|
|
|
|
|
|
|
|
# The tag message is the announcement, so it has to survive the checkout.
|
|
|
|
|
#
|
|
|
|
|
# actions/checkout fetches every tag correctly and then overwrites this one ref
|
|
|
|
|
# with the commit SHA — `fetch-depth: 0` does not save you. The annotated tag
|
|
|
|
|
# becomes a lightweight one pointing at the commit, and reading its message
|
|
|
|
|
# returns the COMMIT message instead: on a merge commit, "Merge pull request
|
|
|
|
|
# #12 from ...". Nothing fails. The release is simply published with the wrong
|
|
|
|
|
# text, and only the page shows it.
|
|
|
|
|
git fetch --force origin "refs/tags/$TAG:refs/tags/$TAG"
|
|
|
|
|
|
|
|
|
|
[ "$(git cat-file -t "$TAG")" = tag ] || {
|
|
|
|
|
echo "$TAG is not an annotated tag. Its message is what gets published, so create it with 'git tag -a'." >&2
|
|
|
|
|
exit 1
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
notes=$(git tag -l --format='%(contents)' "$TAG")
|
|
|
|
|
first_line=$(printf '%s\n' "$notes" | head -1)
|
|
|
|
|
|
|
|
|
|
case "$first_line" in
|
|
|
|
|
'Merge pull request'*|'Merge branch'*|'')
|
|
|
|
|
echo "The release notes read '$first_line'." >&2
|
|
|
|
|
echo "That is a commit message, not the tag message — the annotated tag did not survive the checkout." >&2
|
|
|
|
|
exit 1
|
|
|
|
|
;;
|
|
|
|
|
esac
|
|
|
|
|
|
|
|
|
|
ci_step "release notes"
|
|
|
|
|
printf '%s\n' "$notes" | sed 's/^/ /'
|
|
|
|
|
|
|
|
|
|
# The updater reads this line out of the release body, so wp-admin can show
|
|
|
|
|
# what this version was tested against rather than what the last one claimed.
|
|
|
|
|
readme=
|
|
|
|
|
for f in readme.txt README.txt; do [ -f "$f" ] && readme=$f && break; done
|
|
|
|
|
tested_wp=$([ -n "$readme" ] && sed -n -E 's/^Tested up to:[[:space:]]*([^[:space:]]+).*/\1/p' "$readme" | head -1)
|
|
|
|
|
tested_wc=$(ci_plugin_header 'WC tested up to')
|
|
|
|
|
|
|
|
|
|
body=$(printf '%s\n\n---\n\nTested up to: %s\nWC tested up to: %s\n' \
|
|
|
|
|
"$notes" "${tested_wp:-unknown}" "${tested_wc:-unknown}")
|
|
|
|
|
|
|
|
|
|
api() { # method path [data]
|
|
|
|
|
curl -fsS -X "$1" \
|
|
|
|
|
-H "Authorization: token $GH_TOKEN" \
|
|
|
|
|
-H 'Accept: application/json' \
|
|
|
|
|
-H 'Content-Type: application/json' \
|
|
|
|
|
${3:+-d "$3"} \
|
|
|
|
|
"$2"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if [ "${GITHUB_SERVER_URL:-}" = 'https://github.com' ]; then
|
|
|
|
|
ci_step "publishing to GitHub"
|
|
|
|
|
gh release create "$TAG" "$zip" --title "$TAG" --notes "$body" 2>/dev/null \
|
|
|
|
|
|| gh release upload "$TAG" "$zip" --clobber
|
|
|
|
|
release_api="$GITHUB_API_URL/repos/$owner/$repo/releases/tags/$TAG"
|
|
|
|
|
else
|
|
|
|
|
ci_step "publishing to $GITHUB_SERVER_URL"
|
|
|
|
|
payload=$(printf '%s' "$body" | python3 -c '
|
|
|
|
|
import json, sys, os
|
|
|
|
|
print(json.dumps({
|
|
|
|
|
"tag_name": os.environ["TAG"],
|
|
|
|
|
"name": os.environ["TAG"],
|
|
|
|
|
"body": sys.stdin.read(),
|
|
|
|
|
"draft": False,
|
|
|
|
|
"prerelease": False,
|
|
|
|
|
}))')
|
|
|
|
|
|
|
|
|
|
# A re-run of a release must not fail on "already exists".
|
|
|
|
|
existing=$(curl -fsS -H "Authorization: token $GH_TOKEN" \
|
|
|
|
|
"$GITHUB_API_URL/repos/$owner/$repo/releases/tags/$TAG" 2>/dev/null || true)
|
|
|
|
|
id=$(printf '%s' "$existing" | sed -n -E 's/.*"id":[[:space:]]*([0-9]+).*/\1/p' | head -1)
|
|
|
|
|
|
|
|
|
|
if [ -z "$id" ]; then
|
|
|
|
|
created=$(api POST "$GITHUB_API_URL/repos/$owner/$repo/releases" "$payload")
|
|
|
|
|
id=$(printf '%s' "$created" | sed -n -E 's/.*"id":[[:space:]]*([0-9]+).*/\1/p' | head -1)
|
|
|
|
|
fi
|
|
|
|
|
[ -n "$id" ] || { echo "Could not create or find the release for $TAG." >&2; exit 1; }
|
|
|
|
|
echo " release id $id"
|
|
|
|
|
|
|
|
|
|
curl -fsS -X POST \
|
|
|
|
|
-H "Authorization: token $GH_TOKEN" \
|
|
|
|
|
-F "attachment=@$zip" \
|
|
|
|
|
"$GITHUB_API_URL/repos/$owner/$repo/releases/$id/assets?name=$PLUGIN_SLUG.zip" >/dev/null
|
|
|
|
|
echo " attached $PLUGIN_SLUG.zip"
|
|
|
|
|
|
|
|
|
|
release_api="$GITHUB_API_URL/repos/$owner/$repo/releases/latest"
|
|
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
ci_step "the feed, read the way a shop reads it"
|
2026-09-16 10:49:52 +02:00
|
|
|
|
|
|
|
|
# Only the forge named in "Update URI" actually serves shops. This script runs
|
|
|
|
|
# on both, and the other one is a mirror — requiring a mirror to be anonymously
|
|
|
|
|
# readable would fail every release for a repository that is private on purpose.
|
|
|
|
|
# (It did, on the first v1.4.0 run: GitHub is private and is not the feed.)
|
|
|
|
|
update_uri=$(sed -n -E 's/^[[:space:]]*\*[[:space:]]*Update URI:[[:space:]]*(.*)/\1/p' "$PLUGIN_MAIN" | head -1 | tr -d '[:space:]')
|
|
|
|
|
feed_host=$(printf '%s' "$update_uri" | sed -E 's|^[a-z]+://([^/]+).*|\1|')
|
|
|
|
|
this_host=$(printf '%s' "${GITHUB_SERVER_URL:-}" | sed -E 's|^[a-z]+://([^/]+).*|\1|')
|
|
|
|
|
|
|
|
|
|
if [ -z "$update_uri" ]; then
|
|
|
|
|
echo "The plugin header has no Update URI, so there is no feed to check."
|
|
|
|
|
exit 0
|
|
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
if [ "$feed_host" != "$this_host" ]; then
|
|
|
|
|
echo " $this_host is a mirror; shops read $feed_host. Nothing to verify here."
|
|
|
|
|
exit 0
|
|
|
|
|
fi
|
|
|
|
|
|
2026-09-16 10:34:38 +02:00
|
|
|
feed=$(curl -fsS --max-time 30 "$release_api" 2>/dev/null || true)
|
|
|
|
|
|
|
|
|
|
if [ -z "$feed" ]; then
|
|
|
|
|
echo
|
|
|
|
|
ci_fail "$release_api cannot be read without credentials, so no shop will ever be offered this update."
|
|
|
|
|
echo "Either the repository is still private, or the release was not created."
|
|
|
|
|
echo "For Gitea: Settings → make the repository public. The update feed is a public read by design;"
|
|
|
|
|
echo "that is the whole mechanism, and there is nothing secret in a GPL plugin zip anyway."
|
|
|
|
|
exit 1
|
|
|
|
|
fi
|
|
|
|
|
|
2026-09-16 10:49:52 +02:00
|
|
|
printf '%s' "$feed" | grep -q '"browser_download_url"' || {
|
2026-09-16 10:34:38 +02:00
|
|
|
echo
|
|
|
|
|
ci_fail "The release is readable, but it has no zip attached. WordPress would be offered an update it cannot download."
|
|
|
|
|
exit 1
|
2026-09-16 10:49:52 +02:00
|
|
|
}
|
2026-09-16 10:34:38 +02:00
|
|
|
|
2026-09-16 10:49:52 +02:00
|
|
|
# Checked at the address the PLUGIN builds, not the one the API reports.
|
|
|
|
|
#
|
|
|
|
|
# A forge fills browser_download_url from its own configured root URL, which is
|
|
|
|
|
# not necessarily the address the outside world reaches it on: gitea.nolimit.dk
|
|
|
|
|
# returns http://57.129.89.117:25001/... there, a plain-HTTP raw IP that
|
|
|
|
|
# bypasses the Cloudflare tunnel. Verifying that address would be verifying a
|
|
|
|
|
# URL no shop ever uses — a green check on the wrong thing, which is worse than
|
|
|
|
|
# no check.
|
|
|
|
|
package="$update_uri/releases/download/$TAG/$PLUGIN_SLUG.zip"
|
|
|
|
|
code=$(curl -s -o /tmp/wpci-feed.zip -w '%{http_code}' -L --max-time 120 "$package")
|
2026-09-16 10:34:38 +02:00
|
|
|
echo " $package -> HTTP $code"
|
2026-09-16 10:49:52 +02:00
|
|
|
[ "$code" = 200 ] || { ci_fail "The package URL the plugin will use is not anonymously downloadable."; exit 1; }
|
|
|
|
|
|
|
|
|
|
# And it has to be the plugin, not an error page with a 200 on it.
|
|
|
|
|
python3 -c '
|
|
|
|
|
import sys, zipfile
|
|
|
|
|
names = zipfile.ZipFile("/tmp/wpci-feed.zip").namelist()
|
|
|
|
|
main = sys.argv[1] + "/" + sys.argv[2]
|
|
|
|
|
if main not in names:
|
|
|
|
|
sys.exit("The downloaded zip does not contain " + main)
|
|
|
|
|
print(" it is the plugin: %d entries, %s present" % (len(names), main))
|
|
|
|
|
' "$PLUGIN_SLUG" "$PLUGIN_MAIN"
|
|
|
|
|
rm -f /tmp/wpci-feed.zip
|
2026-09-16 10:34:38 +02:00
|
|
|
|
|
|
|
|
echo
|
|
|
|
|
echo "Published $TAG. A shop running this plugin will be offered it within twelve hours,"
|
|
|
|
|
echo "or immediately on Dashboard → Updates → Check again."
|