diff --git a/tests/wordpress-release-artifact-version-smoke.sh b/tests/wordpress-release-artifact-version-smoke.sh new file mode 100755 index 00000000..629bcc6f --- /dev/null +++ b/tests/wordpress-release-artifact-version-smoke.sh @@ -0,0 +1,90 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Smoke test for wordpress/scripts/release/verify-artifact-version.sh: +# the guard that refuses to publish a release ZIP whose internal plugin/theme +# version does not match the version the release is shipping (the +# data-machine-socials v0.14.0 stale-asset incident). + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +VERIFY="${ROOT_DIR}/wordpress/scripts/release/verify-artifact-version.sh" +TMP_DIR="$(mktemp -d)" +trap 'rm -rf "$TMP_DIR"' EXIT + +make_plugin_zip() { + local zip_path="$1" + local version="$2" + local stage="${TMP_DIR}/stage-$RANDOM" + mkdir -p "${stage}/test-plugin" + cat > "${stage}/test-plugin/test-plugin.php" < "${stage}/test-theme/style.css" <&2 + exit 1 +fi + +# 2. Mismatched plugin version fails (the stale-artifact case). +make_plugin_zip "${TMP_DIR}/stale.zip" "0.8.1" +if bash "${VERIFY}" "${TMP_DIR}/stale.zip" "0.14.0" 2>/dev/null; then + echo "stale artifact (0.8.1 vs expected 0.14.0) was not rejected" >&2 + exit 1 +fi + +# 3. Theme style.css fallback passes on match. +make_theme_zip "${TMP_DIR}/theme.zip" "2.0.0" +got="$(bash "${VERIFY}" "${TMP_DIR}/theme.zip" "2.0.0")" +if [[ "${got}" != "2.0.0" ]]; then + echo "expected theme version output '2.0.0', got '${got}'" >&2 + exit 1 +fi + +# 4. Theme mismatch fails. +if bash "${VERIFY}" "${TMP_DIR}/theme.zip" "2.1.0" 2>/dev/null; then + echo "stale theme artifact (2.0.0 vs expected 2.1.0) was not rejected" >&2 + exit 1 +fi + +# 5. ZIP without any version header fails rather than passing silently. +stage="${TMP_DIR}/stage-noheader" +mkdir -p "${stage}/mystery" +echo " "${stage}/mystery/file.php" +(cd "${stage}" && zip -q -r "${TMP_DIR}/noheader.zip" "mystery/") +if bash "${VERIFY}" "${TMP_DIR}/noheader.zip" "1.0.0" 2>/dev/null; then + echo "artifact without a version header was not rejected" >&2 + exit 1 +fi + +# 6. Missing artifact path fails. +if bash "${VERIFY}" "${TMP_DIR}/does-not-exist.zip" "1.0.0" 2>/dev/null; then + echo "missing artifact was not rejected" >&2 + exit 1 +fi + +echo "wordpress release artifact version smoke passed" diff --git a/wordpress/scripts/release/package.sh b/wordpress/scripts/release/package.sh index 444aa08f..9d2e1cbb 100755 --- a/wordpress/scripts/release/package.sh +++ b/wordpress/scripts/release/package.sh @@ -78,5 +78,35 @@ fi echo "Built ${ARTIFACT_PATH}" >&2 +# Assert the ZIP we just built actually contains the version this release +# is shipping. Catches stale artifacts (e.g. a pre-existing build/*.zip that +# was restored instead of rebuilt) before they can reach the publish step. +# The expected version comes from the release payload when homeboy invokes +# this action; standalone dry-runs fall back to the on-disk header so the +# check still validates build output against source. +EXPECTED_VERSION="" +if [[ -n "${HOMEBOY_SETTINGS_JSON:-}" ]]; then + EXPECTED_VERSION="$(echo "${HOMEBOY_SETTINGS_JSON}" | jq -r '.release.version // empty')" +fi +if [[ -z "${EXPECTED_VERSION}" ]]; then + for candidate in *.php; do + [[ -f "${candidate}" ]] || continue + if grep -qi 'Plugin Name:' "${candidate}"; then + EXPECTED_VERSION="$(grep -i -m1 'Version:' "${candidate}" | sed 's/.*[Vv]ersion:[[:space:]]*//' | tr -d '[:space:]')" + break + fi + done + if [[ -z "${EXPECTED_VERSION}" ]] && [[ -f "style.css" ]]; then + EXPECTED_VERSION="$(grep -i -m1 'Version:' "style.css" | sed 's/.*[Vv]ersion:[[:space:]]*//' | tr -d '[:space:]')" + fi +fi + +if [[ -n "${EXPECTED_VERSION}" ]]; then + bash "${SCRIPT_DIR}/verify-artifact-version.sh" "${ARTIFACT_PATH}" "${EXPECTED_VERSION}" >/dev/null + echo "Verified ${ARTIFACT_PATH} contains version ${EXPECTED_VERSION}" >&2 +else + echo "Warning: could not determine expected version; skipping artifact version verification" >&2 +fi + jq -cn --arg path "${ARTIFACT_PATH}" \ '[{path: $path, type: "wordpress-zip", platform: null}]' diff --git a/wordpress/scripts/release/publish.sh b/wordpress/scripts/release/publish.sh index ff76e120..9376543b 100755 --- a/wordpress/scripts/release/publish.sh +++ b/wordpress/scripts/release/publish.sh @@ -168,6 +168,16 @@ fi apply_github_host_env "${GITHUB_HOST}" +# Assert the artifact's internal version matches the release tag before +# uploading. This is the last chokepoint before a ZIP becomes the GitHub +# Release asset that deploys consume — a stale artifact here means silent +# production rollback (data-machine-socials v0.14.0 shipped a v0.8.1 zip +# for 6 days). Strip the leading "v" and any monorepo "-v" +# prefix from the tag to get the bare semver. +EXPECTED_VERSION="${TAG##*v}" +bash "${SCRIPT_DIR}/verify-artifact-version.sh" "${ARTIFACT_PATH}" "${EXPECTED_VERSION}" >/dev/null +echo "Verified ${ARTIFACT_PATH} contains version ${EXPECTED_VERSION} (tag ${TAG})" >&2 + echo "Uploading ${ARTIFACT_PATH} to ${REPO_SLUG} release ${TAG}..." >&2 gh release upload "${TAG}" "${ARTIFACT_PATH}" \ diff --git a/wordpress/scripts/release/verify-artifact-version.sh b/wordpress/scripts/release/verify-artifact-version.sh new file mode 100755 index 00000000..524c2a74 --- /dev/null +++ b/wordpress/scripts/release/verify-artifact-version.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Verify that a packaged WordPress plugin/theme ZIP contains the expected +# version before it is published. Guards against stale artifacts reaching +# GitHub Release assets — the data-machine-socials v0.14.0 incident shipped +# a v0.8.1 zip (restored from a stale git-tracked blob during release +# recovery) as the release asset, and production silently ran rolled-back +# code for 6 days because nothing in the pipeline ever opened the zip. +# +# Usage: +# verify-artifact-version.sh +# +# is the bare semver (no leading "v"). The internal +# version is read from the plugin main-file header (any top-level +# "/.php" containing a "Plugin Name:" header) or, for themes, +# from "/style.css". +# +# Exits 0 and prints the matched version on stdout when versions agree. +# Exits 1 with a diagnostic on stderr when they do not, or when no version +# header can be located inside the artifact. + +ARTIFACT_PATH="${1:-}" +EXPECTED_VERSION="${2:-}" + +if [[ -z "${ARTIFACT_PATH}" ]] || [[ -z "${EXPECTED_VERSION}" ]]; then + echo "Usage: verify-artifact-version.sh " >&2 + exit 1 +fi + +if [[ ! -f "${ARTIFACT_PATH}" ]]; then + echo "Error: artifact '${ARTIFACT_PATH}' does not exist" >&2 + exit 1 +fi + +if ! command -v unzip >/dev/null 2>&1; then + echo "Error: unzip is required to verify artifact versions" >&2 + exit 1 +fi + +extract_version_header() { + # Pull "Version: x.y.z" out of header text on stdin. + grep -i -m1 '^[[:space:]*]*Version:' | sed 's/.*[Vv]ersion:[[:space:]]*//' | tr -d '[:space:]' +} + +ARTIFACT_VERSION="" + +# Plugin probe: top-level PHP files inside the wrapper directory that carry +# a "Plugin Name:" header. +while IFS= read -r entry; do + header="$(unzip -p "${ARTIFACT_PATH}" "${entry}" 2>/dev/null | head -n 60 || true)" + if echo "${header}" | grep -qi 'Plugin Name:'; then + candidate="$(echo "${header}" | extract_version_header)" + if [[ -n "${candidate}" ]]; then + ARTIFACT_VERSION="${candidate}" + break + fi + fi +done < <(unzip -Z1 "${ARTIFACT_PATH}" | grep -E '^[^/]+/[^/]+\.php$' || true) + +# Theme fallback: style.css at the wrapper root. +if [[ -z "${ARTIFACT_VERSION}" ]]; then + style_entry="$(unzip -Z1 "${ARTIFACT_PATH}" | grep -E '^[^/]+/style\.css$' | head -n 1 || true)" + if [[ -n "${style_entry}" ]]; then + ARTIFACT_VERSION="$(unzip -p "${ARTIFACT_PATH}" "${style_entry}" 2>/dev/null | head -n 60 | extract_version_header || true)" + fi +fi + +if [[ -z "${ARTIFACT_VERSION}" ]]; then + echo "Error: could not locate a plugin/theme version header inside ${ARTIFACT_PATH}" >&2 + exit 1 +fi + +if [[ "${ARTIFACT_VERSION}" != "${EXPECTED_VERSION}" ]]; then + echo "Error: artifact ${ARTIFACT_PATH} contains version ${ARTIFACT_VERSION} but the release expects ${EXPECTED_VERSION} — refusing to ship a stale artifact" >&2 + exit 1 +fi + +echo "${ARTIFACT_VERSION}"