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
46 changes: 46 additions & 0 deletions .github/workflows/update-consumers.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# cspell:ignore shivammathur
# Opens a PR in each consumer plugin bumping stellarwp/harbor to a released
# version. Run it by hand once a release is out and checked.
name: Update Consumers

on:
workflow_dispatch:
inputs:
version:
description: "Harbor version (e.g. 1.7.0). Defaults to the newest stable tag."
required: false
type: string
dry_run:
description: "Show the changes without pushing or opening PRs"
required: false
type: boolean
default: false

jobs:
update-consumers:
name: Open PRs in consumer plugins
runs-on: blacksmith-4vcpu-ubuntu-2404
steps:
- uses: actions/checkout@v4

- uses: shivammathur/setup-php@v2
with:
php-version: "7.4"
coverage: none

# GH_BOT_TOKEN needs push and pull request access to every repo in
# CONSUMERS (dev_scripts/update-consumers.sh), plus read access to their
# private composer dependencies.
- name: Open PRs
env:
GH_TOKEN: ${{ secrets.GH_BOT_TOKEN }}
VERSION: ${{ inputs.version }}
DRY_RUN: ${{ inputs.dry_run }}
run: |
gh auth setup-git
git config --global user.name "github-actions[bot]"
git config --global user.email "github-actions[bot]@users.noreply.github.com"
args=(-y)
[ -z "$VERSION" ] || args+=("$VERSION")
[ "$DRY_RUN" != "true" ] || args+=(--dry-run)
dev_scripts/update-consumers.sh "${args[@]}"
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,7 @@ bunx @stellarwp/changelogger write --overwrite-version <version>
1. Run the **Release Prep** workflow (`Actions → Release Prep → Run workflow`). Supply the target branch, version (e.g. `1.2.0`), and the release date (e.g. `2026-04-29`). The workflow bumps the `VERSION` constant, compiles the changelog, and opens a PR automatically.
2. Review and merge the PR.
3. Create a GitHub Release with a new tag in the format `vX.X.X` targeting the merge commit.
4. Optionally, once the release is checked, run the **Update Consumers** workflow (`Actions → Update Consumers → Run workflow`) instead of bumping each plugin by hand. It opens a PR in every plugin in [Plugins with Harbor](#plugins-with-harbor) that bumps `stellarwp/harbor` to the new version (The Events Calendar and Event Tickets get theirs through tribe-common). To run it locally, or to target specific repos, run `composer release:update-consumers -- [version] [owner/repo ...]`. Add `--dry-run` to see the change without pushing anything. The consumer list lives in `dev_scripts/update-consumers.sh`.

## Documentation

Expand Down
9 changes: 7 additions & 2 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,11 @@
"phpcs:fix": "phpcbf",
"phpcs:ci": "dev_scripts/partial-phpcs.sh",
"release:prep": "dev_scripts/release-prep.sh",
"release:set-version": "dev_scripts/set-numeric-version.sh"
"release:set-version": "dev_scripts/set-numeric-version.sh",
"release:update-consumers": [
"Composer\\Config::disableProcessTimeout",
"dev_scripts/update-consumers.sh"
]
},
"scripts-descriptions": {
"test:analysis": "Run static code analysis.",
Expand All @@ -103,6 +107,7 @@
"compatibility:php-8.4": "Run PHPCompatibilityWP for PHP 8.4.",
"compatibility": "Run PHPCompatibilityWP for all supported PHP versions.",
"release:prep": "Prepare a release: set version, replace TBD tags, and write changelog. Usage: composer release:prep -- <version> [date]",
"release:set-version": "Replace TBD tags and update Harbor::VERSION. Usage: composer release:set-version -- <version>"
"release:set-version": "Replace TBD tags and update Harbor::VERSION. Usage: composer release:set-version -- <version>",
"release:update-consumers": "Open PRs bumping Harbor in consumer plugins. Usage: composer release:update-consumers -- [version] [-y] [--dry-run] [owner/repo ...]"
}
}
155 changes: 155 additions & 0 deletions dev_scripts/update-consumers.sh
Comment thread
jonwaldstein marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
#!/usr/bin/env bash
#
# Opens a PR on each consumer plugin bumping its stellarwp/harbor requirement
# to a released version. Run it from the Update Consumers workflow
# (.github/workflows/update-consumers.yml) or locally.
#
# Usage:
# composer release:update-consumers -- [version] [-y] [--dry-run] [owner/repo ...]
# dev_scripts/update-consumers.sh [version] [-y] [--dry-run] [owner/repo ...]
# version Release version, with or without a leading "v". Defaults to
# the newest tag on origin.
# -y, --yes Skip the confirmation prompt (required when there is no TTY).
# --dry-run Clone and run composer, then show the diff instead of
# pushing it or opening a PR. Needs no confirmation.
# owner/repo Consumer repos to update. Defaults to CONSUMERS below.
#
# Each repo gets a chore/bump-harbor-<version> branch cut from its default
# branch with composer.json and composer.lock updated, and a PR against the
# default branch. Re-running refreshes the branch and keeps the open PR.
#
# Requirements: gh (authenticated with push access to every consumer repo),
# composer, git.

set -euo pipefail

# The Events Calendar and Event Tickets consume Harbor through tribe-common
# (their `common` submodule), so the PR goes there. Keep this in sync with
# the "Plugins with Harbor" table in README.md.
CONSUMERS=(
impress-org/givewp
stellarwp/kadence-blocks
stellarwp/kadence-pro
stellarwp/kadence-shop-kit
stellarwp/learndash-core
stellarwp/memberdash
stellarwp/restrict-content-pro
the-events-calendar/tribe-common
)

PKG="stellarwp/harbor"

VERSION=""
ASSUME_YES=""
DRY_RUN=""
REPOS=()
for arg in "$@"; do
case "$arg" in
-y|--yes) ASSUME_YES=1 ;;
--dry-run) DRY_RUN=1 ;;
-*) echo "ERROR: unknown flag '$arg'"; exit 1 ;;
*/*) REPOS+=("$arg") ;;
*)
[ -z "$VERSION" ] || { echo "ERROR: unexpected argument '$arg'"; exit 1; }
VERSION="$arg"
;;
esac
done
[ ${#REPOS[@]} -gt 0 ] || REPOS=("${CONSUMERS[@]}")

command -v gh >/dev/null || { echo "ERROR: gh CLI is required."; exit 1; }
command -v composer >/dev/null || { echo "ERROR: composer is required."; exit 1; }

if [ -z "$VERSION" ]; then
VERSION="$(git ls-remote --tags --refs origin 'v*' | sed 's|.*refs/tags/||' | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | sort -V | tail -1)"
fi
VERSION="${VERSION#v}"
if ! [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "ERROR: '$VERSION' is not an x.y.z version."
exit 1
fi

BRANCH="chore/bump-harbor-${VERSION}"

echo "Bump ${PKG} to ^${VERSION} on branch ${BRANCH} in:"
printf ' • %s\n' "${REPOS[@]}"

if [ -z "$ASSUME_YES" ] && [ -z "$DRY_RUN" ]; then
[ -t 0 ] || { echo "ERROR: no TTY for confirmation. Pass -y to proceed."; exit 1; }
read -rp "Proceed? [y/N] " REPLY
[[ "$REPLY" == [Yy]* ]] || { echo "Aborted."; exit 0; }
fi

# Lets composer read private VCS repositories (e.g. Kadence's prophecy-*
# packages) with the same auth gh uses.
COMPOSER_AUTH="{\"github-oauth\":{\"github.com\":\"$(gh auth token)\"}}"
export COMPOSER_AUTH

TMP="$(mktemp -d)"
trap 'rm -rf "$TMP"' EXIT

BODY="Updates \`${PKG}\` to [v${VERSION}](https://github.com/stellarwp/harbor/releases/tag/v${VERSION}).

Only \`composer.json\` and \`composer.lock\` change. Run \`composer install\` to pull in the new version.

Opened by Harbor's update-consumers script."

update_repo() {
local repo="$1" dir="$TMP/${1//\//-}"

gh repo clone "$repo" "$dir" -- --depth 1 --quiet
cd "$dir"
git checkout --quiet -B "$BRANCH"

# -w lets Harbor's own dependencies move with it. The runner's PHP may not
# match the plugin's, so missing extensions and PHP upper bounds (old dev
# tools like phpunit 7) are ignored. PHP minimums are still checked, against
# config.platform.php where the plugin sets it.
composer require "${PKG}:^${VERSION}" -w \
--no-install --no-scripts --no-audit --no-interaction \
--ignore-platform-req='ext-*' --ignore-platform-req='php+'

git add composer.json composer.lock
if git diff --cached --quiet; then
echo "${repo}: already on ${VERSION}, nothing to do."
return 0
fi

if [ -n "$DRY_RUN" ]; then
git --no-pager diff --cached -- composer.json composer.lock
echo "${repo}: dry run, not pushing or opening a PR."
return 0
fi

git commit --quiet -m "chore: bump ${PKG} to ${VERSION}"
git push --quiet --force -u origin "$BRANCH"
Comment thread
coderabbitai[bot] marked this conversation as resolved.

local pr
pr="$(gh pr list -R "$repo" --head "$BRANCH" --state open --json url -q '.[0].url')"
if [ -n "$pr" ]; then
echo "${repo}: refreshed ${pr}"
else
gh pr create -R "$repo" --head "$BRANCH" \
--title "Bump ${PKG} to ${VERSION}" --body "$BODY"
fi
}

FAILED=()
for repo in "${REPOS[@]}"; do
echo ""
echo "== ${repo}"
# Child shell so one failing repo does not stop the rest. Not `( … ) || …`:
# that form silently disables errexit inside the child shell.
set +e
( set -e; update_repo "$repo" )
status=$?
set -e
[ $status -eq 0 ] || FAILED+=("$repo")
done

if [ ${#FAILED[@]} -gt 0 ]; then
echo ""
echo "Failed:"
printf ' • %s\n' "${FAILED[@]}"
exit 1
fi
161 changes: 161 additions & 0 deletions tests/shell/update-consumers.bats
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
#!/usr/bin/env bats
# cspell:ignore NOSYSTEM

bats_require_minimum_version 1.5.0

SCRIPT="$BATS_TEST_DIRNAME/../../dev_scripts/update-consumers.sh"
BRANCH="chore/bump-harbor-1.7.0"

# Creates a bare repo standing in for a consumer on GitHub, with composer.json
# and composer.lock requiring the given Harbor constraint.
make_remote() {
local name="${1//\//-}" constraint="$2" work="$TEST_DIR/work/${1//\//-}"
rm -rf "$work" "$REMOTES/$name.git"
git init --quiet -b main "$work"
printf '{"require":{"stellarwp/harbor":"%s"}}\n' "$constraint" > "$work/composer.json"
printf 'locked %s\n' "${constraint#^}" > "$work/composer.lock"
git -C "$work" add .
git -C "$work" commit --quiet -m init
git clone --quiet --bare "$work" "$REMOTES/$name.git"
}

setup() {
TEST_DIR="$(mktemp -d)"
REMOTES="$TEST_DIR/remotes"
export REMOTES
export STUB_LOG="$TEST_DIR/stub.log"
touch "$STUB_LOG"

# Keep the developer's git config (signing, hooks) out of the test.
export GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_NOSYSTEM=1
export GIT_AUTHOR_NAME=test GIT_AUTHOR_EMAIL=test@example.com
export GIT_COMMITTER_NAME=test GIT_COMMITTER_EMAIL=test@example.com

mkdir -p "$TEST_DIR/bin" "$REMOTES"

# gh: clones come from the local bare repos, PR calls are logged.
cat > "$TEST_DIR/bin/gh" <<'SH'
#!/usr/bin/env bash
echo "gh $*" >> "$STUB_LOG"
case "$1 $2" in
"auth token") echo fake-token ;;
"repo clone") git clone --quiet "$REMOTES/${3//\//-}.git" "$4" ;;
"pr list") echo "${GH_OPEN_PR:-}" ;;
"pr create") echo "https://github.com/$4/pull/1" ;;
esac
SH

# composer: writes the requested constraint the way `composer require` would.
cat > "$TEST_DIR/bin/composer" <<'SH'
#!/usr/bin/env bash
echo "composer $*" >> "$STUB_LOG"
[ "$(basename "$PWD")" != "${COMPOSER_FAIL_REPO:-}" ] || { echo "composer failed" >&2; exit 1; }
constraint="${2#*:}"
printf '{"require":{"stellarwp/harbor":"%s"}}\n' "$constraint" > composer.json
printf 'locked %s\n' "${constraint#^}" > composer.lock
SH
chmod +x "$TEST_DIR/bin/gh" "$TEST_DIR/bin/composer"
export PATH="$TEST_DIR/bin:$PATH"

CONSUMERS=(
impress-org/givewp stellarwp/kadence-blocks stellarwp/kadence-pro stellarwp/kadence-shop-kit
stellarwp/learndash-core stellarwp/memberdash stellarwp/restrict-content-pro the-events-calendar/tribe-common
)
for repo in "${CONSUMERS[@]}"; do
make_remote "$repo" "^1.6"
done

# A Harbor checkout whose origin has tags, for the default version lookup.
git init --quiet -b main "$TEST_DIR/harbor-work"
git -C "$TEST_DIR/harbor-work" commit --quiet --allow-empty -m init
for tag in v1.6.1 v1.7.0 v2.0.0-rc.1; do
git -C "$TEST_DIR/harbor-work" tag "$tag"
done
git clone --quiet --bare "$TEST_DIR/harbor-work" "$TEST_DIR/harbor.git"
git clone --quiet "$TEST_DIR/harbor.git" "$TEST_DIR/harbor"
cd "$TEST_DIR/harbor"
}

teardown() {
rm -rf "$TEST_DIR"
}

remote_file() {
git -C "$REMOTES/${1//\//-}.git" show "$BRANCH:$2"
}

@test "rejects a version that is not x.y.z" {
run "$SCRIPT" 1.7 -y impress-org/givewp
[ "$status" -eq 1 ]
[[ "$output" == *"ERROR: '1.7' is not an x.y.z version."* ]]
}

@test "requires -y when there is no TTY" {
run "$SCRIPT" 1.7.0 impress-org/givewp < /dev/null
[ "$status" -eq 1 ]
[[ "$output" == *"Pass -y to proceed."* ]]
}

@test "defaults to the newest stable tag on origin" {
run "$SCRIPT" --dry-run impress-org/givewp
[ "$status" -eq 0 ]
[[ "$output" == *"Bump stellarwp/harbor to ^1.7.0"* ]]
}

@test "defaults to every consumer repo" {
run "$SCRIPT" 1.7.0 --dry-run
[ "$status" -eq 0 ]
for repo in "${CONSUMERS[@]}"; do
[[ "$output" == *"== $repo"* ]]
done
}

@test "pushes the bump and opens a PR" {
run "$SCRIPT" v1.7.0 -y impress-org/givewp
[ "$status" -eq 0 ]
[[ "$(remote_file impress-org/givewp composer.json)" == *'"^1.7.0"'* ]]
[[ "$(remote_file impress-org/givewp composer.lock)" == "locked 1.7.0" ]]
grep -q "gh pr create -R impress-org/givewp --head $BRANCH" "$STUB_LOG"
}

@test "refreshes the branch and keeps an open PR" {
export GH_OPEN_PR="https://github.com/impress-org/givewp/pull/9"
run "$SCRIPT" 1.7.0 -y impress-org/givewp
[ "$status" -eq 0 ]
[[ "$output" == *"refreshed https://github.com/impress-org/givewp/pull/9"* ]]
[[ "$(remote_file impress-org/givewp composer.json)" == *'"^1.7.0"'* ]]
run ! grep -q "gh pr create" "$STUB_LOG"
}

@test "does nothing when the repo is already on the version" {
make_remote impress-org/givewp "^1.7.0"
run "$SCRIPT" 1.7.0 -y impress-org/givewp
[ "$status" -eq 0 ]
[[ "$output" == *"already on 1.7.0, nothing to do."* ]]
run ! git -C "$REMOTES/impress-org-givewp.git" rev-parse --verify --quiet "$BRANCH"
run ! grep -q "gh pr" "$STUB_LOG"
}

@test "dry run shows the diff without committing, pushing or opening a PR" {
# A failing pre-commit hook proves the dry run never commits.
mkdir -p "$TEST_DIR/hooks"
printf '#!/bin/sh\nexit 1\n' > "$TEST_DIR/hooks/pre-commit"
chmod +x "$TEST_DIR/hooks/pre-commit"
export GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=core.hooksPath GIT_CONFIG_VALUE_0="$TEST_DIR/hooks"

run "$SCRIPT" 1.7.0 --dry-run impress-org/givewp
[ "$status" -eq 0 ]
[[ "$output" == *'+{"require":{"stellarwp/harbor":"^1.7.0"}}'* ]]
[[ "$output" == *"impress-org/givewp: dry run"* ]]
run ! git -C "$REMOTES/impress-org-givewp.git" rev-parse --verify --quiet "$BRANCH"
run ! grep -q "gh pr" "$STUB_LOG"
}

@test "keeps going when one repo fails and exits non-zero" {
export COMPOSER_FAIL_REPO=impress-org-givewp
run "$SCRIPT" 1.7.0 -y impress-org/givewp stellarwp/kadence-blocks
[ "$status" -eq 1 ]
[[ "$output" == *"Failed:"*"impress-org/givewp"* ]]
[[ "$output" != *"Failed:"*"kadence-blocks"* ]]
[[ "$(remote_file stellarwp/kadence-blocks composer.json)" == *'"^1.7.0"'* ]]
}
Loading