diff --git a/.github/workflows/update-consumers.yml b/.github/workflows/update-consumers.yml new file mode 100644 index 00000000..4243d5ac --- /dev/null +++ b/.github/workflows/update-consumers.yml @@ -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[@]}" diff --git a/README.md b/README.md index ec9cee61..c0db830f 100644 --- a/README.md +++ b/README.md @@ -160,6 +160,7 @@ bunx @stellarwp/changelogger write --overwrite-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 diff --git a/composer.json b/composer.json index d327fe4e..544a83f7 100644 --- a/composer.json +++ b/composer.json @@ -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.", @@ -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 -- [date]", - "release:set-version": "Replace TBD tags and update Harbor::VERSION. Usage: composer release:set-version -- " + "release:set-version": "Replace TBD tags and update Harbor::VERSION. Usage: composer release:set-version -- ", + "release:update-consumers": "Open PRs bumping Harbor in consumer plugins. Usage: composer release:update-consumers -- [version] [-y] [--dry-run] [owner/repo ...]" } } diff --git a/dev_scripts/update-consumers.sh b/dev_scripts/update-consumers.sh new file mode 100755 index 00000000..c7530f39 --- /dev/null +++ b/dev_scripts/update-consumers.sh @@ -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- 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" + + 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 diff --git a/tests/shell/update-consumers.bats b/tests/shell/update-consumers.bats new file mode 100644 index 00000000..3a35d580 --- /dev/null +++ b/tests/shell/update-consumers.bats @@ -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"'* ]] +}