Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 90 additions & 0 deletions .github/scripts/publish-nightly.sh
Original file line number Diff line number Diff line change
@@ -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}/<the batteries wheel for your platform>"
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}."
83 changes: 82 additions & 1 deletion .github/workflows/on-push.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -176,6 +205,7 @@ jobs:

build:
runs-on: ${{ matrix.os }}
needs: nightly_version
strategy:
matrix:
os: [ "ubuntu-latest", "windows-latest" ]
Expand Down Expand Up @@ -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: |
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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: |
Expand Down Expand Up @@ -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
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
6 changes: 6 additions & 0 deletions .github/workflows/on-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<version>-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,
Expand Down
84 changes: 84 additions & 0 deletions tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand Down Expand Up @@ -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:
Expand Down
31 changes: 31 additions & 0 deletions utest/test_dev_version.py
Original file line number Diff line number Diff line change
@@ -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")
Loading