diff --git a/.claude/skills/release-mageos/SKILL.md b/.claude/skills/release-mageos/SKILL.md new file mode 100644 index 00000000..b3a8dad6 --- /dev/null +++ b/.claude/skills/release-mageos/SKILL.md @@ -0,0 +1,195 @@ +--- +name: release-mageos +description: Orchestrate a full Mage-OS release — preflight gate checks, preview build, artifact verification, production build, and all the release artifacts (GitHub release, website post, social copy). Use when the user is shipping a Mage-OS version and wants the whole process driven, e.g. "release 3.4.0", "let's ship 3.5.0", "run the release process", "what's left for the release". Also use for the individual phases ("run release preflight", "verify the built packages against 3.3.0"). +--- + +# Release a Mage-OS Version + +Drives a Mage-OS release end to end as a **gated checklist**, not an unattended script. Each phase verifies its preconditions, does the mechanical work, and stops at points where a human has to decide. + +This skill sequences the existing release skills rather than replacing them: + +| Phase | Delegates to | +|---|---| +| Preview analysis | [[analyze-release-preview]] | +| History files | [[add-release-history]] | +| Branch coverage (major/minor only) | [[audit-release-branches]], [[prep-release-prs]] | +| Maintainer status update | [[summarize-release-status]] | + +## Required inputs + +1. **Target version** (e.g. `3.4.0`) +2. **Upstream Magento version** (e.g. `2.4.9`) + +Determine the previous version automatically from `repo.mage-os.org` — highest published version below the target. Confirm with the user at a major-version boundary. + +**Upstream version gotcha:** Adobe ships some security fixes as *isolated patches* (e.g. `249-2026-08-001-CE`) rather than tagged releases. When that happens there is **no new upstream tag** and `upstream_release` stays at the current value. Check `magento/magento2` tags before assuming a bump. Getting this wrong silently produces a wrong `extra.magento_version` in every metapackage. + +--- + +## Phase 1 — Preflight gates + +```bash +./.claude/skills/release-mageos/scripts/preflight.sh [UPSTREAM_VERSION] +``` + +Checks, in order: + +1. **Previous release's history files are merged.** If `resource/history/mage-os/*/.json` is missing, **stop**. Building without them makes the previous release's packages drift on rebuild. This is the single most important gate. +2. **Target tag does not already exist** in `mage-os/mageos-magento2` — guards against re-releasing. +3. **Target is not already published** on repo.mage-os.org. +4. **supported-version matrix entry exists** for the target in `mage-os/github-actions@main` — *and* `supported-version/dist/index.js` actually contains it. The source JSON being right does not mean the bundle was rebuilt; `dist/` is what CI consumes. +5. **`gh` is authenticated as the collaborator account.** Two accounts are usually configured; `marcelmtz` is the collaborator, `marcelswiftotter` gets "must be a collaborator" on PR create/edit. +6. **Bundled-repo survey** — which repos have commits since the previous tag. + +Any FAIL blocks the release. WARNs are judgment calls — surface them and ask. + +> **Do not trust the repo survey's commit counts.** `push-release-tag.yml` creates a `Release X.Y.Z` commit that is not on `main`, so tags sit on a diverged lineage and `git compare ...main` reports commits as "ahead" that already shipped. It is a *hint about where to look*, never evidence. Phase 3 is what actually establishes what changed. + +## Phase 2 — Preview build + +Dispatch **Build, deploy & check Release** (`.github/workflows/build-mageos-release.yml`): + +| Input | Value | +|---|---| +| `repo` | `https://preview-repo.mage-os.org/` | +| `remote_dir` | `/var/www/preview-repo.mage-os.org/html/` | +| `mageos_release` | target version | +| `upstream_release` | upstream version | +| `publish_tag` | **false** | + +Poll until complete. Confirm the **Publish release tag** job is `skipped` — if it ran, the wrong inputs were used. + +Then run [[analyze-release-preview]]. + +**Known non-finding:** the installation-check matrix is built from *published* versions, so it never covers the version being released. Every green preview has this hole. Do not report it as a pass for the new version; recommend a manual `composer create-project` against the preview repo. + +## Phase 3 — Artifact verification + +The load-bearing phase. Proves what actually shipped, independent of git history. + +```bash +php ./.claude/skills/release-mageos/scripts/verify-packages.php \ + --packages=module-cms,module-review,module-catalog +# or, for the definitive full sweep (slow — downloads every package twice): +php ./.claude/skills/release-mageos/scripts/verify-packages.php --all +``` + +For each package it downloads the previous published zip and the new one, unzips both, and diffs, ignoring `composer.json` (which always differs by version bump). Output is the set of packages with **real content changes**. + +Use it to confirm: + +- Every change intended for this release is present in a built package +- Nothing unexpected changed +- Packages that *look* changed from commit counts are actually identical + +Start from the PRs the release is supposed to contain, map each to its package, and verify. Anything in the diff that no PR explains needs an answer before shipping. + +## Phase 4 — Human gate + +Present: the dependency diff, the verified content changes, contributors, and any behavior changes needing release-note treatment. **Wait for explicit go/no-go.** Do not dispatch the production build on inference. + +## Phase 5 — Production build + +Same workflow, with: + +| Input | Value | +|---|---| +| `repo` | `https://repo.mage-os.org/` | +| `remote_dir` | `/var/www/repo.mage-os.org/html/` | +| `publish_tag` | **true** | + +`publish_tag: true` triggers `push-release-tag.yml`, which creates and pushes the version tag in every source repo. **There is no manual tagging step.** + +The deploy job is gated to `["vinai", "rhoerr", "marcelmtz", "mage-os-ci"]`. + +Afterwards confirm the target version is live on repo.mage-os.org before continuing. + +## Phase 6 — Release artifacts + +Generate all of these from **one shared content model** so they cannot drift: + +### 6a. GitHub release + +Match the previous release exactly: name = bare version, tag = bare version, `prerelease: false`, target `main`. + +```bash +gh release create -R mage-os/mageos-magento2 --draft --verify-tag \ + --title "" --notes-file +``` + +Always `--draft` first. `--verify-tag` prevents creating a stray tag. To publish: + +```bash +gh release edit -R mage-os/mageos-magento2 --draft=false --latest +``` + +`--latest` is required — GitHub does not infer it from the version number, so without it the badge stays on the old release. + +Structure (from the 3.3.0/3.4.0 releases): header (`**Released:**` / `**Upstream:**`) → intro + upgrade command → Security → Fixes → Bundled add-on updates → Upgrade notes → Contributors. + +### 6b. Website post — `mage-os/mage-os-org` + +**This repo is forked.** Verify remotes before branching: + +- `origin` → the user's fork (`marcelmtz/mage-os-org`) +- `source` → upstream (`mage-os/mage-os-org`) — note it is **not** called `upstream` + +Branch from **`source/main`**, never `origin/main` — the fork's main is routinely dozens of commits stale. Push to `origin`, open the PR against `mage-os/mage-os-org`. + +```bash +git fetch source && git checkout -b release/-announcement source/main +``` + +File: `src/data/post/YYYY-MM-DD-mage-os----release.md`. Copy the previous release post's frontmatter shape; release posts use `~/assets/images/blog/2026/New-Mage-OS-Website.png` and `author: mage-os-team`. + +Post URL is `//` with **no trailing slash** (`trailingSlash: false`), so: +`https://mage-os.org/releases/YYYY-MM-DD-mage-os----release` + +Sections: intro → Security → Other fixes → Upgrade notes → Our foundation (upstream base, certified stack, previous version EOL) → Thanks → How to upgrade. + +### 6c. Social copy + +**LinkedIn** — plain text only; LinkedIn strips Markdown, so `**bold**` renders as literal asterisks. Use `→` for bullets. Longer, contextual, hashtags at the end. + +**Discord** — Markdown renders. Keep it tight, include the copy-pasteable upgrade command, and wrap URLs in `<...>` to suppress duplicate embed cards. + +**Ordering:** the website PR must be merged and the site rebuilt *before* the GitHub release is published or anything is posted — the announcement URL is referenced in all of them and 404s until then. + +## Phase 7 — Post-release + +Run [[add-release-history]] for the new version. It only works after the production build, since it reads published package metadata. **That PR must merge before the next release builds** — it is the Phase 1 gate for the following release. + +--- + +## Contributors + +Build the list from **two** sources, not one: + +1. Commit authors across the bundled repos since the previous tag +2. **Release-engineering work**, which is invisible to `git log` on the content repos — history-file PRs, supported-version matrix entries, build dispatch, patch porting + +Missing the second category is the default failure mode. Ask who drove the release machinery before finalizing. + +Order the list alphabetically, case-insensitive, matching previous posts. + +## Security releases + +- If the release ports an Adobe isolated patch, link the bulletin (`APSBxx-xx`) for CVEs and severities rather than restating them. Do not invent CVE identifiers or CVSS vectors — if the bulletin is unreachable, leave them out and say so. +- **Never assume attack prerequisites from the class of fix.** Check what the controller actually is: `Controller\Adminhtml\*` is admin, `Controller\Account\*` is storefront. A blanket "requires an authenticated admin session" is wrong the moment one fix is storefront-facing or unauthenticated. +- Call out behavior changes prominently, in both intro and upgrade notes, when a fix removes capability that admins currently have. Where the relevant permissions already exist in the previous release, say so — merchants can then fix their roles *before* upgrading. +- If a bulletin lists more issues than the release ships, address the gap explicitly or point at the bulletin without implying completeness. + +## Known traps + +| Trap | Reality | +|---|---| +| `git compare ...main` | Lies — release commits sit on a diverged lineage. Diff published artifacts. | +| `dist/index.js` in github-actions | Must be verified to contain new data; source JSON alone is not enough. | +| `mage-os-org` remotes | `origin` = fork (often stale), `source` = upstream. | +| `gh` account | Two configured; only the collaborator can create/edit PRs. | +| Composer `audit.block-insecure` | Breaks integrity checks when an advisory hits a pinned dep. `COMPOSER_NO_AUDIT=1` does **not** disable it — only `composer config audit.block-insecure false`. | +| Installation-check matrix | Never covers the version being released. | +| Isolated patches | No upstream tag; `upstream_release` does not move. | +| `--latest` on release publish | Not inferred from the version number. | +| Exact version pins | Core packages are pinned exactly, so per-package upgrades need inline aliases and are rarely practical advice. | diff --git a/.claude/skills/release-mageos/scripts/preflight.sh b/.claude/skills/release-mageos/scripts/preflight.sh new file mode 100755 index 00000000..3960f3de --- /dev/null +++ b/.claude/skills/release-mageos/scripts/preflight.sh @@ -0,0 +1,247 @@ +#!/usr/bin/env bash +# +# Preflight gate checks for a Mage-OS release. +# +# Usage: preflight.sh [UPSTREAM_VERSION] +# e.g. preflight.sh 3.4.0 2.4.9 +# +# Exits non-zero if any blocking gate fails. + +set -uo pipefail + +TARGET="${1:-}" +UPSTREAM="${2:-}" + +if [[ -z "$TARGET" ]]; then + echo "Usage: $0 [UPSTREAM_VERSION]" >&2 + exit 64 +fi + +if [[ ! "$TARGET" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "Target version '$TARGET' is not X.Y.Z" >&2 + exit 64 +fi + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../../.." && pwd)" +CORE_REPO="mage-os/mageos-magento2" +ACTIONS_REPO="mage-os/github-actions" +PROD_REPO_URL="https://repo.mage-os.org" + +FAILED=0 +WARNED=0 + +pass() { printf ' \033[32mPASS\033[0m %s\n' "$1"; } +fail() { printf ' \033[31mFAIL\033[0m %s\n' "$1"; FAILED=$((FAILED + 1)); } +warn() { printf ' \033[33mWARN\033[0m %s\n' "$1"; WARNED=$((WARNED + 1)); } +info() { printf ' %s\n' "$1"; } + +echo +echo "Mage-OS release preflight — target $TARGET" +echo "===========================================" + +# --- Determine previous published version -------------------------------- +echo +echo "Previous version" + +PUBLISHED_JSON="$(curl -sS --max-time 30 "$PROD_REPO_URL/p2/mage-os/product-community-edition.json" 2>/dev/null)" + +if [[ -z "$PUBLISHED_JSON" ]]; then + fail "could not fetch published version list from $PROD_REPO_URL" + PREV="" +else + PREV="$(printf '%s' "$PUBLISHED_JSON" | php -r ' + $d = json_decode(stream_get_contents(STDIN), true); + $target = $argv[1]; + $versions = array_map(fn($p) => $p["version"], $d["packages"]["mage-os/product-community-edition"] ?? []); + $lower = array_values(array_filter($versions, fn($v) => version_compare($v, $target, "<"))); + usort($lower, "version_compare"); + echo $lower ? end($lower) : ""; + ' -- "$TARGET" 2>/dev/null)" + + if [[ -z "$PREV" ]]; then + fail "could not determine a published version below $TARGET" + else + pass "previous published version is $PREV" + PREV_MAJOR="${PREV%%.*}" + TARGET_MAJOR="${TARGET%%.*}" + if [[ "$PREV_MAJOR" != "$TARGET_MAJOR" ]]; then + warn "major-version boundary ($PREV -> $TARGET) — confirm the previous version with the user" + fi + fi +fi + +# --- Gate 1: previous release's history files are merged ------------------ +echo +echo "Gate 1 — history files for $PREV" + +if [[ -n "$PREV" ]]; then + MISSING=() + for pkg in magento2-base product-community-edition project-community-edition; do + [[ -f "$REPO_ROOT/resource/history/mage-os/$pkg/$PREV.json" ]] || MISSING+=("$pkg") + done + # minimal editions exist from 3.0.0 onward + if [[ "$(printf '%s\n%s\n' "$PREV" "3.0.0" | sort -V | head -1)" == "3.0.0" ]]; then + for pkg in product-minimal-edition project-minimal-edition; do + [[ -f "$REPO_ROOT/resource/history/mage-os/$pkg/$PREV.json" ]] || MISSING+=("$pkg") + done + fi + + if [[ ${#MISSING[@]} -eq 0 ]]; then + pass "all history files present for $PREV" + else + fail "missing history files for $PREV: ${MISSING[*]}" + info "building without them makes $PREV's packages drift on rebuild" + info "run the add-release-history skill for $PREV and merge that PR first" + fi +fi + +# --- Gate 2: target tag must not already exist ---------------------------- +echo +echo "Gate 2 — target tag" + +if gh api "repos/$CORE_REPO/git/ref/tags/$TARGET" --jq '.ref' >/dev/null 2>&1; then + fail "tag $TARGET already exists in $CORE_REPO — this would be a re-release" +else + pass "tag $TARGET does not exist yet in $CORE_REPO" +fi + +# --- Gate 3: target must not already be published ------------------------- +echo +echo "Gate 3 — not already published" + +if [[ -n "$PUBLISHED_JSON" ]]; then + # Parse rather than grep — the p2 payload's whitespace is not guaranteed. + IS_PUBLISHED="$(printf '%s' "$PUBLISHED_JSON" | php -r ' + $d = json_decode(stream_get_contents(STDIN), true); + $versions = array_map(fn($p) => $p["version"], $d["packages"]["mage-os/product-community-edition"] ?? []); + echo in_array($argv[1], $versions, true) ? "yes" : "no"; + ' -- "$TARGET" 2>/dev/null)" + + if [[ "$IS_PUBLISHED" == "yes" ]]; then + fail "$TARGET is already published on repo.mage-os.org" + elif [[ "$IS_PUBLISHED" == "no" ]]; then + pass "$TARGET is not yet published" + else + warn "could not determine whether $TARGET is published" + fi +fi + +# --- Gate 4: supported-version matrix ------------------------------------- +echo +echo "Gate 4 — supported-version matrix" + +INDIVIDUAL="$(gh api "repos/$ACTIONS_REPO/contents/supported-version/src/versions/mage-os/individual.json?ref=main" \ + --jq '.content' 2>/dev/null | base64 -d 2>/dev/null)" + +if [[ -z "$INDIVIDUAL" ]]; then + warn "could not read individual.json from $ACTIONS_REPO" +else + if printf '%s' "$INDIVIDUAL" | grep -q "project-community-edition:$TARGET"; then + pass "individual.json has an entry for $TARGET" + + if [[ -n "$UPSTREAM" ]]; then + ENTRY_UPSTREAM="$(printf '%s' "$INDIVIDUAL" | php -r ' + $d = json_decode(stream_get_contents(STDIN), true); + echo $d["mage-os/project-community-edition:" . $argv[1]]["upstream"] ?? ""; + ' -- "$TARGET" 2>/dev/null)" + if [[ "$ENTRY_UPSTREAM" == "$UPSTREAM" ]]; then + pass "matrix upstream matches $UPSTREAM" + else + fail "matrix says upstream '$ENTRY_UPSTREAM' but release declares '$UPSTREAM'" + fi + fi + else + fail "individual.json has no entry for $TARGET" + fi +fi + +# dist/ is what CI actually consumes — source JSON being right is not enough +DIST="$(gh api "repos/$ACTIONS_REPO/contents/supported-version/dist/index.js?ref=main" \ + --jq '.content' 2>/dev/null | base64 -d 2>/dev/null)" + +if [[ -z "$DIST" ]]; then + warn "could not read supported-version/dist/index.js" +elif printf '%s' "$DIST" | grep -q "project-community-edition:$TARGET"; then + pass "dist/index.js contains $TARGET (bundle was rebuilt)" +else + fail "dist/index.js does NOT contain $TARGET — 'npm run build' was not run or not committed" +fi + +# --- Gate 5: gh account --------------------------------------------------- +echo +echo "Gate 5 — gh authentication" + +ACTIVE_ACCOUNT="$(gh api user --jq '.login' 2>/dev/null)" +if [[ -z "$ACTIVE_ACCOUNT" ]]; then + fail "gh is not authenticated" +else + # A non-collaborator gets 403 here, so treat any non-permission payload as "no access". + PERM="$(gh api "repos/$CORE_REPO/collaborators/$ACTIVE_ACCOUNT/permission" --jq '.permission' 2>/dev/null)" + case "$PERM" in + admin|maintain|write) + pass "authenticated as '$ACTIVE_ACCOUNT' with '$PERM' on $CORE_REPO" ;; + *) + fail "active gh account '$ACTIVE_ACCOUNT' has no write access to $CORE_REPO" + OTHERS="$(gh auth status 2>&1 | sed -n 's/.*Logged in to github.com account \([^ ]*\).*/\1/p' \ + | grep -vx "$ACTIVE_ACCOUNT" | tr '\n' ' ')" + if [[ -n "${OTHERS// /}" ]]; then + info "other authenticated account(s): ${OTHERS}" + info "switch with: gh auth switch --user " + fi ;; + esac +fi + +# --- Survey: bundled repos with commits since PREV ------------------------ +echo +echo "Survey — repos with commits since $PREV (HINT ONLY, not evidence)" + +if [[ -n "$PREV" ]]; then + REPOS=(mageos-magento2 mageos-inventory mageos-magento2-page-builder mageos-security-package + mageos-composer mageos-magento2-sample-data mageos-composer-root-update-plugin + mageos-composer-dependency-version-audit-plugin mageos-adobe-stock-integration + mageos-magento-coding-standard mageos-magento2-functional-testing-framework + mageos-magento-composer-installer mageos-inventory-composer-installer + mageos-magento-zend-cache mageos-magento-zend-db mageos-magento-zend-log + mageos-magento-zend-pdf mageos-magento-zend-loader mageos-magento-zend-memory + mageos-magento-zend-exception mageos-magento-zf-captcha mageos-magento-zf-db + mageos-magento-zf-soap) + + for r in "${REPOS[@]}"; do + AHEAD="$(gh api "repos/mage-os/$r/compare/$PREV...main" --jq '.ahead_by' 2>/dev/null)" + [[ -z "$AHEAD" || "$AHEAD" == "0" ]] && continue + + # One subject per line — filtering a joined string would test the whole + # blob at once and mislabel any repo that has even one infra commit. + SUBJECTS="$(gh api "repos/mage-os/$r/compare/$PREV...main" \ + --jq '.commits[].commit.message | split("\n")[0]' 2>/dev/null)" + + SUBSTANTIVE="$(printf '%s\n' "$SUBJECTS" | grep -vi \ + -e 'sansec' -e 'terraform' -e '^Merge branch' -e '^Merge remote-tracking' -e '^Merge pull request')" + + if [[ -n "${SUBSTANTIVE//[[:space:]]/}" ]]; then + info "$r: $AHEAD ahead — possible content:" + printf '%s\n' "$SUBSTANTIVE" | while IFS= read -r line; do + [[ -n "${line//[[:space:]]/}" ]] && info " $line" + done + else + info "$r: $AHEAD ahead (infra/merge commits only)" + fi + done + + echo + info "Commit counts are unreliable: push-release-tag.yml puts 'Release X.Y.Z' on a" + info "diverged lineage, so shipped commits can still show as 'ahead'. Use" + info "verify-packages.php to establish what actually changed." +fi + +# --- Summary -------------------------------------------------------------- +echo +echo "===========================================" +if [[ $FAILED -gt 0 ]]; then + printf '\033[31m%d gate(s) failed\033[0m, %d warning(s) — do not build\n' "$FAILED" "$WARNED" + exit 1 +fi +printf '\033[32mAll gates passed\033[0m' +[[ $WARNED -gt 0 ]] && printf ' with %d warning(s) — review before proceeding' "$WARNED" +printf '\n' +exit 0 diff --git a/.claude/skills/release-mageos/scripts/verify-packages.php b/.claude/skills/release-mageos/scripts/verify-packages.php new file mode 100755 index 00000000..7583ed9c --- /dev/null +++ b/.claude/skills/release-mageos/scripts/verify-packages.php @@ -0,0 +1,299 @@ +#!/usr/bin/env php +...main` + * reports already-shipped commits as "ahead". Comparing the published zips is + * the only reliable way to establish the real change surface. + * + * Usage: + * verify-packages.php [options] + * + * Options: + * --source=HOST where to fetch NEW from (default preview-repo.mage-os.org) + * --packages=a,b,c only these packages (short names, without the mage-os/ prefix) + * --all every package present in both releases (slow: 2 downloads each) + * --keep keep the working directory for manual inspection + * + * Exit codes: 0 ok, 1 error, 64 usage. + */ + +const PROD_REPO = 'repo.mage-os.org'; +const DEFAULT_SOURCE = 'preview-repo.mage-os.org'; +const VENDOR = 'mage-os'; + +// composer.json always differs (version bump), so it is never signal. +const IGNORED = ['composer.json']; + +// ---------------------------------------------------------------- arguments + +$args = array_slice($argv, 1); +$positional = []; +$opts = ['source' => DEFAULT_SOURCE, 'packages' => null, 'all' => false, 'keep' => false]; + +foreach ($args as $arg) { + if (str_starts_with($arg, '--source=')) { + $opts['source'] = substr($arg, 9); + } elseif (str_starts_with($arg, '--packages=')) { + $opts['packages'] = array_values(array_filter(array_map('trim', explode(',', substr($arg, 11))))); + } elseif ($arg === '--all') { + $opts['all'] = true; + } elseif ($arg === '--keep') { + $opts['keep'] = true; + } elseif (str_starts_with($arg, '--')) { + fwrite(STDERR, "Unknown option: $arg\n"); + exit(64); + } else { + $positional[] = $arg; + } +} + +if (count($positional) < 2) { + fwrite(STDERR, "Usage: verify-packages.php [--source=HOST] [--packages=a,b,c] [--all] [--keep]\n"); + exit(64); +} + +[$prev, $new] = $positional; + +if (!$opts['all'] && $opts['packages'] === null) { + fwrite(STDERR, "Specify --packages=... or --all.\n"); + fwrite(STDERR, "Start from the PRs this release should contain, map each to its package, and verify those.\n"); + exit(64); +} + +// ---------------------------------------------------------------- helpers + +function fetchJson(string $url): ?array +{ + $ctx = stream_context_create(['http' => ['timeout' => 60, 'ignore_errors' => true]]); + $body = @file_get_contents($url, false, $ctx); + if ($body === false) { + return null; + } + $decoded = json_decode($body, true); + return is_array($decoded) ? $decoded : null; +} + +/** Resolve a package version's dist URL from the Composer v2 metadata. */ +function distUrl(string $host, string $package, string $version): ?string +{ + $meta = fetchJson("https://$host/p2/$package.json"); + foreach ($meta['packages'][$package] ?? [] as $release) { + if (($release['version'] ?? null) === $version) { + return $release['dist']['url'] ?? null; + } + } + return null; +} + +function download(string $url, string $dest): bool +{ + $ctx = stream_context_create(['http' => ['timeout' => 300, 'ignore_errors' => true]]); + $data = @file_get_contents($url, false, $ctx); + if ($data === false || $data === '') { + return false; + } + return file_put_contents($dest, $data) !== false; +} + +function unzipTo(string $zip, string $dir): bool +{ + $archive = new ZipArchive(); + if ($archive->open($zip) !== true) { + return false; + } + $ok = $archive->extractTo($dir); + $archive->close(); + return $ok; +} + +/** Relative path => sha1, for every file in a tree. */ +function hashTree(string $root): array +{ + $out = []; + if (!is_dir($root)) { + return $out; + } + $it = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($root, FilesystemIterator::SKIP_DOTS), + RecursiveIteratorIterator::SELF_FIRST + ); + foreach ($it as $file) { + if (!$file->isFile()) { + continue; + } + $rel = ltrim(substr($file->getPathname(), strlen($root)), '/'); + $out[$rel] = sha1_file($file->getPathname()); + } + ksort($out); + return $out; +} + +function rmrf(string $path): void +{ + if (!is_dir($path)) { + @unlink($path); + return; + } + $it = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($path, FilesystemIterator::SKIP_DOTS), + RecursiveIteratorIterator::CHILD_FIRST + ); + foreach ($it as $entry) { + $entry->isDir() ? @rmdir($entry->getPathname()) : @unlink($entry->getPathname()); + } + @rmdir($path); +} + +/** Every mage-os/* package present in both releases. */ +function packagesInBoth(string $newHost, string $prevVersion, string $newVersion): array +{ + $names = []; + foreach (['product-community-edition', 'magento2-base'] as $meta) { + $data = fetchJson("https://$newHost/p2/" . VENDOR . "/$meta.json"); + foreach ($data['packages'][VENDOR . "/$meta"] ?? [] as $release) { + if (($release['version'] ?? null) !== $newVersion) { + continue; + } + foreach (array_keys($release['require'] ?? []) as $dep) { + if (str_starts_with($dep, VENDOR . '/')) { + $names[] = substr($dep, strlen(VENDOR) + 1); + } + } + } + } + $names = array_values(array_unique($names)); + sort($names); + return $names; +} + +// ---------------------------------------------------------------- run + +$newHost = $opts['source']; + +fwrite(STDERR, "Comparing " . VENDOR . "/* packages: $prev (" . PROD_REPO . ") -> $new ($newHost)\n"); + +$shortNames = $opts['all'] + ? packagesInBoth($newHost, $prev, $new) + : $opts['packages']; + +if (!$shortNames) { + fwrite(STDERR, "No packages resolved to compare.\n"); + exit(1); +} + +fwrite(STDERR, count($shortNames) . " package(s) to check\n\n"); + +$work = sys_get_temp_dir() . '/mageos-verify-' . getmypid(); +@mkdir($work, 0777, true); + +$changed = []; +$identical = []; +$skipped = []; + +foreach ($shortNames as $i => $short) { + $package = VENDOR . "/$short"; + fwrite(STDERR, sprintf("[%d/%d] %s ... ", $i + 1, count($shortNames), $package)); + + $prevUrl = distUrl(PROD_REPO, $package, $prev); + $newUrl = distUrl($newHost, $package, $new); + + if ($prevUrl === null || $newUrl === null) { + $reason = $prevUrl === null && $newUrl === null ? 'absent from both' + : ($prevUrl === null ? "new in $new" : "removed in $new"); + $skipped[$short] = $reason; + fwrite(STDERR, "skip ($reason)\n"); + continue; + } + + $base = "$work/$short"; + @mkdir($base, 0777, true); + + if (!download($prevUrl, "$base/prev.zip") || !download($newUrl, "$base/new.zip")) { + $skipped[$short] = 'download failed'; + fwrite(STDERR, "skip (download failed)\n"); + continue; + } + + if (!unzipTo("$base/prev.zip", "$base/prev") || !unzipTo("$base/new.zip", "$base/new")) { + $skipped[$short] = 'unzip failed'; + fwrite(STDERR, "skip (unzip failed)\n"); + continue; + } + + $before = hashTree("$base/prev"); + $after = hashTree("$base/new"); + + foreach (IGNORED as $ignore) { + unset($before[$ignore], $after[$ignore]); + } + + $added = array_diff_key($after, $before); + $removed = array_diff_key($before, $after); + $modified = []; + foreach ($before as $path => $hash) { + if (isset($after[$path]) && $after[$path] !== $hash) { + $modified[] = $path; + } + } + + if (!$added && !$removed && !$modified) { + $identical[] = $short; + fwrite(STDERR, "identical\n"); + } else { + $changed[$short] = [ + 'added' => array_keys($added), + 'removed' => array_keys($removed), + 'modified' => $modified, + ]; + fwrite(STDERR, sprintf("CHANGED (+%d -%d ~%d)\n", count($added), count($removed), count($modified))); + } + + if (!$opts['keep']) { + rmrf($base); + } +} + +// ---------------------------------------------------------------- report + +echo "\n"; +echo "Package content changes: $prev -> $new\n"; +echo str_repeat('=', 60) . "\n\n"; + +if ($changed) { + foreach ($changed as $short => $delta) { + echo VENDOR . "/$short\n"; + foreach (['added' => '+', 'removed' => '-', 'modified' => '~'] as $key => $sigil) { + foreach ($delta[$key] as $path) { + echo " $sigil $path\n"; + } + } + echo "\n"; + } +} else { + echo "No content changes found (composer.json version bumps excluded).\n\n"; +} + +echo str_repeat('-', 60) . "\n"; +printf("changed: %d identical: %d skipped: %d\n", count($changed), count($identical), count($skipped)); + +if ($skipped) { + echo "\nSkipped:\n"; + foreach ($skipped as $short => $reason) { + echo " " . VENDOR . "/$short — $reason\n"; + } +} + +echo "\nEvery changed file above should be explained by a PR in this release.\n"; +echo "Anything unexplained needs an answer before shipping.\n"; + +if ($opts['keep']) { + echo "\nWorking directory kept: $work\n"; +} else { + rmrf($work); +} + +exit(0);