Skip to content

Commit 4cf714d

Browse files
committed
Add rebase CI
1 parent d318146 commit 4cf714d

File tree

3 files changed

+326
-0
lines changed

3 files changed

+326
-0
lines changed

.github/scripts/rebase_local.sh

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
#!/bin/bash
2+
3+
set -eux
4+
5+
upstream_repo=$1
6+
# of the form [<branch>:<rebase-target>], e.g.:
7+
# BA:master B1:master B2:B1 B3:B2
8+
spec=$2
9+
10+
{
11+
12+
git remote add upstream "${upstream_repo}" || true
13+
git fetch upstream
14+
15+
output_branches=()
16+
17+
# sync master with upstream
18+
git checkout master
19+
git reset --hard upstream/master
20+
output_branches+=( "master" )
21+
22+
# rebase each branch
23+
mkdir -p rebase
24+
for branch_spec in ${spec} ; do
25+
branch=$(echo "${branch_spec}" | awk -F ':' '{ print $1 }')
26+
(
27+
rebase_target=$(echo "${branch_spec}" | awk -F ':' '{ print $2 }')
28+
git checkout "${branch}"
29+
common_ancestor=$(git merge-base "${branch}" "origin/${rebase_target}")
30+
[ -e rebase/"${branch}" ] && exit 1
31+
mkdir -p rebase/"${branch}"
32+
cd rebase/"${branch}"
33+
echo "${common_ancestor}" > BASE_COMMIT
34+
git format-patch "${common_ancestor}".."${branch}"
35+
if compgen -G "*.patch" > /dev/null; then
36+
git reset --hard "${rebase_target}"
37+
git am --3way *.patch
38+
fi
39+
)
40+
output_branches+=( "${branch}" )
41+
done
42+
43+
} >&2
44+
45+
echo "${output_branches[*]}"
46+

.github/scripts/rebase_spec.sh

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
#!/bin/bash
2+
3+
# Branch regex we consider for rebase targets.
4+
# For our purposes this is usually 'stable-haskell/feature/*'.
5+
# 'master' is always considered.
6+
branch_regex=$1
7+
shift 1
8+
declare -a input_branches
9+
input_branches=( "$@" )
10+
set -eux
11+
12+
[ ${#input_branches[@]} -eq 0 ] &&
13+
input_branches=( $(gh pr list --label rebase --state open --json headRefName --jq ".[] | select( .headRefName | match(\"${branch_regex}\")) | .headRefName" --template '{{range .}}{{tablerow .headRefName}}{{end}}') )
14+
15+
branch_list=( )
16+
declare -A branch_map
17+
18+
# @FUNCTION: die
19+
# @USAGE: [msg]
20+
# @DESCRIPTION:
21+
# Exits the shell script with status code 2
22+
# and prints the given message in red to STDERR, if any.
23+
die() {
24+
(>&2 red_message "$1")
25+
exit 2
26+
}
27+
28+
# @FUNCTION: red_message
29+
# @USAGE: <msg>
30+
# @DESCRIPTION:
31+
# Print a red message.
32+
red_message() {
33+
printf "\\033[0;31m%s\\033[0m\\n" "$1"
34+
}
35+
36+
# @FUNCTION: array_contains
37+
# @USAGE: <arr_ref> <val>
38+
# @DESCRIPTION:
39+
# Checks whether the array reference contains the given value.
40+
# @RETURN: 0 if value exists, 1 otherwise
41+
array_contains() {
42+
local -n arr=$1
43+
local val=$2
44+
shift 2
45+
if [[ " ${arr[*]} " =~ [[:space:]]${val}[[:space:]] ]]; then
46+
return 0
47+
else
48+
return 1
49+
fi
50+
}
51+
52+
max_backtrack=10
53+
54+
# @FUNCTION: backtrack
55+
# @USAGE: <map_ref> <start_key> <abort_value>
56+
# @DESCRIPTION:
57+
# Backtrack dependencies through an array list.
58+
# E.g. given an associated array with key value pairs of:
59+
# B1 -> M
60+
# B2 -> B1
61+
# B3 -> B2
62+
#
63+
# ...if we pass B3 as start_key and M as abort_value, then
64+
# we receive the flattened ordered list "B1 B2 B3"
65+
# @STDOUT: space separated list of backtracked values
66+
backtrack() {
67+
backtrack_ 0 "$1" "$2" "$3"
68+
}
69+
70+
# internal to track backtrack depth
71+
backtrack_() {
72+
local depth=$1
73+
if [[ $depth -gt $max_backtrack ]] ; then
74+
die "Dependency backtracking too deep... aborting!"
75+
fi
76+
shift 1
77+
78+
if [[ $1 != map ]] ; then
79+
local -n map=$1
80+
fi
81+
82+
local base=$2
83+
local abort_value=$3
84+
local value
85+
86+
if [ "${base}" = "${abort_value}" ] ; then
87+
return
88+
fi
89+
90+
value=${map[$base]}
91+
92+
if [ "${value}" = "${abort_value}" ] ; then
93+
if ! array_contains branch_list "${base}" ; then
94+
echo "${base}"
95+
fi
96+
else
97+
if array_contains branch_list "${base}" ; then
98+
backtrack_ $((depth++)) map "${map[$value]}" "${abort_value}"
99+
else
100+
echo "$(backtrack_ $((depth++)) map "${map[$base]}" "${abort_value}")" "${base}"
101+
fi
102+
fi
103+
}
104+
105+
{
106+
107+
# create branch rebase tree
108+
# we're doing that on the state of the local tree/master
109+
while IFS= read -r branch || [[ -n $branch ]]; do
110+
rebase_target=$(git branch --merged "${branch}" --sort="ahead-behind:${branch}" --format="%(refname:short)" | grep -e "${branch_regex}" -e '^master$' | awk 'NR==2{print;exit}')
111+
112+
# this is the case when the branch is actually behind master... we then
113+
# still want to rebase against master
114+
if [ -z "${rebase_target}" ] ; then
115+
rebase_target=master
116+
fi
117+
118+
branch_map["${branch}"]="${rebase_target}"
119+
done < <(printf '%s\n' "${input_branches[@]}")
120+
121+
} >&2
122+
123+
# flatten recursively
124+
for key in "${!branch_map[@]}"; do
125+
value=${branch_map[$key]}
126+
if [ "${value}" = "master" ] ; then
127+
if ! array_contains branch_list "${key}" ; then
128+
branch_list+=( "${key}" )
129+
fi
130+
else
131+
# shellcheck disable=SC2207
132+
branch_list+=( $(backtrack branch_map "$key" "master") )
133+
fi
134+
done
135+
unset key
136+
137+
result=( )
138+
for key in "${branch_list[@]}"; do
139+
result+=( "${key}:${branch_map[$key]}" )
140+
done
141+
echo "${result[@]}"
142+

.github/workflows/rebase.yaml

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
name: Rebase against upstream
2+
3+
on:
4+
schedule:
5+
- cron: '0 0 * * *'
6+
workflow_dispatch:
7+
8+
permissions:
9+
contents: write
10+
issues: write
11+
pull-requests: read
12+
13+
jobs:
14+
rebase:
15+
name: Rebase now!
16+
runs-on: ubuntu-latest
17+
env:
18+
UPSTREAM_REPO: https://github.com/haskell/cabal.git
19+
CI_BRANCH: stable-haskell/feature/rebase-CI
20+
outputs:
21+
rebase_output_json: ${{ steps.rebase.outputs.branches_json }}
22+
rebase_output: ${{ steps.rebase.outputs.branches }}
23+
steps:
24+
- name: Checkout code
25+
uses: actions/checkout@v4
26+
with:
27+
fetch-depth: 0
28+
persist-credentials: false
29+
30+
- id: rebase
31+
name: rebase
32+
run: |
33+
set -eux
34+
35+
git switch --detach
36+
git fetch origin refs/heads/*:refs/heads/*
37+
38+
gh repo set-default stable-haskell/cabal
39+
git config checkout.defaultRemote origin
40+
41+
# required to apply patches
42+
git config user.email "[email protected]"
43+
git config user.name "GitHub CI"
44+
45+
# this does not push
46+
branch_spec=$(bash .github/scripts/rebase_spec.sh '^stable-haskell/feature/.*')
47+
rebased_branches=( $(bash .github/scripts/rebase_local.sh ${{ env.UPSTREAM_REPO }} "${branch_spec}") )
48+
49+
echo "branches_json=$(jq --compact-output --null-input '$ARGS.positional' --args -- "${rebased_branches[@]}")" >> "$GITHUB_OUTPUT"
50+
echo "branches=${rebased_branches[*]}" >> "$GITHUB_OUTPUT"
51+
shell: bash
52+
env:
53+
GH_TOKEN: ${{ github.token }}
54+
55+
- if: always()
56+
name: backup
57+
run: |
58+
git checkout -f master || true
59+
git archive master > backup.tar
60+
tar -rf backup.tar .git rebase
61+
62+
- if: always()
63+
name: Upload artifact
64+
uses: actions/upload-artifact@v4
65+
with:
66+
if-no-files-found: error
67+
retention-days: 7
68+
name: backup
69+
path: |
70+
./backup.tar
71+
72+
- name: checkout reusable workflow
73+
run: |
74+
git checkout -f "${{ env.CI_BRANCH }}"
75+
76+
release-workflow:
77+
needs: ["rebase"]
78+
uses: ./.github/workflows/reusable-release.yml
79+
with:
80+
branches: ${{ needs.rebase.outputs.rebase_output_json }}
81+
ghc: "9.6.7"
82+
cabal: "3.12.1.0"
83+
84+
push-job:
85+
runs-on: ubuntu-latest
86+
needs: [rebase, release-workflow]
87+
steps:
88+
- name: update feature branches
89+
run: |
90+
set -eux
91+
92+
# now push to temp branches
93+
for branch in ${{ needs.rebase.outputs.rebase_output_json }} ; do
94+
git checkout "${branch}"
95+
echo "git push -f https://${{ secrets.REBASE_PAT }}@github.com/${{ github.repository }}.git ${branch}
96+
done
97+
98+
shell: bash
99+
env:
100+
GH_TOKEN: ${{ github.token }}
101+
102+
- name: update stable-master
103+
run: |
104+
set -eux
105+
106+
# cherry-pick on stable-master
107+
git checkout stable-master
108+
for branch in ${{ needs.rebase.outputs.rebase_output_json }} ; do
109+
if [ -e "rebase/${branch}" ] && compgen -G "rebase/${branch}"/*.patch > /dev/null; then
110+
git am --3way "rebase/${branch}"/*.patch
111+
fi
112+
done
113+
echo "git push -f https://${{ secrets.REBASE_PAT }}@github.com/${{ github.repository }}.git stable-master
114+
115+
shell: bash
116+
env:
117+
GH_TOKEN: ${{ github.token }}
118+
119+
notify-job:
120+
runs-on: ubuntu-latest
121+
needs: [release-workflow]
122+
if: ${{ always() && contains(needs.*.result, 'failure') }}
123+
steps:
124+
- name: Checkout code
125+
uses: actions/checkout@v4
126+
127+
# create an issue with a link to the workflow run on failure
128+
# TODO: don't create more issues, only use one
129+
- run: |
130+
set -eux
131+
gh repo set-default stable-haskell/cabal
132+
for issue in $(gh issue list --label rebase-failure --json url -q '.[] | .url') ; do
133+
gh issue close "${issue}"
134+
done
135+
gh issue create --title "Rebase failed on $(date -u +"%Y-%m-%d")" --label rebase-failure --body "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
136+
env:
137+
GH_TOKEN: ${{ github.token }}
138+

0 commit comments

Comments
 (0)