PyAuto Release #734
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: PyAuto Release | |
| # Scheduling authority lives in PyAutoBrain's nightly-release.yml (the | |
| # activity-gated, Heart-GREEN-gated driver — docs/nightly_release_design.md §2). | |
| # The former 2 AM cron was removed with it: with a cron here, flipping | |
| # RELEASE_MODE=live would have released ungated. Build executes releases; it | |
| # does not decide when. | |
| on: | |
| workflow_dispatch: | |
| inputs: | |
| minor_version: | |
| description: 'Minor version to release' | |
| required: true | |
| default: '1' | |
| # rehearsal is the one mode switch: false (default) = a full real release; | |
| # true = build + publish to TestPyPI only, then STOP. The Heart/Brain | |
| # release-validation gate dispatches rehearsal=true (Stage 2); it emits the | |
| # resolved version and cannot mutate anything (no PyPI, no tag, no commits). | |
| # (The old force-through knobs skip_scripts / skip_notebooks / skip_release | |
| # and the update_notebook_visualisations path were removed with the | |
| # Heart/Build split — Build is a pure executor with no ad-hoc skip levers; | |
| # "build without releasing" is now exactly what rehearsal mode is for.) | |
| rehearsal: | |
| description: 'Rehearsal mode: build + publish to TestPyPI only, then STOP (no PyPI, no git tag, no notebook commits, no Colab bumps). Used by the Heart/Brain release-validation gate.' | |
| required: false | |
| default: 'false' | |
| jobs: | |
| # Single source of truth for rehearsal-vs-live: the `rehearsal` dispatch | |
| # input, passed through unchanged. (The schedule branch — and the | |
| # RELEASE_MODE repo variable it consulted — went with the cron above; every | |
| # run is now a dispatch, whether human or the Brain nightly driver.) | |
| # The `needs` context is direct-only in GitHub Actions, so every job gating on | |
| # this output must list resolve_mode in its own needs. | |
| resolve_mode: | |
| runs-on: ubuntu-latest | |
| outputs: | |
| rehearsal: ${{ steps.mode.outputs.rehearsal }} | |
| steps: | |
| - name: Resolve rehearsal vs live | |
| id: mode | |
| run: | | |
| REHEARSAL="${{ github.event.inputs.rehearsal || 'false' }}" | |
| echo "rehearsal=${REHEARSAL}" >> "$GITHUB_OUTPUT" | |
| echo "event=\`${{ github.event_name }}\` → rehearsal=\`${REHEARSAL}\`" >> "$GITHUB_STEP_SUMMARY" | |
| version_number: | |
| runs-on: ubuntu-latest | |
| needs: resolve_mode | |
| outputs: | |
| version_number: ${{ steps.version_number.outputs.version_number }} | |
| steps: | |
| - name: Compute version number | |
| run: | | |
| export DATE_FORMATTED=`date +"%Y.%-m.%-d"` | |
| MINOR_VERSION="${{ github.event.inputs.minor_version }}" | |
| # NEVER default to github.run_number: the 2026.6.25.641→2026.7.6.649 | |
| # accidental series was (since-removed) scheduled runs publishing | |
| # run-number versions. The date already makes a nightly version | |
| # unique; a same-day second dispatch must choose its own | |
| # minor_version (twine loudly rejects duplicates). | |
| VERSION="${DATE_FORMATTED}.${MINOR_VERSION:-1}" | |
| RUN_ATTEMPT="${{ github.run_attempt }}" | |
| if [ "$RUN_ATTEMPT" -gt "1" ] | |
| then | |
| VERSION="$VERSION.$RUN_ATTEMPT" | |
| fi | |
| # Rehearsal mode (TestPyPI-only; dispatched repeatedly by Heart/Brain). | |
| # Append a unique PEP 440 dev segment so every rehearsal run publishes a | |
| # FRESH, distinct version. This both dodges TestPyPI's duplicate-filename | |
| # rejection AND guarantees the wheels Heart installs reflect the exact | |
| # source of *this* run — a bare `--skip-existing` would silently leave | |
| # stale wheels from a prior rehearsal of the same date/minor in place. | |
| # run_number*100+run_attempt is unique per dispatch and per re-run attempt. | |
| if [ "${{ needs.resolve_mode.outputs.rehearsal }}" = "true" ] | |
| then | |
| DEV_SEGMENT=$(( ${{ github.run_number }} * 100 + RUN_ATTEMPT )) | |
| VERSION="${DATE_FORMATTED}.${MINOR_VERSION:-1}.dev${DEV_SEGMENT}" | |
| fi | |
| export VERSION | |
| echo "::set-output name=version_number::${VERSION}" | |
| id: version_number | |
| release_test_pypi: | |
| runs-on: ubuntu-latest | |
| needs: version_number | |
| env: | |
| TWINE_REPOSITORY: testpypi | |
| TWINE_USERNAME: __token__ | |
| TWINE_PASSWORD: ${{ secrets.TEST_PYPI }} | |
| strategy: | |
| matrix: | |
| python-version: [3.12] | |
| project: | |
| - repository: PyAutoLabs/PyAutoNerves | |
| ref: main | |
| path: PyAutoNerves | |
| - repository: PyAutoLabs/PyAutoFit | |
| ref: main | |
| path: PyAutoFit | |
| - repository: PyAutoLabs/PyAutoArray | |
| ref: main | |
| path: PyAutoArray | |
| - repository: PyAutoLabs/PyAutoGalaxy | |
| ref: main | |
| path: PyAutoGalaxy | |
| - repository: PyAutoLabs/PyAutoLens | |
| ref: main | |
| path: PyAutoLens | |
| steps: | |
| - uses: actions/checkout@v4 | |
| with: | |
| path: PyAutoHands | |
| - name: Checkout | |
| uses: actions/checkout@v4 | |
| with: | |
| repository: "${{ matrix.project.repository }}" | |
| ref: "${{ matrix.project.ref }}" | |
| path: "${{ matrix.project.path }}" | |
| - name: Set up Python ${{ matrix.python-version }} | |
| uses: actions/setup-python@v5 | |
| with: | |
| python-version: ${{ matrix.python-version }} | |
| - name: Build | |
| run: | | |
| pushd "${{ matrix.project.path }}" | |
| export VERSION="${{ needs.version_number.outputs.version_number }}" | |
| # Never build without an explicit version. Each setup.py falls back | |
| # to a 9999 dev stamp so local source installs satisfy the | |
| # intra-family `>=` floors; publishing that to PyPI would be | |
| # recoverable only by yanking, since nothing sorts above it. | |
| if [ -z "$VERSION" ]; then | |
| echo "::error::VERSION is empty — refusing to build (would stamp the source-install fallback)." | |
| exit 1 | |
| fi | |
| sed -i "s/__version__ = [\.\"\'0-9]*/__version__ = \"$VERSION\"/g" */__init__.py | |
| # The stamp sed is unanchored with a zero-or-more value class, so a | |
| # reshaped assignment would be corrupted into a SyntaxError instead | |
| # of left alone — verify every touched file still parses and the | |
| # package stamp is exactly $VERSION before building the wheel. | |
| STAMPED=0 | |
| for f in */__init__.py; do | |
| python3 -c "import ast,sys; ast.parse(open(sys.argv[1]).read(), sys.argv[1])" "$f" || { | |
| echo "::error::$f is not valid Python after version stamping." | |
| exit 1 | |
| } | |
| if grep -q "__version__ = " "$f"; then | |
| grep -qxF "__version__ = \"$VERSION\"" "$f" || { | |
| echo "::error::$f __version__ line is not exactly \"$VERSION\" after stamping." | |
| exit 1 | |
| } | |
| STAMPED=1 | |
| fi | |
| done | |
| if [ "$STAMPED" -eq 0 ]; then | |
| echo "::error::No __init__.py carries a __version__ stamp — the sed matched nothing." | |
| exit 1 | |
| fi | |
| python3 -m pip install --upgrade build | |
| python3 -m build | |
| popd | |
| - name: Check built distributions for direct-reference URLs | |
| run: | | |
| # Surface any git+ direct-URL footgun early and loudly. PyPI and | |
| # TestPyPI reject any uploaded wheel/sdist whose metadata carries a | |
| # PEP 508 direct-reference dependency (e.g. `pkg @ git+https://...`); | |
| # twine would otherwise fail the upload with a raw HTTP 400. We fail | |
| # here first, naming the offending package and line — this is the exact | |
| # packaging-layer bug the rehearsal exists to catch before any release. | |
| pushd "${{ matrix.project.path }}" | |
| python3 - <<'PY' | |
| import glob, re, sys, zipfile, tarfile | |
| direct = re.compile(r'^Requires-Dist:.*?@\s+(?:git\+|https?:|file:)', re.IGNORECASE) | |
| offending = [] | |
| def scan(text, origin): | |
| for line in text.splitlines(): | |
| if direct.match(line): | |
| offending.append((origin, line.strip())) | |
| for whl in glob.glob('dist/*.whl'): | |
| with zipfile.ZipFile(whl) as z: | |
| for n in z.namelist(): | |
| if n.endswith('METADATA'): | |
| scan(z.read(n).decode('utf-8', 'replace'), whl) | |
| for sd in glob.glob('dist/*.tar.gz'): | |
| with tarfile.open(sd) as t: | |
| for m in t.getmembers(): | |
| if m.name.endswith('PKG-INFO'): | |
| f = t.extractfile(m) | |
| if f: | |
| scan(f.read().decode('utf-8', 'replace'), sd) | |
| if offending: | |
| print('ERROR: direct-reference (git+/url) dependencies in built metadata:') | |
| for origin, line in offending: | |
| print(f' {origin}: {line}') | |
| print('TestPyPI/PyPI will reject these direct-reference URLs.') | |
| sys.exit(1) | |
| print('OK: no direct-reference URLs in built distribution metadata.') | |
| PY | |
| popd | |
| - name: Upload to test PyPI | |
| run: | | |
| set -euo pipefail | |
| python3 -m pip install twine==6.0.1 | |
| pushd "${{ matrix.project.path }}" | |
| # --skip-existing: the rehearsal is dispatched repeatedly, and a re-run | |
| # attempt may re-upload a sibling lib that already landed; tolerate the | |
| # duplicate-filename case instead of hard-failing. (Freshness of the | |
| # rehearsed code is guaranteed separately by the per-run dev version.) | |
| # Bounded retry for the same reason as the live leg: a transient | |
| # connect timeout must not fail a whole rehearsal. | |
| for attempt in 1 2 3; do | |
| if python3 -m twine upload --repository-url https://test.pypi.org/legacy/ --skip-existing --verbose dist/*; then | |
| break | |
| fi | |
| if [ "$attempt" -eq 3 ]; then | |
| echo "twine upload failed after 3 attempts." | |
| exit 1 | |
| fi | |
| DELAY=$(( attempt * 30 )) | |
| echo "twine upload failed (attempt $attempt) — retrying in ${DELAY}s" | |
| sleep "$DELAY" | |
| done | |
| popd | |
| - name: Wait for packages and install | |
| run: | | |
| export PACKAGES=("autonerves" "autoarray" "autofit" "autogalaxy" "autolens") | |
| export VERSION="${{ needs.version_number.outputs.version_number }}" | |
| # Wait for each package to become available on test PyPI. | |
| # 300 attempts × 10s = 50min buffer. Each release_test_pypi matrix | |
| # job uploads its own lib then waits for ALL 5 to be visible on the | |
| # simple index, so the wait must absorb the slowest sibling build — | |
| # on slow CI days the previous 100 × 10s = 17min budget timed out | |
| # waiting for siblings that hadn't finished uploading yet, which | |
| # then cascade-cancelled the rest of the matrix. | |
| for PACKAGE in ${PACKAGES[@]}; do | |
| echo "Checking for $PACKAGE==$VERSION on test PyPI..." | |
| cnt=0 | |
| until python3 -m pip download --no-deps --dest /tmp/pyauto-check \ | |
| --index-url https://test.pypi.org/simple/ "$PACKAGE==$VERSION" 2>/dev/null; do | |
| ((cnt=cnt+1)) | |
| if [[ $cnt -ge 300 ]]; then | |
| echo "ERROR: Timed out waiting for $PACKAGE==$VERSION after 300 attempts (50min)" | |
| exit 1 | |
| fi | |
| echo " Waiting for $PACKAGE==$VERSION... (attempt $cnt)" | |
| sleep 10 | |
| done | |
| echo " Found $PACKAGE==$VERSION" | |
| done | |
| echo "All packages available. Installing with pinned versions..." | |
| # Install all 5 PyAuto packages in a single command with all versions | |
| # pinned. This prevents pip from substituting unrelated packages with | |
| # the same names from production PyPI (e.g. autonerves 0.7.7, autofit 0.73.1) | |
| # during dependency resolution. | |
| # | |
| # Retry the install command — TestPyPI's simple index has CDN | |
| # consistency issues. The wait loop above can succeed for a version | |
| # that the install (which also queries real PyPI via --extra-index-url) | |
| # then fails to resolve, because different requests hit different CDN | |
| # edges with different views. Retry with backoff to ride out the lag. | |
| install_cnt=0 | |
| until python3 -m pip install \ | |
| --index-url https://test.pypi.org/simple/ \ | |
| --extra-index-url https://pypi.org/simple \ | |
| "autonerves[optional]==$VERSION" \ | |
| "autoarray[optional]==$VERSION" \ | |
| "autofit[optional]==$VERSION" \ | |
| "autogalaxy[optional]==$VERSION" \ | |
| "autolens[optional]==$VERSION"; do | |
| ((install_cnt=install_cnt+1)) | |
| if [[ $install_cnt -ge 30 ]]; then | |
| echo "ERROR: Install failed after 30 attempts (5min)" | |
| exit 1 | |
| fi | |
| echo " Install attempt $install_cnt failed, retrying in 10s..." | |
| sleep 10 | |
| done | |
| # Belt-and-braces: the [optional] → [jax] extras chain should | |
| # pull JAX, but installs from test.pypi.org with mixed indexes | |
| # have historically silently dropped the dep. Force the install | |
| # so jax_likelihood_functions / jax_grad / jax_substructure | |
| # scripts can't fail in 0.1s with ModuleNotFoundError. | |
| pip install "jax>=0.7,<0.11" "jaxlib>=0.7,<0.11" | |
| - name: Tests | |
| run: | | |
| pushd "${{ matrix.project.path }}" | |
| export JAX_ENABLE_X64=True | |
| # NOT pinning matplotlib (same call as PyAutoHeart's | |
| # workspace-validation.yml): 3.6.0 predates cp312 wheels, so the pin | |
| # forces a slow source rebuild, and its errorbar/fill_between call | |
| # np.row_stack — removed in NumPy 2.0. Against the numpy 2.x the | |
| # install above resolves, that pin is an immediate AttributeError | |
| # across the graphical/ and tools/ suites. | |
| pip install pynufft==2025.1.1 | |
| pip install pytest | |
| pip install numba | |
| python3 -m pytest | |
| run_smoke_tests: | |
| runs-on: ubuntu-latest | |
| needs: | |
| - resolve_mode | |
| - release_test_pypi | |
| - version_number | |
| # Skipped in rehearsal mode: rehearsal halts after the TestPyPI publish; the | |
| # workspace integration surface is validated downstream by Heart, not here. | |
| if: "${{ needs.resolve_mode.outputs.rehearsal != 'true' }}" | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| python-version: [3.12] | |
| workspace: | |
| - { repo: PyAutoLabs/autofit_workspace, path: autofit_workspace } | |
| - { repo: PyAutoLabs/autogalaxy_workspace, path: autogalaxy_workspace } | |
| - { repo: PyAutoLabs/autolens_workspace, path: autolens_workspace } | |
| env: | |
| PYAUTO_TEST_MODE: "1" | |
| PYAUTO_SMALL_DATASETS: "1" | |
| PYAUTO_FAST_PLOTS: "1" | |
| PYAUTO_SKIP_WORKSPACE_VERSION_CHECK: "1" | |
| NUMBA_CACHE_DIR: /tmp/numba_cache | |
| MPLCONFIGDIR: /tmp/matplotlib | |
| JAX_ENABLE_X64: "True" | |
| steps: | |
| - uses: actions/checkout@v4 | |
| with: | |
| repository: ${{ matrix.workspace.repo }} | |
| ref: main | |
| path: ${{ matrix.workspace.path }} | |
| - uses: actions/setup-python@v5 | |
| with: | |
| python-version: ${{ matrix.python-version }} | |
| - name: Install from TestPyPI at pinned version | |
| run: | | |
| export VERSION="${{ needs.version_number.outputs.version_number }}" | |
| install_cnt=0 | |
| until python3 -m pip install \ | |
| --index-url https://test.pypi.org/simple/ \ | |
| --extra-index-url https://pypi.org/simple \ | |
| "autonerves[optional]==$VERSION" \ | |
| "autoarray[optional]==$VERSION" \ | |
| "autofit[optional]==$VERSION" \ | |
| "autogalaxy[optional]==$VERSION" \ | |
| "autolens[optional]==$VERSION"; do | |
| ((install_cnt=install_cnt+1)) | |
| if [[ $install_cnt -ge 30 ]]; then | |
| echo "ERROR: Install failed after 30 attempts" | |
| exit 1 | |
| fi | |
| sleep 10 | |
| done | |
| # matplotlib deliberately unpinned — see the Tests step above. | |
| pip install pynufft==2025.1.1 numba | |
| # Belt-and-braces JAX install — the [optional] → [jax] chain | |
| # occasionally silently drops jax from test.pypi resolution. | |
| pip install "jax>=0.7,<0.11" "jaxlib>=0.7,<0.11" | |
| - name: Run smoke tests | |
| run: | | |
| cd ${{ matrix.workspace.path }} | |
| if [ ! -f smoke_tests.txt ]; then | |
| echo "No smoke_tests.txt — skipping" | |
| exit 0 | |
| fi | |
| fail=0 | |
| while IFS= read -r script || [ -n "$script" ]; do | |
| [ -z "$script" ] && continue | |
| case "$script" in \#*) continue ;; esac | |
| echo "::group::$script" | |
| if python "scripts/$script"; then | |
| echo "PASS: $script" | |
| else | |
| echo "FAIL: $script" | |
| fail=$((fail + 1)) | |
| fi | |
| echo "::endgroup::" | |
| done < smoke_tests.txt | |
| if [ "$fail" -gt 0 ]; then | |
| echo "$fail smoke test(s) failed" | |
| exit 1 | |
| fi | |
| release: | |
| runs-on: ubuntu-latest | |
| # Rehearsal mode STOPS before PyPI: skipping this job (the git tag/push + | |
| # PyPI upload) cascade-skips every job that needs it — publish_release_notes, | |
| # release_workspaces, bump_library_colab_urls, and (via release_workspaces) | |
| # both wiki_currency_check / wiki_drift_issue pairs (autolens + autofit). The | |
| # full-release path is unchanged | |
| # when rehearsal is not 'true'. | |
| if: "${{ needs.resolve_mode.outputs.rehearsal != 'true' }}" | |
| env: | |
| TWINE_REPOSITORY: pypi | |
| TWINE_USERNAME: __token__ | |
| TWINE_PASSWORD: ${{ secrets.PYPI }} | |
| needs: | |
| # Workspace-integration validation moved to PyAutoHeart | |
| # (workspace-validation.yml). Build is a pure executor: it gates only on | |
| # the TestPyPI build + version. Release readiness is enforced upstream by | |
| # the release agent via `pyauto-heart readiness`. | |
| - resolve_mode | |
| - release_test_pypi | |
| - version_number | |
| strategy: | |
| matrix: | |
| project: | |
| - repository: PyAutoLabs/PyAutoNerves | |
| ref: main | |
| path: PyAutoNerves | |
| pat: PAT_PYAUTOLABS | |
| - repository: PyAutoLabs/PyAutoFit | |
| ref: main | |
| path: PyAutoFit | |
| pat: PAT_PYAUTOLABS | |
| - repository: PyAutoLabs/PyAutoArray | |
| ref: main | |
| path: PyAutoArray | |
| pat: PAT_PYAUTOLABS | |
| - repository: PyAutoLabs/PyAutoGalaxy | |
| ref: main | |
| path: PyAutoGalaxy | |
| pat: PAT_PYAUTOLABS | |
| - repository: PyAutoLabs/PyAutoLens | |
| ref: main | |
| path: PyAutoLens | |
| pat: PAT_PYAUTOLABS | |
| steps: | |
| - uses: actions/checkout@v4 | |
| with: | |
| path: PyAutoHands | |
| - name: Checkout | |
| run: | | |
| PAT="${{ secrets[matrix.project.pat] }}" | |
| git clone -b "${{ matrix.project.ref }}" "https://$PAT@github.com/${{ matrix.project.repository }}.git" "${{ matrix.project.path }}" | |
| - name: Set up Python 3.12 | |
| uses: actions/setup-python@v5 | |
| with: | |
| python-version: "3.12" | |
| - name: Configure Git | |
| run: | | |
| git config --global user.email "richard@rghsoftware.co.uk" | |
| git config --global user.name "GitHub Actions bot" | |
| - name: Stamp version in build tree | |
| # Stamps the wheels only — the stamp is NOT committed back to the library | |
| # repo. Daily "Update version to X" commits to every library main were the | |
| # stale-CI/noise engine behind the June/July 2026 accidental-release | |
| # cascade (PyAutoBuild#118 / #120); pip users get __version__ from the | |
| # stamped wheel, source checkouts use PYAUTO_SKIP_WORKSPACE_VERSION_CHECK. | |
| run: | | |
| pushd "${{ matrix.project.path }}" | |
| VERSION="${{ needs.version_number.outputs.version_number }}" | |
| # Same refusal as the rehearsal Build job: an empty VERSION would stamp | |
| # `__version__ = ""` and publish the source-install fallback semantics. | |
| if [ -z "$VERSION" ]; then | |
| echo "::error::VERSION is empty — refusing to stamp." | |
| exit 1 | |
| fi | |
| sed -i "s/__version__ = [\.\"\'0-9]*/__version__ = \"$VERSION\"/g" */__init__.py | |
| # The stamp sed is unanchored with a zero-or-more value class, so a | |
| # reshaped assignment would be corrupted into a SyntaxError instead of | |
| # left alone — verify every touched file still parses and the package | |
| # stamp is exactly $VERSION before this tree is built and published. | |
| STAMPED=0 | |
| for f in */__init__.py; do | |
| python3 -c "import ast,sys; ast.parse(open(sys.argv[1]).read(), sys.argv[1])" "$f" || { | |
| echo "::error::$f is not valid Python after version stamping." | |
| exit 1 | |
| } | |
| if grep -q "__version__ = " "$f"; then | |
| grep -qxF "__version__ = \"$VERSION\"" "$f" || { | |
| echo "::error::$f __version__ line is not exactly \"$VERSION\" after stamping." | |
| exit 1 | |
| } | |
| STAMPED=1 | |
| fi | |
| done | |
| if [ "$STAMPED" -eq 0 ]; then | |
| echo "::error::No __init__.py carries a __version__ stamp — the sed matched nothing." | |
| exit 1 | |
| fi | |
| - name: Tag | |
| # Idempotent so "Re-run failed jobs" can recover a partially-failed live | |
| # release. 2026-07-25: a transient upload.pypi.org connect timeout killed | |
| # the PyAutoArray twine upload AFTER its tag had been pushed, and every | |
| # re-run then died here — `git tag -a` rejects the existing tag — before | |
| # Build/Upload could run, so the only remedy was a full re-release. | |
| # The clone above is fresh, so the REMOTE is the authority on whether the | |
| # tag exists (local tags say nothing about what was published). | |
| run: | | |
| set -euo pipefail | |
| pushd "${{ matrix.project.path }}" | |
| VERSION="${{ needs.version_number.outputs.version_number }}" | |
| PAT="${{ secrets[matrix.project.pat] }}" | |
| git remote set-url --push origin "https://$PAT@github.com/${{ matrix.project.repository }}.git" | |
| HEAD_SHA="$(git rev-parse HEAD)" | |
| # For an annotated tag `refs/tags/<v>` is the tag object and | |
| # `refs/tags/<v>^{}` the commit it peels to; a lightweight tag has no | |
| # peeled line, so fall back to the ref's own object id. | |
| REMOTE_REFS="$(git ls-remote origin "refs/tags/$VERSION" "refs/tags/$VERSION^{}")" | |
| TAG_SHA="$(printf '%s\n' "$REMOTE_REFS" | awk -v r="refs/tags/$VERSION^{}" '$2 == r {print $1}')" | |
| if [ -z "$TAG_SHA" ]; then | |
| TAG_SHA="$(printf '%s\n' "$REMOTE_REFS" | awk -v r="refs/tags/$VERSION" '$2 == r {print $1}')" | |
| fi | |
| if [ -z "$TAG_SHA" ]; then | |
| git tag -m "Release $VERSION" -a "$VERSION" | |
| git push origin "refs/tags/$VERSION" | |
| elif [ "$TAG_SHA" = "$HEAD_SHA" ]; then | |
| echo "Tag $VERSION already on origin at $HEAD_SHA — re-run, skipping tag and push." | |
| else | |
| echo "ERROR: tag $VERSION already exists on origin at $TAG_SHA," | |
| echo "but this job checked out $HEAD_SHA." | |
| echo "The published tag does not point at the commit being released — that is a" | |
| echo "real inconsistency, not a retry. Resolve it by hand; a release job must" | |
| echo "never silently re-point a published tag." | |
| exit 1 | |
| fi | |
| - name: Build | |
| run: | | |
| pushd "${{ matrix.project.path }}" | |
| export VERSION="${{ needs.version_number.outputs.version_number }}" | |
| sed -i "s/^\(\s*\"auto[a-z]*\)\(==[0-9][0-9.]*\)\{0,1\}\"/\1==$VERSION\"/g" pyproject.toml | |
| python3 -m pip install --upgrade build | |
| python3 -m build | |
| - name: Upload to PyPI | |
| # --skip-existing: a re-run rebuilds artifacts that may already be on | |
| # PyPI; step over those and publish the ones that are not, instead of | |
| # hard-failing on the duplicate filename. Bounded retry: the 2026-07-25 | |
| # partial release was a single transient connect timeout. | |
| run: | | |
| set -euo pipefail | |
| python3 -m pip install twine==6.0.1 | |
| pushd "${{ matrix.project.path }}" | |
| for attempt in 1 2 3; do | |
| if python3 -m twine upload --skip-existing --verbose dist/*; then | |
| break | |
| fi | |
| if [ "$attempt" -eq 3 ]; then | |
| echo "twine upload failed after 3 attempts." | |
| exit 1 | |
| fi | |
| DELAY=$(( attempt * 30 )) | |
| echo "twine upload failed (attempt $attempt) — retrying in ${DELAY}s" | |
| sleep "$DELAY" | |
| done | |
| # One Slack message per LIVE release outcome to #pipreleases | |
| # (PYAUTO_RELEASE_WEBHOOK_URL). Rehearsals stay silent — TestPyPI .dev builds | |
| # are covered by the morning health verdict. Posts on FAILURE too: a broken | |
| # live release must never pass silently (the June/July yank post-mortem, | |
| # #118/#120). `always()` keeps this job alive when `release` fails; the | |
| # rehearsal guard skips it whenever the run is not a live release. | |
| announce_release: | |
| runs-on: ubuntu-latest | |
| needs: | |
| - resolve_mode | |
| - version_number | |
| - release | |
| # Wait for the GitHub Releases so a success post can embed the PyAutoLens | |
| # notes (read back via `gh release view`). Kept in `always()` below so a | |
| # failed release — or a failed notes leg — still pages. | |
| - publish_release_notes | |
| if: "${{ always() && needs.resolve_mode.outputs.rehearsal != 'true' }}" | |
| steps: | |
| - uses: actions/checkout@v4 | |
| with: | |
| path: PyAutoHands | |
| - name: Set up Python | |
| uses: actions/setup-python@v5 | |
| with: | |
| python-version: '3.12' | |
| - name: Announce release outcome in Slack | |
| env: | |
| SLACK_WEBHOOK_URL: ${{ secrets.PYAUTO_RELEASE_WEBHOOK_URL }} | |
| # Cross-repo PAT so `gh release view` can read the PyAutoLens release | |
| # (the default GITHUB_TOKEN is scoped to PyAutoHands only). | |
| GH_TOKEN: ${{ secrets.PAT_PYAUTOLABS }} | |
| run: | | |
| VERSION="${{ needs.version_number.outputs.version_number }}" | |
| RESULT="${{ needs.release.result }}" | |
| RUN_URL="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" | |
| if [ -z "$SLACK_WEBHOOK_URL" ]; then | |
| echo "::warning::PYAUTO_RELEASE_WEBHOOK_URL not set — skipping announcement" | |
| exit 0 | |
| fi | |
| # On success this embeds the full PyAutoLens notes + release-page links; | |
| # it falls back to the one-line summary if the release can't be fetched. | |
| python3 PyAutoHands/autohands/slack_release_notes.py \ | |
| --version "$VERSION" \ | |
| --result "$RESULT" \ | |
| --run-url "$RUN_URL" \ | |
| > payload.json | |
| curl -sS -X POST \ | |
| -H "Content-Type: application/json" \ | |
| --fail-with-body \ | |
| --data @payload.json \ | |
| "$SLACK_WEBHOOK_URL" | |
| publish_release_notes: | |
| runs-on: ubuntu-latest | |
| needs: | |
| - resolve_mode | |
| - release | |
| - version_number | |
| if: "${{ needs.resolve_mode.outputs.rehearsal != 'true' }}" | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| project: | |
| - repo: PyAutoLabs/PyAutoFit | |
| pat: PAT_PYAUTOLABS | |
| - repo: PyAutoLabs/PyAutoArray | |
| pat: PAT_PYAUTOLABS | |
| - repo: PyAutoLabs/PyAutoGalaxy | |
| pat: PAT_PYAUTOLABS | |
| - repo: PyAutoLabs/PyAutoLens | |
| pat: PAT_PYAUTOLABS | |
| steps: | |
| - uses: actions/checkout@v4 | |
| with: | |
| path: PyAutoHands | |
| - name: Set up Python | |
| uses: actions/setup-python@v5 | |
| with: | |
| python-version: '3.12' | |
| - name: Publish release notes | |
| env: | |
| GH_TOKEN: ${{ secrets[matrix.project.pat] }} | |
| run: | | |
| python3 PyAutoHands/autohands/generate_release_notes.py \ | |
| --version "${{ needs.version_number.outputs.version_number }}" \ | |
| --repo "${{ matrix.project.repo }}" | |
| release_workspaces: | |
| runs-on: ubuntu-latest | |
| env: | |
| TWINE_REPOSITORY: pypi | |
| TWINE_USERNAME: __token__ | |
| TWINE_PASSWORD: ${{ secrets.PYPI }} | |
| PAT: ${{ secrets.PAT_PYAUTOLABS }} | |
| needs: | |
| # Validation moved to PyAutoHeart; this job generates notebooks inline and | |
| # commits them — it no longer gates on the (removed) run_scripts/run_notebooks. | |
| # Gate on `release`: workspace version pins must only be written after the | |
| # libraries actually publish to PyPI, else a workspace could pin a version | |
| # that never shipped (pre-existing race, hardened here). | |
| - release | |
| - release_test_pypi | |
| - version_number | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| python-version: [3.12] | |
| workspace: | |
| - repository: PyAutoLabs/autofit_workspace | |
| name: autofit | |
| package: PyAutoFit | |
| generate_notebooks: true | |
| bump_colab_urls: true | |
| - repository: PyAutoLabs/autogalaxy_workspace | |
| name: autogalaxy | |
| package: PyAutoGalaxy | |
| generate_notebooks: true | |
| bump_colab_urls: true | |
| - repository: PyAutoLabs/autolens_workspace | |
| name: autolens | |
| package: PyAutoLens | |
| generate_notebooks: true | |
| bump_colab_urls: true | |
| - repository: PyAutoLabs/HowToGalaxy | |
| name: howtogalaxy | |
| package: PyAutoGalaxy | |
| generate_notebooks: true | |
| bump_colab_urls: true | |
| - repository: PyAutoLabs/HowToLens | |
| name: howtolens | |
| package: PyAutoLens | |
| generate_notebooks: true | |
| bump_colab_urls: true | |
| - repository: PyAutoLabs/HowToFit | |
| name: howtofit | |
| package: PyAutoFit | |
| generate_notebooks: true | |
| bump_colab_urls: true | |
| - repository: PyAutoLabs/euclid_strong_lens_modeling_pipeline | |
| name: euclid_pipeline | |
| package: PyAutoLens | |
| generate_notebooks: false | |
| bump_colab_urls: false | |
| - repository: PyAutoLabs/autolens_assistant | |
| name: autolens | |
| package: PyAutoLens | |
| generate_notebooks: false | |
| bump_colab_urls: false | |
| write_api_baseline: true | |
| - repository: PyAutoLabs/autofit_assistant | |
| name: autofit | |
| package: PyAutoFit | |
| generate_notebooks: false | |
| bump_colab_urls: false | |
| write_api_baseline: true | |
| - repository: PyAutoLabs/autogalaxy_assistant | |
| name: autogalaxy | |
| package: PyAutoGalaxy | |
| generate_notebooks: false | |
| bump_colab_urls: false | |
| write_api_baseline: true | |
| steps: | |
| - uses: actions/checkout@v4 | |
| with: | |
| path: PyAutoHands | |
| - name: Checkout | |
| run: | | |
| git clone -b main "https://$PAT@github.com/${{ matrix.workspace.repository }}.git" workspace | |
| - name: Configure Git | |
| run: | | |
| git config --global user.email "richard@rghsoftware.co.uk" | |
| git config --global user.name "GitHub Actions bot" | |
| - name: Set up Python ${{ matrix.python-version }} | |
| uses: actions/setup-python@v5 | |
| with: | |
| python-version: ${{ matrix.python-version }} | |
| - name: Install Jupyter dependency | |
| run: | | |
| pip install jupyter ipynb-py-convert PyYAML | |
| - name: Update jupyter notebooks | |
| if: "${{ matrix.workspace.generate_notebooks == true }}" | |
| run: | | |
| export PYTHONPATH=$PYTHONPATH:$(pwd)/PyAutoHands | |
| AUTOHANDS_PATH="$(pwd)/PyAutoHands/autohands" | |
| pushd workspace | |
| python3 "$AUTOHANDS_PATH/generate.py" ${{ matrix.workspace.name }} | |
| # Stage exactly what generate.py produces: the notebooks tree, the | |
| # workspace catalogue written alongside them, and the root start_here | |
| # notebook where one exists. The former bare `git add *.ipynb` changed | |
| # meaning with bash glob matching, and the catalogue files were swept | |
| # into the later Colab commit by `git add -A` under a misleading | |
| # message (#156 step 2). | |
| git add -- notebooks/ llms-full.txt workspace_index.json | |
| if compgen -G "*.ipynb" > /dev/null; then git add -- *.ipynb; fi | |
| git commit -m "Release ${{ needs.version_number.outputs.version_number }}: update notebooks and workspace catalogue" || true | |
| # The former "Update version in README" and "Write workspace version" steps | |
| # are gone: workspace version pins are obsolete under the compatibility-floor | |
| # check (PyAutoNerves#118) — the workspace's `version.minimum_library_version` | |
| # is bumped deliberately when scripts need new API, never by the release cron. | |
| - name: Regenerate API audit baseline | |
| if: "${{ matrix.workspace.write_api_baseline == true }}" | |
| env: | |
| NUMBA_CACHE_DIR: /tmp/numba_cache | |
| MPLCONFIGDIR: /tmp/matplotlib | |
| PYAUTO_SKIP_WORKSPACE_VERSION_CHECK: "1" | |
| run: | | |
| VERSION="${{ needs.version_number.outputs.version_number }}" | |
| # Install the just-released wheels so the baseline hashes the published | |
| # API surface, then let the assistant regenerate its own baseline (it | |
| # owns the hash format — no reimplementation here). | |
| install_cnt=0 | |
| until python3 -m pip install \ | |
| --index-url https://test.pypi.org/simple/ \ | |
| --extra-index-url https://pypi.org/simple \ | |
| "autonerves[optional]==$VERSION" \ | |
| "autoarray[optional]==$VERSION" \ | |
| "autofit[optional]==$VERSION" \ | |
| "autogalaxy[optional]==$VERSION" \ | |
| "autolens[optional]==$VERSION"; do | |
| ((install_cnt=install_cnt+1)) | |
| if [[ $install_cnt -ge 30 ]]; then | |
| echo "ERROR: Install failed after 30 attempts" | |
| exit 1 | |
| fi | |
| sleep 10 | |
| done | |
| # matplotlib deliberately unpinned — see the Tests step above. | |
| pip install pynufft==2025.1.1 numba | |
| # Belt-and-braces JAX install (same reason as the install steps above). | |
| pip install "jax>=0.7,<0.11" "jaxlib>=0.7,<0.11" | |
| pushd workspace | |
| python3 autoassistant/audit_skill_apis.py --write-baseline | |
| git add wiki/core/api_audit_baseline.json | |
| # The chat bundle pins its stack version FROM the baseline | |
| # (chat_bundle.py:pinned_stack reads api_audit_baseline.json), and | |
| # `chat_bundle.py --check` compares the committed artifacts byte-for-byte. | |
| # So regenerating the baseline alone leaves every version-headed artifact | |
| # stale the instant this commit lands, and the wiki-currency leg goes red | |
| # at every release — stale-by-construction, not a one-off | |
| # (autolens_assistant#108, and #93 before it under a different check). | |
| # The two must move in the SAME commit; splitting them recreates the bug. | |
| if [[ -f autoassistant/chat_bundle.py ]]; then | |
| python3 autoassistant/chat_bundle.py | |
| git add -- llms-chat.txt chat_pack/ | |
| fi | |
| git commit -m "Release $VERSION: regenerate API audit baseline" || true | |
| popd | |
| - name: Bump Colab URL tag refs | |
| if: "${{ matrix.workspace.bump_colab_urls == true }}" | |
| run: | | |
| VERSION="${{ needs.version_number.outputs.version_number }}" | |
| pushd workspace | |
| bash "$GITHUB_WORKSPACE/PyAutoHands/autohands/bump_colab_urls.sh" "$VERSION" | |
| # Stage tracked-file modifications only — exactly what the URL bumper | |
| # can produce (it rewrites existing *.rst/*.md/*.ipynb/*.py in place). | |
| # The former `git add -A` also swept unrelated leftovers into this | |
| # commit under a misleading message (#156 step 2). | |
| git add -u | |
| git commit -m "Release $VERSION: bump Colab URL tag refs" || true | |
| - name: Tag and push to main | |
| # Same re-run failure mode as the library `Tag` step: a frozen | |
| # `git tag -a` kills every re-run of a job whose tag already reached the | |
| # remote. The nuance here is that this job creates commits (notebooks, | |
| # Colab bumps) before tagging — so on a re-run where main was pushed, the | |
| # `|| true` commits are no-ops, HEAD equals the tagged commit and the step | |
| # skips; if HEAD differs, the published tag genuinely does not describe | |
| # this tree and a human must decide, which is exactly the loud-fail case. | |
| run: | | |
| set -euo pipefail | |
| cd workspace | |
| VERSION="${{ needs.version_number.outputs.version_number }}" | |
| HEAD_SHA="$(git rev-parse HEAD)" | |
| REMOTE_REFS="$(git ls-remote origin "refs/tags/$VERSION" "refs/tags/$VERSION^{}")" | |
| TAG_SHA="$(printf '%s\n' "$REMOTE_REFS" | awk -v r="refs/tags/$VERSION^{}" '$2 == r {print $1}')" | |
| if [ -z "$TAG_SHA" ]; then | |
| TAG_SHA="$(printf '%s\n' "$REMOTE_REFS" | awk -v r="refs/tags/$VERSION" '$2 == r {print $1}')" | |
| fi | |
| if [ -z "$TAG_SHA" ]; then | |
| git tag -m "Release $VERSION" -a "$VERSION" | |
| git push origin main | |
| git push origin "refs/tags/$VERSION" | |
| elif [ "$TAG_SHA" = "$HEAD_SHA" ]; then | |
| echo "Tag $VERSION already on origin at $HEAD_SHA — re-run, skipping tag and push." | |
| git push origin main | |
| else | |
| echo "ERROR: tag $VERSION already exists on origin at $TAG_SHA," | |
| echo "but this job is releasing $HEAD_SHA." | |
| echo "The published tag does not point at the commit being released — that is a" | |
| echo "real inconsistency, not a retry. Resolve it by hand; a release job must" | |
| echo "never silently re-point a published tag." | |
| exit 1 | |
| fi | |
| bump_library_colab_urls: | |
| runs-on: ubuntu-latest | |
| env: | |
| PAT: ${{ secrets.PAT_PYAUTOLABS }} | |
| needs: | |
| - resolve_mode | |
| - release | |
| - version_number | |
| if: "${{ needs.resolve_mode.outputs.rehearsal != 'true' }}" | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| library: | |
| - PyAutoLabs/PyAutoFit | |
| - PyAutoLabs/PyAutoGalaxy | |
| - PyAutoLabs/PyAutoLens | |
| steps: | |
| - uses: actions/checkout@v4 | |
| with: | |
| path: PyAutoHands | |
| - name: Checkout library | |
| run: | | |
| git clone -b main "https://$PAT@github.com/${{ matrix.library }}.git" library | |
| - name: Configure Git | |
| run: | | |
| git config --global user.email "richard@rghsoftware.co.uk" | |
| git config --global user.name "GitHub Actions bot" | |
| - name: Bump Colab URL tag refs | |
| run: | | |
| VERSION="${{ needs.version_number.outputs.version_number }}" | |
| pushd library | |
| bash "$GITHUB_WORKSPACE/PyAutoHands/autohands/bump_colab_urls.sh" "$VERSION" | |
| if ! git diff --quiet; then | |
| # Tracked modifications only — the bumper rewrites existing files in | |
| # place; `git add -A` would sweep any unrelated leftovers (#156 step 2). | |
| git add -u | |
| git commit -m "Release $VERSION: bump Colab URL tag refs" | |
| git push origin main | |
| fi | |
| # --------------------------------------------------------------------------- | |
| # Wiki-currency check (autolens_assistant). | |
| # | |
| # PyAutoHands only orchestrates and reports here — it calls into the assistant's | |
| # reusable workflow, which is the single home of the currency rules (symbol audit, | |
| # idiom deny-list, provenance). No wiki-currency rule is reimplemented in this repo. | |
| # | |
| # Runs AFTER release_workspaces so the assistant's API baseline — regenerated and | |
| # committed to autolens_assistant `main` in that job — is already in place when the | |
| # reusable workflow's `--check-version` compares the just-released stack against it. | |
| # --------------------------------------------------------------------------- | |
| wiki_currency_check: | |
| needs: | |
| - resolve_mode | |
| - version_number | |
| - release_workspaces | |
| if: "${{ needs.resolve_mode.outputs.rehearsal != 'true' }}" | |
| uses: PyAutoLabs/autolens_assistant/.github/workflows/wiki-currency.yml@main | |
| with: | |
| stack_version: ${{ needs.version_number.outputs.version_number }} | |
| assistant_ref: main | |
| # On drift, open a "wiki drift" issue against autolens_assistant listing the offending | |
| # pages (the report artifact the reusable workflow uploaded). Reporting only — the | |
| # judgement of what is stale was made by the assistant's check. | |
| wiki_drift_issue: | |
| needs: | |
| - version_number | |
| - wiki_currency_check | |
| if: "${{ always() && needs.wiki_currency_check.result == 'failure' }}" | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Download drift report | |
| uses: actions/download-artifact@v4 | |
| with: | |
| name: wiki-drift-report | |
| path: drift | |
| - name: Open wiki-drift issue against autolens_assistant | |
| env: | |
| GH_TOKEN: ${{ secrets.PAT_PYAUTOLABS }} | |
| run: | | |
| VERSION="${{ needs.version_number.outputs.version_number }}" | |
| BODY=drift/drift-report.md | |
| if [ ! -f "$BODY" ]; then | |
| printf '# Wiki-currency drift at release %s\n\nThe check failed but no drift-report artifact was found; see the run logs.\n' "$VERSION" > "$BODY" | |
| fi | |
| gh issue create \ | |
| --repo PyAutoLabs/autolens_assistant \ | |
| --title "wiki drift detected at release $VERSION" \ | |
| --body-file "$BODY" \ | |
| || echo "::warning::could not open wiki-drift issue (non-fatal)" | |
| # --------------------------------------------------------------------------- | |
| # Wiki-currency check (autofit_assistant) — same contract as the autolens pair | |
| # above: PyAutoHands orchestrates and reports; the assistant's reusable workflow | |
| # is the single home of the rules. Its drift artifact is named | |
| # wiki-drift-report-autofit because upload-artifact@v4 names are unique per run | |
| # and the autolens check in this same run already claims wiki-drift-report. | |
| # --------------------------------------------------------------------------- | |
| wiki_currency_check_autofit: | |
| needs: | |
| - resolve_mode | |
| - version_number | |
| - release_workspaces | |
| if: "${{ needs.resolve_mode.outputs.rehearsal != 'true' }}" | |
| uses: PyAutoLabs/autofit_assistant/.github/workflows/wiki-currency.yml@main | |
| with: | |
| stack_version: ${{ needs.version_number.outputs.version_number }} | |
| assistant_ref: main | |
| wiki_drift_issue_autofit: | |
| needs: | |
| - version_number | |
| - wiki_currency_check_autofit | |
| if: "${{ always() && needs.wiki_currency_check_autofit.result == 'failure' }}" | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Download drift report | |
| uses: actions/download-artifact@v4 | |
| with: | |
| name: wiki-drift-report-autofit | |
| path: drift | |
| - name: Open wiki-drift issue against autofit_assistant | |
| env: | |
| GH_TOKEN: ${{ secrets.PAT_PYAUTOLABS }} | |
| run: | | |
| VERSION="${{ needs.version_number.outputs.version_number }}" | |
| BODY=drift/drift-report.md | |
| if [ ! -f "$BODY" ]; then | |
| printf '# Wiki-currency drift at release %s\n\nThe check failed but no drift-report artifact was found; see the run logs.\n' "$VERSION" > "$BODY" | |
| fi | |
| gh issue create \ | |
| --repo PyAutoLabs/autofit_assistant \ | |
| --title "wiki drift detected at release $VERSION" \ | |
| --body-file "$BODY" \ | |
| || echo "::warning::could not open wiki-drift issue (non-fatal)" | |
| # --------------------------------------------------------------------------- | |
| # Wiki-currency check (autogalaxy_assistant) — same contract as the autolens and | |
| # autofit pairs above. Its artifact is wiki-drift-report-autogalaxy, already | |
| # unique per run (upload-artifact@v4 names must not collide with the autolens | |
| # check's wiki-drift-report or the autofit check's -autofit in the same run). | |
| # | |
| # autocti_assistant is deliberately NOT wired here: `autocti` on PyPI is | |
| # 2024.11.13.2, so the `autocti==$VERSION` install this contract implies cannot | |
| # resolve, and the baseline step below installs only the five release-train | |
| # packages (autocti is not one). Its own wiki-currency.yml records the same | |
| # constraint — "the CTI release train is not yet wired". Wire the CTI release | |
| # train first; this comment is the pointer for whoever does. | |
| # --------------------------------------------------------------------------- | |
| wiki_currency_check_autogalaxy: | |
| needs: | |
| - resolve_mode | |
| - version_number | |
| - release_workspaces | |
| if: "${{ needs.resolve_mode.outputs.rehearsal != 'true' }}" | |
| uses: PyAutoLabs/autogalaxy_assistant/.github/workflows/wiki-currency.yml@main | |
| with: | |
| stack_version: ${{ needs.version_number.outputs.version_number }} | |
| assistant_ref: main | |
| wiki_drift_issue_autogalaxy: | |
| needs: | |
| - version_number | |
| - wiki_currency_check_autogalaxy | |
| if: "${{ always() && needs.wiki_currency_check_autogalaxy.result == 'failure' }}" | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Download drift report | |
| uses: actions/download-artifact@v4 | |
| with: | |
| name: wiki-drift-report-autogalaxy | |
| path: drift | |
| - name: Open wiki-drift issue against autogalaxy_assistant | |
| env: | |
| GH_TOKEN: ${{ secrets.PAT_PYAUTOLABS }} | |
| run: | | |
| VERSION="${{ needs.version_number.outputs.version_number }}" | |
| BODY=drift/drift-report.md | |
| if [ ! -f "$BODY" ]; then | |
| printf '# Wiki-currency drift at release %s\n\nThe check failed but no drift-report artifact was found; see the run logs.\n' "$VERSION" > "$BODY" | |
| fi | |
| gh issue create \ | |
| --repo PyAutoLabs/autogalaxy_assistant \ | |
| --title "wiki drift detected at release $VERSION" \ | |
| --body-file "$BODY" \ | |
| || echo "::warning::could not open wiki-drift issue (non-fatal)" | |
| # --------------------------------------------------------------------------- | |
| # Rehearsal version emitter (rehearsal mode only). | |
| # | |
| # M1 of the Brain->Health->Heart release-validation redesign. In rehearsal mode | |
| # the workflow builds + publishes every package to TestPyPI and STOPS; this job | |
| # is the terminal step that records the resolved TestPyPI version so the caller | |
| # (the Brain Release Agent / Heart) can `pip install` exactly those wheels. | |
| # | |
| # Gating on `release_test_pypi` means the artifact exists IFF all five wheels | |
| # built, uploaded, and became installable on TestPyPI at this version — so the | |
| # artifact's presence is itself the "rehearsal succeeded" signal. | |
| # --------------------------------------------------------------------------- | |
| rehearsal_version: | |
| runs-on: ubuntu-latest | |
| needs: | |
| - resolve_mode | |
| - version_number | |
| - release_test_pypi | |
| if: "${{ needs.resolve_mode.outputs.rehearsal == 'true' }}" | |
| steps: | |
| - name: Record resolved TestPyPI version | |
| run: | | |
| VERSION="${{ needs.version_number.outputs.version_number }}" | |
| echo "Rehearsal published to TestPyPI at version: $VERSION" | |
| mkdir -p rehearsal | |
| printf '%s\n' "$VERSION" > rehearsal/testpypi_version.txt | |
| cat > rehearsal/rehearsal.json <<JSON | |
| { | |
| "mode": "rehearsal", | |
| "index": "testpypi", | |
| "version": "$VERSION", | |
| "packages": ["autonerves", "autoarray", "autofit", "autogalaxy", "autolens"], | |
| "run_id": "${{ github.run_id }}", | |
| "run_attempt": "${{ github.run_attempt }}", | |
| "build_sha": "${{ github.sha }}" | |
| } | |
| JSON | |
| cat rehearsal/rehearsal.json | |
| - name: Write rehearsal summary | |
| run: | | |
| VERSION="${{ needs.version_number.outputs.version_number }}" | |
| { | |
| echo "## TestPyPI rehearsal complete" | |
| echo "" | |
| echo "Published \`autonerves / autoarray / autofit / autogalaxy / autolens == $VERSION\` to TestPyPI." | |
| echo "Halted before PyPI upload, git tags, and workspace notebook commits." | |
| echo "" | |
| echo "Install exactly these wheels with:" | |
| echo '```bash' | |
| echo "pip install --index-url https://test.pypi.org/simple/ --extra-index-url https://pypi.org/simple \\" | |
| echo " autonerves[optional]==$VERSION autoarray[optional]==$VERSION autofit[optional]==$VERSION \\" | |
| echo " autogalaxy[optional]==$VERSION autolens[optional]==$VERSION" | |
| echo '```' | |
| } >> "$GITHUB_STEP_SUMMARY" | |
| - name: Upload rehearsal version artifact | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: testpypi-rehearsal-version | |
| path: rehearsal/ |