Skip to content

ramses-release

ramses-release #11

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