ramses-release #11
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: sync-upstream-release | |
| # Fired by stepss-ramses or stepss-helios when either publishes a release. | |
| # Refreshes the binaries pyramses bundles, proves the built wheel works on | |
| # every platform, and only then releases and publishes. | |
| on: | |
| repository_dispatch: | |
| types: [ramses-release, helios-release] | |
| # Rehearsal only. The release job is gated on repository_dispatch, so no | |
| # amount of hand-triggering can move master, cut a release or reach PyPI. | |
| workflow_dispatch: | |
| inputs: | |
| source: | |
| description: 'Upstream to rehearse against' | |
| required: true | |
| type: choice | |
| options: [ramses, helios] | |
| tag: | |
| description: 'Upstream release tag, e.g. v3.55' | |
| required: true | |
| type: string | |
| permissions: | |
| contents: read | |
| # Two upstream releases in quick succession must not race for master. | |
| concurrency: | |
| group: pyramses-sync | |
| cancel-in-progress: false | |
| jobs: | |
| fetch: | |
| runs-on: ubuntu-24.04 | |
| timeout-minutes: 20 | |
| permissions: | |
| contents: write | |
| outputs: | |
| source: ${{ steps.vars.outputs.source }} | |
| upstream_tag: ${{ steps.vars.outputs.upstream_tag }} | |
| rehearsal: ${{ steps.vars.outputs.rehearsal }} | |
| duplicate: ${{ steps.dup.outputs.duplicate }} | |
| pyramses_tag: ${{ steps.bump.outputs.pyramses_tag }} | |
| branch: ${{ steps.commit.outputs.branch }} | |
| head_sha: ${{ steps.commit.outputs.head_sha }} | |
| base_sha: ${{ steps.commit.outputs.base_sha }} | |
| steps: | |
| # The payload is untrusted input: validate its shape, and never let it | |
| # reach a run: script except through env. | |
| - name: Resolve and validate the trigger | |
| id: vars | |
| env: | |
| EVENT: ${{ github.event_name }} | |
| ACTION: ${{ github.event.action }} | |
| DISPATCH_TAG: ${{ github.event.client_payload.tag }} | |
| INPUT_SOURCE: ${{ inputs.source }} | |
| INPUT_TAG: ${{ inputs.tag }} | |
| run: | | |
| if [ "$EVENT" = "repository_dispatch" ]; then | |
| # The event type identifies the sender, so no 'source' field in | |
| # the payload is ever trusted. | |
| case "$ACTION" in | |
| ramses-release) SOURCE=ramses ;; | |
| helios-release) SOURCE=helios ;; | |
| *) echo "FAIL: unexpected dispatch type '$ACTION'"; exit 1 ;; | |
| esac | |
| TAG="$DISPATCH_TAG" | |
| REHEARSAL=false | |
| else | |
| SOURCE="$INPUT_SOURCE" | |
| TAG="$INPUT_TAG" | |
| REHEARSAL=true | |
| fi | |
| # Validate the tag as a single whole string before anything is | |
| # written to $GITHUB_OUTPUT. `grep -z` treats the entire input | |
| # (embedded newlines included) as one record, so ^...$ must match | |
| # start-to-end of the whole value. A line-oriented check here would | |
| # let a value like $'v3.55\ntag=EVIL' through. | |
| # | |
| # No hyphen in the character class, which is what rejects a | |
| # prerelease: v3.41-rc1 exists upstream, and the only thing that | |
| # previously stopped it reaching PyPI was the sender's | |
| # `prerelease == false` guard -- one unticked box away from | |
| # publishing rc binaries. This mirrors the hyphen rule Helios already | |
| # applies on its side. No non-prerelease release tag in either | |
| # upstream contains a hyphen. | |
| if ! printf '%s' "$TAG" | grep -qzE '^v[0-9][0-9A-Za-z.+]*$'; then | |
| echo "FAIL: refusing tag: it must match ^v[0-9][0-9A-Za-z.+]*\$ as a single line, with no embedded newlines." | |
| echo "A hyphen is rejected on purpose: a prerelease tag such as v3.41-rc1 must never be published." | |
| exit 1 | |
| fi | |
| echo "source=$SOURCE" >> "$GITHUB_OUTPUT" | |
| echo "upstream_tag=$TAG" >> "$GITHUB_OUTPUT" | |
| echo "rehearsal=$REHEARSAL" >> "$GITHUB_OUTPUT" | |
| echo "Syncing $SOURCE $TAG (rehearsal=$REHEARSAL)" | |
| - uses: actions/checkout@v7 | |
| with: | |
| # repository_dispatch freezes github.sha to the default branch's tip | |
| # at dispatch time, and a concurrency-queued or re-run job replays | |
| # that stale value. Naming the branch makes every execution resolve | |
| # master as it stands right now. | |
| ref: master | |
| fetch-depth: 0 | |
| - uses: actions/setup-python@v7 | |
| with: | |
| python-version: '3.12' | |
| # A duplicate dispatch is not hypothetical. 'Re-run all jobs' on the | |
| # sender's release workflow replays `event_name == 'release'` and fires | |
| # the dispatch again, and the recovery command that | |
| # bundled-drift-check.yml prints into an issue can be pasted twice. The | |
| # computed pyramses tag cannot detect that: it is a fresh patch bump off | |
| # master, so it never pre-exists and the check below it is vacuous | |
| # against this case. What does detect it is _bundled.py -- if master | |
| # already records this exact tag for this source, the binaries are | |
| # already bundled, and going on would spend a second, unreclaimable PyPI | |
| # version on byte-identical content. | |
| # | |
| # The escape hatch: `client_payload[force]`. What _bundled.py cannot tell | |
| # us is *how* the tag got there. A bundle refreshed by hand outside this | |
| # automation -- or one this automation wrote but never got as far as | |
| # publishing -- is byte-for-byte indistinguishable from a genuine | |
| # duplicate, yet in those cases the guard blocks the one run that is | |
| # actually needed: PyPI still ships the older binaries. Setting force to | |
| # exactly the string 'true' bypasses this guard and nothing else; every | |
| # other guard (tag validation, rehearsal isolation, the three-platform | |
| # gate, the fast-forward check) stays exactly as it was. | |
| # | |
| # This is a deliberate manual action, taken by a human who has checked | |
| # https://pypi.org/project/pyramses/#history and knows a new version is | |
| # warranted. An upstream repository must NEVER set it automatically: a | |
| # sender that always forced would turn every re-run of its own release | |
| # workflow into another spent PyPI version. | |
| # | |
| # The payload is untrusted, so force is read only through env: and | |
| # compared against the literal 'true'. Absent, empty, 'false', 'TRUE', | |
| # '1' or anything else leaves the guard fully in force -- it fails | |
| # closed. | |
| - name: Detect a duplicate dispatch | |
| id: dup | |
| env: | |
| SOURCE: ${{ steps.vars.outputs.source }} | |
| TAG: ${{ steps.vars.outputs.upstream_tag }} | |
| REHEARSAL: ${{ steps.vars.outputs.rehearsal }} | |
| FORCE: ${{ github.event.client_payload.force }} | |
| run: | | |
| BUNDLED="src/pyramses/_bundled.py" | |
| if [ ! -f "$BUNDLED" ]; then | |
| echo "WARN: $BUNDLED not found, so nothing can be a duplicate of it" | |
| echo "duplicate=false" >> "$GITHUB_OUTPUT" | |
| exit 0 | |
| fi | |
| if [ "$SOURCE" = "ramses" ]; then | |
| NAME="RAMSES_VERSION" | |
| else | |
| NAME="HELIOS_VERSION" | |
| fi | |
| # Same sed style tools/bump_version.sh's read_assign() uses. | |
| CURRENT="$(sed -n "s/^$NAME[[:space:]]*=[[:space:]]*[\"']\([^\"']*\)[\"'].*/\1/p" "$BUNDLED" | head -n1)" | |
| echo "master bundles $NAME=${CURRENT:-(unset)}; the incoming $SOURCE tag is $TAG" | |
| DUPLICATE=false | |
| if [ "$CURRENT" = "$TAG" ]; then | |
| if [ "$REHEARSAL" = "true" ]; then | |
| # Rehearsing against an already-bundled tag is precisely how this | |
| # pipeline is tested, and a rehearsal can reach neither master nor | |
| # PyPI. Never let this guard block that path. | |
| echo "Rehearsal: $TAG is already bundled; continuing anyway." | |
| elif [ "$FORCE" = "true" ]; then | |
| # Loud on purpose: this line is the audit trail for a deliberate | |
| # re-publish over an already-bundled tag. | |
| echo "==================================================================" | |
| echo "FORCED: proceeding over an already-bundled tag." | |
| echo "master already bundles $SOURCE $TAG, so this run would normally be" | |
| echo "classified a duplicate and skipped entirely. client_payload[force]" | |
| echo "was set to exactly 'true', so the duplicate guard -- and only the" | |
| echo "duplicate guard -- is bypassed." | |
| echo "This run will therefore refresh, bump, build, gate on all three" | |
| echo "platforms, fast-forward master, cut a release AND SPEND A NEW PyPI" | |
| echo "VERSION. A PyPI version can never be reclaimed. Someone asked for" | |
| echo "this deliberately." | |
| echo "==================================================================" | |
| else | |
| DUPLICATE=true | |
| echo "DUPLICATE: $SOURCE $TAG is already bundled on master." | |
| echo "This dispatch repeats one that already completed. The refresh, the" | |
| echo "build, the gate and the release are all skipped: republishing" | |
| echo "byte-identical binaries would spend a second PyPI version, and a" | |
| echo "PyPI version can never be reclaimed. This is an expected, benign" | |
| echo "event -- no issue is filed and nothing is released." | |
| echo "If this bundle was refreshed outside the automation and has never" | |
| echo "been published, re-dispatch by hand with -f 'client_payload[force]=true'." | |
| fi | |
| fi | |
| echo "duplicate=$DUPLICATE" >> "$GITHUB_OUTPUT" | |
| - name: Refresh the bundled libraries | |
| if: steps.dup.outputs.duplicate != 'true' | |
| env: | |
| SOURCE: ${{ steps.vars.outputs.source }} | |
| TAG: ${{ steps.vars.outputs.upstream_tag }} | |
| GH_TOKEN: ${{ secrets.RAMSES_READ_TOKEN }} | |
| run: | | |
| if [ "$SOURCE" = "ramses" ]; then | |
| bash tools/update_ramses_libs.sh "$TAG" | |
| else | |
| bash tools/update_helios_libs.sh "$TAG" | |
| fi | |
| git status --short src/pyramses/libs | |
| - name: Bump the version and record the bundled upstreams | |
| id: bump | |
| if: steps.dup.outputs.duplicate != 'true' | |
| env: | |
| SOURCE: ${{ steps.vars.outputs.source }} | |
| TAG: ${{ steps.vars.outputs.upstream_tag }} | |
| run: | | |
| NEW="$(bash tools/bump_version.sh "$SOURCE" "$TAG")" | |
| echo "pyramses_tag=v$NEW" >> "$GITHUB_OUTPUT" | |
| echo "New pyramses version: $NEW" | |
| cat src/pyramses/_bundled.py | |
| # A backstop against a hand-forced state (a tag or release created out of | |
| # band), not against a duplicate dispatch: the tag it checks is a fresh | |
| # patch bump off master and so cannot pre-exist on the normal path. The | |
| # duplicate guard is the step above. | |
| - name: Refuse to re-release an existing tag | |
| if: steps.dup.outputs.duplicate != 'true' && steps.vars.outputs.rehearsal == 'false' | |
| env: | |
| TAG: ${{ steps.bump.outputs.pyramses_tag }} | |
| GH_TOKEN: ${{ github.token }} | |
| run: | | |
| if git rev-parse -q --verify "refs/tags/$TAG" >/dev/null; then | |
| echo "FAIL: tag $TAG already exists in stepss-pyramses" | |
| exit 1 | |
| fi | |
| if gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then | |
| echo "FAIL: release $TAG already exists in stepss-pyramses" | |
| exit 1 | |
| fi | |
| echo "OK: $TAG is free" | |
| - name: Commit to a sync branch | |
| id: commit | |
| if: steps.dup.outputs.duplicate != 'true' | |
| env: | |
| SOURCE: ${{ steps.vars.outputs.source }} | |
| TAG: ${{ steps.vars.outputs.upstream_tag }} | |
| PYTAG: ${{ steps.bump.outputs.pyramses_tag }} | |
| REHEARSAL: ${{ steps.vars.outputs.rehearsal }} | |
| run: | | |
| BRANCH="sync/$SOURCE-$TAG" | |
| BASE_SHA="$(git rev-parse HEAD)" | |
| git config user.name "github-actions[bot]" | |
| git config user.email "41898282+github-actions[bot]@users.noreply.github.com" | |
| git checkout -b "$BRANCH" | |
| git add -A src/pyramses/libs src/pyramses/_bundled.py src/pyramses/__init__.py | |
| git status --short | |
| git commit -m "Bundle $SOURCE $TAG and release $PYTAG" | |
| if [ "$REHEARSAL" = "true" ]; then | |
| # A rehearsal leaves no branch behind. The build and gate jobs | |
| # check this commit out from the artifact-free local history via | |
| # the workflow's own checkout of the same ref, so nothing is | |
| # pushed. | |
| echo "Rehearsal: not pushing $BRANCH" | |
| else | |
| git push origin "$BRANCH" | |
| fi | |
| echo "branch=$BRANCH" >> "$GITHUB_OUTPUT" | |
| echo "head_sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" | |
| echo "base_sha=$BASE_SHA" >> "$GITHUB_OUTPUT" | |
| # A rehearsal has no pushed branch for later jobs to check out, so the | |
| # refreshed tree travels as an artifact instead. | |
| - name: Upload the refreshed tree (rehearsal only) | |
| if: steps.dup.outputs.duplicate != 'true' && steps.vars.outputs.rehearsal == 'true' | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: rehearsal-tree | |
| path: | | |
| src/ | |
| tests/ | |
| tools/ | |
| if-no-files-found: error | |
| # v4 defaults to overwrite: false, and the recovery this workflow | |
| # prescribes for itself is 'Re-run all jobs' -- which reuses the same | |
| # run, so the second attempt would 409 on the artifact the first one | |
| # already uploaded. | |
| overwrite: true | |
| build-wheel: | |
| needs: [fetch] | |
| # A duplicate dispatch stops here: fetch changed nothing, so there is | |
| # nothing to build, gate or release. Skipping rather than failing is | |
| # deliberate -- a duplicate is expected and benign, and report-failure | |
| # must not file an issue for it. | |
| if: needs.fetch.outputs.duplicate != 'true' | |
| runs-on: ubuntu-24.04 | |
| timeout-minutes: 15 | |
| steps: | |
| - uses: actions/checkout@v7 | |
| with: | |
| # Pinned to the exact commit the release job will publish, not the | |
| # mutable branch head, so the tree that was gated is provably the | |
| # tree that ships. | |
| ref: ${{ needs.fetch.outputs.rehearsal == 'true' && 'master' || needs.fetch.outputs.head_sha }} | |
| - name: Overlay the rehearsal tree | |
| if: needs.fetch.outputs.rehearsal == 'true' | |
| uses: actions/download-artifact@v8 | |
| with: | |
| name: rehearsal-tree | |
| - uses: actions/setup-python@v7 | |
| with: | |
| python-version: '3.12' | |
| - name: Build the sdist and wheel | |
| run: | | |
| python -m pip install --upgrade pip build | |
| cd src | |
| python -m build --sdist --wheel --outdir dist | |
| - name: Show what was built | |
| run: | | |
| ls -l src/dist | |
| python - <<'EOF' | |
| import glob, zipfile | |
| whl = glob.glob('src/dist/*.whl')[0] | |
| names = zipfile.ZipFile(whl).namelist() | |
| # The bundled binaries are the whole point of this package; a | |
| # package_data glob that silently drops one would otherwise only | |
| # surface as a runtime failure for a user on that platform. | |
| for want in ('pyramses/libs/lin/ramses.so', | |
| 'pyramses/libs/win/ramses.dll', | |
| 'pyramses/libs/mac/ramses.so', | |
| 'pyramses/libs/lin/libhelios_api.so', | |
| 'pyramses/libs/win/helios_api.dll', | |
| 'pyramses/libs/mac/libhelios_api.dylib'): | |
| assert want in names, 'wheel is missing %s' % want | |
| print('ok:', want) | |
| EOF | |
| - uses: actions/upload-artifact@v7 | |
| with: | |
| name: dist | |
| path: src/dist/* | |
| if-no-files-found: error | |
| # As above: 'Re-run all jobs' reuses the run, and v4 refuses to | |
| # replace an existing artifact of the same name unless told to. | |
| overwrite: true | |
| nordic: | |
| needs: [fetch, build-wheel] | |
| if: needs.fetch.outputs.duplicate != 'true' | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| # macos-15 is arm64, which the arm64-only RAMSES macOS build needs. | |
| os: [ubuntu-24.04, windows-latest, macos-15] | |
| runs-on: ${{ matrix.os }} | |
| timeout-minutes: 45 | |
| steps: | |
| - uses: actions/checkout@v7 | |
| with: | |
| ref: ${{ needs.fetch.outputs.rehearsal == 'true' && 'master' || needs.fetch.outputs.head_sha }} | |
| - name: Overlay the rehearsal tree | |
| if: needs.fetch.outputs.rehearsal == 'true' | |
| uses: actions/download-artifact@v8 | |
| with: | |
| name: rehearsal-tree | |
| - uses: actions/setup-python@v7 | |
| with: | |
| python-version: '3.12' | |
| - uses: actions/download-artifact@v8 | |
| with: | |
| name: dist | |
| path: dist | |
| # The bundled ramses.so is dynamically linked against OpenBLAS and the | |
| # GCC/gfortran runtimes, which the hosted runners don't have | |
| # preinstalled. Without these the gate errors at library load before it | |
| # ever reaches the trajectory comparison. Windows is exempt: that RAMSES | |
| # build is statically linked and self-contained. Keep in step with | |
| # tests.yml, which installs the same set. | |
| - name: Install system dependencies (Linux) | |
| if: runner.os == 'Linux' | |
| run: | | |
| sudo apt-get update | |
| sudo apt-get install -y libopenblas0 libgfortran5 libgomp1 | |
| - name: Install system dependencies (macOS) | |
| if: runner.os == 'macOS' | |
| run: brew install openblas gcc | |
| # Installing the built wheel, not the source tree: the artifact users | |
| # get is the artifact the gate runs against. | |
| - name: Install the built wheel | |
| shell: bash | |
| run: | | |
| python -m pip install --upgrade pip | |
| python -m pip install pytest | |
| python -m pip install "$(ls dist/*.whl)" | |
| python -c "import pyramses; print(pyramses.__version__, pyramses.__ramses_version__, pyramses.__helios_version__)" | |
| - name: Full test suite against the installed wheel | |
| shell: bash | |
| run: pytest tests/ -v | |
| release: | |
| needs: [fetch, build-wheel, nordic] | |
| # Belt and braces: a workflow_dispatch rehearsal must never reach this | |
| # job. Either of the first two conditions alone would do; both are cheap. | |
| # The third stops a duplicate dispatch from spending a second PyPI | |
| # version; build-wheel already skips, which would skip this job anyway. | |
| if: >- | |
| github.event_name == 'repository_dispatch' | |
| && needs.fetch.outputs.rehearsal == 'false' | |
| && needs.fetch.outputs.duplicate != 'true' | |
| runs-on: ubuntu-24.04 | |
| timeout-minutes: 15 | |
| permissions: | |
| contents: write | |
| outputs: | |
| master_moved: ${{ steps.ff.outputs.moved }} | |
| release_published: ${{ steps.publish.outputs.published }} | |
| # Set by a step of our own, not by the third-party publish action, | |
| # which exposes no outputs. | |
| pypi_published: ${{ steps.pypi_done.outputs.published }} | |
| steps: | |
| - uses: actions/checkout@v7 | |
| with: | |
| ref: ${{ needs.fetch.outputs.head_sha }} | |
| fetch-depth: 0 | |
| - uses: actions/download-artifact@v8 | |
| with: | |
| name: dist | |
| path: dist | |
| - name: Fast-forward master | |
| id: ff | |
| env: | |
| HEAD_SHA: ${{ needs.fetch.outputs.head_sha }} | |
| BASE_SHA: ${{ needs.fetch.outputs.base_sha }} | |
| run: | | |
| git fetch origin master | |
| CURRENT="$(git rev-parse origin/master)" | |
| if [ "$CURRENT" != "$BASE_SHA" ]; then | |
| echo "FAIL: master moved from $BASE_SHA to $CURRENT while the wheel was being gated." | |
| echo "Rebasing would publish a tree that was never gated. Use 'Re-run all jobs'" | |
| echo "(not 'Re-run failed jobs') to retry from the current state, and delete the" | |
| echo "leftover sync branch first or the re-run's own push will be a non-fast-forward." | |
| exit 1 | |
| fi | |
| # Rejected by the server if it is not a fast-forward. | |
| git push origin "$HEAD_SHA:refs/heads/master" | |
| echo "OK: master is now $HEAD_SHA" | |
| echo "moved=true" >> "$GITHUB_OUTPUT" | |
| - name: Cut the GitHub release | |
| id: publish | |
| env: | |
| TAG: ${{ needs.fetch.outputs.pyramses_tag }} | |
| HEAD_SHA: ${{ needs.fetch.outputs.head_sha }} | |
| SOURCE: ${{ needs.fetch.outputs.source }} | |
| UPSTREAM_TAG: ${{ needs.fetch.outputs.upstream_tag }} | |
| GH_TOKEN: ${{ github.token }} | |
| run: | | |
| RAMSES_VER="$(sed -n 's/^RAMSES_VERSION = "\(.*\)"/\1/p' src/pyramses/_bundled.py)" | |
| HELIOS_VER="$(sed -n 's/^HELIOS_VERSION = "\(.*\)"/\1/p' src/pyramses/_bundled.py)" | |
| cat > notes.md <<EOF | |
| Automated release: **$SOURCE $UPSTREAM_TAG** was published upstream and the | |
| bundled libraries were refreshed to match. | |
| | Component | Version | | |
| | --- | --- | | |
| | RAMSES | $RAMSES_VER | | |
| | Helios C API | $HELIOS_VER | | |
| The wheel attached here is the exact artifact the regression gate ran | |
| against on Linux, Windows and macOS: the full test suite, including the | |
| Nordic voltage-collapse trajectory check, passed on all three before this | |
| release was cut. It is this file, not a rebuild, that the automation | |
| then uploads to PyPI. | |
| EOF | |
| # --target creates the tag as part of publishing, collapsing what | |
| # would otherwise be two points of no return into one. | |
| gh release create "$TAG" \ | |
| --repo "$GITHUB_REPOSITORY" \ | |
| --target "$HEAD_SHA" \ | |
| --title "PyRAMSES $TAG" \ | |
| --notes-file notes.md \ | |
| dist/* | |
| echo "published=true" >> "$GITHUB_OUTPUT" | |
| # Last, deliberately. A PyPI version can never be reclaimed, so it runs | |
| # only once master has moved and the release exists. If this step alone | |
| # fails, the tested wheel is attached to the release above and | |
| # python-publish.yml can upload it by hand. | |
| - name: Publish to PyPI | |
| uses: pypa/gh-action-pypi-publish@release/v1 | |
| with: | |
| password: ${{ secrets.PYPI_API_TOKEN }} | |
| packages-dir: dist/ | |
| # Only reached if the upload above succeeded, so this is a faithful | |
| # record of whether the version was spent. report-failure keys the | |
| # difference between "retryable" and "gone" off it. | |
| - name: Mark PyPI published | |
| id: pypi_done | |
| run: echo "published=true" >> "$GITHUB_OUTPUT" | |
| - name: Delete the sync branch | |
| env: | |
| BRANCH: ${{ needs.fetch.outputs.branch }} | |
| run: git push origin --delete "$BRANCH" | |
| report-failure: | |
| needs: [fetch, build-wheel, nordic, release] | |
| # `cancelled()` matters as much as `failure()` here. A manual cancel, or | |
| # the release job's timeout expiring during the PyPI upload, produces no | |
| # failed job at all -- so a `failure()`-only condition reported nothing | |
| # about the single most dangerous moment in the pipeline. | |
| if: >- | |
| (failure() || cancelled()) | |
| && github.event_name == 'repository_dispatch' | |
| && needs.fetch.outputs.duplicate != 'true' | |
| runs-on: ubuntu-24.04 | |
| timeout-minutes: 10 | |
| permissions: | |
| issues: write | |
| steps: | |
| - name: File or update an issue | |
| env: | |
| VALIDATED_TAG: ${{ needs.fetch.outputs.upstream_tag }} | |
| RAW_TAG: ${{ github.event.client_payload.tag }} | |
| SOURCE: ${{ needs.fetch.outputs.source }} | |
| MASTER_MOVED: ${{ needs.release.outputs.master_moved }} | |
| RELEASE_PUBLISHED: ${{ needs.release.outputs.release_published }} | |
| PYPI_PUBLISHED: ${{ needs.release.outputs.pypi_published }} | |
| RELEASE_RESULT: ${{ needs.release.result }} | |
| RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} | |
| GH_TOKEN: ${{ github.token }} | |
| run: | | |
| TAG="$VALIDATED_TAG" | |
| if [ -z "$TAG" ]; then | |
| # Validation itself is what failed, so the raw payload is still | |
| # untrusted free text: strip everything outside the tag charset | |
| # and clamp the length before it reaches a public issue. | |
| TAG="$(printf '%s' "$RAW_TAG" | tr -cd 'A-Za-z0-9.+-' | cut -c1-80)" | |
| [ -n "$TAG" ] || TAG="(unknown)" | |
| fi | |
| SRC="${SOURCE:-unknown}" | |
| if [ "$PYPI_PUBLISHED" = "true" ]; then | |
| STATE="\`master\` moved, the release was cut AND **PyPI was published**. That version number cannot be reused; any fix needs a further patch release." | |
| elif [ "$RELEASE_PUBLISHED" = "true" ]; then | |
| # Deliberately not "PyPI was not published": these markers record | |
| # what reported success, and an upload that was cancelled or timed | |
| # out mid-flight may well have landed. Claiming it did not, and | |
| # being wrong, sends the reader to re-upload a version that is | |
| # already spent. | |
| STATE="\`master\` moved and the release was cut, but **the PyPI upload did not report success**. It may still have completed: check https://pypi.org/project/pyramses/#history before retrying anything. Only if the version is genuinely absent, upload the tested wheel attached to the release with the \`Publish Python Package\` workflow, giving it that release tag -- never by rebuilding." | |
| elif [ "$MASTER_MOVED" = "true" ]; then | |
| # Re-running is no longer a recovery here: master now bundles this | |
| # upstream tag, so the duplicate guard in `fetch` would skip the | |
| # whole run. Say so, rather than leaving the generic re-run advice | |
| # below to send the reader somewhere that does nothing. | |
| STATE="\`master\` WAS fast-forwarded, but no release was cut and nothing reported reaching PyPI. **Re-running this workflow will now be detected as a duplicate and do nothing**, because \`master\` already bundles $SRC \`$TAG\`. Recover by hand: cut the release from the current \`master\`, attaching the \`dist\` artifact from this run, then upload that same wheel with the \`Publish Python Package\` workflow." | |
| else | |
| STATE="\`master\` was NOT moved, no release was cut and nothing reported reaching PyPI." | |
| fi | |
| # A cancelled run stops steps where they stand, so a step that was | |
| # in flight can have completed without ever setting its marker. | |
| # Written flush with this run: block's own indentation, not indented | |
| # under the `if`: it is interpolated into the <<-EOF heredoc below, | |
| # whose tab-stripping applies to the literal heredoc lines and not to | |
| # what a variable expands to. Any leading whitespace here would | |
| # survive into the issue body as a markdown code block. | |
| CAVEAT="" | |
| if [ "$RELEASE_RESULT" = "cancelled" ]; then | |
| CAVEAT=" | |
| **This run was cancelled** (by hand, or by a job timeout), so the state above | |
| records only what had *reported* success before it stopped. A step that was in | |
| flight may have completed anyway. Verify against \`master\`, the releases page | |
| and https://pypi.org/project/pyramses/#history before retrying." | |
| fi | |
| TITLE="$SRC $TAG: pyramses sync failed" | |
| BODY="$(cat <<-EOF | |
| The sync-upstream-release workflow did not complete for $SRC \`$TAG\`. | |
| Run: $RUN_URL | |
| $STATE$CAVEAT | |
| The sync branch \`sync/$SRC-$TAG\` is left in place for inspection. If | |
| \`master\` was NOT moved, delete that branch once the cause is fixed and | |
| re-run the workflow (Re-run all jobs, not just the failed ones) from the | |
| Actions UI. Once \`master\` has moved, a re-run is detected as a duplicate | |
| and does nothing -- follow the state above instead. | |
| EOF | |
| )" | |
| # `in:title` search is word-based, not exact, so fetch titles and | |
| # compare exactly instead. | |
| EXISTING="$(gh issue list --repo "$GITHUB_REPOSITORY" --state open --json number,title \ | |
| | jq -r --arg t "$TITLE" '.[] | select(.title == $t) | .number' | head -n1)" | |
| if [ -n "$EXISTING" ]; then | |
| gh issue comment "$EXISTING" --repo "$GITHUB_REPOSITORY" --body "$BODY" | |
| echo "Commented on existing issue #$EXISTING" | |
| else | |
| if ! gh issue create --repo "$GITHUB_REPOSITORY" \ | |
| --title "$TITLE" --body "$BODY" --label ci; then | |
| echo "WARN: creating the issue with the 'ci' label failed (it may not exist yet); filing without a label" | |
| gh issue create --repo "$GITHUB_REPOSITORY" --title "$TITLE" --body "$BODY" | |
| fi | |
| echo "Opened a new issue" | |
| fi |