Skip to content

Commit 612237d

Browse files
committed
CI: install apt packages from ghcr .deb bundles
Port wolfSSL's install-apt-deps action and ci-deps-image workflow. A daily job on master downloads each package list's .debs and publishes them as ghcr.io/wolfssl/wolfssh-ci-debs; the workflows install from that bundle offline and fall back to the mirror with bounded retries. - two bundles: ubuntu-24.04-tests and ubuntu-24.04-compilers - every apt-get install step in the workflows now uses the action - the package must be made public once after the first build; until then every job takes the mirror fallback - sshd-test job timeout goes from 10 to 15 minutes to fit the install
1 parent 20cb1eb commit 612237d

16 files changed

Lines changed: 416 additions & 62 deletions
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
name: 'Install apt dependencies'
2+
description: 'Install apt packages from a prebuilt ghcr .deb bundle, falling back to the apt mirror'
3+
# Ported from wolfSSL's .github/actions/install-apt-deps.
4+
inputs:
5+
packages:
6+
description: 'Space-separated list of apt packages to install'
7+
required: true
8+
ghcr-debs-tag:
9+
description: >
10+
Tag of a .deb bundle published to ghcr.io/wolfssl/wolfssh-ci-debs by
11+
the ci-deps-image workflow (e.g. "ubuntu-24.04-tests"). The packages
12+
are installed from that bundle with no network access. Any failure -
13+
bundle missing, not public, or not covering every requested package -
14+
falls back to the apt mirror, so it is always safe to set.
15+
required: false
16+
default: ''
17+
retries:
18+
description: 'Attempts at the apt mirror fallback'
19+
required: false
20+
default: '2'
21+
budget-seconds:
22+
description: >
23+
Wall-clock for the whole mirror fallback, split across the attempts as
24+
per-command deadlines so a wedged mirror is reported here instead of
25+
the job being cancelled around it. It overshoots by retry-delay plus
26+
10s of kill grace, and the per-command floors make values below
27+
retries*80 inert. This plus pull-timeout has to fit the caller's
28+
timeout-minutes.
29+
required: false
30+
default: '180'
31+
pull-timeout:
32+
description: 'Deadline in seconds for the ghcr bundle pull'
33+
required: false
34+
default: '60'
35+
retry-delay:
36+
description: 'Initial delay between retries (seconds, doubles each attempt)'
37+
required: false
38+
default: '5'
39+
no-install-recommends:
40+
description: 'Pass --no-install-recommends to apt-get install'
41+
required: false
42+
default: 'false'
43+
runs:
44+
using: 'composite'
45+
steps:
46+
# Resolve against ONLY the bundle's own index, through a private
47+
# sources.list and lists dir. The runner's lists are frozen into its image
48+
# and lag the archive the bundle was built from, so resolving against them
49+
# asks for versions the bundle does not carry. The system lists are left
50+
# untouched for the fallback below.
51+
- name: Install from the ghcr .deb bundle (offline)
52+
id: ghcr
53+
if: inputs.ghcr-debs-tag != ''
54+
shell: bash
55+
run: |
56+
set -u
57+
IMG="ghcr.io/wolfssl/wolfssh-ci-debs:${{ inputs.ghcr-debs-tag }}"
58+
59+
fallback() {
60+
echo "::warning::$IMG: $1; falling back to the apt mirror"
61+
echo "apt fallback: \`$IMG\`: $1" \
62+
>> "${GITHUB_STEP_SUMMARY:-/dev/null}"
63+
exit 0
64+
}
65+
66+
# A bundle holds .debs for one Ubuntu release.
67+
WANT=$(printf '%s' "${{ inputs.ghcr-debs-tag }}" \
68+
| sed -n 's/^\(ubuntu-[0-9]*\.[0-9]*\).*/\1/p')
69+
HAVE=""
70+
if [ -r /etc/os-release ]; then
71+
HAVE=$( . /etc/os-release
72+
printf '%s-%s' "${ID:-?}" "${VERSION_ID:-?}" )
73+
fi
74+
if [ -n "$WANT" ] && [ -n "$HAVE" ] && [ "$WANT" != "$HAVE" ]; then
75+
fallback "holds .debs for $WANT, but this job runs on $HAVE"
76+
fi
77+
command -v docker >/dev/null 2>&1 || fallback "no docker CLI"
78+
79+
BUNDLE="$RUNNER_TEMP/ghcr-debs"
80+
APTD="$RUNNER_TEMP/ghcr-apt"
81+
rm -rf "$BUNDLE" "$APTD"
82+
mkdir -p "$BUNDLE" "$APTD/etc" "$APTD/lists/partial"
83+
84+
# The image must be PUBLIC so an anonymous pull works from fork PRs.
85+
timeout -k 10 ${{ inputs.pull-timeout }} docker pull -q "$IMG" \
86+
>/dev/null 2>&1 || fallback "pull failed or timed out"
87+
cid=$(docker create "$IMG" 2>/dev/null) || fallback "cannot open the image"
88+
docker cp "$cid:/debs/." "$BUNDLE/" >/dev/null \
89+
|| fallback "unpacking failed"
90+
docker rm "$cid" >/dev/null 2>&1 || true
91+
ls "$BUNDLE"/*.deb >/dev/null 2>&1 || fallback "no .debs inside"
92+
93+
if [ -f "$BUNDLE/bundle-info" ]; then
94+
echo "Bundle: $(tr '\n' ' ' < "$BUNDLE/bundle-info")"
95+
echo "Runner: image ${ImageOS:-?} ${ImageVersion:-?}"
96+
fi
97+
[ -s "$BUNDLE/Packages" ] || fallback "no Packages index"
98+
99+
printf 'deb [trusted=yes] file:%s ./\n' "$BUNDLE" > "$APTD/etc/sources.list"
100+
# The bundle is the only repository apt can see. The unroutable proxy
101+
# is a tripwire: a non-file: URI fails at once instead of hanging on
102+
# the mirror.
103+
APT_LOCAL=(-o Dir::Etc::SourceList="$APTD/etc/sources.list"
104+
-o Dir::Etc::SourceParts=/dev/null
105+
-o Dir::State::Lists="$APTD/lists"
106+
-o Acquire::Languages=none
107+
-o Acquire::Retries=0
108+
-o APT::Sandbox::User=root
109+
-o Acquire::http::Proxy=http://127.0.0.1:9
110+
-o Acquire::https::Proxy=http://127.0.0.1:9)
111+
112+
sudo apt-get "${APT_LOCAL[@]}" update >/dev/null 2>&1 \
113+
|| fallback "apt could not read the bundle index"
114+
115+
NO_REC=""
116+
if [ "${{ inputs.no-install-recommends }}" = "true" ]; then
117+
NO_REC="--no-install-recommends"
118+
fi
119+
if sudo DEBIAN_FRONTEND=noninteractive apt-get "${APT_LOCAL[@]}" \
120+
install -y $NO_REC ${{ inputs.packages }}; then
121+
echo "satisfied=true" >> "$GITHUB_OUTPUT"
122+
echo "Installed offline from $IMG"
123+
else
124+
fallback "does not cover ${{ inputs.packages }}"
125+
fi
126+
127+
- name: Install packages
128+
if: steps.ghcr.outputs.satisfied != 'true'
129+
shell: bash
130+
run: |
131+
RETRIES=${{ inputs.retries }}
132+
DELAY=${{ inputs.retry-delay }}
133+
BUDGET=${{ inputs.budget-seconds }}
134+
NO_REC=""
135+
if [ "${{ inputs.no-install-recommends }}" = "true" ]; then
136+
NO_REC="--no-install-recommends"
137+
fi
138+
139+
# A wedged mirror hangs apt rather than failing it. apt drops a
140+
# stalled connection after 30s and retries it, `timeout` kills an
141+
# apt-get that wedged anyway, and the loop re-runs, re-reading
142+
# apt-mirrors.txt so a retry can land on another mirror. apt resumes
143+
# from archives/partial/, so a killed transfer is not restarted.
144+
145+
# No wolfSSH job installs from the runner's Google/Microsoft repos, and
146+
# a bad index on either fails apt-get update. Drop them.
147+
grep -rlE 'dl\.google\.com|packages\.microsoft\.com' \
148+
/etc/apt/sources.list.d/ 2>/dev/null | xargs -r sudo rm -vf || true
149+
APT_OPTS=(-o Acquire::Retries=3 -o Acquire::http::Timeout=30
150+
-o Acquire::https::Timeout=30)
151+
152+
DEADLINE=$(($(date +%s) + BUDGET))
153+
154+
# sudo resets the environment, so DEBIAN_FRONTEND rides along on each
155+
# privileged command.
156+
for i in $(seq 1 $RETRIES); do
157+
# Split what is left over the attempts still to come. update gets
158+
# half an attempt, capped at the 90s apt's own retries need.
159+
PER=$(( (DEADLINE - $(date +%s)) / (RETRIES - i + 1) ))
160+
UPD=$((PER / 2))
161+
[ "$UPD" -le 90 ] || UPD=90
162+
[ "$UPD" -ge 20 ] || UPD=20
163+
INS=$((PER - UPD))
164+
[ "$INS" -ge 40 ] || INS=40
165+
# A previous attempt killed mid-unpack leaves dpkg needing this.
166+
sudo dpkg --configure -a >/dev/null 2>&1 || true
167+
if sudo DEBIAN_FRONTEND=noninteractive timeout -k 10 $UPD \
168+
apt-get "${APT_OPTS[@]}" update -q && \
169+
sudo DEBIAN_FRONTEND=noninteractive timeout -k 10 $INS \
170+
apt-get "${APT_OPTS[@]}" install -y \
171+
$NO_REC ${{ inputs.packages }}; then
172+
exit 0
173+
fi
174+
if [ "$i" -eq "$RETRIES" ] || [ "$(date +%s)" -ge "$DEADLINE" ]; then
175+
echo "::error::apt-get failed after $i attempt(s) in ${BUDGET}s"
176+
exit 1
177+
fi
178+
echo "::warning::apt-get failed (attempt $i/$RETRIES), retrying in ${DELAY}s..."
179+
sleep $DELAY
180+
DELAY=$((DELAY * 2))
181+
done
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# Compilers for multi-compiler, code-coverage and the mingw cross build,
2+
# bundled by ci-deps-image.yml. Kept apart from -tests for its size.
3+
autoconf
4+
automake
5+
clang-14
6+
clang-15
7+
clang-17
8+
clang-18
9+
gcc-11
10+
gcc-12
11+
gcc-13
12+
gcc-mingw-w64-x86-64
13+
libclang-rt-18-dev
14+
libtool
15+
llvm-18
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# Packages for the wolfSSH test workflows, bundled by ci-deps-image.yml.
2+
# A step's whole package list must be here or it falls back to the mirror.
3+
autoconf
4+
autoconf-archive
5+
automake
6+
build-essential
7+
cppcheck
8+
dosfstools
9+
expect
10+
git
11+
libcmocka-dev
12+
libglib2.0-dev
13+
libssl-dev
14+
libtool
15+
netcat-traditional
16+
openssh-client
17+
openssh-server
18+
pkg-config
19+
python3-pip
20+
sshpass
21+
tpm2-tools
22+
uthash-dev
23+
valgrind
24+
zlib1g-dev
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
#!/usr/bin/env bash
2+
# Download the .deb closure for one package list into a directory, for
3+
# .github/workflows/ci-deps-image.yml to publish as a bundle. Ported from
4+
# wolfSSL's .github/scripts/download-deb-closure.sh.
5+
#
6+
# Runs as root on the runner. apt downloads only what is not already
7+
# installed, so the closure carries nothing the runner image preinstalls and
8+
# is only installable on that same runner image.
9+
#
10+
# usage: download-deb-closure.sh <package-list> <dest-dir>
11+
set -uo pipefail
12+
13+
LIST=${1:?package list}
14+
DEST=${2:?destination directory}
15+
16+
mapfile -t PKGS < <(grep -vE '^[[:space:]]*#|^[[:space:]]*$' "$LIST")
17+
echo "Packages (${#PKGS[@]}): ${PKGS[*]}"
18+
export DEBIAN_FRONTEND=noninteractive
19+
mkdir -p "$DEST" && rm -f "$DEST"/*.deb
20+
apt-get clean
21+
# No wolfSSH job installs from the runner's Google/Microsoft apt repos, and a
22+
# bad index on either fails apt-get update. Drop them.
23+
grep -rlE 'dl\.google\.com|packages\.microsoft\.com' \
24+
/etc/apt/sources.list.d/ 2>/dev/null | xargs -r rm -vf || true
25+
# apt drops a stalled connection after 30s and retries it, `timeout` kills a
26+
# wedged apt-get, then retry() re-runs it.
27+
APT_OPTS=(-o Acquire::Retries=3 -o Acquire::http::Timeout=30
28+
-o Acquire::https::Timeout=30)
29+
retry() { local i; for i in 1 2 3 4 5; do "$@" && return 0; sleep $((2**i)); done; "$@"; }
30+
retry timeout -k 10 120 apt-get "${APT_OPTS[@]}" update -q
31+
# One closure per package, so one unbundleable package cannot abort the rest;
32+
# install-apt-deps falls back to apt for anything missing.
33+
skipped=0
34+
for pkg in "${PKGS[@]}"; do
35+
retry timeout -k 10 300 apt-get "${APT_OPTS[@]}" install -y --download-only "$pkg" \
36+
|| { echo "::warning::could not download $pkg"; skipped=$((skipped+1)); }
37+
done
38+
cp /var/cache/apt/archives/*.deb "$DEST/" 2>/dev/null || true
39+
# The index and image steps run unprivileged.
40+
chown --reference="$DEST" "$DEST"/*.deb 2>/dev/null || true
41+
echo "Bundled $(ls "$DEST"/*.deb 2>/dev/null | wc -l) .deb files ($(du -sh "$DEST" | cut -f1)); ${skipped} skipped"
42+
test -n "$(ls "$DEST"/*.deb 2>/dev/null)" # fail if nothing was bundled
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
name: CI deps image
2+
3+
# Builds the apt .deb bundles that .github/actions/install-apt-deps installs
4+
# offline (input ghcr-debs-tag), so PR jobs stay off the apt mirror. Each
5+
# bundle holds the .debs for a package list in .github/ci-deps/ - every
6+
# package plus the dependencies not already on the runner image - and its own
7+
# apt index, published to ghcr.io/wolfssl/wolfssh-ci-debs:<tag>. Ported from
8+
# wolfSSL's ci-deps-image workflow.
9+
#
10+
# ONE-TIME SETUP: after the first successful run, make the package
11+
# `wolfssh-ci-debs` PUBLIC (org > Packages > Package settings > Change
12+
# visibility). Until then install-apt-deps falls back to apt.
13+
14+
on:
15+
schedule:
16+
- cron: '0 2 * * *'
17+
push:
18+
branches: [ master ]
19+
paths:
20+
- '.github/ci-deps/**'
21+
- '.github/scripts/download-deb-closure.sh'
22+
- '.github/workflows/ci-deps-image.yml'
23+
workflow_dispatch:
24+
25+
concurrency:
26+
group: ci-deps-image-${{ github.ref }}
27+
cancel-in-progress: true
28+
29+
permissions:
30+
contents: read
31+
packages: write
32+
33+
jobs:
34+
build:
35+
name: build ${{ matrix.tag }}
36+
if: github.repository_owner == 'wolfssl'
37+
strategy:
38+
fail-fast: false
39+
matrix:
40+
include:
41+
# The .debs must be downloaded on the Ubuntu version that consumes
42+
# them. -compilers is its own tag so the other jobs do not pull it.
43+
- runner: ubuntu-24.04
44+
tag: ubuntu-24.04-tests
45+
- runner: ubuntu-24.04
46+
tag: ubuntu-24.04-compilers
47+
runs-on: ${{ matrix.runner }}
48+
# Backstop only; the download script kills and retries stalled apt calls.
49+
timeout-minutes: 60
50+
steps:
51+
- uses: actions/checkout@v6
52+
53+
- name: Resolve and download the .deb closure
54+
run: |
55+
sudo bash .github/scripts/download-deb-closure.sh \
56+
".github/ci-deps/packages-${{ matrix.tag }}.txt" debs
57+
58+
- name: Index the bundle
59+
run: |
60+
set -euo pipefail
61+
# The consumer resolves against this index, not the runner's lists.
62+
# dpkg-scanpackages is from dpkg-dev, preinstalled on the runners.
63+
( cd debs && dpkg-scanpackages --multiversion . /dev/null > Packages )
64+
gzip -9kf debs/Packages
65+
printf 'tag=%s\nrunner=%s %s\nbuilt=%s\ndebs=%s\n' \
66+
"${{ matrix.tag }}" "${ImageOS:-?}" "${ImageVersion:-?}" \
67+
"$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$(ls debs/*.deb | wc -l)" \
68+
> debs/bundle-info
69+
cat debs/bundle-info
70+
71+
- name: Build bundle image
72+
run: |
73+
printf 'FROM busybox\nCOPY debs /debs\nLABEL org.opencontainers.image.source=https://github.com/wolfSSL/wolfssh\n' \
74+
> Dockerfile.debs
75+
docker build -f Dockerfile.debs -t bundle .
76+
77+
- name: Log in to ghcr
78+
run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin
79+
80+
- name: Push to ghcr
81+
run: |
82+
set -euo pipefail
83+
IMG="ghcr.io/wolfssl/wolfssh-ci-debs:${{ matrix.tag }}"
84+
docker tag bundle "$IMG"
85+
docker push "$IMG"
86+
echo "Pushed $IMG"

‎.github/workflows/code-coverage.yml‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -56,10 +56,11 @@ jobs:
5656

5757
# clang 18 is the min: -fcoverage-mcdc does not exist before it.
5858
- name: Install clang and LLVM coverage tools
59-
timeout-minutes: 5
60-
run: |
61-
sudo apt-get -o Acquire::Retries=3 update
62-
sudo apt-get -o Acquire::Retries=3 install -y clang-18 llvm-18 libclang-rt-18-dev
59+
uses: ./.github/actions/install-apt-deps
60+
with:
61+
packages: clang-18 llvm-18 libclang-rt-18-dev
62+
ghcr-debs-tag: ubuntu-24.04-compilers
63+
budget-seconds: 300
6364

6465
- name: Download wolfSSL
6566
uses: actions/download-artifact@v8

‎.github/workflows/cppcheck.yml‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,10 @@ jobs:
1616

1717
- name: Install cppcheck
1818
if: always()
19-
timeout-minutes: 5
20-
run: |
21-
sudo apt-get -o Acquire::Retries=3 update
22-
sudo apt-get -o Acquire::Retries=3 install -y cppcheck
19+
uses: ./.github/actions/install-apt-deps
20+
with:
21+
packages: cppcheck
22+
ghcr-debs-tag: ubuntu-24.04-tests
2323

2424
- name: Run CppCheck
2525
id: cpp_check_run

0 commit comments

Comments
 (0)