diff --git a/.github/scripts/promotion-utils.sh b/.github/scripts/promotion-utils.sh index 1e1478d..cd578db 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,125 @@ 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" +} + +# Builds the promote-forward items TSV from a changed-ZIP list. Each input path +# is relative to source_root. A ZIP is emitted only when the file exists on +# the source (deletions are skipped) AND the source manifest has a matching +# entry (back-ported/unpinned ZIPs are skipped). Output rows: +# zip_pathversioncategory +# Skip notes go to stderr so they cannot pollute the TSV. +# +# Requires lookup_manifest_entry_for_zip (root-manifest-utils.sh). +# +# collect_promotable_zips +collect_promotable_zips() { + local source_root="${1:?source root is required}" + local manifest_path="${2:?source manifest path is required}" + local changed_zips_file="${3:?changed-zips list file is required}" + + local zip_path src_file zip_file entry version category + while IFS= read -r zip_path; do + [[ -z "$zip_path" ]] && continue + src_file="$zip_path" + if [[ "$zip_path" != /* ]]; then + src_file="$source_root/$zip_path" + fi + [[ -f "$src_file" ]] || continue + zip_file="$(basename "$zip_path")" + entry="$(lookup_manifest_entry_for_zip "$zip_file" "$manifest_path")" + if [[ -z "$entry" ]]; then + echo "No manifest entry for $zip_file; not promoting (back-ported/unpinned artifact)." >&2 + continue + fi + version="$(jq -r '.version // empty' <<< "$entry")" + category="$(jq -r --arg z "$zip_file" ' + to_entries[] + | select(.value | type == "array") + | select(any(.value[]?; .zip? == $z)) + | .key + ' "$manifest_path")" + if [[ -z "$version" || "$version" == "null" || -z "$category" ]]; then + echo "::error file=$manifest_path::Missing version/category for ZIP $zip_file" >&2 + return 1 + fi + printf '%s\t%s\t%s\n' "$zip_path" "$version" "$category" + done < "$changed_zips_file" +} + +# Copies one promoted ZIP onto the target working tree, overlays its source +# manifest entry (monotonic), and seeds INIT catalog.json only when the target +# has no catalog yet. Prints "seeded" or "skipped" (the catalog result) so the +# caller can git-add the catalog only when a new INIT file was written. +# Unlisted apps' ZIPs and catalogs are not touched. +# +# Requires get_manifest_entry_for_zip (root-manifest-utils.sh). +# +# apply_promoted_zip_onto_target +apply_promoted_zip_onto_target() { + local target_root="${1:?target root is required}" + local source_zip="${2:?source ZIP is required}" + local zip_relpath="${3:?zip relpath is required}" + local source_manifest="${4:?source manifest is required}" + local category="${5:?category is required}" + + local dest="$target_root/$zip_relpath" + mkdir -p "$(dirname "$dest")" + cp "$source_zip" "$dest" + + local zip_file entry tmp manifest_path + zip_file="$(basename "$zip_relpath")" + entry="$(get_manifest_entry_for_zip "$zip_file" "$source_manifest")" + manifest_path="$target_root/commerce-apps-manifest/manifest.json" + tmp="$(mktemp)" + merge_manifest_entry "$manifest_path" "$entry" "$category" > "$tmp" + mv "$tmp" "$manifest_path" + + seed_init_catalog_if_absent "$(dirname "$dest")/catalog.json" +} + # 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 +259,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 +276,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..00a4b57 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,96 @@ 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 "" + +# --------------------------------------------------------------------------- +# collect_promotable_zips + apply_promoted_zip_onto_target +# (workflow wiring: only changed ZIPs are copied; unlisted apps stay put) +# --------------------------------------------------------------------------- +echo "--- collect_promotable_zips / apply_promoted_zip_onto_target ---" + +src="$TMPDIR_ROOT/src-26.8" +tgt="$TMPDIR_ROOT/tgt-26.9" +mkdir -p \ + "$src/tax/avalara-tax" \ + "$src/merchandising/approaching-discounts" \ + "$tgt/tax/avalara-tax" \ + "$tgt/commerce-apps-manifest" +printf 'AVALARA-100' > "$src/tax/avalara-tax/avalara-tax-v1.0.0.zip" +printf 'AVALARA-OLD' > "$src/tax/avalara-tax/avalara-tax-v0.9.0.zip" +printf 'AD-100' > "$src/merchandising/approaching-discounts/approaching-discounts-v1.0.0.zip" +printf 'AVALARA-101' > "$tgt/tax/avalara-tax/avalara-tax-v1.0.1.zip" +printf '%s\n' '{"latest":{"version":"1.0.1","tag":"avalara-tax-v1.0.1"},"versions":[{"version":"1.0.1","tag":"avalara-tax-v1.0.1"}]}' \ + > "$tgt/tax/avalara-tax/catalog.json" +printf '%s\n' '{"tax":[{"id":"avalara-tax","zip":"avalara-tax-v1.0.1.zip","version":"1.0.1","isFeatured":true,"badge":"popular"}],"merchandising":[]}' \ + > "$tgt/commerce-apps-manifest/manifest.json" +src_manifest="$(mkfile '{"tax":[{"id":"avalara-tax","zip":"avalara-tax-v1.0.0.zip","version":"1.0.0"}],"merchandising":[{"id":"approaching-discounts","zip":"approaching-discounts-v1.0.0.zip","version":"1.0.0"}]}')" + +# Push analog: only the new Approaching Discounts ZIP changed. +only_ad="$(mkfile "merchandising/approaching-discounts/approaching-discounts-v1.0.0.zip")" +assert_eq "only changed new-app ZIP is selected" \ + "$(printf '%s\t%s\t%s\n' merchandising/approaching-discounts/approaching-discounts-v1.0.0.zip 1.0.0 merchandising)" \ + collect_promotable_zips "$src" "$src_manifest" "$only_ad" + +# A ZIP that exists on source but is not the pinned manifest entry (back-port) +# plus a deletion (missing file) must not appear in the TSV. +mixed="$(mkfile "$(printf '%s\n' \ + merchandising/approaching-discounts/approaching-discounts-v1.0.0.zip \ + tax/avalara-tax/avalara-tax-v0.9.0.zip \ + tax/avalara-tax/does-not-exist.zip)")" +assert_eq "unpinned and missing ZIPs are not selected" \ + "$(printf '%s\t%s\t%s\n' merchandising/approaching-discounts/approaching-discounts-v1.0.0.zip 1.0.0 merchandising)" \ + collect_promotable_zips "$src" "$src_manifest" "$mixed" + +# Apply the selected ZIP onto 26.9, then whole-file merge (manifest_changed). +tsv="$(collect_promotable_zips "$src" "$src_manifest" "$only_ad")" +while IFS=$'\t' read -r zip_path version category; do + [[ -z "$zip_path" ]] && continue + apply_promoted_zip_onto_target "$tgt" "$src/$zip_path" "$zip_path" "$src_manifest" "$category" >/dev/null +done <<< "$tsv" +tmp="$(mktemp)" +merge_manifest_file "$tgt/commerce-apps-manifest/manifest.json" "$src_manifest" > "$tmp" +mv "$tmp" "$tgt/commerce-apps-manifest/manifest.json" + +assert_eq "new-app ZIP is copied onto the target" "AD-100" \ + cat "$tgt/merchandising/approaching-discounts/approaching-discounts-v1.0.0.zip" +assert_json_eq "new-app catalog is INIT" \ + '{"latest":{"version":"INIT","tag":"INIT"},"versions":[]}' \ + cat "$tgt/merchandising/approaching-discounts/catalog.json" +assert_eq "avalara 1.0.1 ZIP bytes are unchanged" "AVALARA-101" \ + cat "$tgt/tax/avalara-tax/avalara-tax-v1.0.1.zip" +assert_eq "avalara 1.0.0 ZIP is not copied" "absent" \ + bash -c "if [[ -f '$tgt/tax/avalara-tax/avalara-tax-v1.0.0.zip' ]]; then echo present; else echo absent; fi" +assert_json_eq "avalara catalog.json is untouched" \ + '{"latest":{"version":"1.0.1","tag":"avalara-tax-v1.0.1"},"versions":[{"version":"1.0.1","tag":"avalara-tax-v1.0.1"}]}' \ + cat "$tgt/tax/avalara-tax/catalog.json" +assert_json_eq "avalara pin stays 1.0.1 with extras; new app is appended" \ + '{"tax":[{"id":"avalara-tax","zip":"avalara-tax-v1.0.1.zip","version":"1.0.1","isFeatured":true,"badge":"popular"}],"merchandising":[{"id":"approaching-discounts","zip":"approaching-discounts-v1.0.0.zip","version":"1.0.0"}]}' \ + cat "$tgt/commerce-apps-manifest/manifest.json" + +echo "" + # --------------------------------------------------------------------------- # merge_manifest_entry # --------------------------------------------------------------------------- @@ -194,7 +284,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 +296,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 +331,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 +402,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"}]}')" @@ -314,6 +417,15 @@ assert_json_eq "older source entry does not regress target's pin" \ '{"shipping":[{"id":"a","zip":"a-v2.0.0.zip","version":"2.0.0"}]}' \ merge_manifest_file "$mf2_target" "$mf2_source" +# Realistic 26.8 → 26.9 hop: source adds a brand-new app (approaching-discounts) +# AND still pins avalara at an OLDER version than the target. Whole-file merge +# must append the new app and must NOT roll avalara back (or drop 26.9 extras). +mf2b_target="$(mkfile '{"tax":[{"id":"avalara-tax","zip":"avalara-tax-v1.0.1.zip","version":"1.0.1","isFeatured":true,"badge":"popular"}],"merchandising":[]}')" +mf2b_source="$(mkfile '{"tax":[{"id":"avalara-tax","zip":"avalara-tax-v1.0.0.zip","version":"1.0.0"}],"merchandising":[{"id":"approaching-discounts","zip":"approaching-discounts-v1.0.0.zip","version":"1.0.0"}]}')" +assert_json_eq "new-app promote does not regress a newer target pin" \ + '{"tax":[{"id":"avalara-tax","zip":"avalara-tax-v1.0.1.zip","version":"1.0.1","isFeatured":true,"badge":"popular"}],"merchandising":[{"id":"approaching-discounts","zip":"approaching-discounts-v1.0.0.zip","version":"1.0.0"}]}' \ + merge_manifest_file "$mf2b_target" "$mf2b_source" + # Source introduces a brand-new app in an existing category -> appended. mf3_target="$(mkfile '{"shipping":[{"id":"a","zip":"a-v1.0.0.zip","version":"1.0.0"}]}')" mf3_source="$(mkfile '{"shipping":[{"id":"a","zip":"a-v1.0.0.zip","version":"1.0.0"}],"tax":[{"id":"t","zip":"t-v1.0.0.zip","version":"1.0.0"}]}')" @@ -328,6 +440,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..65c77a8 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" @@ -319,37 +323,16 @@ jobs: # working tree. cp "$manifest_path" "$stash/source-manifest.json" [[ -d commerce-apps-manifest/translations ]] && cp -r commerce-apps-manifest/translations "$stash/source-translations" - : > "$stash/items.tsv" - - for zip_path in "${changed_zips[@]}"; do - [[ -f "$zip_path" ]] || continue # skip deletions - zip_file="$(basename "$zip_path")" - entry="$(lookup_manifest_entry_for_zip "$zip_file" "$stash/source-manifest.json")" - - # No manifest entry on the source branch: a back-ported (older) ZIP - # the monotonic guard left unreferenced. It is not the pinned - # artifact here and the newer version already cascaded forward on its - # own, so do not carry it to the next branch. - if [[ -z "$entry" ]]; then - echo "No manifest entry for $zip_file on $SOURCE_REF; not promoting (back-ported/unpinned artifact)." - continue - fi - - version="$(jq -r '.version // empty' <<< "$entry")" - category="$(jq -r --arg z "$zip_file" ' - to_entries[] - | select(.value | type == "array") - | select(any(.value[]?; .zip? == $z)) - | .key - ' "$stash/source-manifest.json")" - if [[ -z "$version" || "$version" == "null" || -z "$category" ]]; then - echo "::error file=$manifest_path::Missing version/category for ZIP $zip_file" - exit 1 - fi + : > "$stash/changed-zips.txt" + if [[ ${#changed_zips[@]} -gt 0 ]]; then + printf '%s\n' "${changed_zips[@]}" > "$stash/changed-zips.txt" + fi + collect_promotable_zips "." "$stash/source-manifest.json" "$stash/changed-zips.txt" > "$stash/items.tsv" + while IFS=$'\t' read -r zip_path version category; do + [[ -z "$zip_path" ]] && continue mkdir -p "$stash/zips/$(dirname "$zip_path")" cp "$zip_path" "$stash/zips/$zip_path" - printf '%s\t%s\t%s\n' "$zip_path" "$version" "$category" >> "$stash/items.tsv" - done + done < "$stash/items.tsv" if [[ ! -s "$stash/items.tsv" && "$manifest_changed" == "false" && "$translations_changed" == "false" && "$non_cap_changed" == "false" ]]; then echo "No promotable ZIPs (all deletions) and no manifest/translations/non-CAP changes. Nothing to promote." @@ -376,37 +359,18 @@ jobs: # 5) Apply each stashed artifact onto the target branch. while IFS=$'\t' read -r zip_path version category; do [[ -z "$zip_path" ]] && continue - zip_file="$(basename "$zip_path")" - tag_name="${zip_file%.zip}" - entry="$(get_manifest_entry_for_zip "$zip_file" "$stash/source-manifest.json")" - # 5a) ZIP bytes (creates the app directory on the target if new). - mkdir -p "$(dirname "$zip_path")" - cp "$stash/zips/$zip_path" "$zip_path" + # 5a–5c) ZIP bytes, monotonic manifest overlay, INIT catalog only + # when the target has no catalog.json yet. Unlisted apps (e.g. + # Avalara when only a new app ZIP changed) are not copied. + catalog_result="$(apply_promoted_zip_onto_target "." "$stash/zips/$zip_path" "$zip_path" "$stash/source-manifest.json" "$category")" git add "$zip_path" - - # 5b) Manifest entry (monotonic upsert keyed by app id). - tmp="$(mktemp)" - merge_manifest_entry "$manifest_path" "$entry" "$category" > "$tmp" - 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. - 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" + if [[ "$catalog_result" == "seeded" ]]; then + git add "$(dirname "$zip_path")/catalog.json" else - printf '{\n "latest": {\n "version": "INIT",\n "tag": "INIT"\n },\n "versions": []\n}\n' > "$catalog_path" + echo "Existing catalog.json at $(dirname "$zip_path")/catalog.json; 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 +480,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