Skip to content

Commit a6051dd

Browse files
committed
Add rebase CI
1 parent 4beb9d8 commit a6051dd

File tree

4 files changed

+307
-0
lines changed

4 files changed

+307
-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: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
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+
# check that rebase target is equal or later than local master
113+
if [ "${rebase_target}" != "master" ] ; then
114+
git for-each-ref --contains="master" --format="%(refname)" -- "refs/heads/${branch}" |
115+
grep --quiet "^refs/heads/${branch}$" ||
116+
die "rebase target ${rebase_target} seems to be older in the history than the master branch ...aborting!"
117+
fi
118+
119+
branch_map["${branch}"]="${rebase_target}"
120+
done < <(printf '%s\n' "${input_branches[@]}")
121+
122+
} >&2
123+
124+
# flatten recursively
125+
for key in "${!branch_map[@]}"; do
126+
value=${branch_map[$key]}
127+
if [ "${value}" = "master" ] ; then
128+
if ! array_contains branch_list "${key}" ; then
129+
branch_list+=( "${key}" )
130+
fi
131+
else
132+
# shellcheck disable=SC2207
133+
branch_list+=( $(backtrack branch_map "$key" "master") )
134+
fi
135+
done
136+
unset key
137+
138+
result=( )
139+
for key in "${branch_list[@]}"; do
140+
result+=( "${key}:${branch_map[$key]}" )
141+
done
142+
echo "${result[@]}"
143+

.github/workflows/rebase.yaml

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
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+
BRANCH_PREFIX: sh-rebase
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+
release-workflow:
73+
needs: ["rebase"]
74+
uses: ./.github/workflows/reusable-release.yml
75+
with:
76+
branches: ${{ needs.rebase.outputs.rebase_output_json }}
77+
ghc: "9.6.7"
78+
cabal: "3.12.1.0"
79+
80+
push-job:
81+
runs-on: ubuntu-latest
82+
needs: [rebase, release-workflow]
83+
steps:
84+
- run: |
85+
set -eux
86+
87+
# now push to temp branches
88+
for branch in ${{ needs.rebase.outputs.rebase_output_json }} ; do
89+
git checkout "${branch}"
90+
remote_branch="${{ env.BRANCH_PREFIX }}/${branch}"
91+
echo "git push https://${{ secrets.REBASE_PAT }}@github.com/${{ github.repository }}.git ${branch}:${remote_branch}"
92+
done
93+
94+
shell: bash
95+
env:
96+
GH_TOKEN: ${{ github.token }}
97+
98+
notify-job:
99+
runs-on: ubuntu-latest
100+
needs: [release-workflow]
101+
if: ${{ always() && contains(needs.*.result, 'failure') }}
102+
steps:
103+
- name: Checkout code
104+
uses: actions/checkout@v4
105+
106+
# create an issue with a link to the workflow run on failure
107+
# TODO: don't create more issues, only use one
108+
- run: |
109+
set -eux
110+
gh repo set-default stable-haskell/cabal
111+
for issue in $(gh issue list --label rebase-failure --json url -q '.[] | .url') ; do
112+
gh issue close "${issue}"
113+
done
114+
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 }}"
115+
env:
116+
GH_TOKEN: ${{ github.token }}
117+

.github/workflows/release.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ on:
44
push:
55
branches:
66
- stable-haskell/master
7+
- sh-rebase/**
78
tags:
89
- 'v*'
910
- 'cabal-install-*'

0 commit comments

Comments
 (0)