Prepare release engine

This commit is contained in:
Jan-Niclas Loosen
2026-11-04 14:46:54 +01:00
parent 8fe4eb63c8
commit b2759fba73
13 changed files with 159 additions and 390 deletions
+10 -13
View File
@@ -3,9 +3,9 @@
#
# bin/release-check.sh [version] default: Plugin::VERSION
#
# Fails unless releases/latest is that version, ZIP and .sig are public downloads below the repository's release
# URL (the only place GiteaUpdater accepts a package from), and the downloaded ZIP passes the signature check against
# the key built into the plugin.
# Fails unless releases/latest is that version, the ZIP is a public download below the repository's release URL (the
# only place GiteaUpdater accepts a package from), and the downloaded ZIP is the plugin in that version with its
# autoloader.
#
# Environment: LIT_MCP_GITEA (default https://git.loosen-it.de), only to try the script against another server.
set -euo pipefail
@@ -25,18 +25,15 @@ latest="$(curl -fsS "$gitea/api/v1/repos/$repo/releases/latest")" \
tag="$(jq -r .tag_name <<<"$latest")"
[[ "$tag" == "v$version" ]] || fail "releases/latest liefert '$tag', erwartet v$version"
asset_url() { jq -r --arg n "$1" '.assets[] | select(.name == $n) | .browser_download_url' <<<"$latest"; }
zip_url="$(asset_url "$name")"
sig_url="$(asset_url "$name.sig")"
for url in "$zip_url" "$sig_url"; do
[[ "$url" == "$gitea/$repo/releases/download/"* ]] || fail "Asset-URL fehlt oder liegt woanders: '$url'"
done
zip_url="$(jq -r --arg n "$name" '.assets[] | select(.name == $n) | .browser_download_url' <<<"$latest")"
[[ "$zip_url" == "$gitea/$repo/releases/download/"* ]] || fail "Asset-URL fehlt oder liegt woanders: '$zip_url'"
work="$(mktemp -d)"
trap 'rm -rf "$work"' EXIT
curl -fsSL "$zip_url" -o "$work/$name" || fail "ZIP nicht ohne Anmeldung ladbar: $zip_url"
curl -fsSL "$sig_url" -o "$work/$name.sig" || fail "Signatur nicht ohne Anmeldung ladbar: $sig_url"
php bin/release-sign.php verify "$work/$name" "$work/$name.sig" >/dev/null \
|| fail "Das heruntergeladene Release besteht die Signaturprüfung nicht"
entries="$(bsdtar -tf "$work/$name")" || fail "Das heruntergeladene ZIP ist kein gültiges ZIP"
grep -qx 'wp-lit-mcp/vendor/autoload_packages.php' <<<"$entries" || fail "Im ZIP fehlt der Autoloader (vendor/)"
[[ "$(bsdtar -xOf "$work/$name" wp-lit-mcp/wp-lit-mcp.php | sed -n 's/^ \* Version: //p')" == "$version" ]] \
|| fail "Das ZIP enthält nicht Version $version"
echo "Release v$version in Ordnung: öffentlich ladbar, Signatur gültig"
echo "Release v$version in Ordnung: öffentlich ladbar, Plugin und Autoloader im ZIP"
+69
View File
@@ -0,0 +1,69 @@
#!/usr/bin/env bash
# Publishes a release built by bin/release.sh: draft -> ZIP attached and checked -> published -> checked from outside
# (bin/release-check.sh).
#
# bin/release-publish.sh [version] default: Plugin::VERSION
#
# The release only turns public once the ZIP hangs on it, so no customer site ever sees a release without its
# package. An existing draft for the tag (created by hand or by an earlier run) is reused; a published one is refused.
# An existing tag fixes the commit (a release recreated after deleting it), a new tag points at HEAD.
#
# Needs: curl, jq, bsdtar and a Gitea token with write access to the repository (see bin/token.sh).
# Environment: LIT_MCP_GITEA (default https://git.loosen-it.de), only to try the script against another server.
set -euo pipefail
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$root"
gitea="${LIT_MCP_GITEA:-https://git.loosen-it.de}"
api="$gitea/api/v1/repos/janniclas.loosen/WP-LIT-MCP"
version="${1:-$(sed -n "s/.*const VERSION = '\(.*\)';/\1/p" src/Plugin.php)}"
name="wp-lit-mcp-$version.zip"
zip="dist/$name"
fail() { echo "ABBRUCH: $*" >&2; exit 1; }
step() { echo "==> $*"; }
source "$root/bin/token.sh"
load_token
[[ -r "$zip" ]] || fail "dist/ hat nicht $name: erst bin/release.sh"
# A stale file in dist/ is the cheapest way to ship the wrong release.
step "ZIP prüfen"
[[ "$(bsdtar -xOf "$zip" wp-lit-mcp/wp-lit-mcp.php | sed -n 's/^ \* Version: //p')" == "$version" ]] \
|| fail "Das ZIP enthält nicht Version $version"
auth=(-H "Authorization: token $GITEA_TOKEN")
json=(-H 'Content-Type: application/json')
api_get() { curl -fsS "${auth[@]}" "$api/$1"; }
step "Entwurf v$version suchen oder anlegen"
release="$(api_get 'releases?per_page=50' | jq -c --arg t "v$version" '[.[] | select(.tag_name == $t)][0] // empty')"
if [[ -z "$release" ]]; then
notes="$(awk -v header="= $version =" '$0 == header { on = 1; next } /^= / { on = 0 } on' readme.txt)"
target="$(curl -sS "${auth[@]}" "$api/tags/v$version" | jq -r '.commit.sha // empty')"
[[ -n "$target" ]] || target="$(git rev-parse HEAD)"
request="$(jq -n --arg tag "v$version" --arg sha "$target" --arg body "$notes" \
'{tag_name: $tag, target_commitish: $sha, name: $tag, body: $body, draft: true}')"
release="$(curl -fsS "${auth[@]}" "${json[@]}" -d "$request" "$api/releases")" || fail "Release ließ sich nicht anlegen"
fi
[[ "$(jq -r .draft <<<"$release")" == "true" ]] || fail "v$version ist schon veröffentlicht"
id="$(jq -r .id <<<"$release")"
step "ZIP anhängen"
old="$(jq -r --arg n "$name" '.assets[]? | select(.name == $n) | .id' <<<"$release")"
[[ -z "$old" ]] || curl -fsS -X DELETE "${auth[@]}" "$api/releases/$id/assets/$old" >/dev/null
curl -fsS "${auth[@]}" -F "attachment=@$zip" "$api/releases/$id/assets?name=$name" >/dev/null \
|| fail "Upload von $name fehlgeschlagen"
# Read it back instead of trusting the upload: only a draft that really carries the ZIP goes public.
step "Anhang nachprüfen"
size="$(api_get "releases/$id" | jq -r --arg n "$name" '.assets[]? | select(.name == $n) | .size')"
[[ "$size" == "$(stat -c %s "$zip")" ]] || fail "$name hängt nicht oder mit falscher Größe am Entwurf (Gitea: '$size')"
step "Veröffentlichen"
curl -fsS -X PATCH "${auth[@]}" "${json[@]}" -d '{"draft": false}' "$api/releases/$id" >/dev/null \
|| fail "Veröffentlichen fehlgeschlagen"
step "Ergebnis von außen prüfen"
exec bash bin/release-check.sh "$version"
-64
View File
@@ -1,64 +0,0 @@
<?php
/**
* Release signing for WP-LIT-MCP (called by bin/release.sh, usable by hand).
*
* php bin/release-sign.php keygen <key-file> new key pair; prints the public key for PackageSignature
* php bin/release-sign.php sign <zip> <key-file> prints the signature (the .sig asset)
* php bin/release-sign.php verify <zip> <sig-file> checks against the key built into the plugin
*/
declare(strict_types=1);
// The class guards against direct web access; this is the command line.
define('ABSPATH', __DIR__ . '/');
require_once __DIR__ . '/../src/Updater/PackageSignature.php';
use LIT\MCP\Updater\PackageSignature;
function fail(string $message): never
{
fwrite(STDERR, $message . "\n");
exit(1);
}
[, $command, $first, $second] = $argv + [null, '', '', ''];
switch ($command) {
case 'keygen':
if ($first === '' || file_exists($first)) {
fail('usage: keygen <key-file> (an existing file is never overwritten)');
}
$pair = sodium_crypto_sign_keypair();
if (! is_dir(dirname($first)) && ! mkdir(dirname($first), 0700, true)) {
fail('cannot create ' . dirname($first));
}
// 0600 before the secret is written, so it is never readable by others.
touch($first);
chmod($first, 0600);
file_put_contents($first, base64_encode(sodium_crypto_sign_secretkey($pair)));
echo base64_encode(sodium_crypto_sign_publickey($pair)), "\n";
break;
case 'sign':
$secret = is_readable($second) ? base64_decode(trim((string) file_get_contents($second)), true) : false;
if (! is_readable($first) || $secret === false || strlen($secret) !== SODIUM_CRYPTO_SIGN_SECRETKEYBYTES) {
fail('usage: sign <zip> <key-file> (readable ZIP, key file from keygen)');
}
$message = PackageSignature::message(basename($first), $first);
echo base64_encode(sodium_crypto_sign_detached($message, $secret)), "\n";
break;
case 'verify':
if (! is_readable($first) || ! is_readable($second)) {
fail('usage: verify <zip> <sig-file>');
}
if (! (new PackageSignature())->verifies(basename($first), $first, (string) file_get_contents($second))) {
fail('signature does not verify against the key built into the plugin');
}
echo "ok\n";
break;
default:
fail('usage: keygen <key-file> | sign <zip> <key-file> | verify <zip> <sig-file>');
}
+9 -20
View File
@@ -1,20 +1,17 @@
#!/usr/bin/env bash
# Builds and signs a WP-LIT-MCP release from the committed state. It publishes nothing: tag, Gitea release and the
# upload of the two files in dist/ are done afterwards, then bin/release-check.sh looks at the result.
# Builds a WP-LIT-MCP release ZIP from the committed state into dist/. It publishes nothing: bin/ship.sh does the
# whole way (this script, push, release, upload, check from outside).
#
# bin/release.sh
#
# Needs: bsdtar, composer, php (sodium). The signing key (php bin/release-sign.php keygen) lives outside the
# repository.
#
# Environment: LIT_MCP_RELEASE_KEY (default ~/.config/wp-lit-mcp/release.key), LIT_MCP_DIVI_CHECK (command that must
# succeed before a release, default: ddev wp --user=janniclas lit-mcp divi-check).
# Needs: bsdtar, composer.
# Environment: LIT_MCP_DIVI_CHECK (command that must succeed before a release, default:
# ddev wp --user=janniclas lit-mcp divi-check).
set -euo pipefail
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$root"
key="${LIT_MCP_RELEASE_KEY:-$HOME/.config/wp-lit-mcp/release.key}"
divi_check="${LIT_MCP_DIVI_CHECK:-ddev wp --user=janniclas lit-mcp divi-check}"
fail() { echo "ABBRUCH: $*" >&2; exit 1; }
@@ -29,7 +26,6 @@ version="$(sed -n "s/.*const VERSION = '\(.*\)';/\1/p" src/Plugin.php)"
grep -q "^= $version =\$" readme.txt || fail "readme.txt hat keinen Changelog-Eintrag für $version"
[[ -z "$(git status --porcelain)" ]] || fail "Arbeitsbaum nicht sauber: erst committen, das ZIP entsteht aus HEAD"
! git rev-parse -q --verify "refs/tags/v$version" >/dev/null || fail "Tag v$version existiert schon"
[[ -r "$key" ]] || fail "Signierschlüssel fehlt: $key (php bin/release-sign.php keygen <Datei>)"
# --- 2. Tests and the Divi check must be green ------------------------------------------------------------------------
step "Tests"
@@ -46,8 +42,9 @@ git archive HEAD | tar -x -C "$work/wp-lit-mcp"
(cd "$work/wp-lit-mcp" && composer install --no-dev --optimize-autoloader --no-interaction --quiet)
name="wp-lit-mcp-$version.zip"
mkdir -p dist
# Files of earlier releases would be one wrong click away from the upload.
rm -f dist/wp-lit-mcp-*
zip="$root/dist/$name"
rm -f "$zip" "$zip.sig"
(cd "$work" && bsdtar --format zip -cf "$zip" wp-lit-mcp)
# --- 4. Look into the ZIP like WordPress will: one folder, the plugin, its autoloader, the right version --------------
@@ -57,18 +54,10 @@ if grep -qv '^wp-lit-mcp/' <<<"$entries"; then fail "ZIP enthält Einträge auß
for need in wp-lit-mcp/wp-lit-mcp.php wp-lit-mcp/vendor/autoload_packages.php; do
grep -qx "$need" <<<"$entries" || fail "Im ZIP fehlt $need"
done
if grep -qE '^wp-lit-mcp/(tests|bin|docs/superpowers|\.git)(/|$)' <<<"$entries"; then
if grep -qE '^wp-lit-mcp/(tests|bin|docs/superpowers|\.git|\.gitea-token)(/|$)' <<<"$entries"; then
fail "Entwicklungsdateien im ZIP"
fi
[[ "$(bsdtar -xOf "$zip" wp-lit-mcp/wp-lit-mcp.php | sed -n 's/^ \* Version: //p')" == "$version" ]] \
|| fail "Version im ZIP stimmt nicht mit $version überein"
# --- 5. Sign, and prove the signature against the key built into the plugin -------------------------------------------
step "Signieren"
php bin/release-sign.php sign "$zip" "$key" > "$zip.sig"
php bin/release-sign.php verify "$zip" "$zip.sig" >/dev/null \
|| fail "Signatur passt nicht zum Schlüssel im Plugin (PackageSignature::RELEASE_KEY)"
echo "In Ordnung: dist/$name und dist/$name.sig"
echo "Weiter: Tag v$version und Release auf Gitea anlegen, beide Dateien hochladen, veröffentlichen."
echo "Danach: bin/release-check.sh $version"
echo "In Ordnung: dist/$name"
Executable
+38
View File
@@ -0,0 +1,38 @@
#!/usr/bin/env bash
# One command for a release: build (tests, Divi check, ZIP), push main, publish, check from outside.
#
# bin/ship.sh
#
# Run it when Plugin::VERSION is bumped and committed (release.sh refuses a dirty tree or an existing tag). The token
# comes from $GITEA_TOKEN or a file (see bin/token.sh).
#
# Environment: LIT_MCP_GITEA (default https://git.loosen-it.de), only to try the script against another server.
set -euo pipefail
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$root"
gitea="${LIT_MCP_GITEA:-https://git.loosen-it.de}"
repo="janniclas.loosen/WP-LIT-MCP"
fail() { echo "ABBRUCH: $*" >&2; exit 1; }
step() { echo "==> $*"; }
source "$root/bin/token.sh"
load_token
export GITEA_TOKEN
[[ "$(git rev-parse --abbrev-ref HEAD)" == "main" ]] || fail "Releases gehen nur von main aus"
bash bin/release.sh
# Push only if Gitea does not have HEAD yet. The token travels in a header of this one call, via the environment: not
# in the URL, not in the git configuration, not in the process list.
remote_head="$(curl -fsS "$gitea/api/v1/repos/$repo/branches/main" | jq -r .commit.id)"
if [[ "$remote_head" != "$(git rev-parse HEAD)" ]]; then
step "main pushen"
basic="$(printf 'janniclas.loosen:%s' "$GITEA_TOKEN" | base64 -w0)"
GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=http.extraHeader GIT_CONFIG_VALUE_0="Authorization: Basic $basic" \
git push "$gitea/$repo.git" HEAD:main || fail "Push fehlgeschlagen"
fi
bash bin/release-publish.sh
+17
View File
@@ -0,0 +1,17 @@
# Loads the Gitea token into GITEA_TOKEN: the environment first, then .gitea-token in the repository (gitignored),
# then ~/.config/wp-lit-mcp/gitea-token. Sourced by bin/ship.sh and bin/release-publish.sh, which define $root and fail().
load_token() {
local file
for file in "$root/.gitea-token" "$HOME/.config/wp-lit-mcp/gitea-token"; do
[[ -z "${GITEA_TOKEN:-}" && -r "$file" ]] || continue
# A token file the repository would commit, or that others can read, is refused before it is used.
if [[ "$file" == "$root/.gitea-token" ]]; then
git -C "$root" check-ignore -q .gitea-token || fail ".gitea-token steht nicht in .gitignore"
fi
[[ "$(stat -c %a "$file")" =~ ^[0-7]00$ ]] || fail "$file ist für andere lesbar: chmod 600"
GITEA_TOKEN="$(<"$file")"
done
[[ -n "${GITEA_TOKEN:-}" ]] \
|| fail "Kein Token: GITEA_TOKEN setzen oder .gitea-token im Repo (alternativ ~/.config/wp-lit-mcp/gitea-token) anlegen"
}