From 8ac09eca7d9cfd2c754c222dc476b5993adf6c10 Mon Sep 17 00:00:00 2001 From: jbisa Date: Thu, 20 Aug 2026 11:34:17 -0400 Subject: [PATCH] @W-23938378 | Overlay manifest extras, seed catalog.json only for new apps, and stop release-to-main PRs Equal-or-newer upserts overlay in place so target-only carousel fields survive promote. Auto-promo writes INIT catalog.json only for brand-new apps and leaves existing catalogs to the target branch CI job. The newest release branch is the end of the chain; next_release_branch no longer hops to main. --- .github/scripts/promotion-utils.sh | 77 +++++++++++++++++++------ .github/scripts/test-promotion-utils.sh | 58 ++++++++++++++++--- .github/workflows/update-catalog.yml | 40 +++++++------ 3 files changed, 131 insertions(+), 44 deletions(-) diff --git a/.github/scripts/promotion-utils.sh b/.github/scripts/promotion-utils.sh index 1e1478d..d65b4a5 100755 --- a/.github/scripts/promotion-utils.sh +++ b/.github/scripts/promotion-utils.sh @@ -2,19 +2,23 @@ # Helpers for forward-integration (auto-promotion) of app artifacts across # release branches. Promotion is forward-only along the chain -# release/ -> ... -> release/ -> main +# release/ -> ... -> release/ +# The newest release branch is the end of the chain; auto-promote never opens +# a PR into main. # Each function is pure (reads args / files, writes stdout) so it can be unit # tested by test-promotion-utils.sh without a live checkout. # Prints the next branch in the forward-integration chain for a given ref, or -# nothing when the ref is the end of the chain (main) or not a release branch. +# nothing when the ref is the end of the chain or not a release branch. # # next_release_branch # # Rules: # - "main" -> chain terminates, prints nothing. # - "release/X.Y" -> the smallest release/X'.Y' strictly greater than the -# current version; if none exists, "main". +# current version; if none exists, prints nothing +# (newest release is the end of the chain — never hops +# to main). # - anything else -> prints nothing (feature branches are not promoted). # # Version comparison is numeric on (major, minor); no dependency on `sort -V` @@ -50,8 +54,6 @@ next_release_branch() { if [[ -n "$best" ]]; then echo "$best" - else - echo "main" fi } @@ -109,14 +111,50 @@ merge_catalog_json() { ' "$catalog_path" } +# INIT catalog.json template used when promoting a brand-new app onto a target +# that has no catalog.json yet. Version history is then populated by the +# target branch's own update-catalog-pr job (CONTRIBUTING.md contract). +INIT_CATALOG_JSON='{ + "latest": { + "version": "INIT", + "tag": "INIT" + }, + "versions": [] +}' + +# Seeds catalog.json with the INIT template iff the file does not already +# exist. Prints "seeded" when a new file is written, or "skipped" when an +# existing catalog.json is left untouched. Version bumps of pre-existing apps +# must not rewrite catalog.json in the auto-promo PR — that file is owned by +# the target branch's catalog CI job. +# +# seed_init_catalog_if_absent +seed_init_catalog_if_absent() { + local catalog_path="${1:?catalog path is required}" + if [[ -f "$catalog_path" ]]; then + echo "skipped" + return 0 + fi + mkdir -p "$(dirname "$catalog_path")" + printf '%s\n' "$INIT_CATALOG_JSON" > "$catalog_path" + echo "seeded" +} + # Upserts a manifest entry into a category array of the target branch's # manifest and prints the merged JSON. The manifest holds exactly one entry per # app, pinned to its latest version, keyed by `.id` (e.g. loqate carries nine # catalog versions but a single manifest entry). So the upsert: # - matches the existing entry by `.id` (app identity), not by zip filename; -# - is monotonic on version: it replaces the entry only when the incoming -# version is >= the existing pinned version, so promoting an OLDER artifact -# forward (a back-port hop) never regresses the target's pinned version; +# - is monotonic on version: it overlays the incoming entry onto the existing +# one only when the incoming version is >= the existing pinned version, so +# promoting an OLDER artifact forward (a back-port hop) never regresses the +# target's pinned version; +# - overlays rather than replaces: keys present only on the target (e.g. +# workspace-carousel fields added on a newer release branch) are preserved +# even when the source entry at the same or newer version lacks them. +# Overlapping keys still take the incoming value, so version/zip/sha256 +# advance and an intentional source-side metadata edit still promotes; +# - updates in place (no remove-and-append), so category order is stable; # - appends when the app is not yet present on the target; # - creates the category array when the target manifest lacks it. # A release ranks above a pre-release of the same major.minor.patch. @@ -146,15 +184,15 @@ merge_manifest_entry() { .[$cat] = ($arr + [$entry]) elif ($existing == $entry) then # Byte-for-byte identical already -> true no-op. Without this branch, - # the "replace" branch below would still fire (equal version compares - # >= 0) and re-append the entry at the end of the array, reordering - # every other promoted entry for zero effect and producing a - # spurious diff on an otherwise fully-idempotent re-promotion. + # jq would still rewrite the whole file (indent/key order) for zero + # effect on an otherwise fully-idempotent re-promotion. . elif (semver_cmp($entry.version; ($existing.version // "0.0.0")) >= 0) then - # Incoming version is newer, or equal but with changed metadata -> - # replace the pinned entry. - .[$cat] = ((($arr | map(select((.id? // "") != $id)))) + [$entry]) + # Incoming version is newer, or equal with changed/additional fields. + # Overlay source keys onto the existing entry IN PLACE so target-only + # keys (isFeatured, badge, featured*, companyName, …) are never dropped + # just because the older branch lacks them. + .[$cat] = ($arr | map(if (.id? // "") == $id then . + $entry else . end)) else # Target already pins a newer version -> leave it untouched (monotonic). . @@ -163,12 +201,15 @@ merge_manifest_entry() { } # Merges every entry across every category of a source manifest.json into a -# target manifest.json, applying the same per-id monotonic upsert as +# target manifest.json, applying the same per-id monotonic overlay as # merge_manifest_entry (above) to each entry individually. This is what lets a # manual, hand-edited manifest.json change (not tied to any ZIP push) promote # forward: the edited entry is MERGED into the target - respecting the -# monotonic, id-keyed guard - rather than the whole file being overwritten, -# so it can never regress an entry the target already pins to a newer version. +# monotonic, id-keyed guard - rather than the whole file being overwritten. +# Equal-or-newer source entries overlay onto the target (source wins on +# overlapping keys; target-only keys are preserved), so a newer-branch field +# such as isFeatured cannot be wiped by an older-branch promote that simply +# lacks the key. An older source version never regresses the target's pin. # # A single malformed entry (e.g. a non-semver version string) must not abort # the whole merge under the caller's `set -e` - that would silently discard diff --git a/.github/scripts/test-promotion-utils.sh b/.github/scripts/test-promotion-utils.sh index af9b9c6..709c2ee 100755 --- a/.github/scripts/test-promotion-utils.sh +++ b/.github/scripts/test-promotion-utils.sh @@ -126,7 +126,7 @@ echo "--- next_release_branch ---" assert_eq "26.8 -> 26.9" "release/26.9" next_release_branch "release/26.8" "$BRANCHES" assert_eq "26.9 -> 27.0" "release/27.0" next_release_branch "release/26.9" "$BRANCHES" -assert_eq "highest release -> main" "main" next_release_branch "release/27.0" "$BRANCHES" +assert_eq "highest release terminates" "" next_release_branch "release/27.0" "$BRANCHES" assert_eq "refs/heads/ prefix accepted" "release/26.9" next_release_branch "refs/heads/release/26.8" "$BRANCHES" assert_eq "major rollover picks minimal" "release/27.0" next_release_branch "release/26.9" \ "$(printf '%s\n' refs/heads/release/27.5 refs/heads/release/27.0 refs/heads/release/28.0)" @@ -183,6 +183,29 @@ assert_json_eq "empty catalog seeds first entry" \ echo "" +# --------------------------------------------------------------------------- +# seed_init_catalog_if_absent (auto-promo: INIT only for brand-new apps) +# --------------------------------------------------------------------------- +echo "--- seed_init_catalog_if_absent ---" + +# Brand-new app: no catalog.json on the target -> write INIT, do not invent a version. +new_cat="$TMPDIR_ROOT/new-app/catalog.json" +seeded_result="$(seed_init_catalog_if_absent "$new_cat")" +assert_eq "absent catalog is seeded" "seeded" printf '%s' "$seeded_result" +assert_json_eq "seeded catalog is INIT template" \ + '{"latest":{"version":"INIT","tag":"INIT"},"versions":[]}' \ + cat "$new_cat" + +# Version bump: catalog.json already exists -> leave it untouched (no merge, no INIT overwrite). +existing_cat="$(mkfile '{"latest":{"version":"1.0.0","tag":"app-v1.0.0"},"versions":[{"version":"1.0.0","tag":"app-v1.0.0"}]}')" +skipped_result="$(seed_init_catalog_if_absent "$existing_cat")" +assert_eq "existing catalog is skipped" "skipped" printf '%s' "$skipped_result" +assert_json_eq "existing catalog content is untouched" \ + '{"latest":{"version":"1.0.0","tag":"app-v1.0.0"},"versions":[{"version":"1.0.0","tag":"app-v1.0.0"}]}' \ + cat "$existing_cat" + +echo "" + # --------------------------------------------------------------------------- # merge_manifest_entry # --------------------------------------------------------------------------- @@ -194,7 +217,7 @@ assert_json_eq "appends new app to category" \ '{"shipping":[{"id":"a","zip":"a-v1.0.0.zip","version":"1.0.0"},{"id":"b","zip":"b-v1.0.0.zip","version":"1.0.0"}]}' \ merge_manifest_entry "$m1" '{"id":"b","zip":"b-v1.0.0.zip","version":"1.0.0"}' "shipping" -# Same app id, newer version -> replace the single pinned entry (no dup, zip advances). +# Same app id, newer version -> overlay onto the single pinned entry (no dup, zip advances). m2="$(mkfile '{"shipping":[{"id":"a","zip":"a-v1.0.0.zip","version":"1.0.0"}]}')" assert_json_eq "replaces pinned entry with newer version" \ '{"shipping":[{"id":"a","zip":"a-v1.1.0.zip","version":"1.1.0"}]}' \ @@ -206,6 +229,20 @@ assert_json_eq "idempotent upsert at equal version" \ '{"shipping":[{"id":"a","zip":"a-v1.0.0.zip","version":"1.0.0","sha256":"new"}]}' \ merge_manifest_entry "$m2b" '{"id":"a","zip":"a-v1.0.0.zip","version":"1.0.0","sha256":"new"}' "shipping" +# Same app id, same version, target has extra keys the source lacks (e.g. 26.9 +# carousel fields, 26.8 entry without them) -> extras are preserved, not wiped. +m2d="$(mkfile '{"shipping":[{"id":"a","zip":"a-v1.0.0.zip","version":"1.0.0","sha256":"old","isFeatured":true,"badge":"popular"}]}')" +assert_json_eq "equal-version overlay preserves target-only keys" \ + '{"shipping":[{"id":"a","zip":"a-v1.0.0.zip","version":"1.0.0","sha256":"new","isFeatured":true,"badge":"popular"}]}' \ + merge_manifest_entry "$m2d" '{"id":"a","zip":"a-v1.0.0.zip","version":"1.0.0","sha256":"new"}' "shipping" + +# Same app id, NEWER version, target has extra keys -> version/zip advance and +# extras are still preserved. Also stays in its original array position. +m2e="$(mkfile '{"shipping":[{"id":"a","zip":"a-v1.0.0.zip","version":"1.0.0","isFeatured":true},{"id":"b","zip":"b-v1.0.0.zip","version":"1.0.0"}]}')" +assert_json_eq "newer-version overlay preserves target-only keys and position" \ + '{"shipping":[{"id":"a","zip":"a-v1.1.0.zip","version":"1.1.0","isFeatured":true},{"id":"b","zip":"b-v1.0.0.zip","version":"1.0.0"}]}' \ + merge_manifest_entry "$m2e" '{"id":"a","zip":"a-v1.1.0.zip","version":"1.1.0"}' "shipping" + # Same app id, OLDER version (back-port hop) -> target's newer pin is untouched. m2c="$(mkfile '{"shipping":[{"id":"a","zip":"a-v2.0.0.zip","version":"2.0.0"}]}')" assert_json_eq "older version does not regress pinned entry" \ @@ -227,10 +264,9 @@ m4_indent="$(merge_manifest_entry "$m4" '{"id":"b","zip":"b-v1.0.0.zip","version | grep -m1 '"analytics"' | sed -E 's/[^ ].*//' | tr -d '\n' | wc -c | tr -d ' ')" assert_eq "upsert emits 4-space indentation" "4" printf '%s' "$m4_indent" -# Byte-identical re-upsert must be a true no-op: it must NOT move the entry to -# the end of its category array (which would happen if the equal-version -# "replace" branch fired), or every other entry's promotion would be reordered -# for zero effect on a fully-idempotent re-promotion. +# Byte-identical re-upsert must be a true no-op: it must NOT rewrite or move +# the entry (which would reorder every other entry for zero effect on a +# fully-idempotent re-promotion). m5="$(mkfile '{"shipping":[{"id":"a","zip":"a-v1.0.0.zip","version":"1.0.0"},{"id":"b","zip":"b-v1.0.0.zip","version":"1.0.0"}]}')" assert_json_eq "byte-identical re-upsert does not reorder" \ '{"shipping":[{"id":"a","zip":"a-v1.0.0.zip","version":"1.0.0"},{"id":"b","zip":"b-v1.0.0.zip","version":"1.0.0"}]}' \ @@ -299,7 +335,7 @@ echo "" # --------------------------------------------------------------------------- echo "--- merge_manifest_file ---" -# Source has a newer entry for an app the target already pins -> replaces it, +# Source has a newer entry for an app the target already pins -> overlays it, # same monotonic guard as merge_manifest_entry, applied across every category. mf1_target="$(mkfile '{"shipping":[{"id":"a","zip":"a-v1.0.0.zip","version":"1.0.0"}]}')" mf1_source="$(mkfile '{"shipping":[{"id":"a","zip":"a-v1.1.0.zip","version":"1.1.0"}]}')" @@ -328,6 +364,14 @@ assert_json_eq "idempotent re-merge (no dup)" \ '{"shipping":[{"id":"a","zip":"a-v1.1.0.zip","version":"1.1.0"}]}' \ merge_manifest_file "$mf4_target" "$mf4_source" +# Whole-file promote of an equal-version source that lacks target-only keys +# must not wipe them (the #113 26.8→26.9 carousel-field regression). +mf4b_target="$(mkfile '{"tax":[{"id":"avalara-tax","zip":"avalara-tax-v1.0.0.zip","version":"1.0.0","isFeatured":true,"badge":"popular","featuredLearnMoreUrl":"https://example.com"}]}')" +mf4b_source="$(mkfile '{"tax":[{"id":"avalara-tax","zip":"avalara-tax-v1.0.0.zip","version":"1.0.0"}]}')" +assert_json_eq "whole-file equal-version merge preserves target-only keys" \ + '{"tax":[{"id":"avalara-tax","zip":"avalara-tax-v1.0.0.zip","version":"1.0.0","isFeatured":true,"badge":"popular","featuredLearnMoreUrl":"https://example.com"}]}' \ + merge_manifest_file "$mf4b_target" "$mf4b_source" + # A scalar top-level field (e.g. defaultLocale) has no per-entry version to # compare, so it must still carry forward verbatim rather than being silently # dropped by a merge that only knows how to walk category arrays. diff --git a/.github/workflows/update-catalog.yml b/.github/workflows/update-catalog.yml index 0910cc8..4a33f0f 100644 --- a/.github/workflows/update-catalog.yml +++ b/.github/workflows/update-catalog.yml @@ -207,16 +207,20 @@ jobs: # # When a ZIP lands on a release branch, open a self-contained promotion PR # into the next branch in the chain - # release/ -> release/ -> main - # carrying the ZIP, its manifest entry, the merged catalog.json, and icons. + # release/ -> release/ + # carrying the ZIP, its manifest entry, an INIT catalog.json only for + # brand-new apps, and icons. Pre-existing apps' catalog.json is left for the + # target branch's update-catalog-pr job. # # Because the PR carries a ZIP, merging it re-fires THIS workflow on the next # branch, which advances the chain one hop further. The chain is therefore # self-propagating with one gated (human/App-reviewed) merge per hop and no # cross-workflow wiring. The `update-catalog-pr` job on the next branch sees - # no diff (catalog merge is idempotent, tag is immutable) and is a no-op. + # the new ZIP and writes version history into catalog.json (and is a no-op + # when no ZIP changed). # - # Runs only on release branches; `main` is the end of the chain. + # Runs only on release branches. The newest release branch is the end of the + # chain; auto-promote never opens a PR into main. # --------------------------------------------------------------------------- promote-forward: runs-on: ubuntu-latest @@ -299,7 +303,7 @@ jobs: # 2) Determine the next hop from the live remote branch list. Fail loud # if the remote returned nothing: an empty list would make - # next_release_branch fall through to "main" and skip release hops. + # next_release_branch print nothing and skip every hop. branch_list="$(git ls-remote --heads origin | awk '{print $2}')" if [[ -z "$branch_list" ]]; then echo "::error::git ls-remote returned no branches; cannot determine next hop" @@ -391,22 +395,19 @@ jobs: mv "$tmp" "$manifest_path" git add "$manifest_path" - # 5c) catalog.json next to the ZIP (union versions, monotonic latest). - # A brand-new app on the target has no catalog.json yet - seed - # it with the INIT template and skip merge_catalog_json so the - # target branch's own post-merge update-catalog run is what - # first populates version history (the CONTRIBUTING.md - # contract for new apps), instead of the promotion writing a - # concrete version here and duplicating that work. + # 5c) catalog.json next to the ZIP. Brand-new apps (no catalog.json + # on the target) get the INIT template so the target branch's + # post-merge update-catalog-pr job can populate version history + # (CONTRIBUTING.md contract). Version bumps of apps that already + # have catalog.json are left untouched — that file must not + # appear in the auto-promo PR. catalog_path="$(dirname "$zip_path")/catalog.json" - if [[ -f "$catalog_path" ]]; then - tmp="$(mktemp)" - merge_catalog_json "$catalog_path" "$version" "$tag_name" > "$tmp" - mv "$tmp" "$catalog_path" + catalog_result="$(seed_init_catalog_if_absent "$catalog_path")" + if [[ "$catalog_result" == "seeded" ]]; then + git add "$catalog_path" else - printf '{\n "latest": {\n "version": "INIT",\n "tag": "INIT"\n },\n "versions": []\n}\n' > "$catalog_path" + echo "Existing catalog.json at $catalog_path; leaving it for the target branch catalog CI job." fi - git add "$catalog_path" # 5d) Icons: copy from the ZIP into the shared icons dir. isv_name="$(echo "$zip_path" | cut -d'/' -f2)" @@ -516,7 +517,8 @@ jobs: - Any app ZIP(s) - The matching `commerce-apps-manifest/manifest.json` entry/entries (monotonic upsert keyed by app id) - - The merged `catalog.json` (union `versions`, monotonic `latest`) + - An INIT `catalog.json` only when promoting a brand-new app (existing + apps' catalogs are left to the target branch's catalog CI job) - Any app icons under `commerce-apps-manifest/icons/` - Any `commerce-apps-manifest/translations/*.json` changes (additive per-key merge, keyed by app id) - Any other non-CAP file changes (docs, skills, etc. - excluding `.github/workflows/**`, which