From 059772fe319dee771fd3467a4b81da7e14e54f00 Mon Sep 17 00:00:00 2001 From: "L. Sousa" Date: Thu, 23 Jul 2026 14:58:48 +0100 Subject: [PATCH 01/11] ci: resolve dependencies through branch ancestry --- .github/scripts/resolve-dependency-refs.sh | 129 +++++++++++++++++++++ .github/workflows/copilot-setup-steps.yml | 48 +------- .github/workflows/nightly.yml | 48 +------- 3 files changed, 141 insertions(+), 84 deletions(-) create mode 100644 .github/scripts/resolve-dependency-refs.sh diff --git a/.github/scripts/resolve-dependency-refs.sh b/.github/scripts/resolve-dependency-refs.sh new file mode 100644 index 000000000..efb422960 --- /dev/null +++ b/.github/scripts/resolve-dependency-refs.sh @@ -0,0 +1,129 @@ +#!/usr/bin/env bash + +set -euo pipefail + +if (( $# < 3 || $# % 2 == 0 )); then + echo "Usage: $0 SOURCE_DIRECTORY OUTPUT_PREFIX DEPENDENCY_REPOSITORY [...]" >&2 + exit 2 +fi + +source_directory=$1 +shift + +source_branch=${SOURCE_BRANCH:?SOURCE_BRANCH must name the branch being tested} +integration_branch=${INTEGRATION_BRANCH:-staging} +root_branch=${ROOT_BRANCH:-master} + +declare -a candidates=() +declare -A seen_candidates=() + +add_candidate() { + local branch=$1 + + if [[ -n $branch && -z ${seen_candidates[$branch]+yes} ]]; then + candidates+=("$branch") + seen_candidates[$branch]=1 + fi +} + +source_ref=HEAD +if git -C "$source_directory" show-ref --verify --quiet "refs/remotes/origin/${source_branch}"; then + source_ref="refs/remotes/origin/${source_branch}" +elif git -C "$source_directory" show-ref --verify --quiet "refs/heads/${source_branch}"; then + source_ref="refs/heads/${source_branch}" +fi + +add_candidate "$source_branch" + +# Branch creation has no durable metadata in Git. Branch tips that still occur +# on the source branch's first-parent history are the reliable part of the +# stack. Sort them by their distance from the source tip. +declare -A first_parent_distance=() +distance=0 +root_ref= +if git -C "$source_directory" show-ref --verify --quiet "refs/remotes/origin/${root_branch}"; then + root_ref="refs/remotes/origin/${root_branch}" +elif git -C "$source_directory" show-ref --verify --quiet "refs/heads/${root_branch}"; then + root_ref="refs/heads/${root_branch}" +fi + +while IFS= read -r commit; do + first_parent_distance[$commit]=$distance + ((distance += 1)) + + # The root branch is allowed to advance independently. Stop at the first + # first-parent commit contained in its current history, not at its tip. + if [[ -n $root_ref ]] && + git -C "$source_directory" merge-base --is-ancestor "$commit" "$root_ref"; then + break + fi +done < <(git -C "$source_directory" rev-list --first-parent "$source_ref") + +ancestry_candidates=$(mktemp) +trap 'rm -f "$ancestry_candidates"' EXIT + +while IFS=$'\t' read -r ref_name object_name; do + branch=${ref_name#origin/} + + [[ $ref_name == origin || $branch == HEAD ]] && continue + [[ $branch == "$source_branch" ]] && continue + [[ $branch == "$integration_branch" ]] && continue + [[ $branch == "$root_branch" ]] && continue + [[ -n ${first_parent_distance[$object_name]+yes} ]] || continue + + printf '%s\t%s\n' "${first_parent_distance[$object_name]}" "$branch" \ + >> "$ancestry_candidates" +done < <( + git -C "$source_directory" for-each-ref \ + --format='%(refname:short)%09%(objectname)' \ + refs/remotes/origin +) + +while IFS=$'\t' read -r _ branch; do + add_candidate "$branch" +done < <(sort -k1,1n -k2,2 -u "$ancestry_candidates") + +if [[ $source_branch != "$root_branch" ]]; then + add_candidate "$integration_branch" +fi +add_candidate "$root_branch" + +printf -v candidate_chain '%s -> ' "${candidates[@]}" +candidate_chain=${candidate_chain% -> } +echo "Dependency branch candidates: ${candidate_chain}" + +while (( $# )); do + prefix=$1 + repository=$2 + shift 2 + + url="https://github.com/${repository}.git" + default_branch=$( + git ls-remote --symref "$url" HEAD | + awk '/^ref:/ {sub("refs/heads/", "", $2); print $2; exit}' + ) + + declare -A dependency_branches=() + while IFS=$'\t' read -r _ ref; do + dependency_branches[${ref#refs/heads/}]=1 + done < <(git ls-remote --heads "$url") + + selected= + for branch in "${candidates[@]}"; do + if [[ -n ${dependency_branches[$branch]+yes} ]]; then + selected=$branch + break + fi + done + + if [[ -z $selected ]]; then + echo "None of the candidate branches exists in ${repository}" >&2 + exit 1 + fi + + echo "Using '${selected}' for ${repository}" + { + echo "${prefix}_ref=${selected}" + echo "${prefix}_default=${default_branch}" + } >> "${GITHUB_OUTPUT:?GITHUB_OUTPUT is not set}" +done diff --git a/.github/workflows/copilot-setup-steps.yml b/.github/workflows/copilot-setup-steps.yml index 89db99345..5d1bf25c1 100644 --- a/.github/workflows/copilot-setup-steps.yml +++ b/.github/workflows/copilot-setup-steps.yml @@ -46,58 +46,22 @@ jobs: uses: actions/checkout@v6 with: path: lara-framework + fetch-depth: 0 + ref: ${{ github.event.pull_request.head.sha || github.sha }} - name: Determine repository refs id: repo-refs shell: bash env: - BRANCH_NAME: ${{ env.BRANCH_NAME }} - BASE_BRANCH: ${{ github.base_ref }} + SOURCE_BRANCH: ${{ env.BRANCH_NAME }} run: | - set -euo pipefail - - # For each dependency repository, determine which branch to checkout. - # Priority order: - # 1. A branch with the same name as the current branch - # 2. If this is a PR, the target branch (base_ref) - # 3. The default branch of the repository - - determine_ref() { - local prefix=$1 - local repo=$2 - local url="https://github.com/${repo}.git" - - # Get the default branch - local default_branch - default_branch=$(git ls-remote --symref "$url" HEAD | awk '/^ref:/ {print $2}' | sed 's@refs/heads/@@') - echo "${prefix}_default=${default_branch}" >> "$GITHUB_OUTPUT" - echo "Default branch for ${repo} is '${default_branch}'" - - local ref_to_use="" - - # Priority 1: Same branch name - if [ -n "$(git ls-remote --heads "$url" "refs/heads/${BRANCH_NAME}")" ]; then - ref_to_use="${BRANCH_NAME}" - echo "Using matching branch '${BRANCH_NAME}' in ${repo}" - # Priority 2: PR target branch (if this is a PR) - elif [ -n "${BASE_BRANCH}" ] && [ -n "$(git ls-remote --heads "$url" "refs/heads/${BASE_BRANCH}")" ]; then - ref_to_use="${BASE_BRANCH}" - echo "Using PR target branch '${BASE_BRANCH}' in ${repo}" - # Priority 3: Default branch - else - ref_to_use="${default_branch}" - echo "Using default branch '${default_branch}' for ${repo}" - fi - - echo "${prefix}_ref=${ref_to_use}" >> "$GITHUB_OUTPUT" - } - - determine_ref "specs" "specs-feup/specs-java-libs" + bash lara-framework/.github/scripts/resolve-dependency-refs.sh \ + lara-framework \ + specs specs-feup/specs-java-libs - name: Echo checks run: | echo "Lara branch: ${{ env.BRANCH_NAME }}" - echo "PR target branch (if any): ${{ github.base_ref }}" echo "Specs-java-libs ref: ${{ steps.repo-refs.outputs.specs_ref }}" echo "Specs-java-libs default: ${{ steps.repo-refs.outputs.specs_default }}" diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 0337eb843..08a0500b1 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -45,58 +45,22 @@ jobs: uses: actions/checkout@v6 with: path: lara-framework + fetch-depth: 0 + ref: ${{ github.event.pull_request.head.sha || github.sha }} - name: Determine repository refs id: repo-refs shell: bash env: - BRANCH_NAME: ${{ env.BRANCH_NAME }} - BASE_BRANCH: ${{ github.base_ref }} + SOURCE_BRANCH: ${{ env.BRANCH_NAME }} run: | - set -euo pipefail - - # For each dependency repository, determine which branch to checkout. - # Priority order: - # 1. A branch with the same name as the current branch - # 2. If this is a PR, the target branch (base_ref) - # 3. The default branch of the repository - - determine_ref() { - local prefix=$1 - local repo=$2 - local url="https://github.com/${repo}.git" - - # Get the default branch - local default_branch - default_branch=$(git ls-remote --symref "$url" HEAD | awk '/^ref:/ {print $2}' | sed 's@refs/heads/@@') - echo "${prefix}_default=${default_branch}" >> "$GITHUB_OUTPUT" - echo "Default branch for ${repo} is '${default_branch}'" - - local ref_to_use="" - - # Priority 1: Same branch name - if [ -n "$(git ls-remote --heads "$url" "refs/heads/${BRANCH_NAME}")" ]; then - ref_to_use="${BRANCH_NAME}" - echo "Using matching branch '${BRANCH_NAME}' in ${repo}" - # Priority 2: PR target branch (if this is a PR) - elif [ -n "${BASE_BRANCH}" ] && [ -n "$(git ls-remote --heads "$url" "refs/heads/${BASE_BRANCH}")" ]; then - ref_to_use="${BASE_BRANCH}" - echo "Using PR target branch '${BASE_BRANCH}' in ${repo}" - # Priority 3: Default branch - else - ref_to_use="${default_branch}" - echo "Using default branch '${default_branch}' for ${repo}" - fi - - echo "${prefix}_ref=${ref_to_use}" >> "$GITHUB_OUTPUT" - } - - determine_ref "specs" "specs-feup/specs-java-libs" + bash lara-framework/.github/scripts/resolve-dependency-refs.sh \ + lara-framework \ + specs specs-feup/specs-java-libs - name: Echo checks run: | echo "Lara branch: ${{ env.BRANCH_NAME }}" - echo "PR target branch (if any): ${{ github.base_ref }}" echo "Specs-java-libs ref: ${{ steps.repo-refs.outputs.specs_ref }}" echo "Specs-java-libs default: ${{ steps.repo-refs.outputs.specs_default }}" From cd4f34023a14f44cc7a6e2b2738b25a9bd6f64e6 Mon Sep 17 00:00:00 2001 From: "L. Sousa" Date: Thu, 23 Jul 2026 15:19:25 +0100 Subject: [PATCH 02/11] test: harden dependency branch resolution --- .github/scripts/resolve-dependency-refs.sh | 24 ++++- .../scripts/test-resolve-dependency-refs.sh | 100 ++++++++++++++++++ .github/workflows/copilot-setup-steps.yml | 3 + .github/workflows/nightly.yml | 3 + 4 files changed, 129 insertions(+), 1 deletion(-) create mode 100644 .github/scripts/test-resolve-dependency-refs.sh diff --git a/.github/scripts/resolve-dependency-refs.sh b/.github/scripts/resolve-dependency-refs.sh index efb422960..5e9cd5397 100644 --- a/.github/scripts/resolve-dependency-refs.sh +++ b/.github/scripts/resolve-dependency-refs.sh @@ -32,6 +32,7 @@ if git -C "$source_directory" show-ref --verify --quiet "refs/remotes/origin/${s elif git -C "$source_directory" show-ref --verify --quiet "refs/heads/${source_branch}"; then source_ref="refs/heads/${source_branch}" fi +git -C "$source_directory" rev-parse --verify "${source_ref}^{commit}" >/dev/null add_candidate "$source_branch" @@ -47,6 +48,12 @@ elif git -C "$source_directory" show-ref --verify --quiet "refs/heads/${root_bra root_ref="refs/heads/${root_branch}" fi +if [[ -z $root_ref ]]; then + echo "Cannot find the root branch '${root_branch}' in ${source_directory}" >&2 + exit 1 +fi + +reached_root=false while IFS= read -r commit; do first_parent_distance[$commit]=$distance ((distance += 1)) @@ -55,10 +62,16 @@ while IFS= read -r commit; do # first-parent commit contained in its current history, not at its tip. if [[ -n $root_ref ]] && git -C "$source_directory" merge-base --is-ancestor "$commit" "$root_ref"; then + reached_root=true break fi done < <(git -C "$source_directory" rev-list --first-parent "$source_ref") +if [[ $reached_root != true ]]; then + echo "The first-parent history of '${source_branch}' does not reach '${root_branch}'" >&2 + exit 1 +fi + ancestry_candidates=$(mktemp) trap 'rm -f "$ancestry_candidates"' EXIT @@ -97,11 +110,20 @@ while (( $# )); do repository=$2 shift 2 - url="https://github.com/${repository}.git" + if [[ $repository == *://* || $repository == /* ]]; then + url=$repository + else + url="https://github.com/${repository}.git" + fi + default_branch=$( git ls-remote --symref "$url" HEAD | awk '/^ref:/ {sub("refs/heads/", "", $2); print $2; exit}' ) + if [[ -z $default_branch ]]; then + echo "Cannot determine the default branch for ${repository}" >&2 + exit 1 + fi declare -A dependency_branches=() while IFS=$'\t' read -r _ ref; do diff --git a/.github/scripts/test-resolve-dependency-refs.sh b/.github/scripts/test-resolve-dependency-refs.sh new file mode 100644 index 000000000..d0670ddd7 --- /dev/null +++ b/.github/scripts/test-resolve-dependency-refs.sh @@ -0,0 +1,100 @@ +#!/usr/bin/env bash + +set -euo pipefail + +script_directory=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +resolver=${1:-"${script_directory}/resolve-dependency-refs.sh"} +test_directory=$(mktemp -d) +source_repository="${test_directory}/source" + +git init --quiet --initial-branch=master "$source_repository" +git -C "$source_repository" config user.name "CI Resolver Test" +git -C "$source_repository" config user.email "ci-resolver@example.invalid" + +commit() { + local message=$1 + git -C "$source_repository" commit --quiet --allow-empty -m "$message" +} + +commit "master base" + +git -C "$source_repository" switch --quiet -c staging +commit "staging base" + +git -C "$source_repository" switch --quiet -c lmsousa +commit "branch A" +lmsousa_tip=$(git -C "$source_repository" rev-parse HEAD) + +git -C "$source_repository" switch --quiet -c vitest +commit "branch B" +vitest_tip=$(git -C "$source_repository" rev-parse HEAD) + +git -C "$source_repository" switch --quiet -c java-deprecation +commit "branch C" +java_deprecation_tip=$(git -C "$source_repository" rev-parse HEAD) + +git -C "$source_repository" switch --quiet -c workflow-fix +commit "current branch" +workflow_fix_tip=$(git -C "$source_repository" rev-parse HEAD) + +# Both long-lived branches advance after the stack was created. +git -C "$source_repository" switch --quiet staging +commit "advanced staging" +staging_tip=$(git -C "$source_repository" rev-parse HEAD) + +git -C "$source_repository" switch --quiet master +commit "advanced master" +master_tip=$(git -C "$source_repository" rev-parse HEAD) + +for branch in workflow-fix java-deprecation vitest lmsousa staging master; do + branch_variable=${branch//-/_}_tip + git -C "$source_repository" update-ref \ + "refs/remotes/origin/${branch}" "${!branch_variable}" +done + +make_dependency() { + local name=$1 + shift + local repository="${test_directory}/${name}.git" + + git clone --quiet --bare "$source_repository" "$repository" + while IFS= read -r ref; do + git -C "$repository" update-ref -d "$ref" + done < <(git -C "$repository" for-each-ref --format='%(refname)' refs/heads) + + git -C "$repository" update-ref refs/heads/master "$master_tip" + for branch in "$@"; do + branch_variable=${branch//-/_}_tip + git -C "$repository" update-ref "refs/heads/${branch}" "${!branch_variable}" + done + git -C "$repository" symbolic-ref HEAD refs/heads/master + echo "$repository" +} + +current_dependency=$(make_dependency current workflow-fix lmsousa) +middle_dependency=$(make_dependency middle java-deprecation lmsousa) +older_dependency=$(make_dependency older vitest lmsousa) +integration_dependency=$(make_dependency integration staging) +root_dependency=$(make_dependency root) + +output_file="${test_directory}/github-output" +log_file="${test_directory}/resolver.log" +SOURCE_BRANCH=workflow-fix GITHUB_OUTPUT="$output_file" \ + bash "$resolver" "$source_repository" \ + current "$current_dependency" \ + middle "$middle_dependency" \ + older "$older_dependency" \ + integration "$integration_dependency" \ + root "$root_dependency" | + tee "$log_file" + +grep -Fqx \ + "Dependency branch candidates: workflow-fix -> java-deprecation -> vitest -> lmsousa -> staging -> master" \ + "$log_file" +grep -Fqx "current_ref=workflow-fix" "$output_file" +grep -Fqx "middle_ref=java-deprecation" "$output_file" +grep -Fqx "older_ref=vitest" "$output_file" +grep -Fqx "integration_ref=staging" "$output_file" +grep -Fqx "root_ref=master" "$output_file" + +echo "All dependency ref resolver tests passed" diff --git a/.github/workflows/copilot-setup-steps.yml b/.github/workflows/copilot-setup-steps.yml index 5d1bf25c1..fccd6d7af 100644 --- a/.github/workflows/copilot-setup-steps.yml +++ b/.github/workflows/copilot-setup-steps.yml @@ -49,6 +49,9 @@ jobs: fetch-depth: 0 ref: ${{ github.event.pull_request.head.sha || github.sha }} + - name: Test dependency ref resolver + run: bash lara-framework/.github/scripts/test-resolve-dependency-refs.sh + - name: Determine repository refs id: repo-refs shell: bash diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 08a0500b1..28c88a65c 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -48,6 +48,9 @@ jobs: fetch-depth: 0 ref: ${{ github.event.pull_request.head.sha || github.sha }} + - name: Test dependency ref resolver + run: bash lara-framework/.github/scripts/test-resolve-dependency-refs.sh + - name: Determine repository refs id: repo-refs shell: bash From e3207abff030962051d663e63963e993d81e0785 Mon Sep 17 00:00:00 2001 From: "L. Sousa" Date: Thu, 23 Jul 2026 15:26:15 +0100 Subject: [PATCH 03/11] fix: resolve stack from workflow event commit --- .github/scripts/resolve-dependency-refs.sh | 5 ----- .../scripts/test-resolve-dependency-refs.sh | 18 ++++++++++++++++++ .github/workflows/copilot-setup-steps.yml | 4 ++++ 3 files changed, 22 insertions(+), 5 deletions(-) diff --git a/.github/scripts/resolve-dependency-refs.sh b/.github/scripts/resolve-dependency-refs.sh index 5e9cd5397..e48aaa7b5 100644 --- a/.github/scripts/resolve-dependency-refs.sh +++ b/.github/scripts/resolve-dependency-refs.sh @@ -27,11 +27,6 @@ add_candidate() { } source_ref=HEAD -if git -C "$source_directory" show-ref --verify --quiet "refs/remotes/origin/${source_branch}"; then - source_ref="refs/remotes/origin/${source_branch}" -elif git -C "$source_directory" show-ref --verify --quiet "refs/heads/${source_branch}"; then - source_ref="refs/heads/${source_branch}" -fi git -C "$source_directory" rev-parse --verify "${source_ref}^{commit}" >/dev/null add_candidate "$source_branch" diff --git a/.github/scripts/test-resolve-dependency-refs.sh b/.github/scripts/test-resolve-dependency-refs.sh index d0670ddd7..fd9b33345 100644 --- a/.github/scripts/test-resolve-dependency-refs.sh +++ b/.github/scripts/test-resolve-dependency-refs.sh @@ -46,11 +46,25 @@ git -C "$source_repository" switch --quiet master commit "advanced master" master_tip=$(git -C "$source_repository" rev-parse HEAD) +# Simulate a later push after the workflow event was created. Resolution must +# use the detached event commit, never the newer remote source branch tip. +git -C "$source_repository" switch --quiet workflow-fix +commit "post-event parent branch" +later_parent_tip=$(git -C "$source_repository" rev-parse HEAD) +git -C "$source_repository" branch later-parent +commit "post-event source update" +post_event_source_tip=$(git -C "$source_repository" rev-parse HEAD) +git -C "$source_repository" switch --quiet --detach "$workflow_fix_tip" + for branch in workflow-fix java-deprecation vitest lmsousa staging master; do branch_variable=${branch//-/_}_tip git -C "$source_repository" update-ref \ "refs/remotes/origin/${branch}" "${!branch_variable}" done +git -C "$source_repository" update-ref \ + refs/remotes/origin/workflow-fix "$post_event_source_tip" +git -C "$source_repository" update-ref \ + refs/remotes/origin/later-parent "$later_parent_tip" make_dependency() { local name=$1 @@ -91,6 +105,10 @@ SOURCE_BRANCH=workflow-fix GITHUB_OUTPUT="$output_file" \ grep -Fqx \ "Dependency branch candidates: workflow-fix -> java-deprecation -> vitest -> lmsousa -> staging -> master" \ "$log_file" +if grep -Fq "later-parent" "$log_file"; then + echo "Resolver used a branch created after the workflow event" >&2 + exit 1 +fi grep -Fqx "current_ref=workflow-fix" "$output_file" grep -Fqx "middle_ref=java-deprecation" "$output_file" grep -Fqx "older_ref=vitest" "$output_file" diff --git a/.github/workflows/copilot-setup-steps.yml b/.github/workflows/copilot-setup-steps.yml index fccd6d7af..2b3716cac 100644 --- a/.github/workflows/copilot-setup-steps.yml +++ b/.github/workflows/copilot-setup-steps.yml @@ -9,9 +9,13 @@ on: push: paths: - .github/workflows/copilot-setup-steps.yml + - .github/scripts/resolve-dependency-refs.sh + - .github/scripts/test-resolve-dependency-refs.sh pull_request: paths: - .github/workflows/copilot-setup-steps.yml + - .github/scripts/resolve-dependency-refs.sh + - .github/scripts/test-resolve-dependency-refs.sh env: BRANCH_NAME: ${{ github.head_ref || github.ref_name }} From 428539ba7ab646b2c72bf50d083887fce730b773 Mon Sep 17 00:00:00 2001 From: "L. Sousa" Date: Thu, 23 Jul 2026 20:43:00 +0100 Subject: [PATCH 04/11] ci: infer branch order across repositories --- .github/scripts/resolve-dependency-refs.sh | 323 +++++++++++++----- .../scripts/test-resolve-dependency-refs.sh | 75 +++- .github/workflows/copilot-setup-steps.yml | 4 +- .github/workflows/nightly.yml | 4 +- 4 files changed, 316 insertions(+), 90 deletions(-) diff --git a/.github/scripts/resolve-dependency-refs.sh b/.github/scripts/resolve-dependency-refs.sh index e48aaa7b5..413239c70 100644 --- a/.github/scripts/resolve-dependency-refs.sh +++ b/.github/scripts/resolve-dependency-refs.sh @@ -14,82 +14,138 @@ source_branch=${SOURCE_BRANCH:?SOURCE_BRANCH must name the branch being tested} integration_branch=${INTEGRATION_BRANCH:-staging} root_branch=${ROOT_BRANCH:-master} +declare -a dependency_prefixes=() +declare -a dependency_repositories=() +while (( $# )); do + dependency_prefixes+=("$1") + dependency_repositories+=("$2") + shift 2 +done + +repository_url() { + local repository=$1 + if [[ $repository == *://* || $repository == /* ]]; then + echo "$repository" + else + echo "https://github.com/${repository}.git" + fi +} + +evidence_directory=$(mktemp -d) +declare -a evidence_paths=("$source_directory") +declare -a evidence_ref_prefixes=("refs/remotes/origin/") +declare -A cloned_paths=() + +clone_evidence_repository() { + local repository=$1 + + if [[ -n ${cloned_paths[$repository]+yes} ]]; then + return + fi + + local clone_path="${evidence_directory}/repository-${#cloned_paths[@]}.git" + git clone --quiet --bare --filter=blob:none \ + "$(repository_url "$repository")" "$clone_path" + cloned_paths[$repository]=$clone_path + evidence_paths+=("$clone_path") + evidence_ref_prefixes+=("refs/heads/") +} + +for repository in "${dependency_repositories[@]}"; do + clone_evidence_repository "$repository" +done + +if [[ -n ${EVIDENCE_REPOSITORIES:-} ]]; then + read -r -a additional_evidence <<< "$EVIDENCE_REPOSITORIES" + for repository in "${additional_evidence[@]}"; do + clone_evidence_repository "$repository" + done +fi + declare -a candidates=() declare -A seen_candidates=() add_candidate() { local branch=$1 - if [[ -n $branch && -z ${seen_candidates[$branch]+yes} ]]; then + seen_candidates[$branch]=${#candidates[@]} candidates+=("$branch") - seen_candidates[$branch]=1 fi } -source_ref=HEAD -git -C "$source_directory" rev-parse --verify "${source_ref}^{commit}" >/dev/null - add_candidate "$source_branch" -# Branch creation has no durable metadata in Git. Branch tips that still occur -# on the source branch's first-parent history are the reliable part of the -# stack. Sort them by their distance from the source tip. -declare -A first_parent_distance=() -distance=0 -root_ref= -if git -C "$source_directory" show-ref --verify --quiet "refs/remotes/origin/${root_branch}"; then - root_ref="refs/remotes/origin/${root_branch}" -elif git -C "$source_directory" show-ref --verify --quiet "refs/heads/${root_branch}"; then - root_ref="refs/heads/${root_branch}" -fi - -if [[ -z $root_ref ]]; then - echo "Cannot find the root branch '${root_branch}' in ${source_directory}" >&2 - exit 1 -fi +collect_candidates() { + local repository_path=$1 + local ref_prefix=$2 + local start_ref=$3 + local required=$4 + local root_ref="${ref_prefix}${root_branch}" -reached_root=false -while IFS= read -r commit; do - first_parent_distance[$commit]=$distance - ((distance += 1)) - - # The root branch is allowed to advance independently. Stop at the first - # first-parent commit contained in its current history, not at its tip. - if [[ -n $root_ref ]] && - git -C "$source_directory" merge-base --is-ancestor "$commit" "$root_ref"; then - reached_root=true - break + if ! git -C "$repository_path" rev-parse --verify "${start_ref}^{commit}" >/dev/null 2>&1; then + if [[ $required == true ]]; then + echo "Cannot resolve the workflow commit in ${repository_path}" >&2 + exit 1 + fi + return + fi + if ! git -C "$repository_path" rev-parse --verify "${root_ref}^{commit}" >/dev/null 2>&1; then + echo "Cannot find root branch '${root_branch}' in ${repository_path}" >&2 + exit 1 fi -done < <(git -C "$source_directory" rev-list --first-parent "$source_ref") -if [[ $reached_root != true ]]; then - echo "The first-parent history of '${source_branch}' does not reach '${root_branch}'" >&2 - exit 1 -fi + declare -A distance_by_commit=() + local distance=0 + local reached_root=false + local commit + while IFS= read -r commit; do + distance_by_commit[$commit]=$distance + ((distance += 1)) + if git -C "$repository_path" merge-base --is-ancestor "$commit" "$root_ref"; then + reached_root=true + break + fi + done < <(git -C "$repository_path" rev-list --first-parent "$start_ref") -ancestry_candidates=$(mktemp) -trap 'rm -f "$ancestry_candidates"' EXIT + if [[ $reached_root != true ]]; then + if [[ $required == true ]]; then + echo "The workflow commit's first-parent history does not reach '${root_branch}'" >&2 + exit 1 + fi + return + fi -while IFS=$'\t' read -r ref_name object_name; do - branch=${ref_name#origin/} + local ordered_refs="${evidence_directory}/candidate-refs-${#candidates[@]}-${distance}" + : > "$ordered_refs" + local ref object branch + while IFS=$'\t' read -r ref object; do + branch=${ref#"$ref_prefix"} + [[ $branch == HEAD ]] && continue + [[ $branch == "$source_branch" ]] && continue + [[ $branch == "$integration_branch" ]] && continue + [[ $branch == "$root_branch" ]] && continue + [[ -n ${distance_by_commit[$object]+yes} ]] || continue + printf '%s\t%s\n' "${distance_by_commit[$object]}" "$branch" >> "$ordered_refs" + done < <( + git -C "$repository_path" for-each-ref \ + --format='%(refname)%09%(objectname)' "${ref_prefix%/}" + ) - [[ $ref_name == origin || $branch == HEAD ]] && continue - [[ $branch == "$source_branch" ]] && continue - [[ $branch == "$integration_branch" ]] && continue - [[ $branch == "$root_branch" ]] && continue - [[ -n ${first_parent_distance[$object_name]+yes} ]] || continue + while IFS=$'\t' read -r _ branch; do + add_candidate "$branch" + done < <(sort -k1,1n -k2,2 -u "$ordered_refs") +} - printf '%s\t%s\n' "${first_parent_distance[$object_name]}" "$branch" \ - >> "$ancestry_candidates" -done < <( - git -C "$source_directory" for-each-ref \ - --format='%(refname:short)%09%(objectname)' \ - refs/remotes/origin -) +# HEAD is the immutable event commit. Never substitute the mutable remote tip. +collect_candidates "$source_directory" "refs/remotes/origin/" HEAD true -while IFS=$'\t' read -r _ branch; do - add_candidate "$branch" -done < <(sort -k1,1n -k2,2 -u "$ancestry_candidates") +for ((repository_index = 1; repository_index < ${#evidence_paths[@]}; repository_index++)); do + repository_path=${evidence_paths[$repository_index]} + source_ref="refs/heads/${source_branch}" + if git -C "$repository_path" show-ref --verify --quiet "$source_ref"; then + collect_candidates "$repository_path" "refs/heads/" "$source_ref" false + fi +done if [[ $source_branch != "$root_branch" ]]; then add_candidate "$integration_branch" @@ -100,47 +156,150 @@ printf -v candidate_chain '%s -> ' "${candidates[@]}" candidate_chain=${candidate_chain% -> } echo "Dependency branch candidates: ${candidate_chain}" -while (( $# )); do - prefix=$1 - repository=$2 - shift 2 +candidate_count=${#candidates[@]} +declare -A edges=() - if [[ $repository == *://* || $repository == /* ]]; then - url=$repository - else - url="https://github.com/${repository}.git" +add_edge() { + local newer=$1 + local older=$2 + [[ $newer == "$older" ]] || edges["${newer},${older}"]=1 +} + +is_first_parent_ancestor() { + local repository_path=$1 + local older=$2 + local newer=$3 + local commit + + while IFS= read -r commit; do + [[ $commit == "$older" ]] && return 0 + done < <(git -C "$repository_path" rev-list --first-parent "$newer") + return 1 +} + +# The event branch is newest by definition. staging and master are the two +# conventional terminal levels, even when their tips have advanced. +for ((i = 0; i < candidate_count; i++)); do + branch=${candidates[$i]} + if [[ $branch != "$source_branch" ]]; then + add_edge 0 "$i" + fi + if [[ $source_branch != "$root_branch" && + $branch != "$integration_branch" && $branch != "$root_branch" ]]; then + integration_index=${seen_candidates[$integration_branch]} + add_edge "$i" "$integration_index" fi +done +root_index=${seen_candidates[$root_branch]} +if [[ $source_branch != "$root_branch" ]]; then + integration_index=${seen_candidates[$integration_branch]} + add_edge "$integration_index" "$root_index" +fi - default_branch=$( - git ls-remote --symref "$url" HEAD | - awk '/^ref:/ {sub("refs/heads/", "", $2); print $2; exit}' - ) - if [[ -z $default_branch ]]; then - echo "Cannot determine the default branch for ${repository}" >&2 +# Each repository contributes only strict first-parent evidence. Equal refs +# are neutral; missing or diverged refs contribute no ordering. +for ((repository_index = 0; repository_index < ${#evidence_paths[@]}; repository_index++)); do + repository_path=${evidence_paths[$repository_index]} + ref_prefix=${evidence_ref_prefixes[$repository_index]} + + for ((i = 0; i < candidate_count; i++)); do + left_branch=${candidates[$i]} + [[ $left_branch == "$source_branch" || $left_branch == "$integration_branch" || $left_branch == "$root_branch" ]] && continue + left_ref="${ref_prefix}${left_branch}" + left_sha=$(git -C "$repository_path" rev-parse --verify "${left_ref}^{commit}" 2>/dev/null || true) + [[ -n $left_sha ]] || continue + + for ((j = i + 1; j < candidate_count; j++)); do + right_branch=${candidates[$j]} + [[ $right_branch == "$source_branch" || $right_branch == "$integration_branch" || $right_branch == "$root_branch" ]] && continue + right_ref="${ref_prefix}${right_branch}" + right_sha=$(git -C "$repository_path" rev-parse --verify "${right_ref}^{commit}" 2>/dev/null || true) + [[ -n $right_sha && $left_sha != "$right_sha" ]] || continue + + if is_first_parent_ancestor "$repository_path" "$right_sha" "$left_sha"; then + add_edge "$i" "$j" + elif is_first_parent_ancestor "$repository_path" "$left_sha" "$right_sha"; then + add_edge "$j" "$i" + fi + done + done +done + +# Compute transitive closure and reject contradictory evidence. +for ((k = 0; k < candidate_count; k++)); do + for ((i = 0; i < candidate_count; i++)); do + [[ -n ${edges["${i},${k}"]+yes} ]] || continue + for ((j = 0; j < candidate_count; j++)); do + if [[ -n ${edges["${k},${j}"]+yes} ]]; then + edges["${i},${j}"]=1 + fi + done + done +done + +for ((i = 0; i < candidate_count; i++)); do + if [[ -n ${edges["${i},${i}"]+yes} ]]; then + echo "Contradictory branch ordering evidence involves '${candidates[$i]}'" >&2 exit 1 fi +done - declare -A dependency_branches=() - while IFS=$'\t' read -r _ ref; do - dependency_branches[${ref#refs/heads/}]=1 - done < <(git ls-remote --heads "$url") +for ((dependency_index = 0; dependency_index < ${#dependency_repositories[@]}; dependency_index++)); do + prefix=${dependency_prefixes[$dependency_index]} + repository=${dependency_repositories[$dependency_index]} + repository_path=${cloned_paths[$repository]} - selected= - for branch in "${candidates[@]}"; do - if [[ -n ${dependency_branches[$branch]+yes} ]]; then - selected=$branch - break + default_branch=$(git -C "$repository_path" symbolic-ref --short HEAD) + default_branch=${default_branch#refs/heads/} + + declare -a available_indices=() + declare -A available_shas=() + for ((i = 0; i < candidate_count; i++)); do + branch=${candidates[$i]} + sha=$(git -C "$repository_path" rev-parse --verify "refs/heads/${branch}^{commit}" 2>/dev/null || true) + if [[ -n $sha ]]; then + available_indices+=("$i") + available_shas[$i]=$sha fi done - if [[ -z $selected ]]; then + declare -a newest_indices=() + for i in "${available_indices[@]}"; do + dominated=false + for j in "${available_indices[@]}"; do + if [[ $i != "$j" && -n ${edges["${j},${i}"]+yes} ]]; then + dominated=true + break + fi + done + [[ $dominated == true ]] || newest_indices+=("$i") + done + + if (( ${#newest_indices[@]} == 0 )); then echo "None of the candidate branches exists in ${repository}" >&2 exit 1 fi - echo "Using '${selected}' for ${repository}" + selected_index=${newest_indices[0]} + selected_sha=${available_shas[$selected_index]} + if (( ${#newest_indices[@]} > 1 )); then + ambiguous=() + for i in "${newest_indices[@]}"; do + ambiguous+=("${candidates[$i]}") + if [[ ${available_shas[$i]} != "$selected_sha" ]]; then + printf -v ambiguous_list '%s, ' "${ambiguous[@]}" + ambiguous_list=${ambiguous_list%, } + echo "Ambiguous newest branches in ${repository}: ${ambiguous_list}" >&2 + exit 1 + fi + done + fi + + selected_branch=${candidates[$selected_index]} + echo "Using '${selected_branch}' (${selected_sha}) for ${repository}" { - echo "${prefix}_ref=${selected}" + echo "${prefix}_ref=${selected_sha}" + echo "${prefix}_branch=${selected_branch}" echo "${prefix}_default=${default_branch}" } >> "${GITHUB_OUTPUT:?GITHUB_OUTPUT is not set}" done diff --git a/.github/scripts/test-resolve-dependency-refs.sh b/.github/scripts/test-resolve-dependency-refs.sh index fd9b33345..3cf762597 100644 --- a/.github/scripts/test-resolve-dependency-refs.sh +++ b/.github/scripts/test-resolve-dependency-refs.sh @@ -65,6 +65,10 @@ git -C "$source_repository" update-ref \ refs/remotes/origin/workflow-fix "$post_event_source_tip" git -C "$source_repository" update-ref \ refs/remotes/origin/later-parent "$later_parent_tip" +# Clava's vitest and java-deprecation refs are intentionally equal. Another +# repository must provide their strict ordering. +git -C "$source_repository" update-ref \ + refs/remotes/origin/vitest "$java_deprecation_tip" make_dependency() { local name=$1 @@ -86,10 +90,21 @@ make_dependency() { } current_dependency=$(make_dependency current workflow-fix lmsousa) -middle_dependency=$(make_dependency middle java-deprecation lmsousa) +middle_dependency=$(make_dependency middle java-deprecation vitest lmsousa) older_dependency=$(make_dependency older vitest lmsousa) integration_dependency=$(make_dependency integration staging) root_dependency=$(make_dependency root) +equal_dependency=$(make_dependency equal java-deprecation vitest lmsousa) +git -C "$equal_dependency" update-ref \ + refs/heads/vitest "$java_deprecation_tip" +ambiguous_dependency=$(make_dependency ambiguous java-deprecation vitest lmsousa) +git -C "$ambiguous_dependency" update-ref \ + refs/heads/vitest "$staging_tip" +reverse_dependency=$(make_dependency reverse java-deprecation vitest lmsousa) +git -C "$reverse_dependency" update-ref \ + refs/heads/java-deprecation "$vitest_tip" +git -C "$reverse_dependency" update-ref \ + refs/heads/vitest "$java_deprecation_tip" output_file="${test_directory}/github-output" log_file="${test_directory}/resolver.log" @@ -109,10 +124,58 @@ if grep -Fq "later-parent" "$log_file"; then echo "Resolver used a branch created after the workflow event" >&2 exit 1 fi -grep -Fqx "current_ref=workflow-fix" "$output_file" -grep -Fqx "middle_ref=java-deprecation" "$output_file" -grep -Fqx "older_ref=vitest" "$output_file" -grep -Fqx "integration_ref=staging" "$output_file" -grep -Fqx "root_ref=master" "$output_file" +grep -Fqx "current_branch=workflow-fix" "$output_file" +grep -Fqx "current_ref=${workflow_fix_tip}" "$output_file" +grep -Fqx "middle_branch=java-deprecation" "$output_file" +grep -Fqx "middle_ref=${java_deprecation_tip}" "$output_file" +grep -Fqx "older_branch=vitest" "$output_file" +grep -Fqx "older_ref=${vitest_tip}" "$output_file" +grep -Fqx "integration_branch=staging" "$output_file" +grep -Fqx "integration_ref=${staging_tip}" "$output_file" +grep -Fqx "root_branch=master" "$output_file" +grep -Fqx "root_ref=${master_tip}" "$output_file" + +# Equal, unordered names are safe when they resolve to the same target commit. +equal_output="${test_directory}/equal-output" +SOURCE_BRANCH=workflow-fix GITHUB_OUTPUT="$equal_output" \ + bash "$resolver" "$source_repository" \ + equal "$equal_dependency" >/dev/null +grep -Fqx "equal_ref=${java_deprecation_tip}" "$equal_output" + +# Diverged candidates with no ordering evidence must fail rather than guess. +if SOURCE_BRANCH=workflow-fix GITHUB_OUTPUT="${test_directory}/ambiguous-output" \ + bash "$resolver" "$source_repository" \ + ambiguous "$ambiguous_dependency" \ + >"${test_directory}/ambiguous.log" 2>&1; then + echo "Resolver accepted ambiguous, different dependency refs" >&2 + exit 1 +fi +grep -Fq "Ambiguous newest branches" "${test_directory}/ambiguous.log" + +# Opposite strict orderings from two repositories are contradictory. +if SOURCE_BRANCH=workflow-fix GITHUB_OUTPUT="${test_directory}/contradictory-output" \ + bash "$resolver" "$source_repository" \ + forward "$middle_dependency" \ + reverse "$reverse_dependency" \ + >"${test_directory}/contradictory.log" 2>&1; then + echo "Resolver accepted contradictory repository evidence" >&2 + exit 1 +fi +grep -Fq "Contradictory branch ordering evidence" \ + "${test_directory}/contradictory.log" + +# Terminal branch workflows must not create a staging/master ordering cycle. +git -C "$source_repository" switch --quiet --detach "$staging_tip" +SOURCE_BRANCH=staging GITHUB_OUTPUT="${test_directory}/staging-output" \ + bash "$resolver" "$source_repository" \ + integration "$integration_dependency" >/dev/null +grep -Fqx "integration_ref=${staging_tip}" \ + "${test_directory}/staging-output" + +git -C "$source_repository" switch --quiet --detach "$master_tip" +SOURCE_BRANCH=master GITHUB_OUTPUT="${test_directory}/master-output" \ + bash "$resolver" "$source_repository" \ + root "$root_dependency" >/dev/null +grep -Fqx "root_ref=${master_tip}" "${test_directory}/master-output" echo "All dependency ref resolver tests passed" diff --git a/.github/workflows/copilot-setup-steps.yml b/.github/workflows/copilot-setup-steps.yml index 2b3716cac..d1409bf0e 100644 --- a/.github/workflows/copilot-setup-steps.yml +++ b/.github/workflows/copilot-setup-steps.yml @@ -61,6 +61,7 @@ jobs: shell: bash env: SOURCE_BRANCH: ${{ env.BRANCH_NAME }} + EVIDENCE_REPOSITORIES: specs-feup/clava run: | bash lara-framework/.github/scripts/resolve-dependency-refs.sh \ lara-framework \ @@ -69,7 +70,8 @@ jobs: - name: Echo checks run: | echo "Lara branch: ${{ env.BRANCH_NAME }}" - echo "Specs-java-libs ref: ${{ steps.repo-refs.outputs.specs_ref }}" + echo "Specs-java-libs branch: ${{ steps.repo-refs.outputs.specs_branch }}" + echo "Specs-java-libs commit: ${{ steps.repo-refs.outputs.specs_ref }}" echo "Specs-java-libs default: ${{ steps.repo-refs.outputs.specs_default }}" - name: Checkout specs-java-libs diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 28c88a65c..84dd1d685 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -56,6 +56,7 @@ jobs: shell: bash env: SOURCE_BRANCH: ${{ env.BRANCH_NAME }} + EVIDENCE_REPOSITORIES: specs-feup/clava run: | bash lara-framework/.github/scripts/resolve-dependency-refs.sh \ lara-framework \ @@ -64,7 +65,8 @@ jobs: - name: Echo checks run: | echo "Lara branch: ${{ env.BRANCH_NAME }}" - echo "Specs-java-libs ref: ${{ steps.repo-refs.outputs.specs_ref }}" + echo "Specs-java-libs branch: ${{ steps.repo-refs.outputs.specs_branch }}" + echo "Specs-java-libs commit: ${{ steps.repo-refs.outputs.specs_ref }}" echo "Specs-java-libs default: ${{ steps.repo-refs.outputs.specs_default }}" - name: Checkout specs-java-libs From 059a4f07ba34466b7c390ac1424e5e4bd4c403e9 Mon Sep 17 00:00:00 2001 From: "L. Sousa" Date: Thu, 23 Jul 2026 20:50:23 +0100 Subject: [PATCH 05/11] fix: retain advanced stack branches --- .github/scripts/resolve-dependency-refs.sh | 35 +++++++++++++++++-- .../scripts/test-resolve-dependency-refs.sh | 35 +++++++++++++++++++ .github/workflows/nightly.yml | 1 + 3 files changed, 69 insertions(+), 2 deletions(-) diff --git a/.github/scripts/resolve-dependency-refs.sh b/.github/scripts/resolve-dependency-refs.sh index 413239c70..de7e88f22 100644 --- a/.github/scripts/resolve-dependency-refs.sh +++ b/.github/scripts/resolve-dependency-refs.sh @@ -62,6 +62,23 @@ if [[ -n ${EVIDENCE_REPOSITORIES:-} ]]; then done fi +# A moved branch is relevant only when its name is shared by repositories. +# Count each repository once so private one-off branches do not pollute the +# global candidate set. +declare -A branch_presence=() +for ((repository_index = 0; repository_index < ${#evidence_paths[@]}; repository_index++)); do + repository_path=${evidence_paths[$repository_index]} + ref_prefix=${evidence_ref_prefixes[$repository_index]} + while IFS= read -r ref; do + branch=${ref#"$ref_prefix"} + [[ $branch == HEAD ]] && continue + branch_presence[$branch]=$(( ${branch_presence[$branch]:-0} + 1 )) + done < <( + git -C "$repository_path" for-each-ref \ + --format='%(refname)' "${ref_prefix%/}" + ) +done + declare -a candidates=() declare -A seen_candidates=() @@ -114,6 +131,7 @@ collect_candidates() { fi return fi + local root_distance=$((distance - 1)) local ordered_refs="${evidence_directory}/candidate-refs-${#candidates[@]}-${distance}" : > "$ordered_refs" @@ -124,8 +142,21 @@ collect_candidates() { [[ $branch == "$source_branch" ]] && continue [[ $branch == "$integration_branch" ]] && continue [[ $branch == "$root_branch" ]] && continue - [[ -n ${distance_by_commit[$object]+yes} ]] || continue - printf '%s\t%s\n' "${distance_by_commit[$object]}" "$branch" >> "$ordered_refs" + + if [[ -n ${distance_by_commit[$object]+yes} ]]; then + printf '%s\t%s\n' "${distance_by_commit[$object]}" "$branch" >> "$ordered_refs" + continue + fi + + # The branch may have advanced after a child was created. Its merge-base + # still identifies the old tip on the event's stack. Requiring the name in + # multiple repositories and a fork point newer than the root boundary + # avoids treating every branch from master as part of this stack. + [[ ${branch_presence[$branch]:-0} -ge 2 ]] || continue + merge_base=$(git -C "$repository_path" merge-base "$start_ref" "$ref" 2>/dev/null || true) + [[ -n $merge_base && -n ${distance_by_commit[$merge_base]+yes} ]] || continue + [[ ${distance_by_commit[$merge_base]} -lt $root_distance ]] || continue + printf '%s\t%s\n' "${distance_by_commit[$merge_base]}" "$branch" >> "$ordered_refs" done < <( git -C "$repository_path" for-each-ref \ --format='%(refname)%09%(objectname)' "${ref_prefix%/}" diff --git a/.github/scripts/test-resolve-dependency-refs.sh b/.github/scripts/test-resolve-dependency-refs.sh index 3cf762597..c9663a945 100644 --- a/.github/scripts/test-resolve-dependency-refs.sh +++ b/.github/scripts/test-resolve-dependency-refs.sh @@ -56,6 +56,16 @@ commit "post-event source update" post_event_source_tip=$(git -C "$source_repository" rev-parse HEAD) git -C "$source_repository" switch --quiet --detach "$workflow_fix_tip" +git -C "$source_repository" switch --quiet lmsousa +commit "advanced parent branch" +advanced_lmsousa_tip=$(git -C "$source_repository" rev-parse HEAD) + +git -C "$source_repository" switch --quiet staging +git -C "$source_repository" switch --quiet -c rebased-parent-fixture +commit "rebased parent branch" +rebased_lmsousa_tip=$(git -C "$source_repository" rev-parse HEAD) +git -C "$source_repository" switch --quiet --detach "$workflow_fix_tip" + for branch in workflow-fix java-deprecation vitest lmsousa staging master; do branch_variable=${branch//-/_}_tip git -C "$source_repository" update-ref \ @@ -105,6 +115,12 @@ git -C "$reverse_dependency" update-ref \ refs/heads/java-deprecation "$vitest_tip" git -C "$reverse_dependency" update-ref \ refs/heads/vitest "$java_deprecation_tip" +advanced_dependency=$(make_dependency advanced lmsousa staging) +git -C "$advanced_dependency" update-ref \ + refs/heads/lmsousa "$advanced_lmsousa_tip" +rebased_dependency=$(make_dependency rebased lmsousa staging) +git -C "$rebased_dependency" update-ref \ + refs/heads/lmsousa "$rebased_lmsousa_tip" output_file="${test_directory}/github-output" log_file="${test_directory}/resolver.log" @@ -178,4 +194,23 @@ SOURCE_BRANCH=master GITHUB_OUTPUT="${test_directory}/master-output" \ root "$root_dependency" >/dev/null grep -Fqx "root_ref=${master_tip}" "${test_directory}/master-output" +# A shared parent remains a candidate when its tip advances or is rebased, +# provided its merge-base still identifies this post-master stack. +git -C "$source_repository" switch --quiet --detach "$workflow_fix_tip" +git -C "$source_repository" update-ref \ + refs/remotes/origin/lmsousa "$advanced_lmsousa_tip" +SOURCE_BRANCH=workflow-fix GITHUB_OUTPUT="${test_directory}/advanced-output" \ + bash "$resolver" "$source_repository" \ + advanced "$advanced_dependency" >/dev/null +grep -Fqx "advanced_ref=${advanced_lmsousa_tip}" \ + "${test_directory}/advanced-output" + +git -C "$source_repository" update-ref \ + refs/remotes/origin/lmsousa "$rebased_lmsousa_tip" +SOURCE_BRANCH=workflow-fix GITHUB_OUTPUT="${test_directory}/rebased-output" \ + bash "$resolver" "$source_repository" \ + rebased "$rebased_dependency" >/dev/null +grep -Fqx "rebased_ref=${rebased_lmsousa_tip}" \ + "${test_directory}/rebased-output" + echo "All dependency ref resolver tests passed" diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 84dd1d685..27c735003 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -7,6 +7,7 @@ name: nightly on: push: + pull_request: # Daily at midnight schedule: From f4093e05ce6ee84d3088a6322cde498035aee2b5 Mon Sep 17 00:00:00 2001 From: "L. Sousa" Date: Thu, 23 Jul 2026 20:55:50 +0100 Subject: [PATCH 06/11] fix: reject unproven sibling branches --- .github/scripts/resolve-dependency-refs.sh | 58 +++++++++++++++++-- .../scripts/test-resolve-dependency-refs.sh | 25 +++++++- 2 files changed, 77 insertions(+), 6 deletions(-) diff --git a/.github/scripts/resolve-dependency-refs.sh b/.github/scripts/resolve-dependency-refs.sh index de7e88f22..561f86d96 100644 --- a/.github/scripts/resolve-dependency-refs.sh +++ b/.github/scripts/resolve-dependency-refs.sh @@ -81,12 +81,17 @@ done declare -a candidates=() declare -A seen_candidates=() +declare -A candidate_proven=() add_candidate() { local branch=$1 + local proven=${2:-true} if [[ -n $branch && -z ${seen_candidates[$branch]+yes} ]]; then seen_candidates[$branch]=${#candidates[@]} + candidate_proven[${#candidates[@]}]=$proven candidates+=("$branch") + elif [[ -n $branch && $proven == true ]]; then + candidate_proven[${seen_candidates[$branch]}]=true fi } @@ -144,7 +149,7 @@ collect_candidates() { [[ $branch == "$root_branch" ]] && continue if [[ -n ${distance_by_commit[$object]+yes} ]]; then - printf '%s\t%s\n' "${distance_by_commit[$object]}" "$branch" >> "$ordered_refs" + printf '%s\t%s\ttrue\n' "${distance_by_commit[$object]}" "$branch" >> "$ordered_refs" continue fi @@ -156,14 +161,14 @@ collect_candidates() { merge_base=$(git -C "$repository_path" merge-base "$start_ref" "$ref" 2>/dev/null || true) [[ -n $merge_base && -n ${distance_by_commit[$merge_base]+yes} ]] || continue [[ ${distance_by_commit[$merge_base]} -lt $root_distance ]] || continue - printf '%s\t%s\n' "${distance_by_commit[$merge_base]}" "$branch" >> "$ordered_refs" + printf '%s\t%s\tfalse\n' "${distance_by_commit[$merge_base]}" "$branch" >> "$ordered_refs" done < <( git -C "$repository_path" for-each-ref \ --format='%(refname)%09%(objectname)' "${ref_prefix%/}" ) - while IFS=$'\t' read -r _ branch; do - add_candidate "$branch" + while IFS=$'\t' read -r _ branch proven; do + add_candidate "$branch" "$proven" done < <(sort -k1,1n -k2,2 -u "$ordered_refs") } @@ -189,6 +194,7 @@ echo "Dependency branch candidates: ${candidate_chain}" candidate_count=${#candidates[@]} declare -A edges=() +declare -A strict_edges=() add_edge() { local newer=$1 @@ -249,13 +255,36 @@ for ((repository_index = 0; repository_index < ${#evidence_paths[@]}; repository if is_first_parent_ancestor "$repository_path" "$right_sha" "$left_sha"; then add_edge "$i" "$j" + strict_edges["${i},${j}"]=1 elif is_first_parent_ancestor "$repository_path" "$left_sha" "$right_sha"; then add_edge "$j" "$i" + strict_edges["${j},${i}"]=1 fi done done done +# Strict evidence validates an uncertain candidate only when it connects to a +# branch already observed directly on a stack. Propagate that proof through a +# chain of strict first-parent relationships. +proof_changed=true +while [[ $proof_changed == true ]]; do + proof_changed=false + for edge in "${!strict_edges[@]}"; do + newer_index=${edge%,*} + older_index=${edge#*,} + if [[ ${candidate_proven[$newer_index]:-false} == true && + ${candidate_proven[$older_index]:-false} != true ]]; then + candidate_proven[$older_index]=true + proof_changed=true + elif [[ ${candidate_proven[$older_index]:-false} == true && + ${candidate_proven[$newer_index]:-false} != true ]]; then + candidate_proven[$newer_index]=true + proof_changed=true + fi + done +done + # Compute transitive closure and reject contradictory evidence. for ((k = 0; k < candidate_count; k++)); do for ((i = 0; i < candidate_count; i++)); do @@ -295,6 +324,27 @@ for ((dependency_index = 0; dependency_index < ${#dependency_repositories[@]}; d done declare -a newest_indices=() + + # A merge-base-only branch may be a moved parent or an unrelated sibling. + # Without strict evidence elsewhere, selecting its distinct commit would be + # a guess. Equal target SHAs remain harmless aliases. + for i in "${available_indices[@]}"; do + [[ ${candidate_proven[$i]:-false} == true ]] && continue + harmless_uncertain=false + for j in "${available_indices[@]}"; do + if [[ ${candidate_proven[$j]:-false} == true && + ( ${available_shas[$j]} == "${available_shas[$i]}" || + -n ${edges["${j},${i}"]+yes} ) ]]; then + harmless_uncertain=true + break + fi + done + if [[ $harmless_uncertain != true ]]; then + echo "Cannot place shared branch '${candidates[$i]}' in the stack for ${repository}" >&2 + exit 1 + fi + done + for i in "${available_indices[@]}"; do dominated=false for j in "${available_indices[@]}"; do diff --git a/.github/scripts/test-resolve-dependency-refs.sh b/.github/scripts/test-resolve-dependency-refs.sh index c9663a945..81bb36d0b 100644 --- a/.github/scripts/test-resolve-dependency-refs.sh +++ b/.github/scripts/test-resolve-dependency-refs.sh @@ -64,6 +64,11 @@ git -C "$source_repository" switch --quiet staging git -C "$source_repository" switch --quiet -c rebased-parent-fixture commit "rebased parent branch" rebased_lmsousa_tip=$(git -C "$source_repository" rev-parse HEAD) + +git -C "$source_repository" switch --quiet --detach "$lmsousa_tip" +git -C "$source_repository" switch --quiet -c unrelated-sibling +commit "unrelated sibling branch" +unrelated_sibling_tip=$(git -C "$source_repository" rev-parse HEAD) git -C "$source_repository" switch --quiet --detach "$workflow_fix_tip" for branch in workflow-fix java-deprecation vitest lmsousa staging master; do @@ -121,6 +126,7 @@ git -C "$advanced_dependency" update-ref \ rebased_dependency=$(make_dependency rebased lmsousa staging) git -C "$rebased_dependency" update-ref \ refs/heads/lmsousa "$rebased_lmsousa_tip" +sibling_dependency=$(make_dependency sibling unrelated-sibling staging) output_file="${test_directory}/github-output" log_file="${test_directory}/resolver.log" @@ -199,7 +205,8 @@ grep -Fqx "root_ref=${master_tip}" "${test_directory}/master-output" git -C "$source_repository" switch --quiet --detach "$workflow_fix_tip" git -C "$source_repository" update-ref \ refs/remotes/origin/lmsousa "$advanced_lmsousa_tip" -SOURCE_BRANCH=workflow-fix GITHUB_OUTPUT="${test_directory}/advanced-output" \ +SOURCE_BRANCH=workflow-fix EVIDENCE_REPOSITORIES="$middle_dependency" \ + GITHUB_OUTPUT="${test_directory}/advanced-output" \ bash "$resolver" "$source_repository" \ advanced "$advanced_dependency" >/dev/null grep -Fqx "advanced_ref=${advanced_lmsousa_tip}" \ @@ -207,10 +214,24 @@ grep -Fqx "advanced_ref=${advanced_lmsousa_tip}" \ git -C "$source_repository" update-ref \ refs/remotes/origin/lmsousa "$rebased_lmsousa_tip" -SOURCE_BRANCH=workflow-fix GITHUB_OUTPUT="${test_directory}/rebased-output" \ +SOURCE_BRANCH=workflow-fix EVIDENCE_REPOSITORIES="$middle_dependency" \ + GITHUB_OUTPUT="${test_directory}/rebased-output" \ bash "$resolver" "$source_repository" \ rebased "$rebased_dependency" >/dev/null grep -Fqx "rebased_ref=${rebased_lmsousa_tip}" \ "${test_directory}/rebased-output" +# A shared sibling with the same fork point is not a proven stack member. +git -C "$source_repository" update-ref \ + refs/remotes/origin/unrelated-sibling "$unrelated_sibling_tip" +if SOURCE_BRANCH=workflow-fix GITHUB_OUTPUT="${test_directory}/sibling-output" \ + bash "$resolver" "$source_repository" \ + sibling "$sibling_dependency" \ + >"${test_directory}/sibling.log" 2>&1; then + echo "Resolver selected an unproven sibling branch" >&2 + exit 1 +fi +grep -Fq "Cannot place shared branch 'unrelated-sibling'" \ + "${test_directory}/sibling.log" + echo "All dependency ref resolver tests passed" From f039fde132380e58ee1a9ce0ff1c5b14d8668971 Mon Sep 17 00:00:00 2001 From: "L. Sousa" Date: Thu, 23 Jul 2026 20:57:32 +0100 Subject: [PATCH 07/11] fix: propagate stack proof toward ancestors --- .github/scripts/resolve-dependency-refs.sh | 4 ---- .github/scripts/test-resolve-dependency-refs.sh | 2 ++ 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/.github/scripts/resolve-dependency-refs.sh b/.github/scripts/resolve-dependency-refs.sh index 561f86d96..f0adaebfe 100644 --- a/.github/scripts/resolve-dependency-refs.sh +++ b/.github/scripts/resolve-dependency-refs.sh @@ -277,10 +277,6 @@ while [[ $proof_changed == true ]]; do ${candidate_proven[$older_index]:-false} != true ]]; then candidate_proven[$older_index]=true proof_changed=true - elif [[ ${candidate_proven[$older_index]:-false} == true && - ${candidate_proven[$newer_index]:-false} != true ]]; then - candidate_proven[$newer_index]=true - proof_changed=true fi done done diff --git a/.github/scripts/test-resolve-dependency-refs.sh b/.github/scripts/test-resolve-dependency-refs.sh index 81bb36d0b..a235f1c8e 100644 --- a/.github/scripts/test-resolve-dependency-refs.sh +++ b/.github/scripts/test-resolve-dependency-refs.sh @@ -222,6 +222,8 @@ grep -Fqx "rebased_ref=${rebased_lmsousa_tip}" \ "${test_directory}/rebased-output" # A shared sibling with the same fork point is not a proven stack member. +git -C "$source_repository" update-ref \ + refs/remotes/origin/lmsousa "$lmsousa_tip" git -C "$source_repository" update-ref \ refs/remotes/origin/unrelated-sibling "$unrelated_sibling_tip" if SOURCE_BRANCH=workflow-fix GITHUB_OUTPUT="${test_directory}/sibling-output" \ From 17f5eb98ba8593cd84a719b283b3afe1c344dab3 Mon Sep 17 00:00:00 2001 From: "L. Sousa" Date: Sat, 22 Aug 2026 20:20:44 +0100 Subject: [PATCH 08/11] fix(test): pass file URL for custom Vitest environment on Windows fileURLToPath() produced a plain Windows path (D:\...), which the ESM loader misread as the protocol 'vitest-environment-d:' when spawning forks workers. Passing an href file:// URL works on all platforms. --- Lara-JS/vitest/weaverVitestConfig.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/Lara-JS/vitest/weaverVitestConfig.ts b/Lara-JS/vitest/weaverVitestConfig.ts index 022ec572b..7ef1c66d3 100644 --- a/Lara-JS/vitest/weaverVitestConfig.ts +++ b/Lara-JS/vitest/weaverVitestConfig.ts @@ -1,4 +1,3 @@ -import { fileURLToPath } from "node:url"; import type WeaverConfiguration from "../code/WeaverConfiguration.ts"; import type { WeaverEnvironmentOptions } from "./weaverEnvironment.ts"; import { defineConfig } from "vitest/config"; @@ -23,9 +22,7 @@ export function createWeaverVitestConfig( provider: "v8", reporter: ["text", "lcov"], }, - environment: fileURLToPath( - new URL("./weaverEnvironment.ts", import.meta.url), - ), + environment: new URL("./weaverEnvironment.ts", import.meta.url).href, environmentOptions, experimental: { viteModuleRunner: false, From 76a77f6cf6216db04bc14be6d6dfc2580fd4096b Mon Sep 17 00:00:00 2001 From: "L. Sousa" Date: Sat, 22 Aug 2026 20:32:17 +0100 Subject: [PATCH 09/11] fix(test): resolve custom Vitest environment via root-relative path Vitest only treats the environment value as a path when it starts with '.' or '/'; absolute POSIX paths leaked a file:// scheme that crashed dependency detection, and Windows drive paths were misread as the protocol 'vitest-environment-d:'. Compute a './'-prefixed path relative to the config root instead, which works on every platform. --- Lara-JS/vitest/weaverVitestConfig.ts | 44 +++++++++++++++++----------- 1 file changed, 27 insertions(+), 17 deletions(-) diff --git a/Lara-JS/vitest/weaverVitestConfig.ts b/Lara-JS/vitest/weaverVitestConfig.ts index 7ef1c66d3..63714120f 100644 --- a/Lara-JS/vitest/weaverVitestConfig.ts +++ b/Lara-JS/vitest/weaverVitestConfig.ts @@ -1,3 +1,5 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; import type WeaverConfiguration from "../code/WeaverConfiguration.ts"; import type { WeaverEnvironmentOptions } from "./weaverEnvironment.ts"; import { defineConfig } from "vitest/config"; @@ -15,23 +17,31 @@ export function createWeaverVitestConfig( weaver, }; - return defineConfig({ - test: { - coverage: { - include: ["**/*[^.d].(t|j)s"], - provider: "v8", - reporter: ["text", "lcov"], - }, - environment: new URL("./weaverEnvironment.ts", import.meta.url).href, - environmentOptions, - experimental: { - viteModuleRunner: false, + return defineConfig((userConfig) => { + const root = userConfig.root ?? process.cwd(); + const environmentPath = path + .relative(root, fileURLToPath(new URL("./weaverEnvironment.ts", import.meta.url))) + .split(path.sep) + .join("/"); + + return { + test: { + coverage: { + include: ["**/*[^.d].(t|j)s"], + provider: "v8", + reporter: ["text", "lcov"], + }, + environment: `./${environmentPath}`, + environmentOptions, + experimental: { + viteModuleRunner: false, + }, + fileParallelism: false, + globals: true, + isolate: false, + maxWorkers: 1, + pool: "forks", }, - fileParallelism: false, - globals: true, - isolate: false, - maxWorkers: 1, - pool: "forks", - }, + }; }); } From c163d5c28e25ec69c0ba5f8c2fb74a08860b4a0d Mon Sep 17 00:00:00 2001 From: "L. Sousa" Date: Sat, 22 Aug 2026 20:40:18 +0100 Subject: [PATCH 10/11] fix(test): use process cwd as Vitest root for environment path ConfigEnv does not expose the project root; Vitest defaults to the process cwd, which is where tests are always launched from. --- Lara-JS/vitest/weaverVitestConfig.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Lara-JS/vitest/weaverVitestConfig.ts b/Lara-JS/vitest/weaverVitestConfig.ts index 63714120f..bed81334e 100644 --- a/Lara-JS/vitest/weaverVitestConfig.ts +++ b/Lara-JS/vitest/weaverVitestConfig.ts @@ -17,8 +17,8 @@ export function createWeaverVitestConfig( weaver, }; - return defineConfig((userConfig) => { - const root = userConfig.root ?? process.cwd(); + return defineConfig(() => { + const root = process.cwd(); const environmentPath = path .relative(root, fileURLToPath(new URL("./weaverEnvironment.ts", import.meta.url))) .split(path.sep) From dbeb388749b0a2727cd3ebb12eaaea9078fed457 Mon Sep 17 00:00:00 2001 From: "L. Sousa" Date: Sat, 22 Aug 2026 20:47:42 +0100 Subject: [PATCH 11/11] fix(test): compute environment path eagerly from process cwd The function form of defineConfig does not typecheck against Vitest's overloads; the root-relative environment path is computed eagerly instead, since tests always launch from the package directory. --- Lara-JS/vitest/weaverVitestConfig.ts | 46 +++++++++++++--------------- 1 file changed, 22 insertions(+), 24 deletions(-) diff --git a/Lara-JS/vitest/weaverVitestConfig.ts b/Lara-JS/vitest/weaverVitestConfig.ts index bed81334e..e542cb4fe 100644 --- a/Lara-JS/vitest/weaverVitestConfig.ts +++ b/Lara-JS/vitest/weaverVitestConfig.ts @@ -17,31 +17,29 @@ export function createWeaverVitestConfig( weaver, }; - return defineConfig(() => { - const root = process.cwd(); - const environmentPath = path - .relative(root, fileURLToPath(new URL("./weaverEnvironment.ts", import.meta.url))) - .split(path.sep) - .join("/"); + const root = process.cwd(); + const environmentPath = path + .relative(root, fileURLToPath(new URL("./weaverEnvironment.ts", import.meta.url))) + .split(path.sep) + .join("/"); - return { - test: { - coverage: { - include: ["**/*[^.d].(t|j)s"], - provider: "v8", - reporter: ["text", "lcov"], - }, - environment: `./${environmentPath}`, - environmentOptions, - experimental: { - viteModuleRunner: false, - }, - fileParallelism: false, - globals: true, - isolate: false, - maxWorkers: 1, - pool: "forks", + return defineConfig({ + test: { + coverage: { + include: ["**/*[^.d].(t|j)s"], + provider: "v8", + reporter: ["text", "lcov"], }, - }; + environment: `./${environmentPath}`, + environmentOptions, + experimental: { + viteModuleRunner: false, + }, + fileParallelism: false, + globals: true, + isolate: false, + maxWorkers: 1, + pool: "forks", + }, }); }