Skip to content

Commit 2119118

Browse files
committed
Add rebase CI
1 parent a627062 commit 2119118

File tree

3 files changed

+381
-0
lines changed

3 files changed

+381
-0
lines changed

.github/scripts/rebase_local.sh

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
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+
rebase_target=$(echo "${branch_spec}" | awk -F ':' '{ print $2 }')
27+
git checkout "${branch}"
28+
common_ancestor=$(git merge-base "${branch}" "origin/${rebase_target}")
29+
[ -e rebase/"${branch}" ] && exit 1
30+
mkdir -p rebase/"${branch}"
31+
(
32+
cd rebase/"${branch}"
33+
echo "${common_ancestor}" > BASE_COMMIT
34+
git format-patch "${common_ancestor}".."${branch}"
35+
)
36+
if compgen -G rebase/"${branch}"/*.patch > /dev/null; then
37+
git reset --hard "${rebase_target}"
38+
git am --3way rebase/"${branch}"/*.patch
39+
fi
40+
output_branches+=( "${branch}" )
41+
done
42+
unset branch_spec branch
43+
44+
# cherry-pick on stable-master
45+
git checkout stable-master
46+
git reset --hard upstream/master
47+
for branch_spec in ${spec} ; do
48+
branch=$(echo "${branch_spec}" | awk -F ':' '{ print $1 }')
49+
if compgen -G "rebase/${branch}"/*.patch > /dev/null; then
50+
git am --3way "rebase/${branch}"/*.patch
51+
fi
52+
done
53+
output_branches+=( "stable-master" )
54+
55+
} >&2
56+
57+
echo "${output_branches[*]}"
58+

.github/scripts/rebase_spec.sh

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
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+
create_branch_map() {
106+
local -n arr=$1
107+
local -n discovered=$2
108+
local -n map=$3
109+
110+
while IFS= read -r branch || [[ -n $branch ]]; do
111+
rebase_target=$(git branch --merged "${branch}" --sort="ahead-behind:${branch}" --format="%(refname:short)" | grep -e "${branch_regex}" -e '^master$' | awk 'NR==2{print;exit}')
112+
113+
# this is the case when the branch is actually behind master... we then
114+
# still want to rebase against master
115+
if [ -z "${rebase_target}" ] ; then
116+
rebase_target=master
117+
fi
118+
119+
if ! array_contains input_branches "${rebase_target}" && [ "${rebase_target}" != "master" ] ; then
120+
discovered+=( "${rebase_target}" )
121+
fi
122+
123+
map["${branch}"]="${rebase_target}"
124+
done < <(printf '%s\n' "${arr[@]}")
125+
}
126+
127+
{
128+
129+
# create branch rebase tree
130+
# we're doing that on the state of the local tree/master
131+
newly_detected_input_branches=( )
132+
create_branch_map input_branches newly_detected_input_branches branch_map
133+
# these shenanigns are needed in case the rebase target branches themselves do not have
134+
# the 'rebase' label... this would break cherry-picking on master, so we include "parent"
135+
# branches regardless
136+
while true ; do
137+
if [ ${#newly_detected_input_branches[@]} -eq 0 ] ; then
138+
break
139+
else
140+
nothing=( )
141+
create_branch_map newly_detected_input_branches nothing branch_map
142+
newly_detected_input_branches=( "${nothing[@]}" )
143+
fi
144+
done
145+
146+
} >&2
147+
148+
# flatten recursively
149+
for key in "${!branch_map[@]}"; do
150+
value=${branch_map[$key]}
151+
if [ "${value}" = "master" ] ; then
152+
if ! array_contains branch_list "${key}" ; then
153+
branch_list+=( "${key}" )
154+
fi
155+
else
156+
# shellcheck disable=SC2207
157+
branch_list+=( $(backtrack branch_map "$key" "master") )
158+
fi
159+
done
160+
unset key
161+
162+
result=( )
163+
for key in "${branch_list[@]}"; do
164+
result+=( "${key}:${branch_map[$key]}" )
165+
done
166+
echo "${result[@]}"
167+

.github/workflows/rebase.yaml

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
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+
concurrency:
14+
group: ${{ github.workflow }}
15+
cancel-in-progress: false
16+
17+
jobs:
18+
rebase:
19+
name: Rebase now!
20+
runs-on: ubuntu-latest
21+
env:
22+
UPSTREAM_REPO: https://github.com/haskell/cabal.git
23+
CI_BRANCH: stable-haskell/feature/rebase-CI
24+
outputs:
25+
rebase_output_json: ${{ steps.rebase.outputs.branches_json }}
26+
rebase_output: ${{ steps.rebase.outputs.branches }}
27+
steps:
28+
- name: Checkout code
29+
uses: actions/checkout@v4
30+
with:
31+
fetch-depth: 0
32+
persist-credentials: false
33+
34+
- id: rebase
35+
name: rebase
36+
run: |
37+
set -eux
38+
39+
git switch --detach
40+
git fetch origin refs/heads/*:refs/heads/*
41+
42+
gh repo set-default stable-haskell/cabal
43+
git config checkout.defaultRemote origin
44+
45+
# required to apply patches
46+
git config user.email "[email protected]"
47+
git config user.name "GitHub CI"
48+
49+
# this does not push
50+
branch_spec=$(bash .github/scripts/rebase_spec.sh '^stable-haskell/feature/.*')
51+
rebased_branches=( $(bash .github/scripts/rebase_local.sh ${{ env.UPSTREAM_REPO }} "${branch_spec}") )
52+
53+
# we use branches_json to trigger release workflow, but we don't want to do it for upstream master
54+
echo "branches_json=$(jq --compact-output --null-input '$ARGS.positional' --args -- "${rebased_branches[@]/#/tmp\/}" | jq --compact-output 'del(.[] | select(. == "tmp/master"))')" >> "$GITHUB_OUTPUT"
55+
# this output is used to update remote branches, so it shall include upstream master
56+
echo "branches=${rebased_branches[*]} master" >> "$GITHUB_OUTPUT"
57+
shell: bash
58+
env:
59+
GH_TOKEN: ${{ github.token }}
60+
61+
- name: save branches on remote
62+
run: |
63+
set -eux
64+
65+
for branch in ${{ steps.rebase.outputs.branches }} ; do
66+
git checkout "${branch}"
67+
git push -f https://${{ secrets.REBASE_PAT }}@github.com/${{ github.repository }}.git ${branch}:tmp/${branch}
68+
done
69+
shell: bash
70+
env:
71+
GH_TOKEN: ${{ github.token }}
72+
73+
- if: always()
74+
name: backup
75+
run: |
76+
git checkout -f master || true
77+
git archive master > backup.tar
78+
tar -rf backup.tar .git rebase
79+
80+
- if: always()
81+
name: Upload artifact
82+
uses: actions/upload-artifact@v4
83+
with:
84+
if-no-files-found: error
85+
retention-days: 7
86+
name: backup
87+
path: |
88+
./backup.tar
89+
90+
- name: checkout reusable workflow
91+
run: |
92+
git checkout -f "${{ env.CI_BRANCH }}"
93+
94+
release-workflow:
95+
needs: ["rebase"]
96+
uses: ./.github/workflows/reusable-release.yml
97+
with:
98+
branches: ${{ needs.rebase.outputs.rebase_output_json }}
99+
ghc: "9.6.7"
100+
cabal: "3.12.1.0"
101+
102+
push-job:
103+
runs-on: ubuntu-latest
104+
needs: [rebase, release-workflow]
105+
steps:
106+
- name: Checkout code
107+
uses: actions/checkout@v4
108+
with:
109+
fetch-depth: 0
110+
persist-credentials: false
111+
112+
- name: update branches
113+
run: |
114+
set -eux
115+
116+
for branch in ${{ needs.rebase.outputs.rebase_output }} ; do
117+
git checkout "${branch}"
118+
git push -f https://${{ secrets.REBASE_PAT }}@github.com/${{ github.repository }}.git tmp/${branch}:${branch}
119+
done
120+
git push -f https://${{ secrets.REBASE_PAT }}@github.com/${{ github.repository }}.git tmp/stable-master:stable-master
121+
122+
shell: bash
123+
env:
124+
GH_TOKEN: ${{ github.token }}
125+
126+
- name: delete tmp branches
127+
if: always()
128+
run: |
129+
unset branch
130+
for branch in $(git for-each-ref --format="%(refname:short)" -- 'refs/heads/tmp') ; do
131+
git push -f https://${{ secrets.REBASE_PAT }}@github.com/${{ github.repository }}.git :${branch}
132+
done
133+
shell: bash
134+
env:
135+
GH_TOKEN: ${{ github.token }}
136+
137+
notify-job:
138+
runs-on: ubuntu-latest
139+
needs: [release-workflow]
140+
if: ${{ always() && contains(needs.*.result, 'failure') }}
141+
steps:
142+
- name: Checkout code
143+
uses: actions/checkout@v4
144+
145+
# create an issue with a link to the workflow run on failure
146+
# TODO: don't create more issues, only use one
147+
- run: |
148+
set -eux
149+
gh repo set-default stable-haskell/cabal
150+
for issue in $(gh issue list --label rebase-failure --json url -q '.[] | .url') ; do
151+
gh issue close "${issue}"
152+
done
153+
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 }}"
154+
env:
155+
GH_TOKEN: ${{ github.token }}
156+

0 commit comments

Comments
 (0)