diff --git a/.github/scripts/check-all.sh b/.github/scripts/check-all.sh new file mode 100755 index 0000000..bfcc55f --- /dev/null +++ b/.github/scripts/check-all.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +# Every guard the CI runs, in one local command. The i18n checks need wp-cli and gettext. +set -uo pipefail +cd "$(dirname "$0")/../.." + +fail=0 +for check in check-versions check-no-network check-no-emdash check-js-translations check-pot-fresh check-mo-fresh; do + printf '\n== %s\n' "$check" + .github/scripts/"$check".sh || fail=1 +done + +exit "$fail" diff --git a/.github/scripts/check-js-translations.sh b/.github/scripts/check-js-translations.sh new file mode 100755 index 0000000..ea73cb3 --- /dev/null +++ b/.github/scripts/check-js-translations.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +# wp-cli 2.12 names make-json output after a truncated handle (assets/a.js), +# so WordPress never finds the file and JS strings silently stay in English. +set -uo pipefail + +fail=0 +expected_hash=$(printf '%s' 'assets/admin.js' | md5sum | cut -d' ' -f1) + +for po in languages/biolinks-*.po; do + [ -e "$po" ] || continue + locale=$(basename "$po" .po); locale=${locale#biolinks-} + json="languages/biolinks-${locale}-${expected_hash}.json" + if [ -f "$json" ]; then + printf 'ok %s\n' "$json" + else + printf 'FAIL missing %s (wrong make-json hash?)\n' "$json" >&2 + ls languages/biolinks-"${locale}"-*.json 2>/dev/null >&2 || true + fail=1 + fi +done + +exit "$fail" diff --git a/.github/scripts/check-mo-fresh.sh b/.github/scripts/check-mo-fresh.sh new file mode 100755 index 0000000..d1032e9 --- /dev/null +++ b/.github/scripts/check-mo-fresh.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +# A stale MO silently serves the previous translations: the PO is never read at runtime. +set -uo pipefail + +fail=0 +translated() { grep -oE '[0-9]+ translated' | head -1 | grep -oE '[0-9]+'; } + +for po in languages/biolinks-*.po; do + [ -e "$po" ] || continue + mo="${po%.po}.mo" + if [ ! -f "$mo" ]; then + printf 'FAIL %s has no compiled %s\n' "$po" "$mo" >&2 + fail=1 + continue + fi + po_count=$(msgfmt --statistics -o /dev/null "$po" 2>&1 | translated) + mo_count=$(msgunfmt "$mo" 2>/dev/null | msgfmt --statistics -o /dev/null - 2>&1 | translated) + if [ "${po_count:-x}" = "${mo_count:-y}" ]; then + printf 'ok %s and %s both carry %s translations\n' "$po" "$mo" "$po_count" + else + printf 'FAIL %s carries %s translations but %s carries %s, run wp i18n make-mo\n' \ + "$po" "${po_count:-?}" "$mo" "${mo_count:-?}" >&2 + fail=1 + fi +done + +exit "$fail" diff --git a/.github/scripts/check-no-emdash.sh b/.github/scripts/check-no-emdash.sh new file mode 100755 index 0000000..38c5007 --- /dev/null +++ b/.github/scripts/check-no-emdash.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +# Em dashes are banned from user facing copy. +set -uo pipefail + +hits=$(grep -rn $'—' readme.txt README.md languages includes templates assets/admin.js assets/front.js 2>/dev/null \ + | grep -v 'assets/vendor' || true) + +if [ -n "$hits" ]; then + printf 'FAIL em dash found in user facing copy:\n%s\n' "$hits" >&2 + exit 1 +fi + +printf 'ok no em dash in user facing copy\n' diff --git a/.github/scripts/check-no-network.sh b/.github/scripts/check-no-network.sh new file mode 100755 index 0000000..5809e28 --- /dev/null +++ b/.github/scripts/check-no-network.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# BioLinks ships zero external HTTP calls. That is a product promise, not a +# preference: nothing else in the codebase enforces it. +set -uo pipefail + +paths=(biolinks.php uninstall.php includes templates assets) +prune=(-path './assets/vendor' -prune -o) + +php_hits=$(grep -rnE "wp_remote_(get|post|head|request)|curl_init|curl_exec|fsockopen|file_get_contents\([[:space:]]*['\"]https?://" \ + --include='*.php' "${paths[@]}" 2>/dev/null | grep -v 'assets/vendor' || true) + +asset_hits=$(grep -rnE "src=['\"]https?://|@import[[:space:]]+url\(['\"]?https?://|fetch\(['\"]https?://|XMLHttpRequest" \ + --include='*.php' --include='*.js' --include='*.css' "${paths[@]}" 2>/dev/null | grep -v 'assets/vendor' || true) + +if [ -n "$php_hits$asset_hits" ]; then + printf 'FAIL external network access reintroduced:\n%s\n%s\n' "$php_hits" "$asset_hits" >&2 + exit 1 +fi + +printf 'ok no external HTTP call outside assets/vendor\n' diff --git a/.github/scripts/check-pot-fresh.sh b/.github/scripts/check-pot-fresh.sh new file mode 100755 index 0000000..4f9942e --- /dev/null +++ b/.github/scripts/check-pot-fresh.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# A string added without regenerating the POT never reaches translate.wordpress.org. +set -uo pipefail + +command -v wp >/dev/null || { printf 'FAIL wp-cli is required\n' >&2; exit 1; } + +tmp=$(mktemp -d) +trap 'rm -rf "$tmp"' EXIT + +root_flag=() +[ "$(id -u)" -eq 0 ] && root_flag=(--allow-root) + +if ! wp i18n make-pot . "$tmp/fresh.pot" \ + --slug=biolinks --domain=biolinks --exclude=assets/vendor --package-name="BioLinks" \ + "${root_flag[@]}" >"$tmp/log" 2>&1; then + printf 'FAIL wp i18n make-pot did not run:\n' >&2 + cat "$tmp/log" >&2 + exit 1 +fi + +msgids() { grep -E '^msgid ' "$1" | sort -u; } + +if diff <(msgids languages/biolinks.pot) <(msgids "$tmp/fresh.pot") > "$tmp/diff"; then + printf 'ok languages/biolinks.pot covers every source string\n' +else + printf 'FAIL languages/biolinks.pot is stale, regenerate with wp i18n make-pot\n' >&2 + printf ' < committed POT, > freshly generated\n' >&2 + cat "$tmp/diff" >&2 + exit 1 +fi diff --git a/.github/scripts/check-versions.sh b/.github/scripts/check-versions.sh new file mode 100755 index 0000000..0275ffd --- /dev/null +++ b/.github/scripts/check-versions.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +# The release version lives in four places and a mismatch on "Tested up to" +# makes the plugin drop out of the WordPress.org directory search. +set -uo pipefail + +fail=0 +err() { printf 'FAIL %s\n' "$*" >&2; fail=1; } +ok() { printf 'ok %s\n' "$*"; } + +php_version=$(sed -n 's/^[[:space:]]*\*[[:space:]]*Version:[[:space:]]*\([0-9][0-9.]*\).*/\1/p' biolinks.php | head -1) +php_const=$(sed -n "s/.*BIOLINKS_VERSION'[^']*'\([0-9][0-9.]*\)'.*/\1/p" biolinks.php | head -1) +readme_tag=$(sed -n 's/^Stable tag:[[:space:]]*\([0-9][0-9.]*\).*/\1/p' readme.txt | head -1) +php_tested=$(sed -n 's/^[[:space:]]*\*[[:space:]]*Tested up to:[[:space:]]*\([0-9][0-9.]*\).*/\1/p' biolinks.php | head -1) +readme_tested=$(sed -n 's/^Tested up to:[[:space:]]*\([0-9][0-9.]*\).*/\1/p' readme.txt | head -1) + +for pair in "biolinks.php Version:$php_version" \ + "BIOLINKS_VERSION:$php_const" \ + "readme.txt Stable tag:$readme_tag" \ + "biolinks.php Tested up to:$php_tested" \ + "readme.txt Tested up to:$readme_tested"; do + [ -n "${pair#*:}" ] || err "could not read ${pair%%:*}" +done +[ "$fail" -eq 0 ] || exit 1 + +if [ "$php_version" = "$php_const" ] && [ "$php_version" = "$readme_tag" ]; then + ok "version $php_version consistent across biolinks.php and readme.txt" +else + err "version mismatch: header=$php_version constant=$php_const stable tag=$readme_tag" +fi + +if [ "$php_tested" = "$readme_tested" ]; then + ok "tested up to $php_tested consistent" +else + err "tested up to mismatch: biolinks.php=$php_tested readme.txt=$readme_tested" +fi + +# readme.txt needs the version under both == Changelog == and == Upgrade Notice ==. +entries=$(grep -c "^= ${php_version} =$" readme.txt || true) +if [ "$entries" -ge 2 ]; then + ok "readme.txt documents $php_version in changelog and upgrade notice" +else + err "readme.txt has $entries entries for '= $php_version =', expected 2" +fi + +exit "$fail" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..ad3585b --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,104 @@ +name: CI + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + guards: + name: Project invariants + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v7 + - name: Version is consistent across biolinks.php and readme.txt + run: .github/scripts/check-versions.sh + - name: No external HTTP call + run: .github/scripts/check-no-network.sh + - name: No em dash in user facing copy + run: .github/scripts/check-no-emdash.sh + - name: JS translation files carry the right handle hash + run: .github/scripts/check-js-translations.sh + + lint: + name: PHP ${{ matrix.php }} + runs-on: ubuntu-latest + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + # 8.0 is the floor declared by "Requires PHP" in biolinks.php. + php: ['8.0', '8.1', '8.2', '8.3', '8.4'] + steps: + - uses: actions/checkout@v7 + - uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + coverage: none + - name: Lint every shipped PHP file + run: | + find biolinks.php uninstall.php includes templates -name '*.php' -print0 \ + | xargs -0 -n1 -P4 php -l + + i18n: + name: Translation freshness + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v7 + - uses: shivammathur/setup-php@v2 + with: + php-version: '8.3' + coverage: none + - name: Install wp-cli and gettext + run: | + curl -sSLo "$RUNNER_TEMP/wp" https://raw.githubusercontent.com/wp-cli/builds/gh-pages/phar/wp-cli.phar + chmod +x "$RUNNER_TEMP/wp" + sudo mv "$RUNNER_TEMP/wp" /usr/local/bin/wp + # gettext ships with the runner image. Only reach for apt if it does + # not, and never run apt-get update: unattended-upgrades holds the + # dpkg lock often enough to hang the job for hours. + if ! command -v msgfmt >/dev/null; then + sudo DEBIAN_FRONTEND=noninteractive apt-get install -y -qq gettext + fi + msgfmt --version | head -1 + wp --version + - name: POT covers every source string + run: .github/scripts/check-pot-fresh.sh + - name: MO files match their PO + run: .github/scripts/check-mo-fresh.sh + + plugin-check: + name: WordPress Plugin Check + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v7 + - name: Stage the plugin exactly as shipped + # Same exclusions as the release zip, so the check sees the real artifact + # and not the repo-only tooling. Keep in sync with CLAUDE.md. + run: | + mkdir -p build/biolinks + rsync -a --delete \ + --exclude='.git/' --exclude='.github/' --exclude='build/' \ + --exclude='README.md' --exclude='screenshots/' --exclude='tools/' \ + --exclude='docs/' --exclude='CLAUDE.md' --exclude='Makefile' \ + ./ build/biolinks/ + echo "::group::Staged files" + find build/biolinks -type f | sort + echo "::endgroup::" + - uses: WordPress/plugin-check-action@v1 + with: + build-dir: './build/biolinks' + slug: biolinks + # The DirectDatabaseQuery and InterpolatedNotPrepared warnings are + # structural: the 5.9 floor rules out prepared %i placeholders. + ignore-warnings: true