diff --git a/.github/scripts/.gitignore b/.github/scripts/.gitignore new file mode 100644 index 000000000000..b908d4cbe37c --- /dev/null +++ b/.github/scripts/.gitignore @@ -0,0 +1,3 @@ +__pycache__/ +*.pyc +.DS_Store diff --git a/.github/scripts/check_copyright_pr.php b/.github/scripts/check_copyright_pr.php new file mode 100644 index 000000000000..4b05ea8f6270 --- /dev/null +++ b/.github/scripts/check_copyright_pr.php @@ -0,0 +1,325 @@ + \ + * --template .github/scripts/copyright-template.txt + */ + +// ── Argument parsing ────────────────────────────────────────────────────────── + +$opts = getopt('', ['files:', 'added:', 'base-dir:', 'template:']); + +$missing = []; +foreach (['files', 'base-dir', 'template'] as $req) { + if (empty($opts[$req])) $missing[] = "--$req"; +} +if ($missing) { + fwrite(STDERR, "Error: missing required arguments: " . implode(', ', $missing) . "\n"); + fwrite(STDERR, "Usage: php check_copyright_pr.php --files --added --base-dir --template \n"); + exit(1); +} + +$filesPath = $opts['files']; +$addedPath = $opts['added'] ?? ''; +$baseDir = rtrim((string) $opts['base-dir'], '/'); +$templatePath = $opts['template']; + +if (!file_exists($filesPath)) { + echo "No copyright-eligible changed files found — skipping.\n"; + exit(0); +} + +$template = trim((string) file_get_contents($templatePath)); +$changedFiles = array_filter(array_map('trim', file($filesPath))); +$addedFiles = ($addedPath && file_exists($addedPath)) + ? array_flip(array_filter(array_map('trim', file($addedPath)))) + : []; + +$currentYear = (int) date('Y'); +$issues = []; +$checked = 0; +$skipped = 0; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +/** + * Normalize a copyright block for comparison: strip XML declarations, PHP + * declare(), comment markers, whitespace and punctuation — mirrors the Python + * _normalize_copyright() function. + */ +function normalizeHeader(string $text): string +{ + $text = preg_replace('/<\?xml[^?]*\?>/', '', $text); // strip XML PI + $text = preg_replace('/\bdeclare\s*\([^)]*\)\s*;?/', '', $text); // strip declare() + return strtolower((string) preg_replace('/[\s#\/\*\-<>!?\[\]]+/', '', $text)); +} + +/** + * Returns true when the file should be skipped entirely. + */ +function shouldExclude(string $filePath): bool +{ + static $allowedExts = ['php','phtml','html','js','xml','xsd','less','css','scss','graphqls']; + + $ext = strtolower(pathinfo($filePath, PATHINFO_EXTENSION)); + if (!in_array($ext, $allowedExts, true)) return true; + + // .github/ and vendor/ are always excluded + if (preg_match('#^(\.github|vendor)/#', $filePath)) return true; + + // lib/web/ is excluded except lib/web/mage/ (Adobe-authored JS) + if (preg_match('#^lib/web/#', $filePath) && !preg_match('#^lib/web/mage/#', $filePath)) return true; + + return false; +} + +/** + * Extract the first copyright block from file content. + * Handles C-style (/** ... *\/), HTML (), and hash (# ...) blocks. + * Returns the raw header text, or '' if no copyright found. + */ +function extractCopyrightBlock(string $content): string +{ + $lines = explode("\n", $content); + $blockType = null; // 'c', 'html', 'hash' + $collected = []; + $foundCopyright = false; + + foreach ($lines as $i => $line) { + $s = trim($line); + + // Line 0: skip PHP/XML opening tag but collect it + if ($i === 0 && (str_starts_with($s, ' 2 && str_ends_with($s, '*/')) { + $foundCopyright = stripos($s, 'copyright') !== false; + break; + } + continue; + } + if (str_starts_with($s, '')) { + $foundCopyright = stripos($s, 'copyright') !== false; + break; + } + continue; + } + if (str_starts_with($s, '#')) { + $blockType = 'hash'; + $collected[] = $line; + if (stripos($s, 'copyright') !== false) $foundCopyright = true; + continue; + } + // Empty lines and declare() before the copyright block are allowed + if ($s === '') continue; + if (preg_match('/^declare\s*\([^)]*\)\s*;?$/', $s)) continue; + break; + } + + if ($blockType === 'c') { + $collected[] = $line; + if (stripos($s, 'copyright') !== false) $foundCopyright = true; + if (str_ends_with($s, '*/')) { + if ($foundCopyright) break; + $collected = []; $foundCopyright = false; $blockType = null; // non-copyright block, reset + } + continue; + } + + if ($blockType === 'html') { + $collected[] = $line; + if (stripos($s, 'copyright') !== false) $foundCopyright = true; + if (str_ends_with($s, '-->')) { + if ($foundCopyright) break; + $collected = []; $foundCopyright = false; $blockType = null; + } + continue; + } + + if ($blockType === 'hash') { + if (!str_starts_with($s, '#')) break; // end of hash block + if (stripos($s, 'copyright') !== false) $foundCopyright = true; + $collected[] = $line; + continue; + } + } + + return $foundCopyright ? implode("\n", $collected) : ''; +} + +/** + * Fetch the base-branch content of a file from the pre-fetched directory. + * Returns null when the file did not exist in the base branch. + */ +function getBaseContent(string $filePath, string $baseDir): ?string +{ + $path = $baseDir . '/' . $filePath; + return file_exists($path) ? (string) file_get_contents($path) : null; +} + +/** + * Build the expected copyright header for a given file type using the template. + * The template is in /** ... *\/ block format. + */ +function buildExpectedHeader(string $template, string $filePath, int $year): string +{ + $tpl = str_replace('{{YEAR}}', (string) $year, $template); + $ext = strtolower(pathinfo($filePath, PATHINFO_EXTENSION)); + + // Template is already in /** */ form; adapt wrapping for the file type. + // Strip outer /** and */ to get the inner lines. + $inner = trim((string) preg_replace('/^\/\*\*\s*|\s*\*\/$/', '', $tpl)); + $innerLines = explode("\n", $inner); + $bodyLines = array_map(static fn(string $l): string => ltrim($l, ' *'), $innerLines); + + switch ($ext) { + case 'php': + return "\n"; + case 'xml': + case 'xsd': + $out = ['', ''; + return implode("\n", $out) . "\n"; + case 'graphqls': + $out = []; + foreach ($bodyLines as $l) $out[] = $l !== '' ? "# {$l}" : '#'; + return implode("\n", $out) . "\n"; + case 'html': + $out = [''; + return implode("\n", $out) . "\n"; + default: + return $tpl . "\n"; + } +} + +// ── Main loop ───────────────────────────────────────────────────────────────── + +foreach ($changedFiles as $filePath) { + if (shouldExclude($filePath)) { + $skipped++; + continue; + } + + if (!is_file($filePath)) { + $skipped++; + continue; + } + + $content = (string) file_get_contents($filePath); + + // Skip third-party files that contain no Magento/Adobe reference + if (strpos($content, 'Magento') === false && strpos($content, 'Adobe') === false + && strpos($filePath, 'Magento') === false && strpos($filePath, 'Adobe') === false + ) { + $skipped++; + continue; + } + + $checked++; + $isNew = isset($addedFiles[$filePath]); + $currentHeader = extractCopyrightBlock($content); + + + if ($isNew) { + // ── New file: must have a copyright header in current-year format ───── + if ($currentHeader === '') { + $issues[] = [ + 'file' => $filePath, + 'reason' => 'Missing copyright header', + 'new' => true, + ]; + continue; + } + $currentNorm = normalizeHeader($currentHeader); + $expected = buildExpectedHeader($template, $filePath, $currentYear); + $expectedNorm = normalizeHeader($expected); + if ($expectedNorm !== '' && strpos($currentNorm, $expectedNorm) === false) { + $issues[] = [ + 'file' => $filePath, + 'reason' => "New file: copyright header must use year {$currentYear} in the correct format", + 'new' => true, + ]; + } + } else { + // ── Existing file: compare against base; skip if base had no copyright ─ + $baseContent = getBaseContent($filePath, $baseDir); + + if ($baseContent === null) { + // Modified file but base blob unavailable (partial clone fetch failure). + // Skip rather than emit a false positive. + echo " [SKIP] {$filePath} — base content unavailable (partial clone fetch failure)\n"; + $skipped++; + continue; + } + + $baseHeader = extractCopyrightBlock($baseContent); + if ($baseHeader === '') { + continue; + } + + if ($currentHeader === '') { + $issues[] = [ + 'file' => $filePath, + 'reason' => 'Copyright header was removed in this PR', + 'new' => false, + ]; + continue; + } + + $baseNorm = normalizeHeader($baseHeader); + $currentNorm = normalizeHeader($currentHeader); + if ($baseNorm !== $currentNorm) { + $issues[] = [ + 'file' => $filePath, + 'reason' => 'Copyright header was modified in this PR (year or content changed)', + 'new' => false, + ]; + } + } +} + +// ── Report ──────────────────────────────────────────────────────────────────── + +echo "Copyright check: {$checked} file(s) checked, {$skipped} skipped\n"; + +if (empty($issues)) { + echo "All files have correct copyright headers\n"; + exit(0); +} + +echo count($issues) . " file(s) have copyright issues:\n"; +foreach ($issues as $issue) { + $tag = $issue['new'] ? '[NEW] ' : '[EXISTING]'; + echo " {$tag} {$issue['file']}\n"; + echo " {$issue['reason']}\n"; +} +exit(1); diff --git a/.github/scripts/copyright-template.txt b/.github/scripts/copyright-template.txt new file mode 100644 index 000000000000..74dfda828bcf --- /dev/null +++ b/.github/scripts/copyright-template.txt @@ -0,0 +1,4 @@ +/** + * Copyright {{YEAR}} Adobe + * All Rights Reserved. + */ diff --git a/.github/scripts/dist-qa-local.sh b/.github/scripts/dist-qa-local.sh index e5999f99588a..2056a66fda7e 100755 --- a/.github/scripts/dist-qa-local.sh +++ b/.github/scripts/dist-qa-local.sh @@ -28,12 +28,15 @@ fi echo "Base ref: $BASE" +# .github/ is excluded: it carries CI tooling inherited from upstream (CLI scripts that +# legitimately use echo/exit), which the Magento2 standard rejects and which is not part +# of the distributed code. CHANGED=$( { - git diff --name-only --diff-filter=ACMR "$BASE"...HEAD -- '*.php' - git diff --name-only --diff-filter=ACMR -- '*.php' - git diff --name-only --diff-filter=ACMR --cached -- '*.php' - git ls-files --others --exclude-standard -- '*.php' + git diff --name-only --diff-filter=ACMR "$BASE"...HEAD -- '*.php' ':(exclude).github/' + git diff --name-only --diff-filter=ACMR -- '*.php' ':(exclude).github/' + git diff --name-only --diff-filter=ACMR --cached -- '*.php' ':(exclude).github/' + git ls-files --others --exclude-standard -- '*.php' ':(exclude).github/' } 2>/dev/null | sort -u | grep -v '^$' || true ) diff --git a/.github/scripts/read-pkg-constraint.sh b/.github/scripts/read-pkg-constraint.sh new file mode 100755 index 000000000000..c74113e48561 --- /dev/null +++ b/.github/scripts/read-pkg-constraint.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# +# Copyright 2026 Adobe +# All Rights Reserved. +# +# Usage: read-pkg-constraint.sh +# Prints the version constraint for the package from require or require-dev. +set -euo pipefail + +FILE="$1" +PKG="$2" + +[ -f "$FILE" ] || exit 1 + +php -r ' + $j = json_decode(file_get_contents($argv[1]), true) ?: []; + $p = $argv[2]; + $v = $j["require"][$p] ?? $j["require-dev"][$p] ?? ""; + echo is_string($v) ? $v : ""; +' "$FILE" "$PKG" diff --git a/.github/scripts/resolve_magento_tool_constraints.py b/.github/scripts/resolve_magento_tool_constraints.py new file mode 100755 index 000000000000..36704ded58e0 --- /dev/null +++ b/.github/scripts/resolve_magento_tool_constraints.py @@ -0,0 +1,306 @@ +#!/usr/bin/env python3 +# +# Copyright 2026 Adobe +# All Rights Reserved. +# +""" +Resolve PHP version + magento/magento2 composer.json the same way as pr-quality-gates.yml. + +Used in GitHub Actions (writes GITHUB_OUTPUT and /tmp/magento2-composer.json) and locally: + + REPO_NAME=magento2ce BASE_REF=2.4-develop \\ + python3 .github/scripts/resolve_magento_tool_constraints.py --print-summary + +See emulate_ci_tools_locally.sh for a full local QA-tools install. +""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import sys +import urllib.request +from pathlib import Path +from typing import Any + + +def parse_php_constraint(text: str) -> str: + matches = re.findall(r"\b8\.(\d+)\b", text or "") + if not matches: + return "" + highest_minor = max(int(m) for m in matches) + return f"8.{highest_minor}" + + +def load_json_url(url: str) -> Any: + with urllib.request.urlopen(url) as resp: + return json.load(resp) + + +def read_pkg_constraint(data: dict[str, Any], pkg: str) -> str: + req = data.get("require") or {} + dev = data.get("require-dev") or {} + v = req.get(pkg) or dev.get(pkg) + return v if isinstance(v, str) else "" + + +def resolve( + repo_name: str, + base_ref: str, + fallback_php: str, +) -> tuple[str, str, dict[str, Any] | None, str]: + """ + Returns (php_version, source_ref, composer_json_data, raw_php_constraint_or_empty). + """ + selected_release = "" + package_key = os.environ.get("REPO_PACKAGE_KEY", "") + base_ref = (base_ref or "").strip() + + def base_ref_candidates(ref: str) -> list[str]: + """ + Build lookup candidates for branch-like refs. + Examples: + - 2.4.8-p3-develop -> [2.4.8-p3-develop, 2.4.8-p3] + - 1.2.6-p2-release -> [1.2.6-p2-release, 1.2.6-p2] + """ + if not ref: + return [] + out = [ref] + if ref.endswith("-develop"): + trimmed = ref[: -len("-develop")] + if trimmed: + out.append(trimmed) + if ref.endswith("-release"): + trimmed = ref[: -len("-release")] + if trimmed: + out.append(trimmed) + return list(dict.fromkeys(out)) + + selected_component_version = "" + try: + releases = load_json_url( + "https://raw.githubusercontent.com/magento/quality-patches/master/magento_releases.json" + ) + if package_key: + release_lookup_refs = base_ref_candidates(base_ref) + + for candidate_ref in release_lookup_refs: + if candidate_ref in releases: + selected_release = candidate_ref + break + + if not selected_release and release_lookup_refs: + + def sort_key(v: str) -> tuple: + m = re.match(r"^(\d+\.\d+\.\d+)(?:-p(\d+))?$", v) + if not m: + return (v, -1) + return (m.group(1), int(m.group(2) or 0)) + + for candidate_ref in release_lookup_refs: + release_candidates = [ + key + for key in releases + if key == candidate_ref or key.startswith(f"{candidate_ref}-p") + ] + if release_candidates: + selected_release = sorted(release_candidates, key=sort_key)[-1] + break + + if selected_release: + selected_component_version = ( + releases.get(selected_release, {}).get(package_key, "") or "" + ) + except Exception as exc: + print(f"Could not read magento_releases.json: {exc}", file=sys.stderr) + + if selected_release: + print(f"Resolved Magento release from quality-patches metadata: {selected_release}") + if selected_component_version: + print(f"Resolved component version for {package_key}: {selected_component_version}") + else: + print( + f"No release mapping found for repo '{repo_name}' and base ref '{base_ref}'." + ) + print("Falling back to develop-branch context: 2.4-develop / develop") + + composer_refs: list[str] = [] + if selected_release: + composer_refs.append(selected_release) + composer_refs.extend(["2.4-develop", "develop"]) + + constraint = "" + source_ref = "" + composer_json_data: dict[str, Any] | None = None + + for ref in composer_refs: + try: + with urllib.request.urlopen( + f"https://raw.githubusercontent.com/magento/magento2/{ref}/composer.json" + ) as resp: + data = json.load(resp) + except Exception: + continue + candidate = ( + data.get("config", {}).get("platform", {}).get("php") + or data.get("require", {}).get("php") + or "" + ) + if candidate: + constraint = candidate + source_ref = ref + composer_json_data = data + break + + if composer_json_data is None: + for ref in composer_refs: + try: + with urllib.request.urlopen( + f"https://raw.githubusercontent.com/magento/magento2/{ref}/composer.json" + ) as resp: + data = json.load(resp) + composer_json_data = data + source_ref = ref + print( + f"Loaded magento/magento2 composer.json from ref '{ref}' " + "(no PHP platform/require match)." + ) + break + except Exception: + continue + + if composer_json_data is None: + try: + with urllib.request.urlopen( + "https://api.github.com/repos/magento/magento2" + ) as resp: + meta = json.load(resp) + default_branch = meta.get("default_branch") or "2.4-develop" + with urllib.request.urlopen( + f"https://raw.githubusercontent.com/magento/magento2/{default_branch}/composer.json" + ) as resp: + composer_json_data = json.load(resp) + source_ref = default_branch + print( + f"Fell back to magento/magento2 default branch '{default_branch}' for composer.json." + ) + except Exception as exc: + print(f"Could not load magento/magento2 composer.json: {exc}", file=sys.stderr) + + if constraint: + version = parse_php_constraint(constraint) or fallback_php + print(f"Resolved PHP constraint from magento/magento2 ref '{source_ref}': {constraint}") + else: + version = fallback_php + print( + "No usable PHP constraint found from selected tag or fallback refs, " + f"falling back to PHP {fallback_php}" + ) + + print(f"Selected PHP version: {version}") + return version, source_ref, composer_json_data, constraint + + +def merge_tool_constraints( + magento_data: dict[str, Any] | None, + mcs_composer_path: Path | None, +) -> tuple[str, str]: + phpmd_c = "" + stan_c = "" + if mcs_composer_path and mcs_composer_path.is_file(): + try: + mcs = json.loads(mcs_composer_path.read_text(encoding="utf-8")) + phpmd_c = read_pkg_constraint(mcs, "phpmd/phpmd") + stan_c = read_pkg_constraint(mcs, "phpstan/phpstan") + except Exception as exc: + print(f"Could not read {mcs_composer_path}: {exc}", file=sys.stderr) + if magento_data: + if not phpmd_c: + phpmd_c = read_pkg_constraint(magento_data, "phpmd/phpmd") + if not stan_c: + stan_c = read_pkg_constraint(magento_data, "phpstan/phpstan") + return phpmd_c, stan_c + + +def main() -> int: + p = argparse.ArgumentParser(description=__doc__) + p.add_argument( + "--repo-name", + default=os.environ.get("REPO_NAME", ""), + help="GitHub repository name (e.g. magento2ce). Env: REPO_NAME", + ) + p.add_argument( + "--base-ref", + default=os.environ.get("BASE_REF", ""), + help="PR base branch / release ref (e.g. 2.4.8-p3). Env: BASE_REF", + ) + p.add_argument( + "--output", + default=os.environ.get("MAGENTO_COMPOSER_JSON", "/tmp/magento2-composer.json"), + help="Where to write magento/magento2 composer.json", + ) + p.add_argument( + "--fallback-php", + default="8.3", + help="PHP minor fallback when constraint cannot be parsed", + ) + p.add_argument( + "--mcs-composer", + type=Path, + default=None, + help="Optional path to mcs/composer.json for tool constraint merge", + ) + p.add_argument( + "--print-summary", + action="store_true", + help="Print phpmd/phpstan constraints after resolution", + ) + args = p.parse_args() + + php_version, source_ref, composer_json_data, _ = resolve( + args.repo_name, args.base_ref, args.fallback_php + ) + + out_path = Path(args.output) + if composer_json_data is not None: + out_path.parent.mkdir(parents=True, exist_ok=True) + out_path.write_text( + json.dumps(composer_json_data, indent=2) + "\n", encoding="utf-8" + ) + print( + f"Wrote {out_path} from magento/magento2 ref '{source_ref}' (for PHPMD/PHPStan pins)." + ) + else: + print("ERROR: No composer.json data to write.", file=sys.stderr) + return 1 + + github_output = os.environ.get("GITHUB_OUTPUT") + if github_output: + outp = Path(github_output) + with outp.open("a", encoding="utf-8") as fh: + fh.write(f"php_version={php_version}\n") + if source_ref: + fh.write(f"magento_composer_ref={source_ref}\n") + + mcs_path = args.mcs_composer + if mcs_path is None: + env_mcs = os.environ.get("MCS_COMPOSER") + if env_mcs: + mcs_path = Path(env_mcs) + + if args.print_summary or not github_output: + phpmd_c, stan_c = merge_tool_constraints(composer_json_data, mcs_path) + print("") + print("--- Tool constraints (mcs first, then magento2 composer) ---") + print(f"magento_composer_ref={source_ref}") + print(f"php_version={php_version}") + print(f"phpmd_constraint={phpmd_c or '(missing)'}") + print(f"phpstan_constraint={stan_c or '(missing)'}") + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/scripts/run-phpcs.sh b/.github/scripts/run-phpcs.sh new file mode 100755 index 000000000000..61c2ea8ce2ee --- /dev/null +++ b/.github/scripts/run-phpcs.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# +# Copyright 2026 Adobe +# All Rights Reserved. +# +# Usage: run-phpcs.sh