Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/shell.yml
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,7 @@ jobs:
- path-helpers
- plugins-only-scope
- plugin-upgrade-bounds
- plugin-release-zips
- plugin-terminal-verification
- runtime-signature
- runtime-guidance-desired-state
Expand Down
2 changes: 1 addition & 1 deletion bridges/roadie.sh
Original file line number Diff line number Diff line change
Expand Up @@ -885,7 +885,7 @@ _roadie_require_identity_ability() {
warn " ${prefix}Roadie bridge step failed: $target does not provide the $ROADIE_IDENTITY_ABILITY ability."
fi
warn " Roadie's WordPress identity plugins need Data Machine >= v$ROADIE_IDENTITY_MIN_DATAMACHINE. Update it, then re-run the upgrade:"
warn " $(wp_cli_transport_display) plugin install https://github.com/Extra-Chill/data-machine/releases/latest/download/data-machine.zip --force --path=$target${WP_ROOT_FLAG:+ $WP_ROOT_FLAG}"
warn " Re-run upgrade.sh --plugins-only --wp-path=$target to install the latest stable Data Machine component release zip."
warn " Roadie plugin files and ROADIE_PLUGINS were left unchanged. Do not restart Roadie until this passes."
return 1
}
Expand Down
4 changes: 2 additions & 2 deletions lib/data-machine.sh
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

install_data_machine() {
log "Phase 4: Installing Data Machine..."
install_plugin data-machine https://github.com/Extra-Chill/data-machine.git
install_plugin_from_release_zip data-machine Extra-Chill/data-machine data-machine.zip data-machine || return $?

if [ "$MULTISITE" = true ]; then
log "Data Machine activated on main site. Activate on subsites with:"
Expand Down Expand Up @@ -49,7 +49,7 @@ upgrade_data_machine_plugins() {
if [ "${PLUGINS_ONLY:-false}" = true ] && [ ! -d "$SITE_PATH/wp-content/plugins/data-machine" ]; then
log "[data-machine] terminal=skipped reason=not-installed"
else
plugin_update_execute data-machine update_plugin_to_latest_tag data-machine https://github.com/Extra-Chill/data-machine.git || status=$PLUGIN_UPDATE_EXIT_PARTIAL
plugin_update_execute data-machine update_plugin_from_release_zip data-machine Extra-Chill/data-machine data-machine.zip data-machine || status=$PLUGIN_UPDATE_EXIT_PARTIAL
fi

return "$status"
Expand Down
124 changes: 124 additions & 0 deletions lib/wordpress.sh
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,130 @@ install_plugin_dependencies() {
fi
}

# Resolve component and legacy releases across every API page. Do not use
# /latest: sibling components in a monorepo share the release list.
plugin_release_zip_resolve() {
local repo="$1" asset="$2" prefix="$3" page=1 response releases='[]'
local token="${GH_TOKEN:-${GITHUB_TOKEN:-}}"
while :; do
if command -v gh >/dev/null 2>&1; then
response=$(gh api "repos/$repo/releases?per_page=100&page=$page") || return $?
else
local headers=(-H 'Accept: application/vnd.github+json')
[ -z "$token" ] || headers+=(-H "Authorization: Bearer $token")
response=$(curl --fail --silent --show-error --connect-timeout 10 --max-time 60 \
"${headers[@]}" "https://api.github.com/repos/$repo/releases?per_page=100&page=$page") || return $?
fi
jq -e 'type == "array"' >/dev/null <<<"$response" || return 1
[ "$(jq length <<<"$response")" -ne 0 ] || break
releases=$(jq -cn --argjson old "$releases" --argjson new "$response" '$old + $new') || return 1
page=$((page + 1))
done
# Numeric tuple ordering avoids publication-date and lexicographic ordering.
jq -er --arg prefix "$prefix" --arg asset "$asset" '
[.[] | select(.draft == false and .prerelease == false)
| .tag_name as $tag
| (if ($tag | startswith($prefix + "-v")) then $tag[($prefix | length) + 2:]
elif ($tag | startswith("v")) then $tag[1:] else "" end) as $version
| select($version | test("^[0-9]+\\.[0-9]+\\.[0-9]+$"))
| {tag: $tag, version: $version, key: ($version | split(".") | map(tonumber)),
url: ([.assets[] | select(.name == $asset) | .browser_download_url][0])}]
| sort_by(.key) | last
| select(.url != null and .url != "")
| [.version, .tag, .url] | @tsv' <<<"$releases"
}

# Reads must still execute in dry-run, while all writes use wp_cmd/run_cmd.
plugin_release_installed_version() {
local DRY_RUN=false
wp_cmd plugin get "$1" --field=version
}

# Release-zip installs this tool owns. A plugin directory that is neither a git
# checkout nor recorded here was put there by something else (a deploy
# pipeline, wp-admin, a host); upgrades must leave it alone rather than become
# a second writer of the same directory.
PLUGIN_RELEASE_ZIP_OPTION="wp_coding_agents_release_zip_plugins"

plugin_release_zip_owned() {
local DRY_RUN=false owned
owned=$(wp_cmd option get "$PLUGIN_RELEASE_ZIP_OPTION" 2>/dev/null) || owned=''
printf '%s\n' $owned | grep -qx -- "$1"
}

plugin_release_zip_record() {
local slug="$1" owned
plugin_release_zip_owned "$slug" && return 0
owned=$(DRY_RUN=false wp_cmd option get "$PLUGIN_RELEASE_ZIP_OPTION" 2>/dev/null) || owned=''
wp_cmd option update "$PLUGIN_RELEASE_ZIP_OPTION" "$(printf '%s %s' "$owned" "$slug" | xargs)" >/dev/null
}

install_plugin_from_release_zip() {
local slug="$1" repo="$2" asset="$3" prefix="$4" resolved tree
local plugin_dir="$SITE_PATH/wp-content/plugins/$slug"
if [ -e "$plugin_dir/.git" ]; then
tree=$(git -C "$plugin_dir" status --porcelain) || return $?
if [ -n "$tree" ]; then
warn "Plugin $slug has local changes — skipping release zip install"
return 0
fi
elif [ -d "$plugin_dir" ] && ! plugin_release_zip_owned "$slug"; then
log "Plugin $slug already installed and managed outside wp-coding-agents — leaving it alone"
activate_plugin "$slug"
return 0
fi
resolved=$(plugin_release_zip_resolve "$repo" "$asset" "$prefix") || return $?
local version tag url
IFS=$'\t' read -r version tag url <<<"$resolved"
wp_cmd plugin install "$url" --force || return $?
plugin_release_zip_record "$slug" || warn "Could not record $slug as a wp-coding-agents release-zip install"
activate_plugin "$slug"
fix_ownership "$plugin_dir"
}

update_plugin_from_release_zip() {
local slug="$1" repo="$2" asset="$3" prefix="$4"
local plugin_dir="$SITE_PATH/wp-content/plugins/$slug" migration=false
if [ -e "$plugin_dir/.git" ]; then
plugin_update_run_phase "$slug" working-tree-inspection git -C "$plugin_dir" status --porcelain || return $?
if [ -n "$PLUGIN_PHASE_OUTPUT" ]; then
warn "Plugin $slug has local changes — skipping release zip update"
return 0
fi
migration=true
elif [ -d "$plugin_dir" ] && ! plugin_release_zip_owned "$slug"; then
log "Plugin $slug is managed outside wp-coding-agents (not a git checkout or recorded release-zip install) — skipping"
return 0
fi
plugin_update_run_phase "$slug" release-discovery plugin_release_zip_resolve "$repo" "$asset" "$prefix" || return $?
local version tag url current='' newest
IFS=$'\t' read -r version tag url <<<"$PLUGIN_PHASE_OUTPUT"
if [ -d "$plugin_dir" ]; then
plugin_update_run_phase "$slug" installed-version plugin_release_installed_version "$slug" || return $?
current="$PLUGIN_PHASE_OUTPUT"
[[ "$current" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || { warn "Cannot compare $slug version: $current"; return 1; }
newest=$(printf '%s\n%s\n' "$current" "$version" | sort -V | tail -n 1)
if [ "$newest" = "$current" ] && { [ "$migration" = false ] || [ "$version" != "$current" ]; }; then
log "Plugin $slug already current ($current); no newer release"
return 0
fi
fi
if [ "$DRY_RUN" = true ]; then
log "[dry-run] Would install $slug $tag from $url --force (git migration=$migration)"
return 0
fi
# WP-CLI replaces the plugin directory (including .git), not its database
# records or activation option. Do not deactivate/uninstall during migration.
plugin_update_run_phase "$slug" release-install wp_cmd plugin install "$url" --force || return $?
plugin_update_run_phase "$slug" ownership-record plugin_release_zip_record "$slug" || return $?
UPDATED_ITEMS+=("$slug $tag")
PLUGIN_UPDATE_MUTATED=true
if [ -z "$current" ]; then
plugin_update_run_phase "$slug" activation activate_plugin "$slug" || return $?
fi
plugin_update_run_phase "$slug" ownership-normalization fix_ownership "$plugin_dir" || return $?
}

# Update a git-installed plugin to its latest version tag.
update_plugin_to_latest_tag() {
local slug="$1"
Expand Down
3 changes: 3 additions & 0 deletions tests/fixtures/plugin-updates/releases-page-2.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
[
{"tag_name":"data-machine-v0.201.0","draft":false,"prerelease":false,"assets":[{"name":"data-machine.zip","browser_download_url":"https://example.test/data-machine.zip"}]}
]
6 changes: 6 additions & 0 deletions tests/fixtures/plugin-updates/releases.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
[
{"tag_name":"data-machine-events-v9.0.0","draft":false,"prerelease":false,"assets":[{"name":"data-machine-events.zip","browser_download_url":"https://example.test/events.zip"}]},
{"tag_name":"data-machine-v0.203.0","draft":true,"prerelease":false,"assets":[]},
{"tag_name":"data-machine-v0.202.0","draft":false,"prerelease":true,"assets":[]},
{"tag_name":"v0.200.1","draft":false,"prerelease":false,"assets":[{"name":"data-machine.zip","browser_download_url":"https://example.test/legacy.zip"}]}
]
130 changes: 130 additions & 0 deletions tests/plugin-release-zips.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
#!/bin/bash
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
source "$ROOT/lib/wordpress.sh"
source "$ROOT/lib/plugin-upgrade.sh"
tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT
SITE_PATH="$tmp/site"
DRY_RUN=false
MULTISITE=false
LOCAL_MODE=true
WP_ROOT_FLAG=''
UPDATED_ITEMS=()
PLUGIN_UPDATE_MUTATED=false
mkdir -p "$tmp/bin"
export RELEASE_FIXTURES="$ROOT/tests/fixtures/plugin-updates" API_CALLS="$tmp/api"
# Stub the real API executable; exercise the actual bounded phase runner.
printf '%s\n' '#!/bin/bash' 'echo "$*" >> "$API_CALLS"' 'case "$*" in' '*page=1) /bin/cat "$RELEASE_FIXTURES/releases.json" ;;' '*page=2) if [ "${LEGACY_ONLY:-false}" = true ]; then echo "[]"; else /bin/cat "$RELEASE_FIXTURES/releases-page-2.json"; fi ;;' '*) echo "[]" ;;' 'esac' > "$tmp/bin/gh"
chmod +x "$tmp/bin/gh"
export PATH="$tmp/bin:$PATH"
log() { echo "$*"; }
warn() { echo "$*" >&2; }
wp_cmd() {
printf '%s\n' "$*" >> "$tmp/wp"
if [ "$1 $2" = 'plugin get' ]; then
echo "$installed"
elif [ "$1 $2" = 'option get' ]; then
[ -f "$tmp/owned" ] && cat "$tmp/owned" || return 1
elif [ "$1 $2" = 'option update' ]; then
[ "$DRY_RUN" = true ] || printf '%s\n' "$4" > "$tmp/owned"
elif [ "$1 $2" = 'plugin install' ]; then
[ "$DRY_RUN" = true ] || { rm -rf "$SITE_PATH/wp-content/plugins/data-machine"; mkdir -p "$SITE_PATH/wp-content/plugins/data-machine"; }
fi
}
update() { update_plugin_from_release_zip data-machine Extra-Chill/data-machine data-machine.zip data-machine; }
assert_contains() { grep -Fq -- "$2" "$1" || { echo "Missing: $2" >&2; exit 1; }; }

install_plugin_from_release_zip data-machine Extra-Chill/data-machine data-machine.zip data-machine
assert_contains "$tmp/wp" 'plugin install https://example.test/data-machine.zip --force'
assert_contains "$tmp/wp" 'plugin activate data-machine'
assert_contains "$tmp/api" 'page=3'
assert_contains "$tmp/owned" 'data-machine'
echo 'PASS fresh install, sibling filtering and pagination, ownership recorded'

: > "$tmp/wp"
installed=0.200.1
update
[ "$PLUGIN_UPDATE_MUTATED" = true ]
[ "${UPDATED_ITEMS[0]}" = 'data-machine data-machine-v0.201.0' ]
assert_contains "$tmp/wp" 'plugin install'
echo 'PASS zip upgrade'

: > "$tmp/wp"
PLUGIN_UPDATE_MUTATED=false
installed=0.201.0
update
! grep -q 'plugin install' "$tmp/wp"
[ "$PLUGIN_UPDATE_MUTATED" = false ]
echo 'PASS already current'

plugin_dir="$SITE_PATH/wp-content/plugins/data-machine"
git init -q "$plugin_dir"
git -C "$plugin_dir" -c user.name=Test -c user.email=test@example.test commit -qm initial --allow-empty
update
[ ! -e "$plugin_dir/.git" ]
[ "$PLUGIN_UPDATE_MUTATED" = true ]
echo 'PASS same-version git migration'

git init -q "$plugin_dir"
echo dirty > "$plugin_dir/local.txt"
: > "$tmp/wp"
update
[ -e "$plugin_dir/local.txt" ]
[ ! -s "$tmp/wp" ]
echo 'PASS dirty checkout skipped'
rm -rf "$plugin_dir/.git" "$plugin_dir/local.txt"

installed=0.200.1
DRY_RUN=true
: > "$tmp/wp"
update > "$tmp/dry"
assert_contains "$tmp/dry" '[dry-run] Would install'
assert_contains "$tmp/dry" 'https://example.test/data-machine.zip'
! grep -q 'plugin install' "$tmp/wp"
DRY_RUN=false

installed=0.999.0
: > "$tmp/wp"
update
! grep -q 'plugin install' "$tmp/wp"
echo 'PASS dry run and no downgrade'

# A directory placed by something else (deploy pipeline, wp-admin) is never
# touched: not on upgrade, not on setup.
rm -f "$tmp/owned"
installed=0.100.0
: > "$tmp/wp"
PLUGIN_UPDATE_MUTATED=false
update > "$tmp/foreign"
! grep -q 'plugin install' "$tmp/wp"
[ "$PLUGIN_UPDATE_MUTATED" = false ]
assert_contains "$tmp/foreign" 'managed outside wp-coding-agents'
: > "$tmp/wp"
install_plugin_from_release_zip data-machine Extra-Chill/data-machine data-machine.zip data-machine > "$tmp/foreign"
! grep -q 'plugin install' "$tmp/wp"
assert_contains "$tmp/foreign" 'managed outside wp-coding-agents'
echo 'PASS foreign-managed install left alone'

export LEGACY_ONLY=true
resolved=$(plugin_release_zip_resolve Extra-Chill/data-machine data-machine.zip data-machine)
[[ "$resolved" = *https://example.test/legacy.zip ]]
echo 'PASS legacy-only repository'

# Force the curl fallback without depending on whether gh is host-installed.
command() {
if [ "$*" = '-v gh' ]; then return 1; fi
builtin command "$@"
}
curl() {
printf '%s\n' "$*" >> "$tmp/curl"
case "${*: -1}" in
*page=1) /bin/cat "$RELEASE_FIXTURES/releases.json" ;;
*) echo '[]' ;;
esac
}
export GH_TOKEN=fixture-token
resolved=$(plugin_release_zip_resolve Extra-Chill/data-machine data-machine.zip data-machine)
[[ "$resolved" = *https://example.test/legacy.zip ]]
assert_contains "$tmp/curl" 'Authorization: Bearer fixture-token'
echo 'PASS authenticated curl fallback'
1 change: 1 addition & 0 deletions tests/plugins-only-scope.sh
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ bridge_update_launchd() { printf 'changed\n' >> "$LOCAL_SERVICE"; }
bridge_update_systemd() { printf 'changed\n' >> "$VPS_SERVICE"; }

update_plugin_to_latest_tag() { printf '%s\n' "$1" >> "$TMP/plugins"; }
update_plugin_from_release_zip() { printf '%s\n' "$1" >> "$TMP/plugins"; }
installation_profile_normalize "$INSTALLATION_OPERATION_PLUGINS_ONLY"
update_data_machine_plugins
reconcile_provider_and_service_state
Expand Down
2 changes: 1 addition & 1 deletion tests/roadie-identity-ability-gate.sh
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ run_bridge_step; rc=$?
! grep -q 'wordpress-platform.mjs' "$PLIST"; check $? "ROADIE_PLUGINS not switched"
grep -q 'datamachine/resolve-chat-person' "$TMP/out"; check $? "failure names the missing ability"
grep -q 'Data Machine >= v0.184.0' "$TMP/out"; check $? "failure names the required Data Machine version"
grep -q 'data-machine.zip' "$TMP/out"; check $? "failure says how to update Data Machine"
grep -q 'upgrade.sh --plugins-only' "$TMP/out"; check $? "failure says how to update Data Machine"
grep -q '^eval ' "$STUB_WP_LOG"; check $? "ability probed through WP-CLI"

echo "==> legacy people map: exact chat-identity link commands are printed"
Expand Down
10 changes: 6 additions & 4 deletions upgrade.sh
Original file line number Diff line number Diff line change
Expand Up @@ -360,9 +360,11 @@ NEVER TOUCHED:
- Running chat-bridge service (never restarted automatically)

DEFAULT TOUCHES:
- data-machine and wp-codebox — updates setup-installed sources. Non-git
plugin directories are
skipped. Use --skip-plugins to skip this phase.
- data-machine — installs stable GitHub release zips, migrating clean git
checkouts. Directories managed elsewhere (not a git checkout and not a
recorded release-zip install) are skipped. wp-codebox keeps its
existing source update path.
Use --skip-plugins to skip this phase.
- Carried provider plugins, Homeboy provider configuration, chat-bridge
configuration, and service templates are reconciled during a full upgrade
or explicitly with --reconcile-services.
Expand Down Expand Up @@ -699,7 +701,7 @@ update_data_machine_plugins() {
}

_reconcile_data_machine_plugin() {
plugin_update_execute data-machine update_plugin_to_latest_tag data-machine https://github.com/Extra-Chill/data-machine.git
plugin_update_execute data-machine update_plugin_from_release_zip data-machine Extra-Chill/data-machine data-machine.zip data-machine
}

_reconcile_wp_codebox_plugin() {
Expand Down
Loading