Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
125 changes: 110 additions & 15 deletions .github/tools/git_clone_retry.sh
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@
# and their attention, a slow true red costs 50 seconds.
#
# Usage: git_clone_retry.sh [git clone args...] <url> <dir>
# git_clone_retry.sh --rev <ref> <url> # print the commit it names
# git_clone_retry.sh --commit <sha> <url> <dir> # that commit, exactly
# Env: GIT_CLONE_ATTEMPTS (default 4), GIT_CLONE_BACKOFF (default "5 15 30")
set -euo pipefail

Expand All @@ -50,28 +52,121 @@ read -r -a BACKOFF <<< "${GIT_CLONE_BACKOFF:-5 15 30}"
# different reason than the first and the log stops making sense.
dest="${*: -1}"

# `cmd || rc=$?`, NOT `if cmd; then …; fi; rc=$?`.
#
# After an `if`, `$?` is the status of the IF STATEMENT — which is 0 when the
# body did not run. Written that way this script exits 0 on a clone that never
# succeeded, i.e. it turns a hard failure into a green step with a missing
# checkout. Caught by asserting the exit code in the helper's own test rather
# than by reading the output, which looked correct.
#
# ONE COPY OF IT, for all three verbs below. They back off over the same
# sequence and give up in the same words, so the retry is a property of the
# helper rather than of whichever verb was written last.
failed() { # $1 = attempt number (1-based), $2 = exit status of that attempt
if (( $1 == ATTEMPTS )); then
echo "git_clone_retry: giving up after $ATTEMPTS attempts (last exit $2)" >&2
exit "$2"
fi
delay="${BACKOFF[$(( $1 - 1 < ${#BACKOFF[@]} ? $1 - 1 : ${#BACKOFF[@]} - 1 ))]}"
echo "git_clone_retry: attempt $1/$ATTEMPTS failed (exit $2); retrying in ${delay}s" >&2
sleep "$delay"
}

# ── TWO MORE VERBS, AND WHY THEY ARE IN THIS FILE ──────────────────────────
#
# `--rev` and `--commit` exist because "clone this branch" stopped being one
# act the moment four jobs needed it. Every `build` job of `openkal-cross.yml`
# cloned `main` of the index and of the runtime of its own accord, on runners
# that become available at different moments — the macOS leg waits in a queue
# the others do not — and the `run` job then asserted that all four had built
# the same sources, and failed the run when they had not. On 2026-10-13 that
# assertion was right to fail and the change under test had nothing to do with
# it: the run started at 12:43 UTC, its build jobs at 12:52:08 through
# 13:01:33, and mcpp-index@main took four commits between 12:56:12 and
# 13:17:58 — so clones taken minutes apart straddled pushes, and the run job
# reported `index sources differ`.
#
# The repair is to resolve the ref ONCE, at the start of the run, and have
# every host materialise that commit. Writing the SHA into the workflow would
# be the pin nobody is reminded to move that `openkal-cross.yml` warns about in
# its own header, so the resolution is a job — and these are the two primitives
# it needs: ask what a ref names, and get exactly that commit.

# `--rev <ref> <url>`: print the commit the ref names.
#
# `ls-remote` rather than clone-and-rev-parse: what the caller wants is one
# number, and the tree behind it is tens of megabytes on both of the
# repositories this is pointed at.
if [[ "${1:-}" == "--rev" ]]; then
[ "$#" -eq 3 ] || { echo "git_clone_retry: usage: --rev <ref> <url>" >&2; exit 2; }
ref="$2"; url="$3"
# A bare name is a BRANCH. `refs/…` and `HEAD` are taken as written, so a
# tag or a fully qualified ref can be asked for too.
case "$ref" in
refs/*|HEAD) want="$ref" ;;
*) want="refs/heads/$ref" ;;
esac
for (( i = 1; i <= ATTEMPTS; i++ )); do
rc=0
# stderr is NOT captured: what git says about a URL it cannot reach is
# the diagnostic, and it is the message a plain clone would have shown.
listing=$(git ls-remote "$url" "$want") || rc=$?
if (( rc == 0 )); then
sha=$(printf '%s\n' "$listing" | awk -v w="$want" '$2 == w { print $1; exit }')
# No such ref is an ANSWER, not a blip: the host was reached and
# said the ref is not there, so retrying only delays the report.
[ -n "$sha" ] || { echo "git_clone_retry: $url has no $want" >&2; exit 1; }
printf '%s\n' "$sha"
exit 0
fi
failed "$i" "$rc"
done
fi

# `--commit <sha> <url> <dir>`: materialise exactly that commit, and nothing
# the branch did afterwards.
#
# NOT `git clone --branch <sha>`: that argument takes a branch or a tag, and a
# commit is neither. `init` + `fetch --depth 1 origin <sha>` is the shape that
# names a commit, and however far the branch moves after the resolution, it
# costs one commit's tree once.
if [[ "${1:-}" == "--commit" ]]; then
[ "$#" -eq 4 ] || { echo "git_clone_retry: usage: --commit <sha> <url> <dir>" >&2; exit 2; }
sha="$2"; url="$3" dest="$4"
[[ "$sha" =~ ^[0-9a-f]{40}$ ]] \
|| { echo "git_clone_retry: --commit wants a 40-hex commit, got '$sha'" >&2; exit 2; }
for (( i = 1; i <= ATTEMPTS; i++ )); do
rc=0
{
git -c init.defaultBranch=main init --quiet "$dest" &&
git -C "$dest" remote add origin "$url" &&
git -C "$dest" fetch --quiet --depth 1 origin "$sha" &&
git -C "$dest" checkout --quiet --detach FETCH_HEAD
} || rc=$?
if (( rc == 0 )); then
exit 0
fi
# Nothing a failed attempt made survives it, for the reason the clone
# path gives below about its partial clone: a tree half-made by this
# attempt is not a retry, it is a second failure with a different cause.
# And a caller that gives up should not find a directory claiming to be
# the checkout it asked for.
rm -rf -- "$dest"
failed "$i" "$rc"
done
fi

for (( i = 1; i <= ATTEMPTS; i++ )); do
# `cmd || rc=$?`, NOT `if cmd; then …; fi; rc=$?`.
#
# After an `if`, `$?` is the status of the IF STATEMENT — which is 0 when
# the body did not run. Written that way this script exits 0 on a clone that
# never succeeded, i.e. it turns a hard failure into a green step with a
# missing checkout. Caught by asserting the exit code in the helper's own
# test rather than by reading the output, which looked correct.
rc=0
git clone "$@" || rc=$?
if (( rc == 0 )); then
exit 0
fi
if (( i == ATTEMPTS )); then
echo "git_clone_retry: giving up after $ATTEMPTS attempts (last exit $rc)" >&2
exit "$rc"
fi
if [[ -n "$dest" && "$dest" != -* && -e "$dest" ]]; then
# Nothing to remove on the last attempt: there is no retry to prepare for.
if (( i < ATTEMPTS )) && [[ -n "$dest" && "$dest" != -* && -e "$dest" ]]; then
echo "git_clone_retry: removing partial '$dest' before retrying" >&2
rm -rf -- "$dest"
fi
delay="${BACKOFF[$(( i - 1 < ${#BACKOFF[@]} ? i - 1 : ${#BACKOFF[@]} - 1 ))]}"
echo "git_clone_retry: attempt $i/$ATTEMPTS failed (exit $rc); retrying in ${delay}s" >&2
sleep "$delay"
failed "$i" "$rc"
done
77 changes: 50 additions & 27 deletions .github/workflows/cross-build-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -257,44 +257,67 @@ jobs:
restore-keys: |
xlings-${{ runner.os }}-mingw-cross-v3-

# wine 的包集固定不变 —— 缓存整个 .deb 依赖闭包,命中时跳过 apt update
# 与下载(每轮省 ~1-2min)。镜像月度更新可能改变依赖缺口,dpkg -i 失败时
# 由 apt-get -f 兜底并重新回填缓存。
# THE KEY NAMES THE RUNNER IMAGE, because the image is what the cached
# packages have to agree with. Wine's i386 closure is Multi-Arch: same
# with the amd64 libraries the image ships, and the two must be one
# version. Under the old fixed key (`-v1`) the cache was saved once and
# never again, the image moved on, and on 2026-10-15 (run 38695300904,
# a cache HIT) the cached libxml2:i386 3.9 met the image's
# libxml2:amd64 3.10: dpkg installed the stale i386 package, apt then
# upgraded it, and the upgrade's shared changelog was refused. A new
# image is a new key; until main has saved it, `restore-keys` hands back
# the previous image's packages, which the step below only uses where
# their version is still the one apt asks for.
- name: Runner image version
id: image
run: echo "version=${ImageVersion:-unknown}" >> "$GITHUB_OUTPUT"

- name: Restore wine debs
id: wine
uses: actions/cache/restore@v4
with:
path: ~/wine-debs
key: wine-debs-${{ runner.os }}-ubuntu24.04-v1

key: wine-debs-${{ runner.os }}-ubuntu24.04-${{ steps.image.outputs.version }}
restore-keys: |
wine-debs-${{ runner.os }}-ubuntu24.04-

# APT INSTALLS, ALWAYS; THE CACHE ONLY SPARES IT THE DOWNLOAD. The cached
# packages are put where apt keeps what it downloaded, and apt then
# resolves wine against the image as it is today: a cached package whose
# version is still current is used as is, anything newer is fetched.
# Nothing older than the image is ever installed, which is what
# `dpkg -i` of the cached set did whenever the image had moved on. Cold
# (no cache at all), this is the plain install it always was.
- name: Install wine
timeout-minutes: 20
run: |
sudo dpkg --add-architecture i386 || true
# The runner image carries third-party apt lists (Google Chrome
# among them) that this job does not use, and a transient
# `Hash Sum mismatch` on one of them fails the whole update -- which
# killed two cross-build jobs in setup, before a single byte was
# compiled. Dropping the lists this job has no use for is what makes
# the step's failure mean something about this job.
#
# BY CONTENT, NOT BY FILENAME. The first attempt removed
# `google-chrome.list` and the update failed on the same URL: on
# ubuntu-24.04 the runner writes deb822 `.sources` files, so the
# name was a guess and the guess was wrong.
sudo grep -rlE 'dl[.]google[.]com|packages[.]microsoft[.]com' \
/etc/apt/sources.list.d/ 2>/dev/null | xargs -r sudo rm -f
if ls ~/wine-debs/*.deb >/dev/null 2>&1; then
sudo dpkg -i ~/wine-debs/*.deb 2>/dev/null \
|| { sudo apt-get update -qq; sudo apt-get install -f -y; }
else
# The runner image carries third-party apt lists (Google Chrome
# among them) that this job does not use, and a transient
# `Hash Sum mismatch` on one of them fails the whole update -- which
# killed two cross-build jobs in setup, before a single byte was
# compiled. Dropping the lists this job has no use for is what makes
# the step's failure mean something about this job.
#
# BY CONTENT, NOT BY FILENAME. The first attempt removed
# `google-chrome.list` and the update failed on the same URL: on
# ubuntu-24.04 the runner writes deb822 `.sources` files, so the
# name was a guess and the guess was wrong.
sudo grep -rlE 'dl[.]google[.]com|packages[.]microsoft[.]com' \
/etc/apt/sources.list.d/ 2>/dev/null | xargs -r sudo rm -f
sudo apt-get update -qq
sudo apt-get install -y --download-only wine64 wine \
|| sudo apt-get install -y --download-only wine
mkdir -p ~/wine-debs
cp /var/cache/apt/archives/*.deb ~/wine-debs/ 2>/dev/null || true
sudo apt-get install -y wine64 wine || sudo apt-get install -y wine
sudo cp ~/wine-debs/*.deb /var/cache/apt/archives/
fi
sudo apt-get update -qq
sudo apt-get install -y --download-only wine64 wine \
|| sudo apt-get install -y --download-only wine
# What main saves is what this image installs: packages no longer
# in any index (the previous image's, carried in by restore-keys)
# are dropped first, so the cache does not grow with every image.
sudo apt-get autoclean -qq
rm -rf ~/wine-debs && mkdir -p ~/wine-debs
cp /var/cache/apt/archives/*.deb ~/wine-debs/ 2>/dev/null || true
sudo apt-get install -y wine64 wine || sudo apt-get install -y wine
wine --version

- name: Bootstrap mcpp via xlings
Expand Down
91 changes: 88 additions & 3 deletions .github/workflows/openkal-cross.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,13 @@ name: openkal cross-build (4 hosts × 3 targets)
# Three cells are native. The nine crosses include every output built on Linux
# ARM64, and are what a single-host workflow cannot reach.
#
# A `revisions` JOB RUNS FIRST, before any of them, and resolves the index and
# the runtime branches to one commit each. The four build jobs materialise
# those commits, because "the four hosts built the same sources" — the claim
# the run jobs assert — needs one revision to be about. Four independent
# clones of a moving `main` are not that, and on 2026-10-13 they were not one
# revision (run 38399400601). See the job.
#
# THE RUN JOBS INSTALL NOTHING — not mcpp, not a compiler, not a C runtime.
# A program above openkal carries its C library, its C++ runtime and its
# unwinder; what remains is the operating system it was built for. If a
Expand Down Expand Up @@ -93,8 +100,75 @@ env:
OPENKAL_BRANCH: ${{ github.event.inputs.openkal_ref || 'main' }}

jobs:
# ── THE TWO REVISIONS EVERY HOST BUILDS AGAINST, RESOLVED ONCE ───────────
#
# FOUR JOBS CLONING ONE BRANCH ARE NOT FOUR COPIES OF ONE REVISION. The build
# matrix cloned `main` of the index and of the runtime of its own accord, on
# runners that become available at different moments — the macOS leg waits in
# a queue the others do not — and the `run` job below then asserted that all
# four had built the same sources.
#
# THAT ASSERTION WAS RIGHT AND IT WENT RED FOR A REASON NO HOST CAUSED. On
# 2026-10-13, run 38399400601, all three `run` jobs failed with
#
# index sources differ
#
# — so the four hosts did record different revisions. The run started at
# 12:43 UTC and its build jobs at 12:52:08, 12:53:44, 12:58:20 and 13:01:33
# UTC, and they do not clone at the same point in their own step sequence —
# the macOS leg builds this commit's mcpp first — while mcpp-index@main took
# four commits inside that window: 21b9a77a at 12:56:12, d3a328c8 at 13:04:42,
# cfb808bc at 13:10:06, 0340de61 at 13:17:58. Two clones straddling one push
# is all it takes, and the reading arrives 40 minutes later, in a job that
# built nothing.
#
# THE ALTERNATIVE — pinning a SHA into this file — is the failure
# `OPENKAL_BRANCH` above is annotated with: a pin nobody is reminded to move,
# which keeps a check green by keeping it out of date. So the refs are
# resolved here, once, and every host materialises the commit named below.
# `OPENKAL_BRANCH` keeps its meaning: a run that overrides it still verifies
# exactly what it named, now as one commit rather than as whatever the branch
# had become when each runner woke up.
#
# The `run` job keeps its comparison. It is no longer a race against a moving
# branch; it is what proves each host used the pin rather than re-resolving
# for itself.
revisions:
name: the index and runtime revisions this run builds
runs-on: ubuntu-24.04
timeout-minutes: 10
outputs:
index: ${{ steps.resolve.outputs.index }}
runtime: ${{ steps.resolve.outputs.runtime }}
defaults:
run:
shell: bash
steps:
- uses: actions/checkout@v4

- name: Resolve both refs, once
id: resolve
run: |
set -euo pipefail
RETRY="$GITHUB_WORKSPACE/.github/tools/git_clone_retry.sh"
index=$("$RETRY" --rev main https://github.com/mcpp-community/mcpp-index)
runtime=$("$RETRY" --rev "$OPENKAL_BRANCH" \
https://github.com/mcpplibs/openkal-llvm-runtime)
# The `run` job's comparison already refuses a SHA that is not 40 hex;
# this refuses to PIN one, so a mis-resolution fails here, in 10
# seconds, rather than as four builds of four different trees.
for commit in "$index" "$runtime"; do
[[ "$commit" =~ ^[0-9a-f]{40}$ ]] \
|| { echo "::error::not a commit: '$commit'"; exit 1; }
done
echo "mcpp-index@main is $index"
echo "openkal-llvm-runtime@$OPENKAL_BRANCH is $runtime"
echo "index=$index" >> "$GITHUB_OUTPUT"
echo "runtime=$runtime" >> "$GITHUB_OUTPUT"

build:
name: build 3 targets on ${{ matrix.host }}
needs: revisions
runs-on: ${{ matrix.runner }}
timeout-minutes: 120
strategy:
Expand Down Expand Up @@ -238,6 +312,9 @@ jobs:
command -v x86_64-w64-mingw32-gcc gcc 2>/dev/null || true

- name: The program — one source, three targets
env:
INDEX_SHA: ${{ needs.revisions.outputs.index }}
RUNTIME_SHA: ${{ needs.revisions.outputs.runtime }}
run: |
set -euo pipefail
# THE SEVENTH CALL SITE. `git_clone_retry.sh` was written because a
Expand All @@ -251,11 +328,16 @@ jobs:
#
# One name that did not resolve ended a 120-minute job in its first
# minute, beside a real failure it had nothing to do with.
#
# BOTH CLONES NOW NAME A COMMIT. `--depth 1 -b main` was four jobs
# describing a moving branch as one revision; see the `revisions` job
# for the run that paid for it.
"$GITHUB_WORKSPACE/.github/tools/git_clone_retry.sh" \
--quiet --depth 1 -b "$OPENKAL_BRANCH" \
--commit "$RUNTIME_SHA" \
https://github.com/mcpplibs/openkal-llvm-runtime "$RUNNER_TEMP/okl"
"$GITHUB_WORKSPACE/.github/tools/git_clone_retry.sh" \
--quiet --depth 1 https://github.com/mcpp-community/mcpp-index "$RUNNER_TEMP/openkal-index"
--commit "$INDEX_SHA" \
https://github.com/mcpp-community/mcpp-index "$RUNNER_TEMP/openkal-index"
export MCPP_OPENKAL_INDEX="$RUNNER_TEMP/openkal-index"
cd "$RUNNER_TEMP/okl/examples/same-source"
mkdir -p "$RUNNER_TEMP/out"
Expand Down Expand Up @@ -728,6 +810,9 @@ jobs:

ecosystem-native-arm64:
name: openkal native stack (Linux ARM64, LLVM 23.1.3)
# The same index revision the build jobs pinned, not whatever `main` had
# become when this runner woke up; see the `revisions` job.
needs: revisions
runs-on: ubuntu-24.04-arm
timeout-minutes: 45
defaults:
Expand All @@ -743,7 +828,7 @@ jobs:
- uses: actions/checkout@v4
with:
repository: mcpp-community/mcpp-index
ref: main
ref: ${{ needs.revisions.outputs.index }}
path: _openkal-native-index

- name: Native platform and graph stack
Expand Down
Loading