Skip to content

Commit a6ecade

Browse files
ci: add autonomous CI/CD pipeline with Buildroot build and QEMU tests
Replace the standalone update-hash workflow with a unified CI pipeline that builds the Buildroot image and validates the ShellHub agent installation using QEMU, enabling fully autonomous Renovate PRs. The pipeline runs three jobs: - update-hash: recalculates tarball MD5 for Renovate PRs (with early-exit to prevent double-runs) - build-and-test: cross-compiles via Buildroot and boots the image in QEMU to verify the agent binary and init script - auto-merge: approves and squash-merges Renovate PRs after tests pass
1 parent 12316f6 commit a6ecade

3 files changed

Lines changed: 248 additions & 49 deletions

File tree

.github/scripts/test-qemu.py

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
#!/usr/bin/env python3
2+
"""Boot a Buildroot QEMU image and validate the ShellHub agent installation."""
3+
4+
import sys
5+
import os
6+
import pexpect
7+
8+
TIMEOUT_BOOT = 180
9+
TIMEOUT_CMD = 30
10+
LOG_FILE = "/tmp/qemu-test.log"
11+
12+
13+
def main():
14+
if len(sys.argv) != 2:
15+
print(f"Usage: {sys.argv[0]} <images-dir>")
16+
sys.exit(1)
17+
18+
images_dir = sys.argv[1]
19+
bzimage = os.path.join(images_dir, "bzImage")
20+
rootfs = os.path.join(images_dir, "rootfs.ext2")
21+
22+
for path in (bzimage, rootfs):
23+
if not os.path.exists(path):
24+
print(f"ERROR: {path} not found")
25+
sys.exit(1)
26+
27+
qemu_cmd = (
28+
f"qemu-system-x86_64"
29+
f" -M pc"
30+
f" -kernel {bzimage}"
31+
f" -drive file={rootfs},if=virtio,format=raw"
32+
f" -append \"rootwait root=/dev/vda console=ttyS0\""
33+
f" -nographic"
34+
f" -no-reboot"
35+
)
36+
37+
print(f"Starting QEMU: {qemu_cmd}")
38+
logfile = open(LOG_FILE, "wb")
39+
child = pexpect.spawn(qemu_cmd, timeout=TIMEOUT_BOOT, logfile=logfile, encoding=None)
40+
41+
tests_passed = 0
42+
tests_total = 3
43+
44+
try:
45+
# Wait for login prompt
46+
print("Waiting for login prompt...")
47+
child.expect(b"login:", timeout=TIMEOUT_BOOT)
48+
child.sendline(b"root")
49+
50+
# Wait for shell prompt
51+
child.expect(b"#", timeout=TIMEOUT_CMD)
52+
print("Logged in as root.")
53+
54+
# Test 1: Binary exists and is executable
55+
print("Test 1: Checking binary exists...")
56+
child.sendline(b"test -x /usr/bin/shellhub-agent && echo TEST1_PASS || echo TEST1_FAIL")
57+
child.expect(b"TEST1_(PASS|FAIL)", timeout=TIMEOUT_CMD)
58+
if child.match.group(1) == b"PASS":
59+
print(" PASS: /usr/bin/shellhub-agent exists and is executable")
60+
tests_passed += 1
61+
else:
62+
print(" FAIL: /usr/bin/shellhub-agent not found or not executable")
63+
64+
child.expect(b"#", timeout=TIMEOUT_CMD)
65+
66+
# Test 2: Binary runs (--version or --help)
67+
print("Test 2: Checking binary runs...")
68+
child.sendline(b"shellhub-agent --version && echo TEST2_PASS || echo TEST2_FAIL")
69+
child.expect(b"TEST2_(PASS|FAIL)", timeout=TIMEOUT_CMD)
70+
if child.match.group(1) == b"PASS":
71+
print(" PASS: shellhub-agent --version works")
72+
tests_passed += 1
73+
else:
74+
print(" FAIL: shellhub-agent --version failed")
75+
76+
child.expect(b"#", timeout=TIMEOUT_CMD)
77+
78+
# Test 3: Init script installed
79+
print("Test 3: Checking init script...")
80+
child.sendline(b"test -f /etc/init.d/S42shellhub && echo TEST3_PASS || echo TEST3_FAIL")
81+
child.expect(b"TEST3_(PASS|FAIL)", timeout=TIMEOUT_CMD)
82+
if child.match.group(1) == b"PASS":
83+
print(" PASS: /etc/init.d/S42shellhub exists")
84+
tests_passed += 1
85+
else:
86+
print(" FAIL: /etc/init.d/S42shellhub not found")
87+
88+
child.expect(b"#", timeout=TIMEOUT_CMD)
89+
90+
# Shutdown
91+
print("Shutting down...")
92+
child.sendline(b"poweroff")
93+
child.expect(pexpect.EOF, timeout=60)
94+
95+
except pexpect.TIMEOUT:
96+
print("ERROR: Timeout waiting for QEMU")
97+
child.close(force=True)
98+
logfile.close()
99+
sys.exit(1)
100+
except pexpect.EOF:
101+
print("ERROR: QEMU exited unexpectedly")
102+
logfile.close()
103+
sys.exit(1)
104+
105+
logfile.close()
106+
107+
print(f"\nResults: {tests_passed}/{tests_total} tests passed")
108+
if tests_passed < tests_total:
109+
sys.exit(1)
110+
111+
print("All tests passed!")
112+
113+
114+
if __name__ == "__main__":
115+
main()

.github/workflows/ci.yml

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
name: CI
2+
3+
on:
4+
pull_request:
5+
paths:
6+
- 'package/shellhub/**'
7+
- 'rootfs_overlay/**'
8+
- 'Config.in'
9+
- 'external.mk'
10+
- '.github/**'
11+
12+
permissions:
13+
contents: write
14+
pull-requests: write
15+
16+
jobs:
17+
update-hash:
18+
runs-on: ubuntu-latest
19+
if: github.actor == 'renovate[bot]'
20+
21+
steps:
22+
- name: Checkout PR
23+
uses: actions/checkout@v4
24+
with:
25+
ref: ${{ github.head_ref }}
26+
token: ${{ secrets.GITHUB_TOKEN }}
27+
28+
- name: Update hash
29+
run: |
30+
VERSION=$(grep "^SHELLHUB_VERSION" package/shellhub/shellhub.mk | cut -d= -f2 | tr -d '[:space:]')
31+
URL="https://github.com/shellhub-io/shellhub/releases/download/v${VERSION}/shellhub-agent.tar.gz"
32+
33+
echo "Downloading ${URL}..."
34+
curl -L -o /tmp/shellhub-agent.tar.gz "${URL}"
35+
36+
NEW_MD5=$(md5sum /tmp/shellhub-agent.tar.gz | awk '{print $1}')
37+
CURRENT_MD5=$(awk '{print $2}' package/shellhub/shellhub.hash 2>/dev/null || echo "")
38+
rm /tmp/shellhub-agent.tar.gz
39+
40+
if [ "$NEW_MD5" = "$CURRENT_MD5" ]; then
41+
echo "Hash already up to date, skipping commit."
42+
exit 0
43+
fi
44+
45+
echo "md5 ${NEW_MD5} shellhub-agent.tar.gz" > package/shellhub/shellhub.hash
46+
echo "Hash file updated successfully!"
47+
48+
- name: Commit hash changes
49+
run: |
50+
git config user.name "github-actions[bot]"
51+
git config user.email "github-actions[bot]@users.noreply.github.com"
52+
git add package/shellhub/shellhub.hash
53+
git diff --staged --quiet || git commit -m "Update hash for new version"
54+
git push
55+
56+
build-and-test:
57+
needs: [update-hash]
58+
if: always() && (needs.update-hash.result == 'success' || needs.update-hash.result == 'skipped')
59+
runs-on: ubuntu-latest
60+
timeout-minutes: 120
61+
62+
steps:
63+
- name: Checkout PR
64+
uses: actions/checkout@v4
65+
with:
66+
ref: ${{ github.head_ref }}
67+
68+
- name: Install dependencies
69+
run: |
70+
sudo apt-get update
71+
sudo apt-get install -y \
72+
build-essential libncurses-dev bc python3 rsync cpio unzip wget file \
73+
qemu-system-x86 python3-pexpect
74+
75+
- name: Clone Buildroot
76+
run: |
77+
git clone --depth 1 --branch 2024.11 https://github.com/buildroot/buildroot.git /tmp/buildroot
78+
79+
- name: Cache Buildroot downloads
80+
uses: actions/cache@v4
81+
with:
82+
path: /tmp/buildroot/dl
83+
key: buildroot-dl-${{ hashFiles('package/shellhub/shellhub.mk') }}
84+
restore-keys: buildroot-dl-
85+
86+
- name: Cache ccache
87+
uses: actions/cache@v4
88+
with:
89+
path: ~/.buildroot-ccache
90+
key: buildroot-ccache-${{ github.run_id }}
91+
restore-keys: buildroot-ccache-
92+
93+
- name: Configure Buildroot
94+
working-directory: /tmp/buildroot
95+
run: |
96+
make BR2_EXTERNAL=$GITHUB_WORKSPACE qemu_x86_64_defconfig
97+
cat >> .config << 'EOF'
98+
BR2_PACKAGE_SHELLHUB=y
99+
BR2_CCACHE=y
100+
BR2_CCACHE_DIR="/home/runner/.buildroot-ccache"
101+
EOF
102+
make olddefconfig
103+
104+
- name: Build
105+
working-directory: /tmp/buildroot
106+
run: make -j$(nproc)
107+
108+
- name: Test with QEMU
109+
run: python3 .github/scripts/test-qemu.py /tmp/buildroot/output/images
110+
111+
- name: Upload logs on failure
112+
if: failure()
113+
uses: actions/upload-artifact@v4
114+
with:
115+
name: qemu-test-logs
116+
path: /tmp/qemu-test.log
117+
118+
auto-merge:
119+
needs: [build-and-test]
120+
runs-on: ubuntu-latest
121+
if: github.actor == 'renovate[bot]'
122+
123+
steps:
124+
- name: Checkout
125+
uses: actions/checkout@v4
126+
127+
- name: Auto-approve
128+
uses: hmarr/auto-approve-action@v4
129+
130+
- name: Auto-merge
131+
env:
132+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
133+
run: gh pr merge ${{ github.event.pull_request.number }} --squash --auto

.github/workflows/update-hash.yml

Lines changed: 0 additions & 49 deletions
This file was deleted.

0 commit comments

Comments
 (0)