Skip to content

Commit 62013ab

Browse files
committed
Publish dev versions of the ESP-IDF component from the nightly branch
Projects that need the component before the next release can now pin a dev version from the registry instead of generating the component from an ExecuTorch checkout. A manual run of publish-espressif-idf.yml on the nightly branch publishes X.Y.Z-dev.YYYYMMDD of that branch's commit, through the same packing, testing, review and registry checks as a release. The generator's new --version-suffix -dev stamps HEAD's commit date, and like the other published stamps checks the checkout: HEAD must be on the nightly branch, checked out or fetched from origin, and free of uncommitted changes to what the generator reads. --allow-dev-stamp stays the unchecked stamp for local builds. A dev version's kernel bundles need executorch from the component's exact commit. ExecuTorch's nightly wheels are built from the nightly branch's commits, so the plan publishes dev versions only from the nightly branch, and nothing else from it. That check runs from the nightly branch's copy of the workflow, which is main's, so it guards against mistakes rather than changes to it: the runbook protects the nightly branch with a ruleset before the environment trusts it, and has the approver of a dev upload review what changed since the last approved one in every file the environment's jobs run: the workflow, the upload script and its requirements, and the test script whose fetch mode they run. Before the build, the new wheel mode of test_espressif_idf_nightly.sh checks that the nightly index has the version's wheels for every platform and CPython the wheel workflows build, and that all of them record that commit, which takes about 30 seconds. It refuses a version dated today, since a second nightly the same day, which has happened, replaces that day's wheels; the runbook has the workflow run between 00:00 UTC and the next nightly, and once with dry_run set before the first dev upload. The nightly matrix gains a job, which its pull requests run, that checks the wheel check's refusals, fails if the index's platforms and CPythons of the last 60 days differ from those the check expects, and runs it on the finished nightlies of the last 60 days that have every wheel, newest first, until one passes, since a second nightly the same day can leave a day's wheels from two commits. It fails if none passes, or if the index has every wheel of a day no nightly commit is dated, which means the dev version's dates no longer match the wheels'; otherwise nightly wheels can fail to build for days, so finding none only warns. ExecuTorch's wheels declare no torch, so the component's VERSION now records the torch requirement install_requirements.py builds the wheels against, and the CPU index of its TORCH_URL_BASE, which it installs torch from and which also serves torch release candidates. The generator reads both before --version exits, so the stamp checks catch a pin it cannot read, and counts install_requirements.py among its inputs. The new torch mode checks that the requirement resolves from that index for every platform and CPython, in the package job before upload and in the nightly's prepare job every day, and the component test checks both lines. The component's README gives the exact pip commands for its version, a release's, an RC's or a dev version's, and the component test checks the release's, the RC's and, from a local nightly branch, the dev version's. The stamp test covers a pin the generator cannot read and an install_requirements.py that does not import, and test-espressif.yml now runs for changes to install_requirements.py and install_utils.py. torch comes from its own index in a command of its own, because download.pytorch.org's test and release indexes also serve executorch builds, as X.Y.Z+cpu, which pip would prefer to PyPI's X.Y.Z and which gen_kernels.py accepts for a release without a commit check. and gen_kernels.py names the nightly wheel a dev version needs. The nightly index keeps about 60 days of wheels, of executorch and of a dated torch alike, which a dev version's README says, so a project moves to a newer dev version, or to the release, before its pin ages out; firmware builds from a committed kernel bundle are unaffected. Dev versions are semver pre-releases, which a version range picks up only if it opts in, so they go to production without reaching projects on releases. They read 1.6.0-dev.YYYYMMDD, as the nightly wheels do, until main's version.txt moves past 1.6.0, and sort below 1.6.0 meanwhile; exact pins are unaffected. Dev runs queue in the nightly branch's concurrency group, so one cannot replace a pending release run. Before the first dev upload, a repo admin protects the nightly branch and adds it to the deployment branches of the esp-component-registry environment. The workflow accepts -dev once this change reaches the nightly branch. Authored with Claude Code.
1 parent db8b2f2 commit 62013ab

10 files changed

Lines changed: 470 additions & 57 deletions

File tree

‎.ci/scripts/test_espressif_idf_component.sh‎

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,8 @@ compote manifest lint "${component_dir}"
8181
# release in the clone only, generated without --allow-dev-stamp.
8282
release_version=$(cat version.txt)
8383
release_clone="${build_dir}/release-clone"
84-
rm -rf "${release_clone}" "${build_dir}/release_component" "${build_dir}/rc_component"
84+
rm -rf "${release_clone}" "${build_dir}/release_component" "${build_dir}/rc_component" \
85+
"${build_dir}/dev_component"
8586
git clone -q --shared --no-checkout "${et_root}" "${release_clone}"
8687
git -C "${release_clone}" checkout -q --detach "$(git rev-parse HEAD)"
8788
git -C "${release_clone}" submodule init -q third-party/flatbuffers
@@ -92,19 +93,36 @@ git -C "${release_clone}" tag -f "v${release_version}" > /dev/null
9293
bash "${release_clone}/examples/espressif/build_idf_component.sh" --version-suffix "" \
9394
--out "${build_dir}/release_component" 2>&1 | tee "${build_dir}/generate-release.log"
9495
compote manifest lint "${build_dir}/release_component"
95-
python - "${build_dir}/release_component" "${release_version}" "$(git rev-parse HEAD)" <<'PY'
96+
python - "${build_dir}/release_component" "${release_version}" "$(git rev-parse HEAD)" \
97+
"${release_clone}" <<'PY'
98+
import re
9699
import sys
97100
from pathlib import Path
98101
99-
component, version, commit = Path(sys.argv[1]), sys.argv[2], sys.argv[3]
102+
component, version, commit, clone = Path(sys.argv[1]), sys.argv[2], sys.argv[3], sys.argv[4]
100103
lines = (component / "VERSION").read_text().splitlines()
101104
for line in (f"component_version: {version}", f"executorch_commit: {commit}"):
102105
if line not in lines:
103106
sys.exit(f"FAIL: the release component's VERSION has no '{line}'")
104107
if not any(l.startswith("generator: ") and l.endswith(" --version-suffix ''") for l in lines):
105108
sys.exit("FAIL: the release component's VERSION does not record --version-suffix ''")
109+
sys.path.insert(0, clone)
110+
from install_requirements import TORCH_URL_BASE
111+
112+
if not any(re.fullmatch(r"torch_requirement: torch==\d+\.\d+\.\d+\S*", l) for l in lines):
113+
sys.exit("FAIL: the release component's VERSION has no torch_requirement: torch==X.Y.Z")
114+
if f"torch_index: {TORCH_URL_BASE}/cpu" not in lines:
115+
sys.exit(f"FAIL: the release component's VERSION has no torch_index: {TORCH_URL_BASE}/cpu")
106116
if f'version: "{version}"' not in (component / "idf_component.yml").read_text().splitlines():
107117
sys.exit(f"FAIL: the release component's idf_component.yml is not version {version}")
118+
readme = (component / "README.md").read_text().splitlines()
119+
torch = next(l.split(": ", 1)[1] for l in lines if l.startswith("torch_requirement: "))
120+
for line in (
121+
f'pip install "{torch}" --index-url {TORCH_URL_BASE}/cpu',
122+
f'pip install "executorch=={version}"',
123+
):
124+
if line not in readme:
125+
sys.exit(f"FAIL: the release component's README.md has no '{line}'")
108126
print(f"OK: a clean checkout tagged v{version} generates release {version}")
109127
PY
110128
# An RC at the same commit, which a release commit can also be.
@@ -116,6 +134,27 @@ if ! grep -qxF "component_version: ${release_version}-rc.1" "${build_dir}/rc_com
116134
echo "FAIL: the RC component's VERSION is not ${release_version}-rc.1" >&2
117135
exit 1
118136
fi
137+
rc_install="pip install \"executorch==${release_version}\" --extra-index-url https://download.pytorch.org/whl/test/cpu"
138+
if ! grep -qxF "${rc_install}" "${build_dir}/rc_component/README.md"; then
139+
echo "FAIL: the RC component's README.md has no '${rc_install}'" >&2
140+
exit 1
141+
fi
142+
# A dev version from a local nightly branch at the same commit.
143+
git -C "${release_clone}" branch -f nightly HEAD
144+
bash "${release_clone}/examples/espressif/build_idf_component.sh" --version-suffix -dev \
145+
--out "${build_dir}/dev_component" 2>&1 | tee "${build_dir}/generate-dev.log"
146+
dev_date=$(TZ=UTC git log -1 --format=%cd --date=format-local:%Y%m%d HEAD)
147+
if ! grep -qxF "component_version: ${release_version}-dev.${dev_date}" \
148+
"${build_dir}/dev_component/VERSION"; then
149+
echo "FAIL: the dev component's VERSION is not ${release_version}-dev.${dev_date}" >&2
150+
exit 1
151+
fi
152+
dev_install="pip install \"executorch==${release_version}.dev${dev_date}\" --extra-index-url https://download.pytorch.org/whl/nightly/cpu"
153+
if ! grep -qxF "${dev_install}" "${build_dir}/dev_component/README.md"; then
154+
echo "FAIL: the dev component's README.md has no '${dev_install}'" >&2
155+
exit 1
156+
fi
157+
echo "OK: the clone's local nightly branch generates dev ${release_version}-dev.${dev_date}"
119158
echo "OK: the clean checkout tagged v${release_version}-rc1 generates RC ${release_version}-rc.1"
120159
gen_kernels() {
121160
local out=$1

‎.ci/scripts/test_espressif_idf_nightly.sh‎

Lines changed: 202 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,17 +34,40 @@
3434
# pytorch/executorch, builds the smoke consumer for ESP32-S3 with it, fails
3535
# if the registry serves files other than <dir>'s archive, and runs the
3636
# build on the emulator.
37+
# test_espressif_idf_nightly.sh wheel <dir> <dev version> <commit>
38+
# Checks that the ExecuTorch nightly wheels of the version's date exist for
39+
# every platform and CPython and were built from <commit>, so a project can
40+
# install the executorch its kernel bundles need. Refuses today's nightly,
41+
# which a later one could replace. Logs go under <dir>.
42+
# test_espressif_idf_nightly.sh rehearse <dir>
43+
# Checks that wheel refuses a version that is not a dev version and today's
44+
# nightly, and that the platforms and CPythons it expects are those of the
45+
# index's last 60 days, its window. Then runs it on the finished nightlies
46+
# of origin/nightly's last 60 days that have every wheel, newest first,
47+
# until one passes. Nightly wheels can fail to build for days, so finding
48+
# none only warns, unless the index has every wheel of a day no nightly
49+
# commit is dated, which means the dev version's date no longer matches
50+
# the wheels'.
51+
# test_espressif_idf_nightly.sh torch <dir>
52+
# Needs only <dir> from prepare: checks that the torch the packed
53+
# component's VERSION names resolves from its index for every platform and
54+
# CPython.
3755

3856
set -euo pipefail
3957

4058
et_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)
41-
usage="usage: $0 prepare <dir> [stamp options] | build <dir> <shard> <shards> | fetch <dir> <URL> <sha256> | registry <dir> <URL>"
59+
usage="usage: $0 prepare <dir> [stamp options] | build <dir> <shard> <shards> | fetch <dir> <URL> <sha256> | registry <dir> <URL> | wheel <dir> <dev version> <commit> | rehearse <dir> | torch <dir>"
4260
mode=${1:?${usage}}
4361
dir=${2:?${usage}}
4462
mkdir -p "${dir}"
4563
dir=$(cd "${dir}" && pwd)
4664
cd "${et_root}"
4765
targets=(esp32 esp32s3 esp32c3 esp32c5 esp32c6 esp32p4)
66+
# The platforms and CPython versions ExecuTorch's wheels are built for, as
67+
# .github/workflows/build-wheels-{linux,aarch64-linux,macos,windows}.yml set them.
68+
wheel_platforms=(manylinux_2_28_x86_64 manylinux_2_28_aarch64 macosx_14_0_arm64 win_amd64)
69+
wheel_pythons=(3.11 3.12 3.13 3.14 3.15)
70+
nightly_index=https://download.pytorch.org/whl/nightly/cpu
4871

4972
# Runs a command with its output in a log, and shows the log's tail if it fails.
5073
logged() {
@@ -414,6 +437,181 @@ settings_applied() {
414437
[[ "${applied}" == true ]]
415438
}
416439

440+
# Prints each wheel a dev version lacks: version, the nightly index's listing.
441+
missing_wheels() {
442+
local listed platform python
443+
listed=$(grep -F "executorch-${1/-dev./.dev}" <<< "$2" || true)
444+
for platform in "${wheel_platforms[@]}"; do
445+
for python in "${wheel_pythons[@]}"; do
446+
grep -qF -- "-cp${python/./}-cp${python/./}-${platform}.whl" <<< "${listed}" \
447+
|| echo "cp${python/./}-${platform}"
448+
done
449+
done
450+
}
451+
452+
# Checks a dev version's nightly wheels: version, commit.
453+
check_wheels() {
454+
local version=$1 commit=$2 listing missing wheels platform python
455+
if [[ "${version}" != *-dev.* ]]; then
456+
echo "FAIL: ${version} is not a dev version" >&2
457+
exit 1
458+
fi
459+
# A second nightly the same day would replace the wheels of this one.
460+
if [[ "${version##*-dev.}" == "$(date -u +%Y%m%d)" ]]; then
461+
echo "FAIL: ${version} is today's nightly, which a later nightly today could replace;" \
462+
"publish it after 00:00 UTC, before the next nightly." >&2
463+
exit 1
464+
fi
465+
# The version's date is its nightly commit's UTC date, and the wheels' is
466+
# the day they were built; when the two differ, no wheels are found.
467+
listing=$(curl -sSf --retry 3 "${nightly_index}/executorch/")
468+
missing=$(missing_wheels "${version}" "${listing}")
469+
if [[ -n "${missing}" ]]; then
470+
echo "FAIL: the nightly index has no ${version} wheel for ${missing//$'\n'/ }" >&2
471+
exit 1
472+
fi
473+
wheels=$(mktemp -d)
474+
# shellcheck disable=SC2064 # wheels is local, so expand it now.
475+
trap "rm -rf '${wheels}'" EXIT
476+
mkdir -p "${dir}/logs"
477+
for platform in "${wheel_platforms[@]}"; do
478+
for python in "${wheel_pythons[@]}"; do
479+
logged "${dir}/logs/nightly-wheel-cp${python/./}-${platform}.log" python -m pip download \
480+
--no-deps --only-binary=:all: --platform "${platform}" \
481+
--python-version "${python}" --implementation cp --index-url "${nightly_index}" \
482+
--dest "${wheels}" "executorch==${version/-dev./.dev}"
483+
done
484+
done
485+
python - "${commit}" "${wheels}"/executorch-*.whl <<'PY'
486+
import re
487+
import sys
488+
import zipfile
489+
490+
commit, wheels = sys.argv[1], sys.argv[2:]
491+
wrong = []
492+
for wheel in wheels:
493+
version_py = zipfile.ZipFile(wheel).read("executorch/version.py").decode()
494+
built = re.search(r"^git_version: .* = '(\w+)'", version_py, re.MULTILINE)
495+
if not built or built.group(1) != commit:
496+
wrong.append(f"{wheel} ({built.group(1) if built else 'no commit'})")
497+
if wrong:
498+
sys.exit(f"FAIL: not built from the component's {commit}: {', '.join(wrong)}")
499+
print(f"OK: all {len(wheels)} wheels were built from {commit}")
500+
PY
501+
}
502+
503+
wheel() {
504+
check_wheels "${3:?${usage}}" "${4:?${usage}}"
505+
}
506+
507+
rehearse() {
508+
local today output listing commit day version
509+
today=$(date -u +%Y%m%d)
510+
output=$( (check_wheels 1.6.0 0) 2>&1 || true)
511+
if [[ "${output}" != *"is not a dev version"* ]]; then
512+
echo "FAIL: the wheel check did not refuse 1.6.0: ${output}" >&2
513+
exit 1
514+
fi
515+
output=$( (check_wheels "1.6.0-dev.${today}" 0) 2>&1 || true)
516+
if [[ "${output}" != *"is today's nightly"* ]]; then
517+
echo "FAIL: the wheel check did not refuse today's nightly: ${output}" >&2
518+
exit 1
519+
fi
520+
echo "OK: the wheel check refuses a version that is not a dev version and today's nightly"
521+
listing=$(curl -sSf --retry 3 "${nightly_index}/executorch/")
522+
# A day inside the commits' window, whose edge can differ by a day.
523+
local since expected=() platform python
524+
since=$(date -u -d '59 days ago' +%Y%m%d)
525+
# A change to the wheel workflows' platforms or CPythons fails here, not at
526+
# the next dev publish.
527+
for platform in "${wheel_platforms[@]}"; do
528+
for python in "${wheel_pythons[@]}"; do
529+
expected+=("cp${python/./}-${platform}")
530+
done
531+
done
532+
printf '%s' "${listing}" > "${dir}/listing.html"
533+
python - "${dir}/listing.html" "${since}" "${today}" "${expected[@]}" <<'PY'
534+
import re
535+
import sys
536+
537+
listing = open(sys.argv[1]).read().replace("%2B", "+")
538+
since, today, expected = sys.argv[2], sys.argv[3], set(sys.argv[4:])
539+
days = {}
540+
pattern = r"executorch-\d+\.\d+\.\d+\.dev(\d{8})(?:\+\w+)?-(cp\d+)-cp\d+-(\w+)\.whl"
541+
for day, python, platform in re.findall(pattern, listing):
542+
if since < day != today:
543+
days.setdefault(day, set()).add(f"{python}-{platform}")
544+
if days:
545+
problems = []
546+
missing = sorted(expected - set().union(*days.values()))
547+
if missing:
548+
problems.append(f"no wheel for {', '.join(missing)} in the last 60 days")
549+
newest = max(days)
550+
unknown = sorted(days[newest] - expected)
551+
if unknown:
552+
problems.append(f"{newest}'s wheels include {', '.join(unknown)}")
553+
if problems:
554+
sys.exit(
555+
"FAIL: the nightly index no longer matches wheel_platforms and "
556+
"wheel_pythons: " + "; ".join(problems)
557+
)
558+
print("OK: the nightly index builds the platforms and CPythons the wheel check expects")
559+
PY
560+
local failures=()
561+
for commit in $(git rev-list --first-parent --since=60.days origin/nightly); do
562+
day=$(TZ=UTC git log -1 --format=%cd --date=format-local:%Y%m%d "${commit}")
563+
version="$(git show "${commit}:version.txt")-dev.${day}"
564+
if [[ "${day}" != "${today}" && -z "$(missing_wheels "${version}" "${listing}")" ]]; then
565+
# A second nightly the same day replaces the wheels it builds, so
566+
# a day's wheels can come from two commits; an older day can pass.
567+
if output=$( (check_wheels "${version}" "${commit}") 2>&1); then
568+
echo "${output}"
569+
return
570+
fi
571+
failures+=("${version}: ${output##*$'\n'}")
572+
fi
573+
done
574+
if (( ${#failures[@]} )); then
575+
printf 'FAIL: %s\n' "${failures[@]}" >&2
576+
exit 1
577+
fi
578+
local listed
579+
for listed in $(grep -oE 'executorch-[0-9]+\.[0-9]+\.[0-9]+\.dev[0-9]{8}' <<< "${listing}" \
580+
| sort -u); do
581+
day=${listed##*.dev}
582+
version="${listed#executorch-}"
583+
version="${version%.dev*}-dev.${day}"
584+
if [[ "${day}" > "${since}" && "${day}" != "${today}" ]] \
585+
&& [[ -z "$(missing_wheels "${version}" "${listing}")" ]]; then
586+
echo "FAIL: the nightly index has every wheel of ${version}, but no nightly commit" \
587+
"of that version is dated ${day}; the dev version's date no longer matches the" \
588+
"wheels'." >&2
589+
exit 1
590+
fi
591+
done
592+
echo "::warning::no nightly of the last 60 days has every wheel; the dev wheel check went unrehearsed."
593+
}
594+
595+
torch() {
596+
local stamp requirement index target platform python
597+
stamp=$(tar -xzOf "${dir}/dist/executorch_$(cat "${dir}/version").tgz" ./VERSION)
598+
requirement=$(sed -n 's/^torch_requirement: *//p' <<< "${stamp}")
599+
index=$(sed -n 's/^torch_index: *//p' <<< "${stamp}")
600+
target=$(mktemp -d)
601+
# shellcheck disable=SC2064 # target is local, so expand it now.
602+
trap "rm -rf '${target}'" EXIT
603+
mkdir -p "${dir}/logs"
604+
for platform in "${wheel_platforms[@]}"; do
605+
for python in "${wheel_pythons[@]}"; do
606+
logged "${dir}/logs/torch-cp${python/./}-${platform}.log" python -m pip install --dry-run \
607+
--no-deps --only-binary=:all: --platform "${platform}" \
608+
--python-version "${python}" --implementation cp --target "${target}" \
609+
--index-url "${index}" "${requirement}"
610+
done
611+
done
612+
echo "OK: ${requirement} resolves from ${index} for CPython ${wheel_pythons[*]} on ${wheel_platforms[*]}"
613+
}
614+
417615
# Runs a build on its emulator: build dir, target, log, then idf.py arguments.
418616
run_firmware() {
419617
local build=$1 target=$2 log=$3
@@ -448,5 +646,8 @@ case "${mode}" in
448646
build) build "$@" ;;
449647
fetch) fetch "$@" ;;
450648
registry) registry "$@" ;;
649+
wheel) wheel "$@" ;;
650+
rehearse) rehearse ;;
651+
torch) torch ;;
451652
*) echo "unknown mode ${mode}; ${usage}" >&2; exit 1 ;;
452653
esac

‎.github/workflows/nightly-espressif-idf.yml‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ jobs:
5050
source .ci/scripts/utils.sh
5151
install_executorch --use-pt-pinned-commit --minimal
5252
bash .ci/scripts/test_espressif_idf_nightly.sh prepare "${RUNNER_ARTIFACT_DIR}"
53+
bash .ci/scripts/test_espressif_idf_nightly.sh torch "${RUNNER_ARTIFACT_DIR}"
5354
# Runs the release stamp's checks at a throwaway local tag, so they do
5455
# not first run at a real one. The check of the executorch package,
5556
# which --version stops before, runs only when publishing.
@@ -65,6 +66,26 @@ jobs:
6566
bash .ci/scripts/upload_espressif_idf_component.sh "${RUNNER_ARTIFACT_DIR}" \
6667
https://components.espressif.com --dry-run
6768
69+
# The dev versions' wheel check, which publishing otherwise first runs on a
70+
# run on the nightly branch.
71+
dev-wheel-check:
72+
name: Rehearse the dev version's wheel check
73+
runs-on: ubuntu-22.04
74+
timeout-minutes: 15
75+
steps:
76+
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
77+
with:
78+
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
79+
persist-credentials: false
80+
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
81+
with:
82+
python-version: '3.11'
83+
- run: |
84+
set -euo pipefail
85+
git fetch -q --shallow-since='60 days ago' origin \
86+
+refs/heads/nightly:refs/remotes/origin/nightly
87+
bash .ci/scripts/test_espressif_idf_nightly.sh rehearse "${RUNNER_TEMP}/wheel"
88+
6889
build:
6990
name: ESP-IDF ${{ matrix.idf-version }} (${{ matrix.shard }}/4)
7091
needs: [docker-image, prepare]

0 commit comments

Comments
 (0)