diff --git a/.github/scripts/merge-main-into-release.sh b/.github/scripts/merge-main-into-release.sh new file mode 100644 index 000000000..dca716029 --- /dev/null +++ b/.github/scripts/merge-main-into-release.sh @@ -0,0 +1,162 @@ +#!/usr/bin/env bash +# Usage: merge-main-into-release.sh +# Run from a clean release checkout. The caller owns fetching and pushing. +set -euo pipefail + +MAIN_SHA="$(git rev-parse --verify "${1:?main ref required}^{commit}")" +BRANCH="${2:?release branch required}" +RELEASE_SHA="$(git rev-parse HEAD)" +MERGE_BASE="$(git merge-base HEAD "$MAIN_SHA")" + +fail() { + echo "::error::$*" >&2 + exit 1 +} + +[[ -z "$(git status --porcelain)" ]] || fail "Release checkout must be clean." +if git rev-parse -q --verify MERGE_HEAD >/dev/null; then + fail "A merge is already in progress." +fi + +report() { + printf '%s\n' "$@" + if [[ -n "${GITHUB_STEP_SUMMARY:-}" ]]; then + printf '%s\n' "$@" >> "$GITHUB_STEP_SUMMARY" + fi +} + +report "## Release sync: $BRANCH" \ + "- Release SHA: \`$RELEASE_SHA\`" \ + "- Main SHA: \`$MAIN_SHA\`" \ + "- Merge base: \`$MERGE_BASE\`" + +ancestor_status=0 +git merge-base --is-ancestor "$MAIN_SHA" HEAD || ancestor_status=$? +case "$ancestor_status" in + 0) + report "Main is already an ancestor of release; nothing to merge." + exit 0 + ;; + 1) ;; + *) fail "Could not check main ancestry (exit $ancestor_status)." ;; +esac + +generated_files=( + src/frontend/src/data/aspire-integrations.json + src/frontend/src/data/github-stats.json + src/frontend/src/data/samples.json + src/frontend/src/data/twoslash/aspire.d.ts +) +generated_dirs=( + src/frontend/src/data/pkgs + src/frontend/src/data/ts-modules + src/frontend/src/assets/samples +) + +is_generated() { + local path + for path in "${generated_files[@]}"; do + [[ "$1" != "$path" ]] || return 0 + done + for path in "${generated_dirs[@]}"; do + case "$1" in "$path"|"$path"/*) return 0 ;; esac + done + return 1 +} + +is_curated() { + case "$1" in + src/frontend/src/assets/samples/*) return 1 ;; + src/frontend/src/content/*|src/frontend/src/assets/*) return 0 ;; + *) return 1 ;; + esac +} + +keep_release() { + if git cat-file -e "$RELEASE_SHA:$1" 2>/dev/null; then + git checkout "$RELEASE_SHA" -- "$1" + else + git rm -q --force --ignore-unmatch -- "$1" + fi +} + +conflicts_file="$(mktemp)" +cleanup() { + local status=$? + if [[ "$status" -ne 0 ]] && git rev-parse -q --verify MERGE_HEAD >/dev/null; then + if ! git merge --abort; then + echo "::error::Could not abort the failed release merge." >&2 + status=1 + fi + fi + rm -f -- "$conflicts_file" + exit "$status" +} +trap cleanup EXIT + +merge_status=0 +git merge --no-commit --no-ff "$MAIN_SHA" || merge_status=$? +if [[ "$merge_status" -gt 1 ]] || ! git rev-parse -q --verify MERGE_HEAD >/dev/null; then + fail "Git merge failed (exit $merge_status); main is not already integrated." +fi + +git diff --name-only --diff-filter=U -z > "$conflicts_file" +mapfile -d '' -t conflicts < "$conflicts_file" +if [[ "$merge_status" -ne 0 && "${#conflicts[@]}" -eq 0 ]]; then + fail "Git merge failed without resolvable conflict entries (exit $merge_status)." +fi + +unresolved=() +for path in "${conflicts[@]}"; do + if is_generated "$path"; then + continue + elif is_curated "$path"; then + keep_release "$path" + else + unresolved+=("$path") + fi +done + +if [[ "${#unresolved[@]}" -ne 0 ]]; then + report "" "### Conflicts requiring human review" + for path in "${unresolved[@]}"; do + report "- \`$path\`" + done + report "" \ + "Create a repair branch from \`$BRANCH\`, merge main into it, and resolve these conflicts without discarding release-only work." \ + "Land the repair PR with **Create a merge commit**, never squash or rebase. Copying content alone does not repair ancestry." \ + "After landing, verify \`git merge-base --is-ancestor $MAIN_SHA origin/$BRANCH\`, then dispatch a fresh workflow run from main." + fail "Automated merge aborted: ${#unresolved[@]} conflicts require human review." +fi + +# Only these producer-owned paths are mirrored. Other data is hand-authored. +for path in "${generated_files[@]}" "${generated_dirs[@]}"; do + diff_status=0 + git diff --quiet "$MAIN_SHA" -- "$path" || diff_status=$? + [[ "$diff_status" -le 1 ]] || fail "Could not compare generated path $path." + if [[ "$diff_status" -eq 0 && -z "$(git ls-files -u -- "$path")" ]]; then + continue + fi + git rm -r -q --force --ignore-unmatch -- "$path" + if git cat-file -e "$MAIN_SHA:$path" 2>/dev/null; then + git checkout "$MAIN_SHA" -- "$path" + fi +done + +[[ -z "$(git ls-files -u)" ]] || fail "Unexpected unmerged entries remain." +if ! markers="$(git diff --cached --check)"; then + [[ -n "$markers" ]] || fail "Could not check the merged diff." + if grep -q 'conflict marker' <<< "$markers"; then + fail "Conflict markers detected after auto-resolution." + fi + echo "::warning::Merged diff has whitespace warnings:" + printf '%s\n' "$markers" +fi + +git commit --no-edit --trailer "Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>" +git merge-base --is-ancestor "$MAIN_SHA" HEAD || fail "Merge did not preserve main ancestry; do not push." +read -r commit first_parent second_parent extra <<< "$(git rev-list --parents -n 1 HEAD)" +if [[ "$first_parent" != "$RELEASE_SHA" || "$second_parent" != "$MAIN_SHA" || -n "$extra" ]]; then + fail "Expected a two-parent release/main merge commit; do not push." +fi +report "" "Merged main into release with both parents preserved: \`$commit\`." diff --git a/.github/scripts/test-merge-main-into-release.sh b/.github/scripts/test-merge-main-into-release.sh new file mode 100644 index 000000000..cce4b78e0 --- /dev/null +++ b/.github/scripts/test-merge-main-into-release.sh @@ -0,0 +1,210 @@ +#!/usr/bin/env bash +set -euo pipefail + +export GIT_CONFIG_NOSYSTEM=1 +export GIT_CONFIG_GLOBAL=/dev/null +unset GITHUB_STEP_SUMMARY + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +HELPER="$SCRIPT_DIR/merge-main-into-release.sh" +TEST_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/release-sync-tests.XXXXXX")" +trap 'cd "$SCRIPT_DIR"; rm -rf -- "$TEST_ROOT"' EXIT + +fail() { + echo "FAIL: $*" >&2 + exit 1 +} + +write_file() { + mkdir -p -- "$(dirname -- "$1")" + printf '%s\n' "$2" > "$1" +} + +commit_all() { + git add -A + git commit -qm "$1" +} + +new_repo() { + mkdir "$TEST_ROOT/$1" + cd "$TEST_ROOT/$1" + git init -q --initial-branch=main + git config user.name "Release sync test" + git config user.email "release-sync@example.invalid" + git config commit.gpgsign false + git config core.autocrlf false + write_file shared.txt base + commit_all base + git switch -qc release/test +} + +run_helper() { + if bash "$HELPER" main release/test > "$TEST_ROOT/output" 2>&1; then + return 0 + else + cat "$TEST_ROOT/output" >&2 + return 1 + fi +} + +expect_failure() { + if bash "$HELPER" main release/test > "$TEST_ROOT/output" 2>&1; then + fail "Expected merge failure." + fi + grep -q "$1" "$TEST_ROOT/output" || { cat "$TEST_ROOT/output"; fail "Missing expected diagnostic: $1"; } + [[ "$(git rev-parse HEAD)" == "$release_sha" ]] || fail "Failed merge changed release HEAD." + [[ -z "$(git status --porcelain)" ]] || fail "Failed merge left a dirty checkout." + if git rev-parse -q --verify MERGE_HEAD >/dev/null; then + fail "Failed merge was not aborted." + fi +} + +assert_parents() { + [[ "$(git show -s --format=%P HEAD)" == "$release_sha $(git rev-parse main)" ]] || fail "Merge parents differ." + git merge-base --is-ancestor main HEAD || fail "Main ancestry was lost." +} + +new_repo no-op +release_sha="$(git rev-parse HEAD)" +run_helper +[[ "$(git rev-parse HEAD)" == "$release_sha" ]] || fail "No-op created a commit." +grep -q "already an ancestor" "$TEST_ROOT/output" || fail "Missing no-op diagnostic." +echo "PASS: ancestor no-op" + +new_repo clean-merge +write_file release.txt release +commit_all release +release_sha="$(git rev-parse HEAD)" +git switch -q main +write_file main.txt main +commit_all main +git switch -q release/test +run_helper +assert_parents +[[ -f main.txt && -f release.txt ]] || fail "Clean merge lost files." +echo "PASS: clean merge preserves both parents" + +new_repo path-policies +generated_files=( + src/frontend/src/data/aspire-integrations.json + src/frontend/src/data/github-stats.json + src/frontend/src/data/samples.json + src/frontend/src/data/twoslash/aspire.d.ts +) +generated_dirs=( + src/frontend/src/data/pkgs + src/frontend/src/data/ts-modules + src/frontend/src/assets/samples +) +curated=src/frontend/src/content/docs/curated.mdx +deleted=src/frontend/src/content/docs/deleted.mdx +asset=src/frontend/src/assets/dashboard.txt +for path in "${generated_files[@]}" "$curated" "$deleted" "$asset"; do + write_file "$path" base +done +for path in "${generated_dirs[@]}"; do + write_file "$path/base-version.txt" base +done +commit_all shared-paths +git switch -q main +git merge -q --ff-only release/test +for path in "${generated_files[@]}" "$curated" "$deleted" "$asset"; do + write_file "$path" main +done +git rm -q -f -- src/frontend/src/data/samples.json +for path in "${generated_dirs[@]}"; do + git mv "$path/base-version.txt" "$path/main-version.txt" +done +write_file src/frontend/src/content/docs/new.mdx "nonconflicting main content" +commit_all main-paths +git switch -q release/test +for path in "${generated_files[@]}" "$curated" "$asset"; do + write_file "$path" release +done +git rm -q -- "$deleted" +for path in "${generated_dirs[@]}"; do + git mv "$path/base-version.txt" "$path/release-version.txt" +done +write_file src/frontend/src/data/hand-authored.json release +commit_all release-paths +release_sha="$(git rev-parse HEAD)" +run_helper +assert_parents +git diff --exit-code main HEAD -- "${generated_files[@]}" "${generated_dirs[@]}" +[[ "$(cat "$curated")" == release && "$(cat "$asset")" == release ]] || fail "Curated content was overwritten." +[[ ! -e "$deleted" ]] || fail "Release-side deletion was lost." +[[ -f src/frontend/src/content/docs/new.mdx ]] || fail "Nonconflicting content did not merge." +[[ "$(cat src/frontend/src/data/hand-authored.json)" == release ]] || fail "Hand-authored data was overwritten." +echo "PASS: generated mirroring, curated conflicts/deletions, nonconflicting content" + +new_repo unknown-conflict +write_file shared.txt release +commit_all release +release_sha="$(git rev-parse HEAD)" +git switch -q main +write_file shared.txt main +commit_all main +git switch -q release/test +export GITHUB_STEP_SUMMARY="$TEST_ROOT/summary" +expect_failure "conflicts require human review" +grep -q 'shared.txt' "$GITHUB_STEP_SUMMARY" || fail "Summary omitted conflict path." +grep -q 'never squash or rebase' "$GITHUB_STEP_SUMMARY" || fail "Summary omitted ancestry guidance." +unset GITHUB_STEP_SUMMARY +echo "PASS: unknown conflict aborts with recovery instructions" + +new_repo fatal-merge +release_sha="$(git rev-parse HEAD)" +git switch -q main +write_file main.txt main +commit_all main +git switch -q release/test +# Refuse Git's index lock without introducing tracked or untracked changes. +write_file .git/index.lock locked +expect_failure "Git merge failed" +[[ ! "$(cat "$TEST_ROOT/output")" == *"nothing to merge"* ]] || fail "Fatal error was reported as a no-op." +rm -- .git/index.lock +echo "PASS: fatal merge without MERGE_HEAD is not a no-op" + +new_repo conflict-markers +release_sha="$(git rev-parse HEAD)" +git switch -q main +write_file marker.txt '<<<<<<< HEAD' +commit_all markers +git switch -q release/test +expect_failure "Conflict markers detected" +echo "PASS: conflict marker rejection" + +new_repo squash-ancestry +git switch -q main +write_file shared.txt first-main +commit_all first-main +git switch -q release/test +git merge -q --squash main +git commit -qm squash-repair +if git merge-base --is-ancestor main HEAD; then + fail "Squash fixture unexpectedly preserved ancestry." +fi +write_file shared.txt release-after-squash +commit_all release-after-squash +release_sha="$(git rev-parse HEAD)" +git switch -q main +write_file shared.txt second-main +commit_all second-main +git switch -q release/test +expect_failure "conflicts require human review" +# Reconcile deliberately as a human would, retaining both parent histories. +merge_status=0 +git merge --no-commit --no-ff main > "$TEST_ROOT/manual-merge" 2>&1 || merge_status=$? +[[ "$merge_status" == 1 ]] || fail "Expected a manual repair conflict." +write_file shared.txt reconciled +commit_all real-repair +git merge-base --is-ancestor main HEAD || fail "Real repair did not establish ancestry." +git switch -q main +write_file next.txt next +commit_all next-main +git switch -q release/test +release_sha="$(git rev-parse HEAD)" +run_helper +assert_parents +[[ "$(cat shared.txt)" == reconciled && -f next.txt ]] || fail "Next sync revisited repaired content." +echo "PASS: real repair avoids repeating squash-era conflicts" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5b80614e2..070a50454 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -102,8 +102,19 @@ jobs: if: ${{ needs.changes.outputs.apphost == 'true' }} uses: ./.github/workflows/apphost-build.yml + release-sync-tests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Test release merge policy and ancestry + shell: bash + run: | + bash -n .github/scripts/merge-main-into-release.sh + bash -n .github/scripts/test-merge-main-into-release.sh + bash .github/scripts/test-merge-main-into-release.sh + ci-gate: - needs: [changes, frontend-build, apphost-build] + needs: [changes, frontend-build, apphost-build, release-sync-tests] if: ${{ always() && !cancelled() }} runs-on: ubuntu-latest steps: @@ -115,12 +126,19 @@ jobs: APPHOST_CHANGED: ${{ needs.changes.outputs.apphost }} FRONTEND_RESULT: ${{ needs['frontend-build'].result }} APPHOST_RESULT: ${{ needs['apphost-build'].result }} + RELEASE_SYNC_RESULT: ${{ needs['release-sync-tests'].result }} run: | echo "changes result: $CHANGES_RESULT" echo "frontend changed: $FRONTEND_CHANGED" echo "frontend-build result: $FRONTEND_RESULT" echo "apphost changed: $APPHOST_CHANGED" echo "apphost-build result: $APPHOST_RESULT" + echo "release-sync-tests result: $RELEASE_SYNC_RESULT" + + if [[ "$RELEASE_SYNC_RESULT" != "success" ]]; then + echo "The release sync tests must succeed." + exit 1 + fi if [[ "$CHANGES_RESULT" != "success" ]]; then echo "The changes job must succeed." diff --git a/.github/workflows/update-release-branch.yml b/.github/workflows/update-release-branch.yml index 7ec4b09be..5c6dcfcf3 100644 --- a/.github/workflows/update-release-branch.yml +++ b/.github/workflows/update-release-branch.yml @@ -84,130 +84,8 @@ jobs: git fetch origin main MAIN_SHA="$(git rev-parse origin/main)" - # The release branch is a curated snapshot of the shipped site. Merging - # main into it conflicts in three, and only three, well-understood ways: - # 1. Generated data -> main is authoritative (mirror it). - # 2. Curated content/imagery -> the release branch is authoritative. - # 3. Anything else -> a human must decide; abort cleanly. - # - # (1) is the exact output set of the update-integration-data workflow - # (its `allowed-files`); those paths are 100% machine-generated, so main - # always wins and mirroring also clears the version-stamped rename/rename - # orphans they routinely produce. (2) is site content plus documentation - # imagery, which the release branch pins to release-specific versions (the - # "Aspire 13.5 is available" banner, 13.5 dashboard screenshots, ...) and - # must never regress. (3) is a genuine divergence we refuse to guess at. - - GEN_FILES=" - src/frontend/src/data/aspire-integrations.json - src/frontend/src/data/github-stats.json - src/frontend/src/data/samples.json - src/frontend/src/data/twoslash/aspire.d.ts - " - GEN_DIRS=" - src/frontend/src/data/pkgs - src/frontend/src/data/ts-modules - src/frontend/src/assets/samples - " - - is_generated() { - case "$1" in - src/frontend/src/data/aspire-integrations.json|\ - src/frontend/src/data/github-stats.json|\ - src/frontend/src/data/samples.json|\ - src/frontend/src/data/twoslash/aspire.d.ts|\ - src/frontend/src/data/pkgs/*|\ - src/frontend/src/data/ts-modules/*|\ - src/frontend/src/assets/samples/*) return 0 ;; - *) return 1 ;; - esac - } - - # assets/samples/** is generated and is matched by is_generated first; - # it is excluded here defensively in case the checks are reordered. - is_curated() { - case "$1" in - src/frontend/src/assets/samples/*) return 1 ;; - src/frontend/src/content/*) return 0 ;; - src/frontend/src/assets/*) return 0 ;; - *) return 1 ;; - esac - } - - # Keep the release branch's version, honoring a release-side deletion. - keep_release() { - if git cat-file -e "HEAD:$1" 2>/dev/null; then - git checkout HEAD -- "$1" - git add -- "$1" - else - git rm -q --force --ignore-unmatch -- "$1" >/dev/null - fi - } - - # A generated path needs syncing when it has a conflict stage or its - # merged content differs from main. - needs_mirror() { - [ -n "$(git ls-files -u -- "$1")" ] && return 0 - git diff --quiet "$MAIN_SHA" -- "$1" 2>/dev/null || return 0 - return 1 - } - - git merge --no-commit --no-ff origin/main || true - - if ! git rev-parse -q --verify MERGE_HEAD >/dev/null; then - echo "Already up to date with origin/main; nothing to merge." - exit 0 - fi - - # Triage conflicts: generated ones are handled by the mirror step below; - # keep-release for curated paths; flag anything else as unresolvable. - unresolved=0 - while IFS= read -r f; do - [ -z "$f" ] && continue - if is_generated "$f"; then - continue - elif is_curated "$f"; then - keep_release "$f" - else - echo "::error::Unresolvable merge conflict in ${f} (not a generated or curated path)." - unresolved=1 - fi - done < <(git diff --name-only --diff-filter=U) - - if [ "$unresolved" -ne 0 ]; then - git merge --abort - echo "::error::Automated merge of main into ${BRANCH} aborted; a human must resolve the conflicts above." - exit 1 - fi - - # Force every generated producer path to match main exactly. This clears - # remaining generated conflicts and drops stale version-stamped orphans, - # without ever touching hand-authored files under data/. - for p in $GEN_FILES $GEN_DIRS; do - if git cat-file -e "$MAIN_SHA:$p" 2>/dev/null; then - if needs_mirror "$p"; then - git rm -r -q --force --ignore-unmatch -- "$p" >/dev/null 2>&1 || true - git checkout "$MAIN_SHA" -- "$p" - git add -A -- "$p" - fi - else - git rm -r -q --force --ignore-unmatch -- "$p" >/dev/null 2>&1 || true - fi - done - - if [ -n "$(git ls-files -u)" ]; then - git merge --abort - echo "::error::Unexpected unmerged entries remain after auto-resolution; aborting." - exit 1 - fi - - markers="$(git diff --cached --check || true)" - if printf '%s' "$markers" | grep -q 'conflict marker'; then - git merge --abort - echo "::error::Conflict markers detected after auto-resolution; aborting." - exit 1 - fi - - git commit --no-edit - echo "Merged origin/main into ${BRANCH} with automated conflict resolution." - git push + # Checkout is on release; always run the helper from the pinned main + # commit, including when release has not received the helper yet. + git show "$MAIN_SHA:.github/scripts/merge-main-into-release.sh" > "$RUNNER_TEMP/merge-main-into-release.sh" + bash "$RUNNER_TEMP/merge-main-into-release.sh" "$MAIN_SHA" "$BRANCH" + git push origin "HEAD:refs/heads/$BRANCH" diff --git a/src/frontend/src/data/aspire-integrations.json b/src/frontend/src/data/aspire-integrations.json index e172056f7..2413a9c32 100644 --- a/src/frontend/src/data/aspire-integrations.json +++ b/src/frontend/src/data/aspire-integrations.json @@ -2329,30 +2329,6 @@ "downloads": 705, "version": "13.5.0" }, - { - "title": "CommunityToolkit.Aspire.Hosting.Floci", - "description": "An Aspire hosting integration for the Floci local cloud emulators: AddFlociAws (AWS), AddFlociAzure (Azure) and AddFlociGcp (GCP). Includes a WithFlociUI() extension for running the Floci UI web console alongside one or more emulators.", - "icon": "https://api.nuget.org/v3-flatcontainer/communitytoolkit.aspire.hosting.floci/13.5.0/icon", - "href": "https://www.nuget.org/packages/CommunityToolkit.Aspire.Hosting.Floci", - "tags": [ - "aspire", - "integration", - "communitytoolkit", - "dotnetcommunitytoolkit", - "floci", - "aws", - "azure", - "gcp", - "localstack", - "azurite", - "emulator", - "hosting", - "ui", - "tls" - ], - "downloads": 100, - "version": "13.5.0" - }, { "title": "CommunityToolkit.Aspire.Hosting.Flyway", "description": "An Aspire integration for Flyway database migration tool.", diff --git a/src/frontend/tests/unit/update-integrations.vitest.test.ts b/src/frontend/tests/unit/update-integrations.vitest.test.ts index 490e245da..f9bc2ef2d 100644 --- a/src/frontend/tests/unit/update-integrations.vitest.test.ts +++ b/src/frontend/tests/unit/update-integrations.vitest.test.ts @@ -33,6 +33,15 @@ function docsPageExists( ); } +describe('integration catalog integrity', () => { + test('contains each NuGet package ID only once, ignoring case', () => { + const packageIds = aspireIntegrations.map(({ title }) => title.toLowerCase()); + const duplicateIds = packageIds.filter((id, index) => packageIds.indexOf(id) !== index); + + expect(duplicateIds).toEqual([]); + }); +}); + describe('update-integrations icon handling', () => { test('uses the package version for official Aspire packages from nuget.org', () => { expect(