From 106f495495edee43f694d13a6b70638ad3679369 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rene=CC=81?= Date: Sun, 9 Aug 2026 16:16:38 +0200 Subject: [PATCH] added nightly workflow action --- .github/scripts/publish-nightly.sh | 90 ++++++++++++++++++++++++++++++ .github/workflows/on-push.yml | 83 ++++++++++++++++++++++++++- .github/workflows/on-release.yml | 6 ++ README.md | 20 +++++++ tasks.py | 84 ++++++++++++++++++++++++++++ utest/test_dev_version.py | 31 ++++++++++ 6 files changed, 313 insertions(+), 1 deletion(-) create mode 100755 .github/scripts/publish-nightly.sh create mode 100644 utest/test_dev_version.py diff --git a/.github/scripts/publish-nightly.sh b/.github/scripts/publish-nightly.sh new file mode 100755 index 000000000..e591891d0 --- /dev/null +++ b/.github/scripts/publish-nightly.sh @@ -0,0 +1,90 @@ +#!/usr/bin/env bash +# +# Put the wheels built from main on the `nightly` release. +# +# The release is created once and from then on only has its assets replaced. +# That is the whole point of this script: GitHub notifies everybody watching the +# repository when a release is *published*, and a build of main is not news, so +# nothing here ever publishes a second time. The tag is moved to the commit the +# wheels were built from, which is quiet. +# +# Wants GH_TOKEN, GITHUB_REPOSITORY and GITHUB_SHA from the workflow, VERSION +# from `inv dev-version`, and the wheels in WHEEL_DIR. +set -euo pipefail + +TAG="nightly" +WHEEL_DIR="${WHEEL_DIR:-nightly-wheels}" +: "${VERSION:?the dev version the wheels were built with}" + +shopt -s nullglob +wheels=("${WHEEL_DIR}"/*.whl) +shopt -u nullglob +if [ ${#wheels[@]} -eq 0 ]; then + echo "No wheels in ${WHEEL_DIR}, nothing to publish." >&2 + exit 1 +fi + +# Wheels whose version does not match mean the stamping step did not run +# everywhere, and shipping a mixed set would hand testers a BrowserBatteries +# that refuses to install next to its own Browser wheel. +for wheel in "${wheels[@]}"; do + case "$(basename "${wheel}")" in + *-"${VERSION}"-*) ;; + *) + echo "$(basename "${wheel}") is not version ${VERSION}." >&2 + exit 1 + ;; + esac +done + +notes="$(mktemp)" +{ + echo "Wheels built from the latest green \`main\`, for trying out changes" + echo "before they are released. Not a release: the version is a \`.dev\`" + echo "build of the next milestone and it is replaced on every push to main." + echo + echo "Version \`${VERSION}\`, built from commit \`${GITHUB_SHA}\`." + echo + echo '## Install' + echo + echo "Install both wheels together. \`robotframework-browser-batteries\` pins" + echo "the exact \`robotframework-browser\` it was built with, so a mixed pair" + echo "will not resolve. Pick the batteries wheel for your platform." + echo + echo '```' + echo "base=https://github.com/${GITHUB_REPOSITORY}/releases/download/${TAG}" + echo "pip install --pre \\" + echo " \${base}/robotframework_browser-${VERSION}-py3-none-any.whl \\" + echo " \${base}/" + echo "rfbrowser init" + echo '```' + echo + echo '## Wheels in this build' + echo + for wheel in "${wheels[@]}"; do + echo "- \`$(basename "${wheel}")\`" + done + echo + echo "Please report anything broken in these builds as an issue, mentioning" + echo "the version above." +} > "${notes}" + +if gh release view "${TAG}" > /dev/null 2>&1; then + echo "Replacing the assets on the existing ${TAG} release." + git tag --force "${TAG}" "${GITHUB_SHA}" + git push --force origin "refs/tags/${TAG}" + for asset in $(gh release view "${TAG}" --json assets --jq '.assets[].name'); do + gh release delete-asset "${TAG}" "${asset}" --yes + done + gh release edit "${TAG}" --notes-file "${notes}" --prerelease +else + echo "No ${TAG} release yet, creating it. This one does notify watchers." + gh release create "${TAG}" \ + --target "${GITHUB_SHA}" \ + --title "Nightly builds from main" \ + --notes-file "${notes}" \ + --prerelease +fi + +gh release upload "${TAG}" "${wheels[@]}" --clobber +echo "Published ${#wheels[@]} wheels as ${VERSION}." diff --git a/.github/workflows/on-push.yml b/.github/workflows/on-push.yml index 8e897e563..be43508e5 100644 --- a/.github/workflows/on-push.yml +++ b/.github/workflows/on-push.yml @@ -17,6 +17,35 @@ env: SYS_VAR_CI_INSTALL_TEST: 0 jobs: + # The version the wheels of this run carry. Empty unless this is a push to + # main, which keeps pull request and scheduled runs building exactly the + # versions they build today. + nightly_version: + name: Work out the nightly version + runs-on: ubuntu-latest + timeout-minutes: 10 + outputs: + version: ${{ steps.dev.outputs.version }} + steps: + - uses: actions/checkout@v7 + - name: Set up Python 3.13 + uses: actions/setup-python@v7 + with: + python-version: "3.13" + cache: 'pip' + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install uv + uv pip install invoke --python 3.13 --system + - name: Work out the version + id: dev + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + inv dev-version + testing: runs-on: ${{ matrix.os }} timeout-minutes: 30 @@ -176,6 +205,7 @@ jobs: build: runs-on: ${{ matrix.os }} + needs: nightly_version strategy: matrix: os: [ "ubuntu-latest", "windows-latest" ] @@ -204,6 +234,13 @@ jobs: uv pip install wheel --python 3.12 --system --verbose uv pip install -r Browser/dev-requirements.txt --python 3.12 --system uv pip install -r pyproject.toml --python 3.12 --system + # A nightly wheel has to say which build it is, and the version has to be + # stamped before packaging rather than renamed afterwards, because it also + # goes into the metadata and into the BrowserBatteries dependency pin. + - name: Stamp the nightly version + if: needs.nightly_version.outputs.version != '' + run: | + inv version ${{ needs.nightly_version.outputs.version }} - name: Build stubs if: matrix.os == 'ubuntu-latest' run: | @@ -326,6 +363,7 @@ jobs: build_browser_batteries_wheels: name: Build wheels on ${{ matrix.os }} for BrowserBatteries tests runs-on: ${{ matrix.os }} + needs: nightly_version timeout-minutes: 45 env: # This job only builds wheels, no need to download Playwright browsers @@ -361,6 +399,10 @@ jobs: python -c "import sysconfig;print(sysconfig.get_platform())" python -c "import platform;print(platform.machine().lower())" inv deps + - name: Stamp the nightly version + if: needs.nightly_version.outputs.version != '' + run: | + inv version ${{ needs.nightly_version.outputs.version }} - name: Build stubs if: matrix.os == 'ubuntu-latest' run: | @@ -585,4 +627,43 @@ jobs: if: ${{ always() }} run: | python -m GHAReports --robotlog output_docker/output.xml --markdown fail.md --no-totals --no-passes --no-skipped --fails --no-warnings - cat fail.md \ No newline at end of file + cat fail.md + + # Everything green on main becomes the wheels at + # https://github.com/MarketSquare/robotframework-browser/releases/tag/nightly + # so that a fix can be tried out without waiting for a release. Needs the test + # jobs and not just the build jobs, because untested wheels are worse than no + # wheels: whoever installs them cannot tell our breakage from theirs. + publish_nightly: + name: Publish the nightly wheels + runs-on: ubuntu-latest + timeout-minutes: 15 + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + needs: + - nightly_version + - testing + - build + - test-install + - build_browser_batteries_wheels + - test_browser_batteries_wheels + permissions: + contents: write + steps: + - uses: actions/checkout@v7 + - name: Download the Browser wheel + uses: actions/download-artifact@v8 + with: + name: rfbrowser-wheel + path: nightly-wheels + - name: Download the BrowserBatteries wheels + uses: actions/download-artifact@v8 + with: + pattern: browser-batteries-wheels-bb-test-* + merge-multiple: true + path: nightly-wheels + - name: Replace the assets on the nightly release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + VERSION: ${{ needs.nightly_version.outputs.version }} + run: | + bash .github/scripts/publish-nightly.sh \ No newline at end of file diff --git a/.github/workflows/on-release.yml b/.github/workflows/on-release.yml index cd69bd3e0..2261c2aae 100644 --- a/.github/workflows/on-release.yml +++ b/.github/workflows/on-release.yml @@ -5,7 +5,12 @@ on: types: [ published ] jobs: + # The `nightly` prerelease in on-push.yml is not a release: it must never + # publish docs or reach PyPI. Creating it from CI does not start a workflow + # anyway, since the GITHUB_TOKEN cannot trigger one, but a maintainer editing + # it by hand could, so the two jobs everything else hangs off say no here. gh-pages: + if: ${{ !github.event.release.prerelease }} runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 @@ -40,6 +45,7 @@ jobs: build_browser_wheels: name: Build wheels on ${{ matrix.os }} + if: ${{ !github.event.release.prerelease }} runs-on: ${{ matrix.os }} timeout-minutes: 60 env: diff --git a/README.md b/README.md index a86842d7a..0502ee053 100644 --- a/README.md +++ b/README.md @@ -118,6 +118,26 @@ To upgrade your already installed robotframework-browser library 2. Clean old node side dependencies and browser binaries: `rfbrowser clean-node` 3. Install the node dependencies for the newly installed version: `rfbrowser init` +## Nightly builds + +Wheels built from the latest green `main` are always available on the +[nightly release](https://github.com/MarketSquare/robotframework-browser/releases/tag/nightly). +They are meant for trying out a fix or a new keyword before it is released, and +they are replaced on every push to `main`, so pick up the current file names +from that page: + +``` +base=https://github.com/MarketSquare/robotframework-browser/releases/download/nightly +pip install --pre ${base}/robotframework_browser--py3-none-any.whl +rfbrowser init +``` + +Install the matching `robotframework_browser_batteries` wheel for your platform +from the same page if you use BrowserBatteries instead of your own NodeJS. The +two packages are tied together and a nightly pair only installs as a pair. +Nightly versions are `.dev` builds of the next milestone, so `pip` only +considers them with `--pre` and a later real release always wins. + ## Uninstall instructions To completely uninstall library, including the browser binaries installed by Playwright, diff --git a/tasks.py b/tasks.py index 673c8e4fb..a7de56ce5 100644 --- a/tasks.py +++ b/tasks.py @@ -61,6 +61,11 @@ NODE_MIN_MACOS = NODE_PIN["min_macos"] NODE_DIST_BASE = "https://nodejs.org/dist" NODE_DIST_INDEX = f"{NODE_DIST_BASE}/index.json" +GITHUB_API = "https://api.github.com/repos/MarketSquare/robotframework-browser" +# The release the nightly wheels are downloaded from. It is created once and +# then only ever has its assets replaced, because publishing a release is what +# notifies everybody watching the repository and a build of main is not news. +NIGHTLY_TAG = "nightly" RELEASE_PROCESS = ( "Raise an issue and add it to the release milestone so this reaches the " "release notes, then close it once the PR is merged." @@ -1774,6 +1779,85 @@ def version(c, version): ) +def _released_version() -> str: + """What Browser/version.py says, read without importing the library.""" + version_file = ROOT_DIR / "Browser" / "version.py" + match = re.search( + r'__version__ = "([^"]+)"', version_file.read_text(encoding="utf-8") + ) + if not match: + raise Exit(f"No __version__ in {version_file}.") + return match.group(1) + + +def _open_milestone_titles() -> list: + """Titles of the milestones that are still open, or [] if we cannot ask. + + Only ever called to work out a nightly version, and a nightly is not worth + failing a build of main over, so an unreachable or unhappy API falls back to + the version file rather than raising. + """ + request = urllib.request.Request( + f"{GITHUB_API}/milestones?state=open&per_page=100", + headers={"Accept": "application/vnd.github+json"}, + ) + token = os.environ.get("GITHUB_TOKEN") + if token: + request.add_header("Authorization", f"Bearer {token}") + try: + with urllib.request.urlopen(request, timeout=30) as response: + return [milestone["title"] for milestone in json.load(response)] + except Exception as error: + print(f"Could not read the open milestones ({error}), using version.py.") + return [] + + +def _next_version(milestone_titles: Iterable[str], released: str) -> str: + """The version main is working towards, for a nightly wheel to carry. + + The lowest open milestone is the next release, so that is the number the + wheels built from main belong to. Milestones named something other than a + plain version are ignored, and with none left the next patch of the released + version is the honest guess. + """ + planned = [ + _as_version(match.group(1)) + for match in ( + re.fullmatch(r"v?(\d+\.\d+\.\d+)", title.strip()) + for title in milestone_titles + ) + if match + ] + if planned: + return _as_version_string(min(planned)) + match = re.match(r"(\d+)\.(\d+)\.(\d+)", released) + if not match: + raise Exit(f"Cannot read a version number out of '{released}'.") + major, minor, patch = (int(part) for part in match.groups()) + return _as_version_string((major, minor, patch + 1)) + + +@task +def dev_version(c): + """Print the version a wheel built from main should carry. + + A nightly has to sort above the release that is out and below the release it + is heading for, or pip has no way to tell a tester's `--pre` install from + the version they already have. PEP 440 spells that as a .devN of the coming + release, and the coming release is whatever the lowest open milestone says, + so nobody has to remember to bump anything here. The timestamp is UTC and + goes down to the second, because two pushes to main can land in one minute. + + Prints the version and, on GitHub Actions, hands it to later steps as + `version`. Both wheels are stamped with it by `inv version`, which keeps the + `robotframework-browser==` pin in the BrowserBatteries wheel matching. + """ + version = _next_version(_open_milestone_titles(), _released_version()) + stamp = time.strftime("%Y%m%d%H%M%S", time.gmtime()) + _step_output("version", f"{version}.dev{stamp}") + print(f"{version}.dev{stamp}") + + def _replace_version(filepath, matcher, version, count=0): content = filepath.open().read() with open(filepath, "w") as out: diff --git a/utest/test_dev_version.py b/utest/test_dev_version.py new file mode 100644 index 000000000..05720d7d0 --- /dev/null +++ b/utest/test_dev_version.py @@ -0,0 +1,31 @@ +import pytest +from invoke import Exit + +from tasks import _next_version + + +def test_lowest_open_milestone_wins(): + assert _next_version(["v20.5.0", "v20.4.0", "v21.0.0"], "20.3.0") == "20.4.0" + + +def test_milestone_without_the_v_prefix(): + assert _next_version(["20.4.0"], "20.3.0") == "20.4.0" + + +def test_titles_that_are_not_versions_are_ignored(): + titles = ["Backlog", "v20.4.0", "Nice to have someday"] + assert _next_version(titles, "20.3.0") == "20.4.0" + + +def test_no_open_milestone_falls_back_to_the_next_patch(): + assert _next_version([], "20.3.0") == "20.3.1" + assert _next_version(["Backlog"], "20.3.9") == "20.3.10" + + +def test_fallback_ignores_a_suffix_on_the_released_version(): + assert _next_version([], "20.3.0.dev20260809101500") == "20.3.1" + + +def test_unreadable_released_version_is_an_error(): + with pytest.raises(Exit): + _next_version([], "not a version")