diff --git a/.github/workflows/deploy-wordpress-org.yml b/.github/workflows/deploy-wordpress-org.yml index bf057ca..f7fd55a 100644 --- a/.github/workflows/deploy-wordpress-org.yml +++ b/.github/workflows/deploy-wordpress-org.yml @@ -15,19 +15,19 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: "22" cache: npm cache-dependency-path: mailmojo/package-lock.json - name: Setup PHP - uses: shivammathur/setup-php@v2 + uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2.37.2 with: php-version: "8.2" coverage: none @@ -74,34 +74,11 @@ jobs: echo "ZIP_NAME=${ZIP_NAME}" >> "$GITHUB_ENV" rm -rf release - mkdir -p release/mailmojo - - rsync -a \ - --exclude '.*' \ - --exclude 'node_modules/' \ - --exclude 'src/' \ - --exclude 'composer.lock' \ - --exclude 'package.json' \ - --exclude 'package-lock.json' \ - --exclude 'phpcs.xml' \ - --exclude 'test/' \ - --exclude 'tests/' \ - --exclude 'Test/' \ - --exclude 'Tests/' \ - --exclude 'docs/' \ - --exclude '*.md' \ - --exclude '*.sh' \ - --exclude 'Dockerfile' \ - --exclude '*.dist' \ - mailmojo/ release/mailmojo/ - - test -f release/mailmojo/mailmojo.php - test -d release/mailmojo/build - test -d release/mailmojo/includes - test -d release/mailmojo/assets - test -d release/mailmojo/vendor - test -f release/mailmojo/readme.txt + bin/stage-release.sh release/mailmojo + # Zip and upload before verifying, so a failed release still leaves an + # artifact to download and inspect. The deploy step below only runs when + # every preceding step succeeded, so verification still gates the release. - name: Create release zip run: | set -euo pipefail @@ -109,17 +86,29 @@ jobs: ZIP_NAME="mailmojo-${GITHUB_REF_NAME#v}.zip" (cd release && zip -qr "../${ZIP_NAME}" mailmojo) - unzip -l "${ZIP_NAME}" | grep -E 'mailmojo/(mailmojo\.php|readme\.txt|build/|includes/|assets/|vendor/)' >/dev/null - - name: Upload release zip - uses: actions/upload-artifact@v4 + if: ${{ !cancelled() }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: mailmojo-${{ github.ref_name }} path: ${{ env.ZIP_NAME }} - if-no-files-found: error + if-no-files-found: warn + + - name: Verify staged package + run: bin/verify-release.sh release/mailmojo + + - name: Verify release zip + run: | + set -euo pipefail + + rm -rf zip-check + mkdir -p zip-check + unzip -q "${ZIP_NAME}" -d zip-check + bin/verify-release.sh zip-check/mailmojo + rm -rf zip-check - name: Deploy to WordPress.org - uses: 10up/action-wordpress-plugin-deploy@2.3.0 + uses: 10up/action-wordpress-plugin-deploy@54bd289b8525fd23a5c365ec369185f2966529c2 # 2.3.0 env: SLUG: mailmojo BUILD_DIR: release/mailmojo diff --git a/.github/workflows/verify-package.yml b/.github/workflows/verify-package.yml new file mode 100644 index 0000000..a1ace43 --- /dev/null +++ b/.github/workflows/verify-package.yml @@ -0,0 +1,81 @@ +name: Verify Release Package + +# Dry run of the release packaging on every change, so a packaging regression is +# caught here instead of at tag time. Uses the same bin/stage-release.sh and +# bin/verify-release.sh as .github/workflows/deploy-wordpress-org.yml. + +on: + pull_request: + push: + branches: + - main + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: verify-package-${{ github.ref }} + cancel-in-progress: true + +jobs: + verify: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Setup Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: "22" + cache: npm + cache-dependency-path: mailmojo/package-lock.json + + - name: Setup PHP + uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2.37.2 + with: + php-version: "8.2" + coverage: none + + - name: Install Node dependencies + run: npm ci --prefix mailmojo + + - name: Build plugin assets + run: npm run build --prefix mailmojo + + - name: Install Composer dependencies + run: composer install --working-dir=mailmojo --no-dev --prefer-dist --no-interaction --no-progress --optimize-autoloader + + - name: Stage release package + run: bin/stage-release.sh release/mailmojo + + # Zip before verifying, so a failing verification still leaves an artifact + # to download and inspect. + - name: Create release zip + run: | + set -euo pipefail + + (cd release && zip -qr ../mailmojo-package-check.zip mailmojo) + + - name: Upload package for inspection + if: ${{ !cancelled() }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: mailmojo-package-check + path: mailmojo-package-check.zip + if-no-files-found: warn + retention-days: 7 + + - name: Verify staged package + run: bin/verify-release.sh release/mailmojo + + - name: Verify release zip + run: | + set -euo pipefail + + rm -rf zip-check + mkdir -p zip-check + unzip -q mailmojo-package-check.zip -d zip-check + bin/verify-release.sh zip-check/mailmojo diff --git a/bin/package-plugin.sh b/bin/package-plugin.sh index a6ca39d..7cc6ff0 100755 --- a/bin/package-plugin.sh +++ b/bin/package-plugin.sh @@ -1,6 +1,9 @@ #!/usr/bin/env bash set -euo pipefail +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "${REPO_ROOT}" + VERSION="$(sed -n 's/^[[:space:]]*\* Version:[[:space:]]*//p' mailmojo/mailmojo.php | head -n 1 | xargs)" ZIP_NAME="dist/mailmojo-${VERSION}.zip" @@ -12,26 +15,11 @@ npm run build --prefix mailmojo mkdir -p dist rm -f "${ZIP_NAME}" rm -rf release -mkdir -p release/mailmojo - -rsync -a \ - --exclude '.*' \ - --exclude 'node_modules/' \ - --exclude '/src/' \ - --exclude 'composer.lock' \ - --exclude 'package.json' \ - --exclude 'package-lock.json' \ - --exclude 'phpcs.xml' \ - --exclude 'test/' \ - --exclude 'tests/' \ - --exclude 'Test/' \ - --exclude 'Tests/' \ - --exclude 'docs/' \ - --exclude '*.md' \ - --exclude '*.sh' \ - --exclude 'Dockerfile' \ - --exclude '*.dist' \ - mailmojo/ release/mailmojo/ + +# Staging and verification are shared with the WordPress.org deploy workflow so +# the zip built here matches what actually gets released. +bin/stage-release.sh release/mailmojo +bin/verify-release.sh release/mailmojo (cd release && zip -qr "$(pwd)/../${ZIP_NAME}" mailmojo) diff --git a/bin/stage-release.sh b/bin/stage-release.sh new file mode 100755 index 0000000..4c3dc93 --- /dev/null +++ b/bin/stage-release.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +# +# Stage the distributable plugin directory. +# +# This is the single source of truth for what ships to WordPress.org, shared by +# bin/package-plugin.sh (local zips) and .github/workflows/deploy-wordpress-org.yml +# (the actual release). Keep the exclude list here and nowhere else. +# +# Usage: bin/stage-release.sh [DEST_DIR] +# +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +SOURCE_DIR="${REPO_ROOT}/mailmojo" +DEST_DIR="${1:-${REPO_ROOT}/release/mailmojo}" + +if [ ! -f "${SOURCE_DIR}/mailmojo.php" ]; then + echo "Error: ${SOURCE_DIR}/mailmojo.php not found." >&2 + exit 1 +fi + +if [ ! -f "${SOURCE_DIR}/vendor/autoload.php" ]; then + echo "Error: ${SOURCE_DIR}/vendor is missing. Run:" >&2 + echo " composer install --working-dir=mailmojo --no-dev --optimize-autoloader" >&2 + exit 1 +fi + +if [ ! -d "${SOURCE_DIR}/build" ]; then + echo "Error: ${SOURCE_DIR}/build is missing. Run: npm run build --prefix mailmojo" >&2 + exit 1 +fi + +echo "Staging ${SOURCE_DIR} -> ${DEST_DIR}" + +rm -rf "${DEST_DIR}" +mkdir -p "${DEST_DIR}" + +# rsync pattern anchoring matters here. A pattern with a leading slash is +# anchored to the transfer root (the plugin directory); a pattern without one +# matches at *every* depth. Writing 'src/' instead of '/src/' silently deletes +# vendor/*/src -- i.e. every Guzzle and PSR class -- and produces a plugin that +# fatals on load. Anything meant to match only the plugin root is anchored below. +rsync -a \ + --exclude '.*' \ + --exclude 'node_modules/' \ + --exclude '/src/' \ + --exclude '/composer.lock' \ + --exclude '/package.json' \ + --exclude '/package-lock.json' \ + --exclude '/phpcs.xml' \ + --exclude 'test/' \ + --exclude 'tests/' \ + --exclude 'Test/' \ + --exclude 'Tests/' \ + --exclude 'docs/' \ + --exclude '*.md' \ + --exclude '*.sh' \ + --exclude 'Dockerfile' \ + --exclude '*.dist' \ + "${SOURCE_DIR}/" "${DEST_DIR}/" + +echo "Staged $(find "${DEST_DIR}" -type f | wc -l | xargs) files." diff --git a/bin/verify-release.sh b/bin/verify-release.sh new file mode 100755 index 0000000..17a6130 --- /dev/null +++ b/bin/verify-release.sh @@ -0,0 +1,151 @@ +#!/usr/bin/env bash +# +# Verify that a staged (or extracted) plugin directory is complete and loadable. +# +# The important check is autoload integrity: every path Composer's generated +# autoload files point at must exist on disk. A packaging mistake that strips +# vendor source directories leaves those references dangling, and Composer's +# autoload_files entries are `require`d unconditionally -- so the plugin fatals +# on load rather than failing quietly. +# +# Usage: bin/verify-release.sh [PLUGIN_DIR] +# +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +PLUGIN_DIR="${1:-${REPO_ROOT}/release/mailmojo}" +PLUGIN_DIR="${PLUGIN_DIR%/}" +VENDOR_DIR="${PLUGIN_DIR}/vendor" + +failed=0 +pass() { echo " ok $*"; } +fail() { + echo " FAIL $*" >&2 + failed=1 +} + +if [ ! -d "${PLUGIN_DIR}" ]; then + echo "Error: ${PLUGIN_DIR} does not exist." >&2 + exit 1 +fi + +echo "Verifying ${PLUGIN_DIR}" + +echo "Required contents:" +for path in \ + mailmojo.php \ + readme.txt \ + uninstall.php \ + build/blocks-manifest.php \ + includes \ + assets \ + languages \ + vendor/autoload.php \ + vendor/composer/autoload_real.php \ + vendor/eliksir/mailmojo-php-sdk/MailMojo/Configuration.php \ + vendor/guzzlehttp/guzzle/src/Client.php; do + if [ -e "${PLUGIN_DIR}/${path}" ]; then + pass "${path}" + else + fail "missing ${path}" + fi +done + +echo "Excluded contents:" +for path in \ + src \ + node_modules \ + composer.lock \ + package.json \ + package-lock.json \ + phpcs.xml; do + if [ -e "${PLUGIN_DIR}/${path}" ]; then + fail "${path} should not be shipped" + else + pass "no ${path}" + fi +done + +echo "Composer autoload integrity:" +if [ ! -d "${VENDOR_DIR}/composer" ]; then + fail "vendor/composer is missing" +else + # Composer's generated autoload_*.php files reference every classmap target, + # PSR-4/PSR-0 root and always-required file as $vendorDir/$baseDir + literal. + refs="$(grep -hoE '\$(vendorDir|baseDir) \. .[^'\'']+' "${VENDOR_DIR}"/composer/autoload_*.php 2>/dev/null | sort -u || true)" + + if [ -z "${refs}" ]; then + fail "found no autoload references to check" + else + checked=0 + missing=0 + while IFS= read -r ref; do + [ -n "${ref}" ] || continue + rel="${ref#*\'}" + case "${ref}" in + '$vendorDir'*) target="${VENDOR_DIR}${rel}" ;; + '$baseDir'*) target="${PLUGIN_DIR}${rel}" ;; + *) continue ;; + esac + checked=$((checked + 1)) + if [ ! -e "${target}" ]; then + missing=$((missing + 1)) + # Report relative to the plugin dir to keep output readable. + fail "dangling autoload reference: ${target#"${PLUGIN_DIR}"/}" + fi + done <&2 + fi + fi +fi + +echo "Runtime load test:" +if command -v php >/dev/null 2>&1; then + if php -r ' + $dir = $argv[1]; + require $dir . "/vendor/autoload.php"; + $classes = array( + "MailMojo\\Configuration", + "MailMojo\\ApiException", + "MailMojo\\Api\\AccountApi", + "MailMojo\\Api\\ListApi", + "MailMojo\\Model\\AccountSdkDetails", + "GuzzleHttp\\Client", + "GuzzleHttp\\Psr7\\Request", + "Psr\\Http\\Message\\RequestInterface", + ); + $bad = array(); + foreach ($classes as $class) { + if (!class_exists($class) && !interface_exists($class)) { + $bad[] = $class; + } + } + if (!function_exists("getallheaders")) { + $bad[] = "getallheaders()"; + } + if ($bad) { + fwrite(STDERR, "unresolvable: " . implode(", ", $bad) . PHP_EOL); + exit(1); + } + exit(0); + ' "${PLUGIN_DIR}"; then + pass "autoloader loads and resolves the classes the plugin uses" + else + fail "autoloader could not load the classes the plugin uses" + fi +else + echo " skip (php not on PATH; the autoload integrity check above still ran)" +fi + +if [ "${failed}" -ne 0 ]; then + echo "Verification FAILED for ${PLUGIN_DIR}" >&2 + exit 1 +fi + +echo "Verification passed for ${PLUGIN_DIR}"