From 950dd9ac87a4d71f274c5f9b43eeee6341c8d8c5 Mon Sep 17 00:00:00 2001 From: miguel Date: Tue, 24 Jun 2025 10:13:58 -0700 Subject: [PATCH 01/19] create pr for version bump gh bot --- .github/workflows/publish.yml | 69 +++++++++++++++++++++++++++++++---- 1 file changed, 61 insertions(+), 8 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index fd23b6ad..37bf55e5 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -20,6 +20,7 @@ on: permissions: contents: write + pull-requests: write jobs: build-and-publish: @@ -108,11 +109,68 @@ jobs: git config --local user.email "action@github.com" git config --local user.name "GitHub Action" - - name: Commit version bump + - name: Create version bump branch and PR + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | + NEW_VERSION="${{ steps.version.outputs.new_version }}" + BRANCH_NAME="release/v${NEW_VERSION}" + + # Create and switch to new branch + git checkout -b "$BRANCH_NAME" + + # Commit changes git add pyproject.toml stagehand/__init__.py - git commit -m "Bump version to ${{ steps.version.outputs.new_version }}" - git tag "v${{ steps.version.outputs.new_version }}" + git commit -m "Bump version to v${NEW_VERSION}" + + # Push branch + git push origin "$BRANCH_NAME" + + # Create PR + gh pr create \ + --title "Release v${NEW_VERSION}" \ + --body "Automated version bump to v${NEW_VERSION} for ${{ github.event.inputs.release_type }} release." \ + --base main \ + --head "$BRANCH_NAME" + + - name: Wait for PR to be merged + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + NEW_VERSION="${{ steps.version.outputs.new_version }}" + BRANCH_NAME="release/v${NEW_VERSION}" + + echo "Waiting for PR to be merged..." + + # Wait for PR to be merged (check every 30 seconds for up to 10 minutes) + for i in {1..20}; do + if gh pr view "$BRANCH_NAME" --json state --jq '.state' | grep -q "MERGED"; then + echo "PR has been merged!" + break + elif gh pr view "$BRANCH_NAME" --json state --jq '.state' | grep -q "CLOSED"; then + echo "PR was closed without merging. Exiting." + exit 1 + else + echo "PR is still open. Waiting 30 seconds... (attempt $i/20)" + sleep 30 + fi + + if [ $i -eq 20 ]; then + echo "Timeout waiting for PR to be merged." + exit 1 + fi + done + + - name: Checkout main and create tag + run: | + # Switch back to main and pull latest changes + git checkout main + git pull origin main + + # Create and push tag + NEW_VERSION="${{ steps.version.outputs.new_version }}" + git tag "v${NEW_VERSION}" + git push origin "v${NEW_VERSION}" - name: Build package run: | @@ -125,11 +183,6 @@ jobs: run: | twine upload dist/* - - name: Push version bump - run: | - git push - git push --tags - - name: Create GitHub Release if: ${{ github.event.inputs.create_release == 'true' }} uses: softprops/action-gh-release@v1 From e35223bf30b6d09aa49f70f54aec8c0a5b1d6516 Mon Sep 17 00:00:00 2001 From: Roaring <216452114+the-roaring@users.noreply.github.com> Date: Tue, 24 Jun 2025 14:05:41 -0700 Subject: [PATCH 02/19] add python-semantic-release config to pyproject.toml --- pyproject.toml | 102 ++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 92 insertions(+), 10 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index aba12323..1004f6d2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,10 +7,8 @@ name = "stagehand" version = "0.0.6" description = "Python SDK for Stagehand" readme = "README.md" -license = {text = "MIT"} -authors = [ - {name = "Browserbase, Inc.", email = "support@browserbase.com"} -] +license = { text = "MIT" } +authors = [{ name = "Browserbase, Inc.", email = "support@browserbase.com" }] classifiers = [ "Programming Language :: Python :: 3", "License :: OSI Approved :: MIT License", @@ -62,7 +60,7 @@ stagehand = ["domScripts.js"] line-length = 88 # Target Python version -target-version = "py39" # Adjust to your version +target-version = "py39" # Adjust to your version # Exclude a variety of commonly ignored directories exclude = [ @@ -72,7 +70,7 @@ exclude = [ "venv", ".venv", "dist", - "tests" + "tests", ] # Define lint-specific settings here @@ -123,7 +121,7 @@ addopts = [ "--strict-markers", "--strict-config", "-ra", - "--tb=short" + "--tb=short", ] markers = [ "unit: Unit tests for individual components", @@ -135,13 +133,13 @@ markers = [ "llm: Tests involving LLM interactions", "mock: Tests using mock objects only", "performance: Performance and load tests", - "smoke: Quick smoke tests for basic functionality" + "smoke: Quick smoke tests for basic functionality", ] filterwarnings = [ "ignore::DeprecationWarning", "ignore::PendingDeprecationWarning", "ignore::UserWarning:pytest_asyncio", - "ignore::RuntimeWarning" + "ignore::RuntimeWarning", ] minversion = "7.0" @@ -179,4 +177,88 @@ force_grid_wrap = 0 use_parentheses = true ensure_newline_before_comments = true skip_gitignore = true -skip_glob = ["**/venv/**", "**/.venv/**", "**/__pycache__/**"] \ No newline at end of file +skip_glob = ["**/venv/**", "**/.venv/**", "**/__pycache__/**"] + +[tool.semantic_release] +assets = [] +build_command_env = [] +commit_message = "{version}\n\nAutomatically generated by python-semantic-release" +commit_parser = "conventional" +logging_use_named_masks = false +major_on_zero = true +allow_zero_version = false +no_git_verify = false +tag_format = "v{version}" + +[tool.semantic_release.branches.main] +match = "(main|master)" +prerelease_token = "rc" +prerelease = false + +[tool.semantic_release.changelog] +changelog_file = "" +exclude_commit_patterns = [] +mode = "update" +insertion_flag = "" +template_dir = "templates" + +[tool.semantic_release.changelog.default_templates] +changelog_file = "CHANGELOG.md" +output_format = "md" +mask_initial_release = true + +[tool.semantic_release.changelog.environment] +block_start_string = "{%" +block_end_string = "%}" +variable_start_string = "{{" +variable_end_string = "}}" +comment_start_string = "{#" +comment_end_string = "#}" +trim_blocks = false +lstrip_blocks = false +newline_sequence = "\n" +keep_trailing_newline = false +extensions = [] +autoescape = false + +[tool.semantic_release.commit_author] +env = "GIT_COMMIT_AUTHOR" +default = "semantic-release " + +[tool.semantic_release.commit_parser_options] +minor_tags = ["feat"] +patch_tags = ["fix", "perf"] +other_allowed_tags = [ + "build", + "chore", + "ci", + "docs", + "style", + "refactor", + "test", +] +allowed_tags = [ + "feat", + "fix", + "perf", + "build", + "chore", + "ci", + "docs", + "style", + "refactor", + "test", +] +default_bump_level = 0 +parse_squash_commits = true +ignore_merge_commits = true + +[tool.semantic_release.remote] +name = "origin" +type = "github" +ignore_token_for_push = false +insecure = false + +[tool.semantic_release.publish] +dist_glob_patterns = ["dist/*"] +upload_to_vcs_release = true From 916ce9a77d3e911e9d298f14ed0a1c4a8a36678f Mon Sep 17 00:00:00 2001 From: Roaring <216452114+the-roaring@users.noreply.github.com> Date: Tue, 24 Jun 2025 14:07:53 -0700 Subject: [PATCH 03/19] track version variables with psr instead of bumpversion --- .bumpversion.cfg | 12 ------------ pyproject.toml | 2 ++ 2 files changed, 2 insertions(+), 12 deletions(-) delete mode 100644 .bumpversion.cfg diff --git a/.bumpversion.cfg b/.bumpversion.cfg deleted file mode 100644 index d1c662bc..00000000 --- a/.bumpversion.cfg +++ /dev/null @@ -1,12 +0,0 @@ -[bumpversion] -current_version = 0.0.1 -commit = True -tag = True - -[bumpversion:file:pyproject.toml] -search = version = "{current_version}" -replace = version = "{new_version}" - -[bumpversion:file:stagehand/__init__.py] -search = __version__ = "{current_version}" -replace = __version__ = "{new_version}" \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 1004f6d2..4b662152 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -180,6 +180,8 @@ skip_gitignore = true skip_glob = ["**/venv/**", "**/.venv/**", "**/__pycache__/**"] [tool.semantic_release] +version_toml = ["pyproject.toml:project.version"] +version_variables = ["stagehand/__init__.py:__version__"] assets = [] build_command_env = [] commit_message = "{version}\n\nAutomatically generated by python-semantic-release" From c2da2e04dbed71dd19ec44a4c8f254b02d08e665 Mon Sep 17 00:00:00 2001 From: Roaring <216452114+the-roaring@users.noreply.github.com> Date: Tue, 24 Jun 2025 14:10:18 -0700 Subject: [PATCH 04/19] set major_on_zero to false for now read more https://python-semantic-release.readthedocs.io/en/latest/configuration/configuration.html#allow-zero-version --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 4b662152..fec69365 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -187,7 +187,7 @@ build_command_env = [] commit_message = "{version}\n\nAutomatically generated by python-semantic-release" commit_parser = "conventional" logging_use_named_masks = false -major_on_zero = true +major_on_zero = false # set this to true when ready for v1 allow_zero_version = false no_git_verify = false tag_format = "v{version}" From 8494b0930e550166256301ea3f449b18f014a7e9 Mon Sep 17 00:00:00 2001 From: Roaring <216452114+the-roaring@users.noreply.github.com> Date: Tue, 24 Jun 2025 14:25:22 -0700 Subject: [PATCH 05/19] fix semantic-release configs - allow_zero_version since we've been on 0.x.y - removed deprecated changelog_file field - add build command --- pyproject.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index fec69365..326d9a4c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -184,11 +184,12 @@ version_toml = ["pyproject.toml:project.version"] version_variables = ["stagehand/__init__.py:__version__"] assets = [] build_command_env = [] +build_command = "python -m build" # TODO move to uv commit_message = "{version}\n\nAutomatically generated by python-semantic-release" commit_parser = "conventional" logging_use_named_masks = false major_on_zero = false # set this to true when ready for v1 -allow_zero_version = false +allow_zero_version = true no_git_verify = false tag_format = "v{version}" @@ -198,7 +199,6 @@ prerelease_token = "rc" prerelease = false [tool.semantic_release.changelog] -changelog_file = "" exclude_commit_patterns = [] mode = "update" insertion_flag = "" From 2c53673ca6f33398af880c8e52c969512da8b550 Mon Sep 17 00:00:00 2001 From: Roaring <216452114+the-roaring@users.noreply.github.com> Date: Wed, 25 Jun 2025 12:01:52 -0700 Subject: [PATCH 06/19] add excluded conventional commit patterns --- pyproject.toml | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 326d9a4c..37ad0274 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -199,7 +199,15 @@ prerelease_token = "rc" prerelease = false [tool.semantic_release.changelog] -exclude_commit_patterns = [] +exclude_commit_patterns = [ + '''chore(?:\([^)]*?\))?: .+''', + '''ci(?:\([^)]*?\))?: .+''', + '''refactor(?:\([^)]*?\))?: .+''', + '''style(?:\([^)]*?\))?: .+''', + '''test(?:\([^)]*?\))?: .+''', + '''build\((?!deps\): .+)''', + '''Initial [Cc]ommit.*''', +] mode = "update" insertion_flag = "" template_dir = "templates" From a87601aed53d383ae9ababc165282e2cf33c52b0 Mon Sep 17 00:00:00 2001 From: Roaring <216452114+the-roaring@users.noreply.github.com> Date: Wed, 25 Jun 2025 12:02:14 -0700 Subject: [PATCH 07/19] format changelog as .rst --- pyproject.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 37ad0274..25235d41 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -213,8 +213,8 @@ insertion_flag = "" template_dir = "templates" [tool.semantic_release.changelog.default_templates] -changelog_file = "CHANGELOG.md" -output_format = "md" +changelog_file = "CHANGELOG.rst" +output_format = "rst" mask_initial_release = true [tool.semantic_release.changelog.environment] From db8cc3e58811837c31a77d8bd68a9ead686e79ff Mon Sep 17 00:00:00 2001 From: Roaring <216452114+the-roaring@users.noreply.github.com> Date: Wed, 25 Jun 2025 12:02:38 -0700 Subject: [PATCH 08/19] fix toml formatting --- pyproject.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 25235d41..d61587ab 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -184,11 +184,11 @@ version_toml = ["pyproject.toml:project.version"] version_variables = ["stagehand/__init__.py:__version__"] assets = [] build_command_env = [] -build_command = "python -m build" # TODO move to uv +build_command = "python -m build" # TODO move to uv commit_message = "{version}\n\nAutomatically generated by python-semantic-release" commit_parser = "conventional" logging_use_named_masks = false -major_on_zero = false # set this to true when ready for v1 +major_on_zero = false # set this to true when ready for v1 allow_zero_version = true no_git_verify = false tag_format = "v{version}" From 14de1a4dd8922b562fddf0a2688fb6265484ffc7 Mon Sep 17 00:00:00 2001 From: Roaring <216452114+the-roaring@users.noreply.github.com> Date: Wed, 25 Jun 2025 13:39:04 -0700 Subject: [PATCH 09/19] add python-semantic-release github action sets up build deps, lints, prevents releases from racing, automatically updates release variables, publishes releases to github and tags them. TODO: pypi distribution --- .github/workflows/release.yml | 157 ++++++++++++++++++++++++++++++++++ 1 file changed, 157 insertions(+) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..93eaf7c4 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,157 @@ +name: Semantic Release + +on: + push: + branches: + - main + workflow_dispatch: + +permissions: + contents: write + pull-requests: write + id-token: write + +jobs: + release: + runs-on: ubuntu-latest + concurrency: + group: ${{ github.workflow }}-release-${{ github.ref_name }} + cancel-in-progress: false + + permissions: + contents: write + + steps: + # Note: We checkout the repository at the branch that triggered the workflow + # with the entire history to ensure to match PSR's release branch detection + # and history evaluation. + # However, we forcefully reset the branch to the workflow sha because it is + # possible that the branch was updated while the workflow was running. This + # prevents accidentally releasing un-evaluated changes. + - name: Setup | Checkout Repository on Release Branch + uses: actions/checkout@v4 + with: + ref: ${{ github.ref_name }} + fetch-depth: 0 + + - name: Setup | Force release branch to be at workflow sha + run: | + git reset --hard ${{ github.sha }} + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: "3.11" + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install build twine wheel setuptools ruff black + pip install -r requirements.txt + if [ -f requirements-dev.txt ]; then pip install -r requirements-dev.txt; fi + # TODO add playwright install for CI pytest + # TODO update to use uv or move into build_command in pyproject.toml + + - name: Run linting and formatting + run: | + # Run linter + black --check --diff stagehand + + # Run Ruff formatter check (without modifying files) + ruff check stagehand + + # TODO: add back as soon as CI is passing + # - name: Run tests + # run: | + # pytest + + - name: Evaluate | Verify upstream has NOT changed + # Last chance to abort before causing an error as another PR/push was applied to + # the upstream branch while this workflow was running. This is important + # because we are committing a version change (--commit). You may omit this step + # if you have 'commit: false' in your configuration. + # + # You may consider moving this to a repo script and call it from this step instead + # of writing it in-line. + shell: bash + run: | + set +o pipefail + + UPSTREAM_BRANCH_NAME="$(git status -sb | head -n 1 | cut -d' ' -f2 | grep -E '\.{3}' | cut -d'.' -f4)" + printf '%s\n' "Upstream branch name: $UPSTREAM_BRANCH_NAME" + + set -o pipefail + + if [ -z "$UPSTREAM_BRANCH_NAME" ]; then + printf >&2 '%s\n' "::error::Unable to determine upstream branch name!" + exit 1 + fi + + git fetch "${UPSTREAM_BRANCH_NAME%%/*}" + + if ! UPSTREAM_SHA="$(git rev-parse "$UPSTREAM_BRANCH_NAME")"; then + printf >&2 '%s\n' "::error::Unable to determine upstream branch sha!" + exit 1 + fi + + HEAD_SHA="$(git rev-parse HEAD)" + + if [ "$HEAD_SHA" != "$UPSTREAM_SHA" ]; then + printf >&2 '%s\n' "[HEAD SHA] $HEAD_SHA != $UPSTREAM_SHA [UPSTREAM SHA]" + printf >&2 '%s\n' "::error::Upstream has changed, aborting release..." + exit 1 + fi + + printf '%s\n' "Verified upstream branch has not changed, continuing with release..." + + - name: Action | Semantic Version Release + id: release + uses: python-semantic-release/python-semantic-release@v10.1.0 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + git_committer_name: "github-actions" + git_committer_email: "actions@users.noreply.github.com" + + - name: Publish | Upload to GitHub Release Assets + uses: python-semantic-release/publish-action@v10.1.0 + if: steps.release.outputs.released == 'true' + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + tag: ${{ steps.release.outputs.tag }} + + - name: Upload | Distribution Artifacts + uses: actions/upload-artifact@v4 + with: + name: distribution-artifacts + path: dist + if-no-files-found: error + + deploy: + # 1. Separate out the deploy step from the publish step to run each step at + # the least amount of token privilege + # 2. Also, deployments can fail, and its better to have a separate job if you need to retry + # and it won't require reversing the release. + runs-on: ubuntu-latest + needs: release + if: ${{ needs.release.outputs.released == 'true' }} + + permissions: + contents: read + id-token: write + + steps: + - name: Setup | Download Build Artifacts + uses: actions/download-artifact@v4 + id: artifact-download + with: + name: distribution-artifacts + path: dist + + # TODO set up trusted publisher + # see https://docs.pypi.org/trusted-publishers/ + - name: Publish package distributions to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + packages-dir: dist + print-hash: true + verbose: true From 65adcd1ae6a554fe1ef563aa81941ce740f1abe7 Mon Sep 17 00:00:00 2001 From: Roaring <216452114+the-roaring@users.noreply.github.com> Date: Wed, 25 Jun 2025 13:39:36 -0700 Subject: [PATCH 10/19] fix package version variable to match pyproject version --- stagehand/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/stagehand/__init__.py b/stagehand/__init__.py index 8f3a5f09..b9cf724c 100644 --- a/stagehand/__init__.py +++ b/stagehand/__init__.py @@ -21,7 +21,7 @@ ObserveResult, ) -__version__ = "0.0.1" +__version__ = "0.0.6" __all__ = [ "Stagehand", From 2086a5545c3b1a0b9aabd85a167556da3311b1fc Mon Sep 17 00:00:00 2001 From: Roaring <216452114+the-roaring@users.noreply.github.com> Date: Wed, 25 Jun 2025 17:02:43 -0700 Subject: [PATCH 11/19] add changeset scripts and github action this is inspired by [changesets](https://github.com/changesets/changesets). it's intended to recreate the ux of them with some simple scripts --- .changeset/README.md | 64 ++++++ .changeset/config.json | 26 +++ .changeset/hard-rivers-dance.md | 5 + .changeset/scripts/changelog.py | 251 +++++++++++++++++++++ .changeset/scripts/changeset.py | 176 +++++++++++++++ .changeset/scripts/check-changeset.py | 101 +++++++++ .changeset/scripts/validate-changesets.py | 83 +++++++ .changeset/scripts/version.py | 253 ++++++++++++++++++++++ .github/workflows/changeset-publish.yml | 67 ++++++ .github/workflows/changesets.yml | 129 +++++++++++ .gitignore | 4 +- CHANGELOG.md | 22 ++ changeset | 4 + 13 files changed, 1183 insertions(+), 2 deletions(-) create mode 100644 .changeset/README.md create mode 100644 .changeset/config.json create mode 100644 .changeset/hard-rivers-dance.md create mode 100755 .changeset/scripts/changelog.py create mode 100755 .changeset/scripts/changeset.py create mode 100755 .changeset/scripts/check-changeset.py create mode 100755 .changeset/scripts/validate-changesets.py create mode 100755 .changeset/scripts/version.py create mode 100644 .github/workflows/changeset-publish.yml create mode 100644 .github/workflows/changesets.yml create mode 100644 CHANGELOG.md create mode 100755 changeset diff --git a/.changeset/README.md b/.changeset/README.md new file mode 100644 index 00000000..75a5001d --- /dev/null +++ b/.changeset/README.md @@ -0,0 +1,64 @@ +# Changesets + +This directory contains changeset files that track changes to the codebase. The changeset system is inspired by the JavaScript changesets tool but adapted for Python projects. + +## How it works + +1. **Creating a changeset**: When you make changes that should be included in the changelog, run: + ```bash + python .changeset/scripts/changeset.py + # or use the wrapper script: + ./changeset + ``` + + This will prompt you to: + - Select the type of change (major, minor, or patch) + - Provide a description of the change + + A markdown file will be created in this directory with a random name like `warm-chefs-sell.md`. + +2. **Version bumping**: The GitHub Action will automatically: + - Detect changesets in PRs to main + - Create or update a "Version Packages" PR + - Bump the version based on the changesets + - Update the CHANGELOG.md + +3. **Publishing**: When the "Version Packages" PR is merged: + - The package is automatically published to PyPI + - A GitHub release is created + - The changesets are archived + +## Changeset format + +Each changeset file looks like: +```markdown +--- +"stagehand": patch +--- + +Fixed a bug in the browser automation logic +``` + +## Configuration + +The changeset behavior is configured in `.changeset/config.json`: +- `baseBranch`: The branch to compare against (usually "main") +- `changeTypes`: Definitions for major, minor, and patch changes +- `package`: Package-specific configuration + +## Best practices + +1. Create a changeset for every user-facing change +2. Use clear, concise descriptions +3. Choose the appropriate change type: + - `patch`: Bug fixes and small improvements + - `minor`: New features that are backwards compatible + - `major`: Breaking changes + +## Workflow + +1. Make your code changes +2. Run `./changeset` to create a changeset +3. Commit both your code changes and the changeset file +4. Open a PR +5. The changeset will be processed when the PR is merged to main \ No newline at end of file diff --git a/.changeset/config.json b/.changeset/config.json new file mode 100644 index 00000000..92bb6c30 --- /dev/null +++ b/.changeset/config.json @@ -0,0 +1,26 @@ +{ + "baseBranch": "main", + "changelogFormat": "markdown", + "access": "public", + "commit": false, + "changeTypes": { + "major": { + "description": "Breaking changes", + "emoji": "šŸ’„" + }, + "minor": { + "description": "New features", + "emoji": "✨" + }, + "patch": { + "description": "Bug fixes", + "emoji": "šŸ›" + } + }, + "package": { + "name": "stagehand", + "versionPath": "stagehand/__init__.py", + "versionPattern": "__version__ = \"(.*)\"", + "pyprojectPath": "pyproject.toml" + } +} \ No newline at end of file diff --git a/.changeset/hard-rivers-dance.md b/.changeset/hard-rivers-dance.md new file mode 100644 index 00000000..44a27665 --- /dev/null +++ b/.changeset/hard-rivers-dance.md @@ -0,0 +1,5 @@ +--- +"stagehand": patch +--- + +Test manual changeset creation diff --git a/.changeset/scripts/changelog.py b/.changeset/scripts/changelog.py new file mode 100755 index 00000000..26bbd04a --- /dev/null +++ b/.changeset/scripts/changelog.py @@ -0,0 +1,251 @@ +#!/usr/bin/env python3 +""" +Changelog generation script - Generates changelog from processed changesets. +""" + +import json +import os +import re +import subprocess +import sys +from datetime import datetime +from pathlib import Path +from typing import Dict, List, Optional + +import click + + +CHANGESET_DIR = Path(".changeset") +CONFIG_FILE = CHANGESET_DIR / "config.json" +CHANGELOG_FILE = Path("CHANGELOG.md") + + +def load_config() -> Dict: + """Load changeset configuration.""" + if not CONFIG_FILE.exists(): + click.echo(click.style("āŒ No changeset config found.", fg="red")) + sys.exit(1) + + with open(CONFIG_FILE) as f: + return json.load(f) + + +def load_changeset_data() -> Optional[Dict]: + """Load processed changeset data.""" + data_file = CHANGESET_DIR / ".changeset-data.json" + + if not data_file.exists(): + return None + + with open(data_file) as f: + data = json.load(f) + + # Set current date if not set + if data.get("date") is None: + data["date"] = datetime.now().strftime("%Y-%m-%d") + + return data + + +def get_pr_info() -> Optional[Dict[str, str]]: + """Get PR information if available.""" + try: + # Try to get PR info from GitHub context (in Actions) + pr_number = os.environ.get("GITHUB_PR_NUMBER") + if pr_number: + return { + "number": pr_number, + "url": f"https://github.com/{os.environ.get('GITHUB_REPOSITORY')}/pull/{pr_number}" + } + + # Try to get from git branch name (if it contains PR number) + result = subprocess.run( + ["git", "branch", "--show-current"], + capture_output=True, + text=True + ) + + if result.returncode == 0: + branch = result.stdout.strip() + # Look for patterns like "pr-123" or "pull/123" + match = re.search(r'(?:pr|pull)[/-](\d+)', branch, re.IGNORECASE) + if match: + pr_number = match.group(1) + # Try to get repo info + repo_result = subprocess.run( + ["git", "remote", "get-url", "origin"], + capture_output=True, + text=True + ) + if repo_result.returncode == 0: + repo_url = repo_result.stdout.strip() + # Extract owner/repo from URL + match = re.search(r'github\.com[:/]([^/]+/[^/]+?)(?:\.git)?$', repo_url) + if match: + repo = match.group(1) + return { + "number": pr_number, + "url": f"https://github.com/{repo}/pull/{pr_number}" + } + except Exception: + pass + + return None + + +def format_changelog_entry(entry: Dict, config: Dict) -> str: + """Format a single changelog entry.""" + change_type = entry["type"] + description = entry["description"] + + # Get emoji if configured + emoji = config["changeTypes"].get(change_type, {}).get("emoji", "") + + # Format entry + if emoji: + line = f"- {emoji} **{change_type}**: {description}" + else: + line = f"- **{change_type}**: {description}" + + return line + + +def generate_version_section(data: Dict, config: Dict) -> str: + """Generate changelog section for a version.""" + version = data["version"] + date = data["date"] + entries = data["entries"] + + # Start with version header + section = f"## [{version}] - {date}\n\n" + + # Get PR info if available + pr_info = get_pr_info() + if pr_info: + section += f"[View Pull Request]({pr_info['url']})\n\n" + + # Group entries by type + grouped = {} + for entry in entries: + change_type = entry["type"] + if change_type not in grouped: + grouped[change_type] = [] + grouped[change_type].append(entry) + + # Add entries by type (in order: major, minor, patch) + type_order = ["major", "minor", "patch"] + + for change_type in type_order: + if change_type in grouped: + type_info = config["changeTypes"].get(change_type, {}) + type_name = type_info.get("description", change_type.capitalize()) + + section += f"### {type_name}\n\n" + + for entry in grouped[change_type]: + section += format_changelog_entry(entry, config) + "\n" + + section += "\n" + + return section.strip() + "\n" + + +def update_changelog(new_section: str, version: str): + """Update the changelog file with new section.""" + if CHANGELOG_FILE.exists(): + with open(CHANGELOG_FILE) as f: + current_content = f.read() + else: + # Create new changelog with header + current_content = """# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +""" + + # Check if version already exists + if f"## [{version}]" in current_content: + click.echo(click.style(f"āš ļø Version {version} already exists in changelog", fg="yellow")) + return False + + # Find where to insert (after the header, before first version) + lines = current_content.split("\n") + insert_index = None + + # Look for first version entry or end of header + for i, line in enumerate(lines): + if line.startswith("## ["): + insert_index = i + break + + if insert_index is None: + # No versions yet, add at the end + new_content = current_content.rstrip() + "\n\n" + new_section + "\n" + else: + # Insert before first version + lines.insert(insert_index, new_section) + lines.insert(insert_index + 1, "") # Add blank line + new_content = "\n".join(lines) + + # Write updated changelog + with open(CHANGELOG_FILE, "w") as f: + f.write(new_content) + + return True + + +@click.command() +@click.option("--dry-run", is_flag=True, help="Show what would be added without making changes") +@click.option("--date", help="Override the date (YYYY-MM-DD format)") +def main(dry_run: bool, date: Optional[str]): + """Generate changelog from processed changesets.""" + + click.echo(click.style("šŸ“œ Generating changelog...\n", fg="cyan", bold=True)) + + config = load_config() + data = load_changeset_data() + + if not data: + click.echo(click.style("No changeset data found. Run version script first!", fg="red")) + return + + # Override date if provided + if date: + data["date"] = date + + # Generate changelog section + new_section = generate_version_section(data, config) + + click.echo(click.style("Generated changelog entry:", fg="green")) + click.echo("-" * 60) + click.echo(new_section) + click.echo("-" * 60) + + if dry_run: + click.echo(click.style("\nšŸ” Dry run - no changes made", fg="yellow")) + return + + # Update changelog file + if update_changelog(new_section, data["version"]): + click.echo(click.style(f"\nāœ… Updated {CHANGELOG_FILE}", fg="green", bold=True)) + + # Clean up data file + data_file = CHANGESET_DIR / ".changeset-data.json" + if data_file.exists(): + os.remove(data_file) + else: + click.echo(click.style("\nāŒ Failed to update changelog", fg="red")) + return + + # Show next steps + click.echo(click.style("\nšŸ“ Next steps:", fg="yellow")) + click.echo(" 1. Review the updated CHANGELOG.md") + click.echo(" 2. Commit the version and changelog changes") + click.echo(" 3. Create a pull request for the release") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/.changeset/scripts/changeset.py b/.changeset/scripts/changeset.py new file mode 100755 index 00000000..b63c6678 --- /dev/null +++ b/.changeset/scripts/changeset.py @@ -0,0 +1,176 @@ +#!/usr/bin/env python3 +""" +Changeset CLI - Interactive tool for creating changeset files. +Similar to JavaScript changesets but for Python projects. +""" + +import json +import os +import random +import string +import subprocess +import sys +from datetime import datetime +from pathlib import Path +from typing import Dict, List, Optional + +import click +import git + + +CHANGESET_DIR = Path(".changeset") +CONFIG_FILE = CHANGESET_DIR / "config.json" + + +def load_config() -> Dict: + """Load changeset configuration.""" + if not CONFIG_FILE.exists(): + click.echo(click.style("āŒ No changeset config found. Please run from project root.", fg="red")) + sys.exit(1) + + with open(CONFIG_FILE) as f: + return json.load(f) + + +def get_changed_files() -> List[str]: + """Get list of changed files compared to base branch.""" + config = load_config() + base_branch = config.get("baseBranch", "main") + + try: + repo = git.Repo(".") + + # Get current branch + current_branch = repo.active_branch.name + + # Get diff between current branch and base + diff_output = repo.git.diff(f"{base_branch}...HEAD", "--name-only") + + if not diff_output: + return [] + + return diff_output.strip().split("\n") + except Exception as e: + click.echo(click.style(f"Error getting changed files: {e}", fg="yellow")) + return [] + + +def generate_changeset_name() -> str: + """Generate a random changeset filename like 'warm-chefs-sell'.""" + adjectives = [ + "warm", "cool", "fast", "slow", "bright", "dark", "soft", "hard", + "sweet", "sour", "fresh", "stale", "new", "old", "big", "small", + "happy", "sad", "brave", "shy", "clever", "silly", "calm", "wild" + ] + + nouns = [ + "dogs", "cats", "birds", "fish", "lions", "bears", "rabbits", "foxes", + "chefs", "artists", "writers", "singers", "dancers", "actors", "poets", "musicians", + "stars", "moons", "suns", "clouds", "rivers", "mountains", "oceans", "forests" + ] + + verbs = [ + "run", "jump", "sing", "dance", "write", "paint", "cook", "bake", + "sell", "buy", "trade", "share", "give", "take", "make", "break", + "fly", "swim", "walk", "talk", "think", "dream", "play", "work" + ] + + return f"{random.choice(adjectives)}-{random.choice(nouns)}-{random.choice(verbs)}" + + +def create_changeset(change_type: str, description: str) -> str: + """Create a changeset file and return its path.""" + config = load_config() + package_name = config["package"]["name"] + + # Generate filename + filename = f"{generate_changeset_name()}.md" + filepath = CHANGESET_DIR / filename + + # Create changeset content + content = f"""--- +"{package_name}": {change_type} +--- + +{description} +""" + + with open(filepath, "w") as f: + f.write(content) + + return str(filepath) + + +@click.command() +@click.option("--type", type=click.Choice(["major", "minor", "patch"]), help="Change type (if not provided, will prompt)") +@click.option("--message", "-m", help="Change description (if not provided, will prompt)") +def main(type: Optional[str], message: Optional[str]): + """Create a new changeset for tracking changes.""" + + click.echo(click.style("šŸ¦‹ Creating a new changeset...\n", fg="cyan", bold=True)) + + # Check for changed files + changed_files = get_changed_files() + + if changed_files: + click.echo(click.style("šŸ“ Changed files detected:", fg="green")) + for file in changed_files[:10]: # Show first 10 files + click.echo(f" • {file}") + if len(changed_files) > 10: + click.echo(f" ... and {len(changed_files) - 10} more files") + click.echo() + + # Load config for change types + config = load_config() + change_types = config.get("changeTypes", {}) + + # Prompt for change type if not provided + if not type: + click.echo(click.style("What kind of change is this?", fg="yellow", bold=True)) + + choices = [] + for ct, info in change_types.items(): + emoji = info.get("emoji", "") + desc = info.get("description", ct) + choices.append(f"{emoji} {ct} - {desc}") + + for i, choice in enumerate(choices, 1): + click.echo(f" {i}) {choice}") + + choice_num = click.prompt("\nSelect change type", type=int) + + if 1 <= choice_num <= len(change_types): + type = list(change_types.keys())[choice_num - 1] + else: + click.echo(click.style("Invalid choice!", fg="red")) + sys.exit(1) + + # Prompt for description if not provided + if not message: + click.echo(click.style("\nšŸ“ Please describe the change:", fg="yellow", bold=True)) + click.echo(click.style("(This will be used in the changelog)", fg="bright_black")) + + message = click.prompt("Description", type=str) + + if not message.strip(): + click.echo(click.style("Description cannot be empty!", fg="red")) + sys.exit(1) + + # Create the changeset + changeset_path = create_changeset(type, message.strip()) + + click.echo(click.style(f"\nāœ… Changeset created: {changeset_path}", fg="green", bold=True)) + + # Show preview + click.echo(click.style("\nPreview:", fg="cyan")) + with open(changeset_path) as f: + content = f.read() + for line in content.split("\n"): + if line.strip(): + click.echo(f" {line}") + + click.echo(click.style("\nšŸ’” Tip: Commit this changeset with your changes!", fg="bright_black")) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/.changeset/scripts/check-changeset.py b/.changeset/scripts/check-changeset.py new file mode 100755 index 00000000..4483eaf4 --- /dev/null +++ b/.changeset/scripts/check-changeset.py @@ -0,0 +1,101 @@ +#!/usr/bin/env python3 +""" +Check if changeset exists for feature branches. +""" + +import os +import sys +from pathlib import Path + +import click +import git + + +CHANGESET_DIR = Path(".changeset") +SKIP_BRANCHES = ["main", "master", "develop", "release/*", "hotfix/*"] +SKIP_PREFIXES = ["chore/", "docs/", "test/", "ci/", "build/"] + + +def should_skip_branch(branch_name: str) -> bool: + """Check if branch should skip changeset requirement.""" + # Check exact matches + if branch_name in SKIP_BRANCHES: + return True + + # Check prefixes + for prefix in SKIP_PREFIXES: + if branch_name.startswith(prefix): + return True + + # Check patterns + for pattern in SKIP_BRANCHES: + if "*" in pattern: + import fnmatch + if fnmatch.fnmatch(branch_name, pattern): + return True + + return False + + +@click.command() +@click.option("--skip-ci", is_flag=True, help="Skip check in CI environment") +def main(skip_ci): + """Check if changeset exists for the current branch.""" + + # Skip in CI if requested + if skip_ci and os.environ.get("CI"): + click.echo("Skipping changeset check in CI") + sys.exit(0) + + try: + repo = git.Repo(".") + + # Get current branch + try: + current_branch = repo.active_branch.name + except TypeError: + # Detached HEAD state (common in CI) + click.echo("Skipping changeset check in detached HEAD state") + sys.exit(0) + + # Check if we should skip this branch + if should_skip_branch(current_branch): + click.echo(f"Skipping changeset check for branch: {current_branch}") + sys.exit(0) + + # Get uncommitted changeset files + uncommitted_changesets = [] + for item in repo.index.entries: + filepath = item[0] + if filepath.startswith(".changeset/") and filepath.endswith(".md") and "README" not in filepath: + uncommitted_changesets.append(filepath) + + # Get staged changeset files + staged_changesets = [] + diff = repo.index.diff("HEAD") + for item in diff: + if item.a_path and item.a_path.startswith(".changeset/") and item.a_path.endswith(".md") and "README" not in item.a_path: + staged_changesets.append(item.a_path) + + # Check if any changesets exist + if uncommitted_changesets or staged_changesets: + click.echo(f"āœ… Found changeset(s) for branch: {current_branch}") + if uncommitted_changesets: + click.echo(f" Uncommitted: {', '.join(uncommitted_changesets)}") + if staged_changesets: + click.echo(f" Staged: {', '.join(staged_changesets)}") + sys.exit(0) + else: + click.echo(f"āŒ No changeset found for feature branch: {current_branch}") + click.echo("šŸ’” Create a changeset by running: python .changeset/scripts/changeset.py") + click.echo(" Or use: ./changeset") + sys.exit(1) + + except Exception as e: + click.echo(f"Warning: Could not check for changesets: {e}") + # Don't fail on errors + sys.exit(0) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/.changeset/scripts/validate-changesets.py b/.changeset/scripts/validate-changesets.py new file mode 100755 index 00000000..75e4e185 --- /dev/null +++ b/.changeset/scripts/validate-changesets.py @@ -0,0 +1,83 @@ +#!/usr/bin/env python3 +""" +Validate changeset files format. +""" + +import re +import sys +from pathlib import Path + +import click + + +def validate_changeset_file(filepath: Path) -> bool: + """Validate a changeset file format.""" + try: + with open(filepath) as f: + content = f.read() + + lines = content.strip().split("\n") + + # Check frontmatter + if len(lines) < 3 or lines[0] != "---": + click.echo(f"āŒ {filepath}: Missing or invalid frontmatter start") + return False + + # Find end of frontmatter + end_idx = None + for i, line in enumerate(lines[1:], 1): + if line == "---": + end_idx = i + break + + if end_idx is None: + click.echo(f"āŒ {filepath}: Missing frontmatter end") + return False + + # Validate package and change type + found_valid_entry = False + for line in lines[1:end_idx]: + if line.strip(): + match = re.match(r'^"([^"]+)":\s*(major|minor|patch)$', line.strip()) + if match: + found_valid_entry = True + break + + if not found_valid_entry: + click.echo(f"āŒ {filepath}: Invalid package/change type format") + return False + + # Check for description + description = "\n".join(lines[end_idx + 1:]).strip() + if not description: + click.echo(f"āŒ {filepath}: Missing change description") + return False + + click.echo(f"āœ… {filepath}: Valid changeset") + return True + + except Exception as e: + click.echo(f"āŒ {filepath}: Error reading file: {e}") + return False + + +@click.command() +@click.argument('files', nargs=-1, type=click.Path(exists=True)) +def main(files): + """Validate changeset files.""" + if not files: + sys.exit(0) + + all_valid = True + for filepath in files: + path = Path(filepath) + if path.name != "README.md" and path.suffix == ".md": + if not validate_changeset_file(path): + all_valid = False + + if not all_valid: + sys.exit(1) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/.changeset/scripts/version.py b/.changeset/scripts/version.py new file mode 100755 index 00000000..4e405962 --- /dev/null +++ b/.changeset/scripts/version.py @@ -0,0 +1,253 @@ +#!/usr/bin/env python3 +""" +Version management script - Processes changesets and bumps version. +""" + +import json +import os +import re +import shutil +import sys +from pathlib import Path +from typing import Dict, List, Tuple + +import click + +# No TOML dependency needed - we'll use regex parsing + + +CHANGESET_DIR = Path(".changeset") +CONFIG_FILE = CHANGESET_DIR / "config.json" + + +def load_config() -> Dict: + """Load changeset configuration.""" + if not CONFIG_FILE.exists(): + click.echo(click.style("āŒ No changeset config found.", fg="red")) + sys.exit(1) + + with open(CONFIG_FILE) as f: + return json.load(f) + + +def parse_changeset(filepath: Path) -> Tuple[str, str, str]: + """Parse a changeset file and return (package, change_type, description).""" + with open(filepath) as f: + content = f.read() + + # Parse frontmatter + lines = content.strip().split("\n") + + if lines[0] != "---": + raise ValueError(f"Invalid changeset format in {filepath}") + + # Find end of frontmatter + end_idx = None + for i, line in enumerate(lines[1:], 1): + if line == "---": + end_idx = i + break + + if end_idx is None: + raise ValueError(f"Invalid changeset format in {filepath}") + + # Parse package and change type + for line in lines[1:end_idx]: + if line.strip(): + match = re.match(r'"(.+)":\s*(\w+)', line.strip()) + if match: + package = match.group(1) + change_type = match.group(2) + break + else: + raise ValueError(f"Could not parse package and change type from {filepath}") + + # Get description (everything after frontmatter) + description = "\n".join(lines[end_idx + 1:]).strip() + + return package, change_type, description + + +def get_changesets() -> List[Tuple[Path, str, str, str]]: + """Get all changeset files and parse them.""" + changesets = [] + + for filepath in CHANGESET_DIR.glob("*.md"): + if filepath.name == "README.md": + continue + + try: + package, change_type, description = parse_changeset(filepath) + changesets.append((filepath, package, change_type, description)) + except Exception as e: + click.echo(click.style(f"āš ļø Error parsing {filepath}: {e}", fg="yellow")) + + return changesets + + +def determine_version_bump(changesets: List[Tuple[Path, str, str, str]]) -> str: + """Determine the version bump type based on changesets.""" + has_major = any(ct == "major" for _, _, ct, _ in changesets) + has_minor = any(ct == "minor" for _, _, ct, _ in changesets) + + if has_major: + return "major" + elif has_minor: + return "minor" + else: + return "patch" + + +def parse_version(version_str: str) -> Tuple[int, int, int]: + """Parse semantic version string.""" + match = re.match(r"(\d+)\.(\d+)\.(\d+)", version_str) + if not match: + raise ValueError(f"Invalid version format: {version_str}") + + return int(match.group(1)), int(match.group(2)), int(match.group(3)) + + +def bump_version(current_version: str, bump_type: str) -> str: + """Bump version based on type.""" + major, minor, patch = parse_version(current_version) + + if bump_type == "major": + return f"{major + 1}.0.0" + elif bump_type == "minor": + return f"{major}.{minor + 1}.0" + else: # patch + return f"{major}.{minor}.{patch + 1}" + + + + +def update_pyproject_version(filepath: Path, new_version: str): + """Update version in pyproject.toml.""" + # Read the file as text to preserve formatting + with open(filepath) as f: + content = f.read() + + # Update version using regex + content = re.sub( + r'(version\s*=\s*")[^"]+(")', + f'\\g<1>{new_version}\\g<2>', + content + ) + + # Write back + with open(filepath, "w") as f: + f.write(content) + + +def get_current_version(config: Dict) -> str: + """Get current version from pyproject.toml.""" + pyproject_path = Path(config["package"]["pyprojectPath"]) + + with open(pyproject_path) as f: + content = f.read() + + # Extract version using regex + match = re.search(r'version\s*=\s*"([^"]+)"', content) + if match: + return match.group(1) + else: + raise ValueError("Could not find version in pyproject.toml") + + +@click.command() +@click.option("--dry-run", is_flag=True, help="Show what would be done without making changes") +@click.option("--skip-changelog", is_flag=True, help="Skip changelog generation") +def main(dry_run: bool, skip_changelog: bool): + """Process changesets and bump version.""" + + click.echo(click.style("šŸ“¦ Processing changesets...\n", fg="cyan", bold=True)) + + config = load_config() + changesets = get_changesets() + + if not changesets: + click.echo(click.style("No changesets found. Nothing to do!", fg="yellow")) + return + + # Show changesets + click.echo(click.style(f"Found {len(changesets)} changeset(s):", fg="green")) + for filepath, package, change_type, desc in changesets: + emoji = config["changeTypes"].get(change_type, {}).get("emoji", "") + desc_line = desc.split('\n')[0][:60] + click.echo(f" {emoji} {change_type}: {desc_line}...") + + # Determine version bump + bump_type = determine_version_bump(changesets) + current_version = get_current_version(config) + new_version = bump_version(current_version, bump_type) + + click.echo(f"\nšŸ“Š Version bump: {current_version} → {new_version} ({bump_type})") + + if dry_run: + click.echo(click.style("\nšŸ” Dry run - no changes made", fg="yellow")) + return + + # Update version in files + click.echo(click.style("\nšŸ“ Updating version files...", fg="cyan")) + + # Update __init__.py + version_file = Path(config["package"]["versionPath"]) + + with open(version_file) as f: + content = f.read() + + # Simple regex replacement for __version__ = "x.y.z" + content = re.sub( + r'(__version__\s*=\s*")[^"]+(")', + f'\\g<1>{new_version}\\g<2>', + content + ) + + with open(version_file, "w") as f: + f.write(content) + click.echo(f" āœ“ Updated {version_file}") + + # Update pyproject.toml + pyproject_path = Path(config["package"]["pyprojectPath"]) + update_pyproject_version(pyproject_path, new_version) + click.echo(f" āœ“ Updated {pyproject_path}") + + # Generate changelog entries + if not skip_changelog: + click.echo(click.style("\nšŸ“œ Generating changelog entries...", fg="cyan")) + + changelog_entries = [] + for filepath, package, change_type, desc in changesets: + changelog_entries.append({ + "type": change_type, + "description": desc, + "changeset": filepath.name + }) + + # Save changelog data for the changelog script + changelog_data = { + "version": new_version, + "previous_version": current_version, + "date": None, # Will be set by changelog script + "entries": changelog_entries + } + + with open(CHANGESET_DIR / ".changeset-data.json", "w") as f: + json.dump(changelog_data, f, indent=2) + + # Archive processed changesets + click.echo(click.style("\nšŸ—‚ļø Archiving changesets...", fg="cyan")) + + archive_dir = CHANGESET_DIR / "archive" / new_version + archive_dir.mkdir(parents=True, exist_ok=True) + + for filepath, _, _, _ in changesets: + shutil.move(str(filepath), str(archive_dir / filepath.name)) + click.echo(f" āœ“ Archived {filepath.name}") + + click.echo(click.style(f"\nāœ… Version bumped to {new_version}!", fg="green", bold=True)) + click.echo(click.style("šŸ“ Don't forget to run the changelog script next!", fg="yellow")) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/.github/workflows/changeset-publish.yml b/.github/workflows/changeset-publish.yml new file mode 100644 index 00000000..a651d797 --- /dev/null +++ b/.github/workflows/changeset-publish.yml @@ -0,0 +1,67 @@ +name: Changeset Publish + +on: + pull_request: + types: [closed] + branches: + - main + +permissions: + contents: write + packages: write + +jobs: + publish: + if: github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'changeset-release') + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: '3.11' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install build twine wheel setuptools + pip install -r requirements.txt + if [ -f requirements-dev.txt ]; then pip install -r requirements-dev.txt; fi + + - name: Get version + id: version + run: | + VERSION=$(python -c "import tomllib; print(tomllib.load(open('pyproject.toml', 'rb'))['project']['version'])") + echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "Publishing version $VERSION" + + - name: Build package + run: | + python -m build + + - name: Upload to PyPI + env: + TWINE_USERNAME: __token__ + TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }} + run: | + twine upload dist/* + + - name: Create GitHub Release + uses: softprops/action-gh-release@v1 + with: + tag_name: v${{ steps.version.outputs.version }} + name: v${{ steps.version.outputs.version }} + body: | + See [CHANGELOG.md](https://github.com/${{ github.repository }}/blob/main/CHANGELOG.md) for details. + generate_release_notes: false + + - name: Create git tag + run: | + git config --local user.email "action@github.com" + git config --local user.name "GitHub Action" + git tag "v${{ steps.version.outputs.version }}" + git push origin "v${{ steps.version.outputs.version }}" \ No newline at end of file diff --git a/.github/workflows/changesets.yml b/.github/workflows/changesets.yml new file mode 100644 index 00000000..74ee0a8d --- /dev/null +++ b/.github/workflows/changesets.yml @@ -0,0 +1,129 @@ +name: Changesets + +on: + push: + branches: + - main + +permissions: + contents: write + pull-requests: write + +jobs: + version: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: '3.11' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install click toml gitpython + + - name: Check for changesets + id: changesets + run: | + if ls .changeset/*.md 2>/dev/null | grep -v README.md > /dev/null; then + echo "has_changesets=true" >> $GITHUB_OUTPUT + echo "Found changesets to process" + else + echo "has_changesets=false" >> $GITHUB_OUTPUT + echo "No changesets found" + fi + + - name: Create or Update Release PR + if: steps.changesets.outputs.has_changesets == 'true' + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + # Configure git + git config --local user.email "action@github.com" + git config --local user.name "GitHub Action" + + # Check if release PR already exists + EXISTING_PR=$(gh pr list --state open --label "changeset-release" --json number --jq '.[0].number' || echo "") + + if [ -n "$EXISTING_PR" ]; then + echo "Existing release PR found: #$EXISTING_PR" + BRANCH_NAME=$(gh pr view $EXISTING_PR --json headRefName --jq '.headRefName') + + # Checkout existing branch + git fetch origin $BRANCH_NAME + git checkout $BRANCH_NAME + git merge main --no-edit + else + echo "Creating new release branch" + BRANCH_NAME="changeset-release/next" + git checkout -b $BRANCH_NAME + fi + + # Run version script + python .changeset/scripts/version.py + + # Run changelog script + python .changeset/scripts/changelog.py + + # Get the new version + NEW_VERSION=$(python -c "import tomllib; print(tomllib.load(open('pyproject.toml', 'rb'))['project']['version'])") + + # Commit changes + git add -A + git commit -m "Version packages to v${NEW_VERSION}" || echo "No changes to commit" + + # Push branch + git push origin $BRANCH_NAME --force + + # Create or update PR + if [ -n "$EXISTING_PR" ]; then + echo "Updating existing PR #$EXISTING_PR" + gh pr edit $EXISTING_PR \ + --title "Version Packages (v${NEW_VERSION})" \ + --body "$(cat < Date: Wed, 25 Jun 2025 17:05:04 -0700 Subject: [PATCH 12/19] add testing script for the changesets tool --- .changeset/CHANGESET_TESTING.md | 122 ++++++++++ test_changesets.py | 405 ++++++++++++++++++++++++++++++++ 2 files changed, 527 insertions(+) create mode 100644 .changeset/CHANGESET_TESTING.md create mode 100755 test_changesets.py diff --git a/.changeset/CHANGESET_TESTING.md b/.changeset/CHANGESET_TESTING.md new file mode 100644 index 00000000..d4a9b995 --- /dev/null +++ b/.changeset/CHANGESET_TESTING.md @@ -0,0 +1,122 @@ +# Testing the Changeset System + +This document explains how to test the changeset system implementation. + +## Quick Test + +Run the automated test suite: +```bash +./test_changesets.py +# or +python3 test_changesets.py +``` + +This will: +1. Backup your current files +2. Test all components of the changeset system +3. Restore your files to their original state +4. Show a summary of test results + +## Manual Testing + +### 1. Create a Changeset + +```bash +# Interactive mode +./changeset + +# Or with parameters +python3 .changeset/scripts/changeset.py --type patch --message "Fixed a bug" +``` + +### 2. Test Version Bumping + +```bash +# Dry run to see what would happen +python3 .changeset/scripts/version.py --dry-run + +# Actually bump the version +python3 .changeset/scripts/version.py +``` + +### 3. Test Changelog Generation + +```bash +# Dry run to preview changelog +python3 .changeset/scripts/changelog.py --dry-run + +# Generate changelog +python3 .changeset/scripts/changelog.py +``` + +### 4. Test Pre-commit Hooks + +```bash +# Validate changeset files +python3 .changeset/scripts/validate-changesets.py .changeset/*.md + +# Check if changeset exists (will fail on main branch) +python3 .changeset/scripts/check-changeset.py +``` + +## GitHub Actions Testing + +The GitHub Actions will trigger when: +1. **Push to main**: Creates/updates a "Version Packages" PR +2. **Merge version PR**: Publishes to PyPI and creates GitHub release + +To test locally: +1. Create a feature branch +2. Make some changes +3. Create a changeset: `./changeset` +4. Commit and push +5. Open PR to main +6. When merged, the actions will run + +## Expected Behavior + +### Changeset Creation +- Creates a markdown file in `.changeset/` with a random name +- File contains package name, change type, and description +- Shows changed files compared to main branch + +### Version Bumping +- Reads all changesets +- Determines version bump (major > minor > patch) +- Updates version in `pyproject.toml` and `__init__.py` +- Archives processed changesets +- Creates data file for changelog generation + +### Changelog Generation +- Reads changeset data from version script +- Generates formatted changelog entry +- Updates CHANGELOG.md with new version section +- Groups changes by type (major/minor/patch) + +### GitHub Actions +- `changesets.yml`: Runs on push to main +- `changeset-publish.yml`: Runs when version PR is merged +- Creates PR with version bumps and changelog updates +- Publishes to PyPI when PR is merged + +## Troubleshooting + +### "No changeset found" error +- Make sure you're not on main/master branch +- Create a changeset with `./changeset` +- Check `.changeset/` directory for `.md` files + +### Version not bumping correctly +- Check `.changeset/config.json` is valid JSON +- Ensure changesets have correct format +- Look for error messages in script output + +### Changelog not generating +- Make sure version script ran first +- Check for `.changeset/.changeset-data.json` +- Verify CHANGELOG.md exists or will be created + +### Pre-commit hooks not working +- Install pre-commit: `pip install pre-commit` +- Set up hooks: `pre-commit install` +- Run manually: `pre-commit run --all-files` \ No newline at end of file diff --git a/test_changesets.py b/test_changesets.py new file mode 100755 index 00000000..f6c533bd --- /dev/null +++ b/test_changesets.py @@ -0,0 +1,405 @@ +#!/usr/bin/env python3 +""" +Test script for the changeset system. +Run this to validate all components work correctly. +""" + +import json +import os +import shutil +import subprocess +import sys +from pathlib import Path +from datetime import datetime + +# Colors for output +GREEN = "\033[92m" +RED = "\033[91m" +YELLOW = "\033[93m" +BLUE = "\033[94m" +RESET = "\033[0m" + + +def print_header(message): + """Print a formatted header.""" + print(f"\n{BLUE}{'=' * 60}{RESET}") + print(f"{BLUE}{message}{RESET}") + print(f"{BLUE}{'=' * 60}{RESET}") + + +def print_success(message): + """Print success message.""" + print(f"{GREEN}āœ“ {message}{RESET}") + + +def print_error(message): + """Print error message.""" + print(f"{RED}āœ— {message}{RESET}") + + +def print_info(message): + """Print info message.""" + print(f"{YELLOW}ℹ {message}{RESET}") + + +def run_command(cmd, capture_output=True): + """Run a shell command and return result.""" + print(f" Running: {cmd}") + if capture_output: + result = subprocess.run(cmd, shell=True, capture_output=True, text=True) + if result.returncode != 0: + print(f" Error: {result.stderr}") + return result + else: + return subprocess.run(cmd, shell=True) + + +def backup_files(): + """Backup important files before testing.""" + files_to_backup = [ + "pyproject.toml", + "stagehand/__init__.py", + "CHANGELOG.md", + ".changeset/config.json" + ] + + backup_dir = Path(".changeset-test-backup") + backup_dir.mkdir(exist_ok=True) + + for file in files_to_backup: + if Path(file).exists(): + dest = backup_dir / file.replace("/", "_") + shutil.copy2(file, dest) + print_info(f"Backed up {file}") + + return backup_dir + + +def restore_files(backup_dir): + """Restore backed up files.""" + files_to_restore = [ + ("pyproject.toml", "pyproject.toml"), + ("stagehand___init__.py", "stagehand/__init__.py"), + ("CHANGELOG.md", "CHANGELOG.md"), + (".changeset_config.json", ".changeset/config.json") + ] + + for backup_name, original_path in files_to_restore: + backup_file = backup_dir / backup_name + if backup_file.exists(): + shutil.copy2(backup_file, original_path) + print_info(f"Restored {original_path}") + + # Clean up backup directory + shutil.rmtree(backup_dir) + + +def test_changeset_creation(): + """Test creating changesets.""" + print_header("Testing Changeset Creation") + + # Test 1: Create patch changeset + print("\nTest 1: Creating patch changeset...") + result = run_command( + 'python3 .changeset/scripts/changeset.py --type patch --message "Test patch change"' + ) + if result.returncode == 0: + print_success("Patch changeset created successfully") + else: + print_error("Failed to create patch changeset") + return False + + # Test 2: Create minor changeset + print("\nTest 2: Creating minor changeset...") + result = run_command( + 'python3 .changeset/scripts/changeset.py --type minor --message "Test minor feature"' + ) + if result.returncode == 0: + print_success("Minor changeset created successfully") + else: + print_error("Failed to create minor changeset") + return False + + # Test 3: Create major changeset + print("\nTest 3: Creating major changeset...") + result = run_command( + 'python3 .changeset/scripts/changeset.py --type major --message "Test breaking change"' + ) + if result.returncode == 0: + print_success("Major changeset created successfully") + else: + print_error("Failed to create major changeset") + return False + + # Check changesets were created + changesets = list(Path(".changeset").glob("*.md")) + changesets = [cs for cs in changesets if cs.name != "README.md"] + + if len(changesets) >= 3: + print_success(f"Found {len(changesets)} changesets") + for cs in changesets: + print(f" - {cs.name}") + else: + print_error(f"Expected at least 3 changesets, found {len(changesets)}") + return False + + return True + + +def test_version_bumping(): + """Test version bumping logic.""" + print_header("Testing Version Bumping") + + # Get current version + with open("pyproject.toml") as f: + content = f.read() + import re + match = re.search(r'version = "([^"]+)"', content) + current_version = match.group(1) if match else "0.0.0" + + print(f"Current version: {current_version}") + + # Run version script (dry run first) + print("\nTest 1: Dry run version bump...") + result = run_command("python3 .changeset/scripts/version.py --dry-run") + if result.returncode == 0: + print_success("Dry run completed successfully") + # Check output for expected version bump + if "→" in result.stdout: + print(f" {result.stdout.split('→')[0].split('Version bump:')[1].strip()} → {result.stdout.split('→')[1].split()[0]}") + else: + print_error("Dry run failed") + return False + + # Run actual version bump + print("\nTest 2: Actual version bump...") + result = run_command("python3 .changeset/scripts/version.py") + if result.returncode == 0: + print_success("Version bump completed successfully") + + # Verify files were updated + with open("pyproject.toml") as f: + content = f.read() + match = re.search(r'version = "([^"]+)"', content) + new_version = match.group(1) if match else "0.0.0" + + print(f" New version in pyproject.toml: {new_version}") + + # Check __init__.py + with open("stagehand/__init__.py") as f: + content = f.read() + if f'__version__ = "{new_version}"' in content: + print_success("Version updated in __init__.py") + else: + print_error("Version not updated correctly in __init__.py") + return False + + # Check if changesets were archived + archive_dir = Path(".changeset/archive") / new_version + if archive_dir.exists(): + archived = list(archive_dir.glob("*.md")) + print_success(f"Changesets archived ({len(archived)} files)") + else: + print_error("Changesets not archived") + return False + + else: + print_error("Version bump failed") + return False + + return True + + +def test_changelog_generation(): + """Test changelog generation.""" + print_header("Testing Changelog Generation") + + # Check if changeset data exists + data_file = Path(".changeset/.changeset-data.json") + if not data_file.exists(): + print_error("No changeset data file found") + return False + + # Test dry run + print("\nTest 1: Dry run changelog generation...") + result = run_command("python3 .changeset/scripts/changelog.py --dry-run") + if result.returncode == 0: + print_success("Dry run completed successfully") + # Show preview + if "Generated changelog entry:" in result.stdout: + print("\nChangelog preview:") + lines = result.stdout.split("\n") + in_preview = False + for line in lines: + if "Generated changelog entry:" in line: + in_preview = True + elif "-" * 60 in line: + if in_preview: + break + elif in_preview: + print(f" {line}") + else: + print_error("Dry run failed") + return False + + # Test actual changelog generation + print("\nTest 2: Actual changelog generation...") + result = run_command("python3 .changeset/scripts/changelog.py") + if result.returncode == 0: + print_success("Changelog generation completed successfully") + + # Verify CHANGELOG.md was updated + if Path("CHANGELOG.md").exists(): + with open("CHANGELOG.md") as f: + content = f.read() + if "## [" in content: + print_success("CHANGELOG.md updated with new version") + # Show first few lines of new entry + lines = content.split("\n") + print("\nNew changelog entry:") + for i, line in enumerate(lines): + if line.startswith("## ["): + for j in range(min(10, len(lines) - i)): + print(f" {lines[i + j]}") + break + else: + print_error("CHANGELOG.md not updated correctly") + return False + else: + print_error("CHANGELOG.md not found") + return False + else: + print_error("Changelog generation failed") + return False + + return True + + +def test_pre_commit_hooks(): + """Test pre-commit hook scripts.""" + print_header("Testing Pre-commit Hooks") + + # Test changeset validation + print("\nTest 1: Validating changeset files...") + + # Create a valid changeset for testing + valid_changeset = Path(".changeset/test-valid.md") + valid_changeset.write_text("""--- +"stagehand": patch +--- + +Test changeset for validation +""") + + result = run_command(f"python3 .changeset/scripts/validate-changesets.py {valid_changeset}") + if result.returncode == 0: + print_success("Valid changeset passed validation") + else: + print_error("Valid changeset failed validation") + valid_changeset.unlink() + return False + + # Create an invalid changeset + invalid_changeset = Path(".changeset/test-invalid.md") + invalid_changeset.write_text("""--- +invalid format +--- +""") + + result = run_command(f"python3 .changeset/scripts/validate-changesets.py {invalid_changeset}") + if result.returncode != 0: + print_success("Invalid changeset correctly rejected") + else: + print_error("Invalid changeset was not rejected") + valid_changeset.unlink() + invalid_changeset.unlink() + return False + + # Clean up test files + valid_changeset.unlink() + invalid_changeset.unlink() + + # Test changeset check (will fail on main branch, which is expected) + print("\nTest 2: Testing changeset check...") + result = run_command("python3 .changeset/scripts/check-changeset.py") + print_info("Changeset check completed (may fail on main branch - that's expected)") + + return True + + +def main(): + """Run all tests.""" + print_header("Changeset System Test Suite") + print_info("This will test the changeset system components") + print_info("Original files will be backed up and restored") + + # Check we're in the right directory + if not Path(".changeset/config.json").exists(): + print_error("Not in project root directory (no .changeset/config.json found)") + sys.exit(1) + + # Backup files + print("\nBacking up files...") + backup_dir = backup_files() + + try: + # Run tests + tests = [ + ("Changeset Creation", test_changeset_creation), + ("Version Bumping", test_version_bumping), + ("Changelog Generation", test_changelog_generation), + ("Pre-commit Hooks", test_pre_commit_hooks), + ] + + results = [] + for name, test_func in tests: + try: + result = test_func() + results.append((name, result)) + except Exception as e: + print_error(f"Exception in {name}: {e}") + results.append((name, False)) + + # Summary + print_header("Test Summary") + passed = sum(1 for _, result in results if result) + total = len(results) + + for name, result in results: + if result: + print_success(f"{name}: PASSED") + else: + print_error(f"{name}: FAILED") + + print(f"\nTotal: {passed}/{total} tests passed") + + if passed == total: + print_success("\nAll tests passed! šŸŽ‰") + else: + print_error(f"\n{total - passed} tests failed") + + finally: + # Restore files + print("\nRestoring original files...") + restore_files(backup_dir) + + # Clean up any remaining test changesets + for cs in Path(".changeset").glob("*.md"): + if cs.name not in ["README.md", "wild-rivers-sing.md"]: # Keep the original one + cs.unlink() + + # Clean up archive directory if it exists + archive_dir = Path(".changeset/archive") + if archive_dir.exists(): + shutil.rmtree(archive_dir) + + # Clean up changeset data file + data_file = Path(".changeset/.changeset-data.json") + if data_file.exists(): + data_file.unlink() + + print_success("Cleanup completed") + + +if __name__ == "__main__": + main() \ No newline at end of file From d165779997eabbb1ef25be57e6c7341999a33336 Mon Sep 17 00:00:00 2001 From: Roaring <216452114+the-roaring@users.noreply.github.com> Date: Fri, 27 Jun 2025 19:13:18 -0700 Subject: [PATCH 13/19] revert python-semantic-release related changes This reverts commit e35223bf30b6d09aa49f70f54aec8c0a5b1d6516. --- .bumpversion.cfg | 12 + .changeset/CHANGESET_TESTING.md | 122 ------- .changeset/README.md | 64 ---- .changeset/config.json | 26 -- .changeset/hard-rivers-dance.md | 5 - .changeset/scripts/changelog.py | 251 -------------- .changeset/scripts/changeset.py | 176 ---------- .changeset/scripts/check-changeset.py | 101 ------ .changeset/scripts/validate-changesets.py | 83 ----- .changeset/scripts/version.py | 253 -------------- .github/workflows/changeset-publish.yml | 67 ---- .github/workflows/changesets.yml | 129 ------- .github/workflows/release.yml | 157 --------- .gitignore | 4 +- CHANGELOG.md | 22 -- changeset | 4 - pyproject.toml | 112 +----- stagehand/__init__.py | 2 +- test_changesets.py | 405 ---------------------- 19 files changed, 25 insertions(+), 1970 deletions(-) create mode 100644 .bumpversion.cfg delete mode 100644 .changeset/CHANGESET_TESTING.md delete mode 100644 .changeset/README.md delete mode 100644 .changeset/config.json delete mode 100644 .changeset/hard-rivers-dance.md delete mode 100755 .changeset/scripts/changelog.py delete mode 100755 .changeset/scripts/changeset.py delete mode 100755 .changeset/scripts/check-changeset.py delete mode 100755 .changeset/scripts/validate-changesets.py delete mode 100755 .changeset/scripts/version.py delete mode 100644 .github/workflows/changeset-publish.yml delete mode 100644 .github/workflows/changesets.yml delete mode 100644 .github/workflows/release.yml delete mode 100644 CHANGELOG.md delete mode 100755 changeset delete mode 100755 test_changesets.py diff --git a/.bumpversion.cfg b/.bumpversion.cfg new file mode 100644 index 00000000..d1c662bc --- /dev/null +++ b/.bumpversion.cfg @@ -0,0 +1,12 @@ +[bumpversion] +current_version = 0.0.1 +commit = True +tag = True + +[bumpversion:file:pyproject.toml] +search = version = "{current_version}" +replace = version = "{new_version}" + +[bumpversion:file:stagehand/__init__.py] +search = __version__ = "{current_version}" +replace = __version__ = "{new_version}" \ No newline at end of file diff --git a/.changeset/CHANGESET_TESTING.md b/.changeset/CHANGESET_TESTING.md deleted file mode 100644 index d4a9b995..00000000 --- a/.changeset/CHANGESET_TESTING.md +++ /dev/null @@ -1,122 +0,0 @@ -# Testing the Changeset System - -This document explains how to test the changeset system implementation. - -## Quick Test - -Run the automated test suite: -```bash -./test_changesets.py -# or -python3 test_changesets.py -``` - -This will: -1. Backup your current files -2. Test all components of the changeset system -3. Restore your files to their original state -4. Show a summary of test results - -## Manual Testing - -### 1. Create a Changeset - -```bash -# Interactive mode -./changeset - -# Or with parameters -python3 .changeset/scripts/changeset.py --type patch --message "Fixed a bug" -``` - -### 2. Test Version Bumping - -```bash -# Dry run to see what would happen -python3 .changeset/scripts/version.py --dry-run - -# Actually bump the version -python3 .changeset/scripts/version.py -``` - -### 3. Test Changelog Generation - -```bash -# Dry run to preview changelog -python3 .changeset/scripts/changelog.py --dry-run - -# Generate changelog -python3 .changeset/scripts/changelog.py -``` - -### 4. Test Pre-commit Hooks - -```bash -# Validate changeset files -python3 .changeset/scripts/validate-changesets.py .changeset/*.md - -# Check if changeset exists (will fail on main branch) -python3 .changeset/scripts/check-changeset.py -``` - -## GitHub Actions Testing - -The GitHub Actions will trigger when: -1. **Push to main**: Creates/updates a "Version Packages" PR -2. **Merge version PR**: Publishes to PyPI and creates GitHub release - -To test locally: -1. Create a feature branch -2. Make some changes -3. Create a changeset: `./changeset` -4. Commit and push -5. Open PR to main -6. When merged, the actions will run - -## Expected Behavior - -### Changeset Creation -- Creates a markdown file in `.changeset/` with a random name -- File contains package name, change type, and description -- Shows changed files compared to main branch - -### Version Bumping -- Reads all changesets -- Determines version bump (major > minor > patch) -- Updates version in `pyproject.toml` and `__init__.py` -- Archives processed changesets -- Creates data file for changelog generation - -### Changelog Generation -- Reads changeset data from version script -- Generates formatted changelog entry -- Updates CHANGELOG.md with new version section -- Groups changes by type (major/minor/patch) - -### GitHub Actions -- `changesets.yml`: Runs on push to main -- `changeset-publish.yml`: Runs when version PR is merged -- Creates PR with version bumps and changelog updates -- Publishes to PyPI when PR is merged - -## Troubleshooting - -### "No changeset found" error -- Make sure you're not on main/master branch -- Create a changeset with `./changeset` -- Check `.changeset/` directory for `.md` files - -### Version not bumping correctly -- Check `.changeset/config.json` is valid JSON -- Ensure changesets have correct format -- Look for error messages in script output - -### Changelog not generating -- Make sure version script ran first -- Check for `.changeset/.changeset-data.json` -- Verify CHANGELOG.md exists or will be created - -### Pre-commit hooks not working -- Install pre-commit: `pip install pre-commit` -- Set up hooks: `pre-commit install` -- Run manually: `pre-commit run --all-files` \ No newline at end of file diff --git a/.changeset/README.md b/.changeset/README.md deleted file mode 100644 index 75a5001d..00000000 --- a/.changeset/README.md +++ /dev/null @@ -1,64 +0,0 @@ -# Changesets - -This directory contains changeset files that track changes to the codebase. The changeset system is inspired by the JavaScript changesets tool but adapted for Python projects. - -## How it works - -1. **Creating a changeset**: When you make changes that should be included in the changelog, run: - ```bash - python .changeset/scripts/changeset.py - # or use the wrapper script: - ./changeset - ``` - - This will prompt you to: - - Select the type of change (major, minor, or patch) - - Provide a description of the change - - A markdown file will be created in this directory with a random name like `warm-chefs-sell.md`. - -2. **Version bumping**: The GitHub Action will automatically: - - Detect changesets in PRs to main - - Create or update a "Version Packages" PR - - Bump the version based on the changesets - - Update the CHANGELOG.md - -3. **Publishing**: When the "Version Packages" PR is merged: - - The package is automatically published to PyPI - - A GitHub release is created - - The changesets are archived - -## Changeset format - -Each changeset file looks like: -```markdown ---- -"stagehand": patch ---- - -Fixed a bug in the browser automation logic -``` - -## Configuration - -The changeset behavior is configured in `.changeset/config.json`: -- `baseBranch`: The branch to compare against (usually "main") -- `changeTypes`: Definitions for major, minor, and patch changes -- `package`: Package-specific configuration - -## Best practices - -1. Create a changeset for every user-facing change -2. Use clear, concise descriptions -3. Choose the appropriate change type: - - `patch`: Bug fixes and small improvements - - `minor`: New features that are backwards compatible - - `major`: Breaking changes - -## Workflow - -1. Make your code changes -2. Run `./changeset` to create a changeset -3. Commit both your code changes and the changeset file -4. Open a PR -5. The changeset will be processed when the PR is merged to main \ No newline at end of file diff --git a/.changeset/config.json b/.changeset/config.json deleted file mode 100644 index 92bb6c30..00000000 --- a/.changeset/config.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "baseBranch": "main", - "changelogFormat": "markdown", - "access": "public", - "commit": false, - "changeTypes": { - "major": { - "description": "Breaking changes", - "emoji": "šŸ’„" - }, - "minor": { - "description": "New features", - "emoji": "✨" - }, - "patch": { - "description": "Bug fixes", - "emoji": "šŸ›" - } - }, - "package": { - "name": "stagehand", - "versionPath": "stagehand/__init__.py", - "versionPattern": "__version__ = \"(.*)\"", - "pyprojectPath": "pyproject.toml" - } -} \ No newline at end of file diff --git a/.changeset/hard-rivers-dance.md b/.changeset/hard-rivers-dance.md deleted file mode 100644 index 44a27665..00000000 --- a/.changeset/hard-rivers-dance.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"stagehand": patch ---- - -Test manual changeset creation diff --git a/.changeset/scripts/changelog.py b/.changeset/scripts/changelog.py deleted file mode 100755 index 26bbd04a..00000000 --- a/.changeset/scripts/changelog.py +++ /dev/null @@ -1,251 +0,0 @@ -#!/usr/bin/env python3 -""" -Changelog generation script - Generates changelog from processed changesets. -""" - -import json -import os -import re -import subprocess -import sys -from datetime import datetime -from pathlib import Path -from typing import Dict, List, Optional - -import click - - -CHANGESET_DIR = Path(".changeset") -CONFIG_FILE = CHANGESET_DIR / "config.json" -CHANGELOG_FILE = Path("CHANGELOG.md") - - -def load_config() -> Dict: - """Load changeset configuration.""" - if not CONFIG_FILE.exists(): - click.echo(click.style("āŒ No changeset config found.", fg="red")) - sys.exit(1) - - with open(CONFIG_FILE) as f: - return json.load(f) - - -def load_changeset_data() -> Optional[Dict]: - """Load processed changeset data.""" - data_file = CHANGESET_DIR / ".changeset-data.json" - - if not data_file.exists(): - return None - - with open(data_file) as f: - data = json.load(f) - - # Set current date if not set - if data.get("date") is None: - data["date"] = datetime.now().strftime("%Y-%m-%d") - - return data - - -def get_pr_info() -> Optional[Dict[str, str]]: - """Get PR information if available.""" - try: - # Try to get PR info from GitHub context (in Actions) - pr_number = os.environ.get("GITHUB_PR_NUMBER") - if pr_number: - return { - "number": pr_number, - "url": f"https://github.com/{os.environ.get('GITHUB_REPOSITORY')}/pull/{pr_number}" - } - - # Try to get from git branch name (if it contains PR number) - result = subprocess.run( - ["git", "branch", "--show-current"], - capture_output=True, - text=True - ) - - if result.returncode == 0: - branch = result.stdout.strip() - # Look for patterns like "pr-123" or "pull/123" - match = re.search(r'(?:pr|pull)[/-](\d+)', branch, re.IGNORECASE) - if match: - pr_number = match.group(1) - # Try to get repo info - repo_result = subprocess.run( - ["git", "remote", "get-url", "origin"], - capture_output=True, - text=True - ) - if repo_result.returncode == 0: - repo_url = repo_result.stdout.strip() - # Extract owner/repo from URL - match = re.search(r'github\.com[:/]([^/]+/[^/]+?)(?:\.git)?$', repo_url) - if match: - repo = match.group(1) - return { - "number": pr_number, - "url": f"https://github.com/{repo}/pull/{pr_number}" - } - except Exception: - pass - - return None - - -def format_changelog_entry(entry: Dict, config: Dict) -> str: - """Format a single changelog entry.""" - change_type = entry["type"] - description = entry["description"] - - # Get emoji if configured - emoji = config["changeTypes"].get(change_type, {}).get("emoji", "") - - # Format entry - if emoji: - line = f"- {emoji} **{change_type}**: {description}" - else: - line = f"- **{change_type}**: {description}" - - return line - - -def generate_version_section(data: Dict, config: Dict) -> str: - """Generate changelog section for a version.""" - version = data["version"] - date = data["date"] - entries = data["entries"] - - # Start with version header - section = f"## [{version}] - {date}\n\n" - - # Get PR info if available - pr_info = get_pr_info() - if pr_info: - section += f"[View Pull Request]({pr_info['url']})\n\n" - - # Group entries by type - grouped = {} - for entry in entries: - change_type = entry["type"] - if change_type not in grouped: - grouped[change_type] = [] - grouped[change_type].append(entry) - - # Add entries by type (in order: major, minor, patch) - type_order = ["major", "minor", "patch"] - - for change_type in type_order: - if change_type in grouped: - type_info = config["changeTypes"].get(change_type, {}) - type_name = type_info.get("description", change_type.capitalize()) - - section += f"### {type_name}\n\n" - - for entry in grouped[change_type]: - section += format_changelog_entry(entry, config) + "\n" - - section += "\n" - - return section.strip() + "\n" - - -def update_changelog(new_section: str, version: str): - """Update the changelog file with new section.""" - if CHANGELOG_FILE.exists(): - with open(CHANGELOG_FILE) as f: - current_content = f.read() - else: - # Create new changelog with header - current_content = """# Changelog - -All notable changes to this project will be documented in this file. - -The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), -and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - -""" - - # Check if version already exists - if f"## [{version}]" in current_content: - click.echo(click.style(f"āš ļø Version {version} already exists in changelog", fg="yellow")) - return False - - # Find where to insert (after the header, before first version) - lines = current_content.split("\n") - insert_index = None - - # Look for first version entry or end of header - for i, line in enumerate(lines): - if line.startswith("## ["): - insert_index = i - break - - if insert_index is None: - # No versions yet, add at the end - new_content = current_content.rstrip() + "\n\n" + new_section + "\n" - else: - # Insert before first version - lines.insert(insert_index, new_section) - lines.insert(insert_index + 1, "") # Add blank line - new_content = "\n".join(lines) - - # Write updated changelog - with open(CHANGELOG_FILE, "w") as f: - f.write(new_content) - - return True - - -@click.command() -@click.option("--dry-run", is_flag=True, help="Show what would be added without making changes") -@click.option("--date", help="Override the date (YYYY-MM-DD format)") -def main(dry_run: bool, date: Optional[str]): - """Generate changelog from processed changesets.""" - - click.echo(click.style("šŸ“œ Generating changelog...\n", fg="cyan", bold=True)) - - config = load_config() - data = load_changeset_data() - - if not data: - click.echo(click.style("No changeset data found. Run version script first!", fg="red")) - return - - # Override date if provided - if date: - data["date"] = date - - # Generate changelog section - new_section = generate_version_section(data, config) - - click.echo(click.style("Generated changelog entry:", fg="green")) - click.echo("-" * 60) - click.echo(new_section) - click.echo("-" * 60) - - if dry_run: - click.echo(click.style("\nšŸ” Dry run - no changes made", fg="yellow")) - return - - # Update changelog file - if update_changelog(new_section, data["version"]): - click.echo(click.style(f"\nāœ… Updated {CHANGELOG_FILE}", fg="green", bold=True)) - - # Clean up data file - data_file = CHANGESET_DIR / ".changeset-data.json" - if data_file.exists(): - os.remove(data_file) - else: - click.echo(click.style("\nāŒ Failed to update changelog", fg="red")) - return - - # Show next steps - click.echo(click.style("\nšŸ“ Next steps:", fg="yellow")) - click.echo(" 1. Review the updated CHANGELOG.md") - click.echo(" 2. Commit the version and changelog changes") - click.echo(" 3. Create a pull request for the release") - - -if __name__ == "__main__": - main() \ No newline at end of file diff --git a/.changeset/scripts/changeset.py b/.changeset/scripts/changeset.py deleted file mode 100755 index b63c6678..00000000 --- a/.changeset/scripts/changeset.py +++ /dev/null @@ -1,176 +0,0 @@ -#!/usr/bin/env python3 -""" -Changeset CLI - Interactive tool for creating changeset files. -Similar to JavaScript changesets but for Python projects. -""" - -import json -import os -import random -import string -import subprocess -import sys -from datetime import datetime -from pathlib import Path -from typing import Dict, List, Optional - -import click -import git - - -CHANGESET_DIR = Path(".changeset") -CONFIG_FILE = CHANGESET_DIR / "config.json" - - -def load_config() -> Dict: - """Load changeset configuration.""" - if not CONFIG_FILE.exists(): - click.echo(click.style("āŒ No changeset config found. Please run from project root.", fg="red")) - sys.exit(1) - - with open(CONFIG_FILE) as f: - return json.load(f) - - -def get_changed_files() -> List[str]: - """Get list of changed files compared to base branch.""" - config = load_config() - base_branch = config.get("baseBranch", "main") - - try: - repo = git.Repo(".") - - # Get current branch - current_branch = repo.active_branch.name - - # Get diff between current branch and base - diff_output = repo.git.diff(f"{base_branch}...HEAD", "--name-only") - - if not diff_output: - return [] - - return diff_output.strip().split("\n") - except Exception as e: - click.echo(click.style(f"Error getting changed files: {e}", fg="yellow")) - return [] - - -def generate_changeset_name() -> str: - """Generate a random changeset filename like 'warm-chefs-sell'.""" - adjectives = [ - "warm", "cool", "fast", "slow", "bright", "dark", "soft", "hard", - "sweet", "sour", "fresh", "stale", "new", "old", "big", "small", - "happy", "sad", "brave", "shy", "clever", "silly", "calm", "wild" - ] - - nouns = [ - "dogs", "cats", "birds", "fish", "lions", "bears", "rabbits", "foxes", - "chefs", "artists", "writers", "singers", "dancers", "actors", "poets", "musicians", - "stars", "moons", "suns", "clouds", "rivers", "mountains", "oceans", "forests" - ] - - verbs = [ - "run", "jump", "sing", "dance", "write", "paint", "cook", "bake", - "sell", "buy", "trade", "share", "give", "take", "make", "break", - "fly", "swim", "walk", "talk", "think", "dream", "play", "work" - ] - - return f"{random.choice(adjectives)}-{random.choice(nouns)}-{random.choice(verbs)}" - - -def create_changeset(change_type: str, description: str) -> str: - """Create a changeset file and return its path.""" - config = load_config() - package_name = config["package"]["name"] - - # Generate filename - filename = f"{generate_changeset_name()}.md" - filepath = CHANGESET_DIR / filename - - # Create changeset content - content = f"""--- -"{package_name}": {change_type} ---- - -{description} -""" - - with open(filepath, "w") as f: - f.write(content) - - return str(filepath) - - -@click.command() -@click.option("--type", type=click.Choice(["major", "minor", "patch"]), help="Change type (if not provided, will prompt)") -@click.option("--message", "-m", help="Change description (if not provided, will prompt)") -def main(type: Optional[str], message: Optional[str]): - """Create a new changeset for tracking changes.""" - - click.echo(click.style("šŸ¦‹ Creating a new changeset...\n", fg="cyan", bold=True)) - - # Check for changed files - changed_files = get_changed_files() - - if changed_files: - click.echo(click.style("šŸ“ Changed files detected:", fg="green")) - for file in changed_files[:10]: # Show first 10 files - click.echo(f" • {file}") - if len(changed_files) > 10: - click.echo(f" ... and {len(changed_files) - 10} more files") - click.echo() - - # Load config for change types - config = load_config() - change_types = config.get("changeTypes", {}) - - # Prompt for change type if not provided - if not type: - click.echo(click.style("What kind of change is this?", fg="yellow", bold=True)) - - choices = [] - for ct, info in change_types.items(): - emoji = info.get("emoji", "") - desc = info.get("description", ct) - choices.append(f"{emoji} {ct} - {desc}") - - for i, choice in enumerate(choices, 1): - click.echo(f" {i}) {choice}") - - choice_num = click.prompt("\nSelect change type", type=int) - - if 1 <= choice_num <= len(change_types): - type = list(change_types.keys())[choice_num - 1] - else: - click.echo(click.style("Invalid choice!", fg="red")) - sys.exit(1) - - # Prompt for description if not provided - if not message: - click.echo(click.style("\nšŸ“ Please describe the change:", fg="yellow", bold=True)) - click.echo(click.style("(This will be used in the changelog)", fg="bright_black")) - - message = click.prompt("Description", type=str) - - if not message.strip(): - click.echo(click.style("Description cannot be empty!", fg="red")) - sys.exit(1) - - # Create the changeset - changeset_path = create_changeset(type, message.strip()) - - click.echo(click.style(f"\nāœ… Changeset created: {changeset_path}", fg="green", bold=True)) - - # Show preview - click.echo(click.style("\nPreview:", fg="cyan")) - with open(changeset_path) as f: - content = f.read() - for line in content.split("\n"): - if line.strip(): - click.echo(f" {line}") - - click.echo(click.style("\nšŸ’” Tip: Commit this changeset with your changes!", fg="bright_black")) - - -if __name__ == "__main__": - main() \ No newline at end of file diff --git a/.changeset/scripts/check-changeset.py b/.changeset/scripts/check-changeset.py deleted file mode 100755 index 4483eaf4..00000000 --- a/.changeset/scripts/check-changeset.py +++ /dev/null @@ -1,101 +0,0 @@ -#!/usr/bin/env python3 -""" -Check if changeset exists for feature branches. -""" - -import os -import sys -from pathlib import Path - -import click -import git - - -CHANGESET_DIR = Path(".changeset") -SKIP_BRANCHES = ["main", "master", "develop", "release/*", "hotfix/*"] -SKIP_PREFIXES = ["chore/", "docs/", "test/", "ci/", "build/"] - - -def should_skip_branch(branch_name: str) -> bool: - """Check if branch should skip changeset requirement.""" - # Check exact matches - if branch_name in SKIP_BRANCHES: - return True - - # Check prefixes - for prefix in SKIP_PREFIXES: - if branch_name.startswith(prefix): - return True - - # Check patterns - for pattern in SKIP_BRANCHES: - if "*" in pattern: - import fnmatch - if fnmatch.fnmatch(branch_name, pattern): - return True - - return False - - -@click.command() -@click.option("--skip-ci", is_flag=True, help="Skip check in CI environment") -def main(skip_ci): - """Check if changeset exists for the current branch.""" - - # Skip in CI if requested - if skip_ci and os.environ.get("CI"): - click.echo("Skipping changeset check in CI") - sys.exit(0) - - try: - repo = git.Repo(".") - - # Get current branch - try: - current_branch = repo.active_branch.name - except TypeError: - # Detached HEAD state (common in CI) - click.echo("Skipping changeset check in detached HEAD state") - sys.exit(0) - - # Check if we should skip this branch - if should_skip_branch(current_branch): - click.echo(f"Skipping changeset check for branch: {current_branch}") - sys.exit(0) - - # Get uncommitted changeset files - uncommitted_changesets = [] - for item in repo.index.entries: - filepath = item[0] - if filepath.startswith(".changeset/") and filepath.endswith(".md") and "README" not in filepath: - uncommitted_changesets.append(filepath) - - # Get staged changeset files - staged_changesets = [] - diff = repo.index.diff("HEAD") - for item in diff: - if item.a_path and item.a_path.startswith(".changeset/") and item.a_path.endswith(".md") and "README" not in item.a_path: - staged_changesets.append(item.a_path) - - # Check if any changesets exist - if uncommitted_changesets or staged_changesets: - click.echo(f"āœ… Found changeset(s) for branch: {current_branch}") - if uncommitted_changesets: - click.echo(f" Uncommitted: {', '.join(uncommitted_changesets)}") - if staged_changesets: - click.echo(f" Staged: {', '.join(staged_changesets)}") - sys.exit(0) - else: - click.echo(f"āŒ No changeset found for feature branch: {current_branch}") - click.echo("šŸ’” Create a changeset by running: python .changeset/scripts/changeset.py") - click.echo(" Or use: ./changeset") - sys.exit(1) - - except Exception as e: - click.echo(f"Warning: Could not check for changesets: {e}") - # Don't fail on errors - sys.exit(0) - - -if __name__ == "__main__": - main() \ No newline at end of file diff --git a/.changeset/scripts/validate-changesets.py b/.changeset/scripts/validate-changesets.py deleted file mode 100755 index 75e4e185..00000000 --- a/.changeset/scripts/validate-changesets.py +++ /dev/null @@ -1,83 +0,0 @@ -#!/usr/bin/env python3 -""" -Validate changeset files format. -""" - -import re -import sys -from pathlib import Path - -import click - - -def validate_changeset_file(filepath: Path) -> bool: - """Validate a changeset file format.""" - try: - with open(filepath) as f: - content = f.read() - - lines = content.strip().split("\n") - - # Check frontmatter - if len(lines) < 3 or lines[0] != "---": - click.echo(f"āŒ {filepath}: Missing or invalid frontmatter start") - return False - - # Find end of frontmatter - end_idx = None - for i, line in enumerate(lines[1:], 1): - if line == "---": - end_idx = i - break - - if end_idx is None: - click.echo(f"āŒ {filepath}: Missing frontmatter end") - return False - - # Validate package and change type - found_valid_entry = False - for line in lines[1:end_idx]: - if line.strip(): - match = re.match(r'^"([^"]+)":\s*(major|minor|patch)$', line.strip()) - if match: - found_valid_entry = True - break - - if not found_valid_entry: - click.echo(f"āŒ {filepath}: Invalid package/change type format") - return False - - # Check for description - description = "\n".join(lines[end_idx + 1:]).strip() - if not description: - click.echo(f"āŒ {filepath}: Missing change description") - return False - - click.echo(f"āœ… {filepath}: Valid changeset") - return True - - except Exception as e: - click.echo(f"āŒ {filepath}: Error reading file: {e}") - return False - - -@click.command() -@click.argument('files', nargs=-1, type=click.Path(exists=True)) -def main(files): - """Validate changeset files.""" - if not files: - sys.exit(0) - - all_valid = True - for filepath in files: - path = Path(filepath) - if path.name != "README.md" and path.suffix == ".md": - if not validate_changeset_file(path): - all_valid = False - - if not all_valid: - sys.exit(1) - - -if __name__ == "__main__": - main() \ No newline at end of file diff --git a/.changeset/scripts/version.py b/.changeset/scripts/version.py deleted file mode 100755 index 4e405962..00000000 --- a/.changeset/scripts/version.py +++ /dev/null @@ -1,253 +0,0 @@ -#!/usr/bin/env python3 -""" -Version management script - Processes changesets and bumps version. -""" - -import json -import os -import re -import shutil -import sys -from pathlib import Path -from typing import Dict, List, Tuple - -import click - -# No TOML dependency needed - we'll use regex parsing - - -CHANGESET_DIR = Path(".changeset") -CONFIG_FILE = CHANGESET_DIR / "config.json" - - -def load_config() -> Dict: - """Load changeset configuration.""" - if not CONFIG_FILE.exists(): - click.echo(click.style("āŒ No changeset config found.", fg="red")) - sys.exit(1) - - with open(CONFIG_FILE) as f: - return json.load(f) - - -def parse_changeset(filepath: Path) -> Tuple[str, str, str]: - """Parse a changeset file and return (package, change_type, description).""" - with open(filepath) as f: - content = f.read() - - # Parse frontmatter - lines = content.strip().split("\n") - - if lines[0] != "---": - raise ValueError(f"Invalid changeset format in {filepath}") - - # Find end of frontmatter - end_idx = None - for i, line in enumerate(lines[1:], 1): - if line == "---": - end_idx = i - break - - if end_idx is None: - raise ValueError(f"Invalid changeset format in {filepath}") - - # Parse package and change type - for line in lines[1:end_idx]: - if line.strip(): - match = re.match(r'"(.+)":\s*(\w+)', line.strip()) - if match: - package = match.group(1) - change_type = match.group(2) - break - else: - raise ValueError(f"Could not parse package and change type from {filepath}") - - # Get description (everything after frontmatter) - description = "\n".join(lines[end_idx + 1:]).strip() - - return package, change_type, description - - -def get_changesets() -> List[Tuple[Path, str, str, str]]: - """Get all changeset files and parse them.""" - changesets = [] - - for filepath in CHANGESET_DIR.glob("*.md"): - if filepath.name == "README.md": - continue - - try: - package, change_type, description = parse_changeset(filepath) - changesets.append((filepath, package, change_type, description)) - except Exception as e: - click.echo(click.style(f"āš ļø Error parsing {filepath}: {e}", fg="yellow")) - - return changesets - - -def determine_version_bump(changesets: List[Tuple[Path, str, str, str]]) -> str: - """Determine the version bump type based on changesets.""" - has_major = any(ct == "major" for _, _, ct, _ in changesets) - has_minor = any(ct == "minor" for _, _, ct, _ in changesets) - - if has_major: - return "major" - elif has_minor: - return "minor" - else: - return "patch" - - -def parse_version(version_str: str) -> Tuple[int, int, int]: - """Parse semantic version string.""" - match = re.match(r"(\d+)\.(\d+)\.(\d+)", version_str) - if not match: - raise ValueError(f"Invalid version format: {version_str}") - - return int(match.group(1)), int(match.group(2)), int(match.group(3)) - - -def bump_version(current_version: str, bump_type: str) -> str: - """Bump version based on type.""" - major, minor, patch = parse_version(current_version) - - if bump_type == "major": - return f"{major + 1}.0.0" - elif bump_type == "minor": - return f"{major}.{minor + 1}.0" - else: # patch - return f"{major}.{minor}.{patch + 1}" - - - - -def update_pyproject_version(filepath: Path, new_version: str): - """Update version in pyproject.toml.""" - # Read the file as text to preserve formatting - with open(filepath) as f: - content = f.read() - - # Update version using regex - content = re.sub( - r'(version\s*=\s*")[^"]+(")', - f'\\g<1>{new_version}\\g<2>', - content - ) - - # Write back - with open(filepath, "w") as f: - f.write(content) - - -def get_current_version(config: Dict) -> str: - """Get current version from pyproject.toml.""" - pyproject_path = Path(config["package"]["pyprojectPath"]) - - with open(pyproject_path) as f: - content = f.read() - - # Extract version using regex - match = re.search(r'version\s*=\s*"([^"]+)"', content) - if match: - return match.group(1) - else: - raise ValueError("Could not find version in pyproject.toml") - - -@click.command() -@click.option("--dry-run", is_flag=True, help="Show what would be done without making changes") -@click.option("--skip-changelog", is_flag=True, help="Skip changelog generation") -def main(dry_run: bool, skip_changelog: bool): - """Process changesets and bump version.""" - - click.echo(click.style("šŸ“¦ Processing changesets...\n", fg="cyan", bold=True)) - - config = load_config() - changesets = get_changesets() - - if not changesets: - click.echo(click.style("No changesets found. Nothing to do!", fg="yellow")) - return - - # Show changesets - click.echo(click.style(f"Found {len(changesets)} changeset(s):", fg="green")) - for filepath, package, change_type, desc in changesets: - emoji = config["changeTypes"].get(change_type, {}).get("emoji", "") - desc_line = desc.split('\n')[0][:60] - click.echo(f" {emoji} {change_type}: {desc_line}...") - - # Determine version bump - bump_type = determine_version_bump(changesets) - current_version = get_current_version(config) - new_version = bump_version(current_version, bump_type) - - click.echo(f"\nšŸ“Š Version bump: {current_version} → {new_version} ({bump_type})") - - if dry_run: - click.echo(click.style("\nšŸ” Dry run - no changes made", fg="yellow")) - return - - # Update version in files - click.echo(click.style("\nšŸ“ Updating version files...", fg="cyan")) - - # Update __init__.py - version_file = Path(config["package"]["versionPath"]) - - with open(version_file) as f: - content = f.read() - - # Simple regex replacement for __version__ = "x.y.z" - content = re.sub( - r'(__version__\s*=\s*")[^"]+(")', - f'\\g<1>{new_version}\\g<2>', - content - ) - - with open(version_file, "w") as f: - f.write(content) - click.echo(f" āœ“ Updated {version_file}") - - # Update pyproject.toml - pyproject_path = Path(config["package"]["pyprojectPath"]) - update_pyproject_version(pyproject_path, new_version) - click.echo(f" āœ“ Updated {pyproject_path}") - - # Generate changelog entries - if not skip_changelog: - click.echo(click.style("\nšŸ“œ Generating changelog entries...", fg="cyan")) - - changelog_entries = [] - for filepath, package, change_type, desc in changesets: - changelog_entries.append({ - "type": change_type, - "description": desc, - "changeset": filepath.name - }) - - # Save changelog data for the changelog script - changelog_data = { - "version": new_version, - "previous_version": current_version, - "date": None, # Will be set by changelog script - "entries": changelog_entries - } - - with open(CHANGESET_DIR / ".changeset-data.json", "w") as f: - json.dump(changelog_data, f, indent=2) - - # Archive processed changesets - click.echo(click.style("\nšŸ—‚ļø Archiving changesets...", fg="cyan")) - - archive_dir = CHANGESET_DIR / "archive" / new_version - archive_dir.mkdir(parents=True, exist_ok=True) - - for filepath, _, _, _ in changesets: - shutil.move(str(filepath), str(archive_dir / filepath.name)) - click.echo(f" āœ“ Archived {filepath.name}") - - click.echo(click.style(f"\nāœ… Version bumped to {new_version}!", fg="green", bold=True)) - click.echo(click.style("šŸ“ Don't forget to run the changelog script next!", fg="yellow")) - - -if __name__ == "__main__": - main() \ No newline at end of file diff --git a/.github/workflows/changeset-publish.yml b/.github/workflows/changeset-publish.yml deleted file mode 100644 index a651d797..00000000 --- a/.github/workflows/changeset-publish.yml +++ /dev/null @@ -1,67 +0,0 @@ -name: Changeset Publish - -on: - pull_request: - types: [closed] - branches: - - main - -permissions: - contents: write - packages: write - -jobs: - publish: - if: github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'changeset-release') - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Set up Python - uses: actions/setup-python@v4 - with: - python-version: '3.11' - - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install build twine wheel setuptools - pip install -r requirements.txt - if [ -f requirements-dev.txt ]; then pip install -r requirements-dev.txt; fi - - - name: Get version - id: version - run: | - VERSION=$(python -c "import tomllib; print(tomllib.load(open('pyproject.toml', 'rb'))['project']['version'])") - echo "version=$VERSION" >> $GITHUB_OUTPUT - echo "Publishing version $VERSION" - - - name: Build package - run: | - python -m build - - - name: Upload to PyPI - env: - TWINE_USERNAME: __token__ - TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }} - run: | - twine upload dist/* - - - name: Create GitHub Release - uses: softprops/action-gh-release@v1 - with: - tag_name: v${{ steps.version.outputs.version }} - name: v${{ steps.version.outputs.version }} - body: | - See [CHANGELOG.md](https://github.com/${{ github.repository }}/blob/main/CHANGELOG.md) for details. - generate_release_notes: false - - - name: Create git tag - run: | - git config --local user.email "action@github.com" - git config --local user.name "GitHub Action" - git tag "v${{ steps.version.outputs.version }}" - git push origin "v${{ steps.version.outputs.version }}" \ No newline at end of file diff --git a/.github/workflows/changesets.yml b/.github/workflows/changesets.yml deleted file mode 100644 index 74ee0a8d..00000000 --- a/.github/workflows/changesets.yml +++ /dev/null @@ -1,129 +0,0 @@ -name: Changesets - -on: - push: - branches: - - main - -permissions: - contents: write - pull-requests: write - -jobs: - version: - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - with: - fetch-depth: 0 - token: ${{ secrets.GITHUB_TOKEN }} - - - name: Set up Python - uses: actions/setup-python@v4 - with: - python-version: '3.11' - - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install click toml gitpython - - - name: Check for changesets - id: changesets - run: | - if ls .changeset/*.md 2>/dev/null | grep -v README.md > /dev/null; then - echo "has_changesets=true" >> $GITHUB_OUTPUT - echo "Found changesets to process" - else - echo "has_changesets=false" >> $GITHUB_OUTPUT - echo "No changesets found" - fi - - - name: Create or Update Release PR - if: steps.changesets.outputs.has_changesets == 'true' - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - # Configure git - git config --local user.email "action@github.com" - git config --local user.name "GitHub Action" - - # Check if release PR already exists - EXISTING_PR=$(gh pr list --state open --label "changeset-release" --json number --jq '.[0].number' || echo "") - - if [ -n "$EXISTING_PR" ]; then - echo "Existing release PR found: #$EXISTING_PR" - BRANCH_NAME=$(gh pr view $EXISTING_PR --json headRefName --jq '.headRefName') - - # Checkout existing branch - git fetch origin $BRANCH_NAME - git checkout $BRANCH_NAME - git merge main --no-edit - else - echo "Creating new release branch" - BRANCH_NAME="changeset-release/next" - git checkout -b $BRANCH_NAME - fi - - # Run version script - python .changeset/scripts/version.py - - # Run changelog script - python .changeset/scripts/changelog.py - - # Get the new version - NEW_VERSION=$(python -c "import tomllib; print(tomllib.load(open('pyproject.toml', 'rb'))['project']['version'])") - - # Commit changes - git add -A - git commit -m "Version packages to v${NEW_VERSION}" || echo "No changes to commit" - - # Push branch - git push origin $BRANCH_NAME --force - - # Create or update PR - if [ -n "$EXISTING_PR" ]; then - echo "Updating existing PR #$EXISTING_PR" - gh pr edit $EXISTING_PR \ - --title "Version Packages (v${NEW_VERSION})" \ - --body "$(cat <&2 '%s\n' "::error::Unable to determine upstream branch name!" - exit 1 - fi - - git fetch "${UPSTREAM_BRANCH_NAME%%/*}" - - if ! UPSTREAM_SHA="$(git rev-parse "$UPSTREAM_BRANCH_NAME")"; then - printf >&2 '%s\n' "::error::Unable to determine upstream branch sha!" - exit 1 - fi - - HEAD_SHA="$(git rev-parse HEAD)" - - if [ "$HEAD_SHA" != "$UPSTREAM_SHA" ]; then - printf >&2 '%s\n' "[HEAD SHA] $HEAD_SHA != $UPSTREAM_SHA [UPSTREAM SHA]" - printf >&2 '%s\n' "::error::Upstream has changed, aborting release..." - exit 1 - fi - - printf '%s\n' "Verified upstream branch has not changed, continuing with release..." - - - name: Action | Semantic Version Release - id: release - uses: python-semantic-release/python-semantic-release@v10.1.0 - with: - github_token: ${{ secrets.GITHUB_TOKEN }} - git_committer_name: "github-actions" - git_committer_email: "actions@users.noreply.github.com" - - - name: Publish | Upload to GitHub Release Assets - uses: python-semantic-release/publish-action@v10.1.0 - if: steps.release.outputs.released == 'true' - with: - github_token: ${{ secrets.GITHUB_TOKEN }} - tag: ${{ steps.release.outputs.tag }} - - - name: Upload | Distribution Artifacts - uses: actions/upload-artifact@v4 - with: - name: distribution-artifacts - path: dist - if-no-files-found: error - - deploy: - # 1. Separate out the deploy step from the publish step to run each step at - # the least amount of token privilege - # 2. Also, deployments can fail, and its better to have a separate job if you need to retry - # and it won't require reversing the release. - runs-on: ubuntu-latest - needs: release - if: ${{ needs.release.outputs.released == 'true' }} - - permissions: - contents: read - id-token: write - - steps: - - name: Setup | Download Build Artifacts - uses: actions/download-artifact@v4 - id: artifact-download - with: - name: distribution-artifacts - path: dist - - # TODO set up trusted publisher - # see https://docs.pypi.org/trusted-publishers/ - - name: Publish package distributions to PyPI - uses: pypa/gh-action-pypi-publish@release/v1 - with: - packages-dir: dist - print-hash: true - verbose: true diff --git a/.gitignore b/.gitignore index cac49d78..1ca635a6 100644 --- a/.gitignore +++ b/.gitignore @@ -94,7 +94,7 @@ dmypy.json .vscode/ # Local scripts -/scripts/ +scripts/ # Logs -*.log +*.log \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index 61d0dd44..00000000 --- a/CHANGELOG.md +++ /dev/null @@ -1,22 +0,0 @@ -# Changelog - -All notable changes to this project will be documented in this file. - -The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), -and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - -## [1.0.0] - 2025-06-25 - -### Breaking changes - -- šŸ’„ **major**: Test breaking change - -### New features - -- ✨ **minor**: Test minor feature - -### Bug fixes - -- šŸ› **patch**: Test patch change -- šŸ› **patch**: Set up changeset-like system for automated changelog generation and version bumping - diff --git a/changeset b/changeset deleted file mode 100755 index 9b51864b..00000000 --- a/changeset +++ /dev/null @@ -1,4 +0,0 @@ -#!/bin/bash -# Wrapper script for changeset CLI - -python3 .changeset/scripts/changeset.py "$@" \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index d61587ab..aba12323 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,8 +7,10 @@ name = "stagehand" version = "0.0.6" description = "Python SDK for Stagehand" readme = "README.md" -license = { text = "MIT" } -authors = [{ name = "Browserbase, Inc.", email = "support@browserbase.com" }] +license = {text = "MIT"} +authors = [ + {name = "Browserbase, Inc.", email = "support@browserbase.com"} +] classifiers = [ "Programming Language :: Python :: 3", "License :: OSI Approved :: MIT License", @@ -60,7 +62,7 @@ stagehand = ["domScripts.js"] line-length = 88 # Target Python version -target-version = "py39" # Adjust to your version +target-version = "py39" # Adjust to your version # Exclude a variety of commonly ignored directories exclude = [ @@ -70,7 +72,7 @@ exclude = [ "venv", ".venv", "dist", - "tests", + "tests" ] # Define lint-specific settings here @@ -121,7 +123,7 @@ addopts = [ "--strict-markers", "--strict-config", "-ra", - "--tb=short", + "--tb=short" ] markers = [ "unit: Unit tests for individual components", @@ -133,13 +135,13 @@ markers = [ "llm: Tests involving LLM interactions", "mock: Tests using mock objects only", "performance: Performance and load tests", - "smoke: Quick smoke tests for basic functionality", + "smoke: Quick smoke tests for basic functionality" ] filterwarnings = [ "ignore::DeprecationWarning", "ignore::PendingDeprecationWarning", "ignore::UserWarning:pytest_asyncio", - "ignore::RuntimeWarning", + "ignore::RuntimeWarning" ] minversion = "7.0" @@ -177,98 +179,4 @@ force_grid_wrap = 0 use_parentheses = true ensure_newline_before_comments = true skip_gitignore = true -skip_glob = ["**/venv/**", "**/.venv/**", "**/__pycache__/**"] - -[tool.semantic_release] -version_toml = ["pyproject.toml:project.version"] -version_variables = ["stagehand/__init__.py:__version__"] -assets = [] -build_command_env = [] -build_command = "python -m build" # TODO move to uv -commit_message = "{version}\n\nAutomatically generated by python-semantic-release" -commit_parser = "conventional" -logging_use_named_masks = false -major_on_zero = false # set this to true when ready for v1 -allow_zero_version = true -no_git_verify = false -tag_format = "v{version}" - -[tool.semantic_release.branches.main] -match = "(main|master)" -prerelease_token = "rc" -prerelease = false - -[tool.semantic_release.changelog] -exclude_commit_patterns = [ - '''chore(?:\([^)]*?\))?: .+''', - '''ci(?:\([^)]*?\))?: .+''', - '''refactor(?:\([^)]*?\))?: .+''', - '''style(?:\([^)]*?\))?: .+''', - '''test(?:\([^)]*?\))?: .+''', - '''build\((?!deps\): .+)''', - '''Initial [Cc]ommit.*''', -] -mode = "update" -insertion_flag = "" -template_dir = "templates" - -[tool.semantic_release.changelog.default_templates] -changelog_file = "CHANGELOG.rst" -output_format = "rst" -mask_initial_release = true - -[tool.semantic_release.changelog.environment] -block_start_string = "{%" -block_end_string = "%}" -variable_start_string = "{{" -variable_end_string = "}}" -comment_start_string = "{#" -comment_end_string = "#}" -trim_blocks = false -lstrip_blocks = false -newline_sequence = "\n" -keep_trailing_newline = false -extensions = [] -autoescape = false - -[tool.semantic_release.commit_author] -env = "GIT_COMMIT_AUTHOR" -default = "semantic-release " - -[tool.semantic_release.commit_parser_options] -minor_tags = ["feat"] -patch_tags = ["fix", "perf"] -other_allowed_tags = [ - "build", - "chore", - "ci", - "docs", - "style", - "refactor", - "test", -] -allowed_tags = [ - "feat", - "fix", - "perf", - "build", - "chore", - "ci", - "docs", - "style", - "refactor", - "test", -] -default_bump_level = 0 -parse_squash_commits = true -ignore_merge_commits = true - -[tool.semantic_release.remote] -name = "origin" -type = "github" -ignore_token_for_push = false -insecure = false - -[tool.semantic_release.publish] -dist_glob_patterns = ["dist/*"] -upload_to_vcs_release = true +skip_glob = ["**/venv/**", "**/.venv/**", "**/__pycache__/**"] \ No newline at end of file diff --git a/stagehand/__init__.py b/stagehand/__init__.py index b9cf724c..8f3a5f09 100644 --- a/stagehand/__init__.py +++ b/stagehand/__init__.py @@ -21,7 +21,7 @@ ObserveResult, ) -__version__ = "0.0.6" +__version__ = "0.0.1" __all__ = [ "Stagehand", diff --git a/test_changesets.py b/test_changesets.py deleted file mode 100755 index f6c533bd..00000000 --- a/test_changesets.py +++ /dev/null @@ -1,405 +0,0 @@ -#!/usr/bin/env python3 -""" -Test script for the changeset system. -Run this to validate all components work correctly. -""" - -import json -import os -import shutil -import subprocess -import sys -from pathlib import Path -from datetime import datetime - -# Colors for output -GREEN = "\033[92m" -RED = "\033[91m" -YELLOW = "\033[93m" -BLUE = "\033[94m" -RESET = "\033[0m" - - -def print_header(message): - """Print a formatted header.""" - print(f"\n{BLUE}{'=' * 60}{RESET}") - print(f"{BLUE}{message}{RESET}") - print(f"{BLUE}{'=' * 60}{RESET}") - - -def print_success(message): - """Print success message.""" - print(f"{GREEN}āœ“ {message}{RESET}") - - -def print_error(message): - """Print error message.""" - print(f"{RED}āœ— {message}{RESET}") - - -def print_info(message): - """Print info message.""" - print(f"{YELLOW}ℹ {message}{RESET}") - - -def run_command(cmd, capture_output=True): - """Run a shell command and return result.""" - print(f" Running: {cmd}") - if capture_output: - result = subprocess.run(cmd, shell=True, capture_output=True, text=True) - if result.returncode != 0: - print(f" Error: {result.stderr}") - return result - else: - return subprocess.run(cmd, shell=True) - - -def backup_files(): - """Backup important files before testing.""" - files_to_backup = [ - "pyproject.toml", - "stagehand/__init__.py", - "CHANGELOG.md", - ".changeset/config.json" - ] - - backup_dir = Path(".changeset-test-backup") - backup_dir.mkdir(exist_ok=True) - - for file in files_to_backup: - if Path(file).exists(): - dest = backup_dir / file.replace("/", "_") - shutil.copy2(file, dest) - print_info(f"Backed up {file}") - - return backup_dir - - -def restore_files(backup_dir): - """Restore backed up files.""" - files_to_restore = [ - ("pyproject.toml", "pyproject.toml"), - ("stagehand___init__.py", "stagehand/__init__.py"), - ("CHANGELOG.md", "CHANGELOG.md"), - (".changeset_config.json", ".changeset/config.json") - ] - - for backup_name, original_path in files_to_restore: - backup_file = backup_dir / backup_name - if backup_file.exists(): - shutil.copy2(backup_file, original_path) - print_info(f"Restored {original_path}") - - # Clean up backup directory - shutil.rmtree(backup_dir) - - -def test_changeset_creation(): - """Test creating changesets.""" - print_header("Testing Changeset Creation") - - # Test 1: Create patch changeset - print("\nTest 1: Creating patch changeset...") - result = run_command( - 'python3 .changeset/scripts/changeset.py --type patch --message "Test patch change"' - ) - if result.returncode == 0: - print_success("Patch changeset created successfully") - else: - print_error("Failed to create patch changeset") - return False - - # Test 2: Create minor changeset - print("\nTest 2: Creating minor changeset...") - result = run_command( - 'python3 .changeset/scripts/changeset.py --type minor --message "Test minor feature"' - ) - if result.returncode == 0: - print_success("Minor changeset created successfully") - else: - print_error("Failed to create minor changeset") - return False - - # Test 3: Create major changeset - print("\nTest 3: Creating major changeset...") - result = run_command( - 'python3 .changeset/scripts/changeset.py --type major --message "Test breaking change"' - ) - if result.returncode == 0: - print_success("Major changeset created successfully") - else: - print_error("Failed to create major changeset") - return False - - # Check changesets were created - changesets = list(Path(".changeset").glob("*.md")) - changesets = [cs for cs in changesets if cs.name != "README.md"] - - if len(changesets) >= 3: - print_success(f"Found {len(changesets)} changesets") - for cs in changesets: - print(f" - {cs.name}") - else: - print_error(f"Expected at least 3 changesets, found {len(changesets)}") - return False - - return True - - -def test_version_bumping(): - """Test version bumping logic.""" - print_header("Testing Version Bumping") - - # Get current version - with open("pyproject.toml") as f: - content = f.read() - import re - match = re.search(r'version = "([^"]+)"', content) - current_version = match.group(1) if match else "0.0.0" - - print(f"Current version: {current_version}") - - # Run version script (dry run first) - print("\nTest 1: Dry run version bump...") - result = run_command("python3 .changeset/scripts/version.py --dry-run") - if result.returncode == 0: - print_success("Dry run completed successfully") - # Check output for expected version bump - if "→" in result.stdout: - print(f" {result.stdout.split('→')[0].split('Version bump:')[1].strip()} → {result.stdout.split('→')[1].split()[0]}") - else: - print_error("Dry run failed") - return False - - # Run actual version bump - print("\nTest 2: Actual version bump...") - result = run_command("python3 .changeset/scripts/version.py") - if result.returncode == 0: - print_success("Version bump completed successfully") - - # Verify files were updated - with open("pyproject.toml") as f: - content = f.read() - match = re.search(r'version = "([^"]+)"', content) - new_version = match.group(1) if match else "0.0.0" - - print(f" New version in pyproject.toml: {new_version}") - - # Check __init__.py - with open("stagehand/__init__.py") as f: - content = f.read() - if f'__version__ = "{new_version}"' in content: - print_success("Version updated in __init__.py") - else: - print_error("Version not updated correctly in __init__.py") - return False - - # Check if changesets were archived - archive_dir = Path(".changeset/archive") / new_version - if archive_dir.exists(): - archived = list(archive_dir.glob("*.md")) - print_success(f"Changesets archived ({len(archived)} files)") - else: - print_error("Changesets not archived") - return False - - else: - print_error("Version bump failed") - return False - - return True - - -def test_changelog_generation(): - """Test changelog generation.""" - print_header("Testing Changelog Generation") - - # Check if changeset data exists - data_file = Path(".changeset/.changeset-data.json") - if not data_file.exists(): - print_error("No changeset data file found") - return False - - # Test dry run - print("\nTest 1: Dry run changelog generation...") - result = run_command("python3 .changeset/scripts/changelog.py --dry-run") - if result.returncode == 0: - print_success("Dry run completed successfully") - # Show preview - if "Generated changelog entry:" in result.stdout: - print("\nChangelog preview:") - lines = result.stdout.split("\n") - in_preview = False - for line in lines: - if "Generated changelog entry:" in line: - in_preview = True - elif "-" * 60 in line: - if in_preview: - break - elif in_preview: - print(f" {line}") - else: - print_error("Dry run failed") - return False - - # Test actual changelog generation - print("\nTest 2: Actual changelog generation...") - result = run_command("python3 .changeset/scripts/changelog.py") - if result.returncode == 0: - print_success("Changelog generation completed successfully") - - # Verify CHANGELOG.md was updated - if Path("CHANGELOG.md").exists(): - with open("CHANGELOG.md") as f: - content = f.read() - if "## [" in content: - print_success("CHANGELOG.md updated with new version") - # Show first few lines of new entry - lines = content.split("\n") - print("\nNew changelog entry:") - for i, line in enumerate(lines): - if line.startswith("## ["): - for j in range(min(10, len(lines) - i)): - print(f" {lines[i + j]}") - break - else: - print_error("CHANGELOG.md not updated correctly") - return False - else: - print_error("CHANGELOG.md not found") - return False - else: - print_error("Changelog generation failed") - return False - - return True - - -def test_pre_commit_hooks(): - """Test pre-commit hook scripts.""" - print_header("Testing Pre-commit Hooks") - - # Test changeset validation - print("\nTest 1: Validating changeset files...") - - # Create a valid changeset for testing - valid_changeset = Path(".changeset/test-valid.md") - valid_changeset.write_text("""--- -"stagehand": patch ---- - -Test changeset for validation -""") - - result = run_command(f"python3 .changeset/scripts/validate-changesets.py {valid_changeset}") - if result.returncode == 0: - print_success("Valid changeset passed validation") - else: - print_error("Valid changeset failed validation") - valid_changeset.unlink() - return False - - # Create an invalid changeset - invalid_changeset = Path(".changeset/test-invalid.md") - invalid_changeset.write_text("""--- -invalid format ---- -""") - - result = run_command(f"python3 .changeset/scripts/validate-changesets.py {invalid_changeset}") - if result.returncode != 0: - print_success("Invalid changeset correctly rejected") - else: - print_error("Invalid changeset was not rejected") - valid_changeset.unlink() - invalid_changeset.unlink() - return False - - # Clean up test files - valid_changeset.unlink() - invalid_changeset.unlink() - - # Test changeset check (will fail on main branch, which is expected) - print("\nTest 2: Testing changeset check...") - result = run_command("python3 .changeset/scripts/check-changeset.py") - print_info("Changeset check completed (may fail on main branch - that's expected)") - - return True - - -def main(): - """Run all tests.""" - print_header("Changeset System Test Suite") - print_info("This will test the changeset system components") - print_info("Original files will be backed up and restored") - - # Check we're in the right directory - if not Path(".changeset/config.json").exists(): - print_error("Not in project root directory (no .changeset/config.json found)") - sys.exit(1) - - # Backup files - print("\nBacking up files...") - backup_dir = backup_files() - - try: - # Run tests - tests = [ - ("Changeset Creation", test_changeset_creation), - ("Version Bumping", test_version_bumping), - ("Changelog Generation", test_changelog_generation), - ("Pre-commit Hooks", test_pre_commit_hooks), - ] - - results = [] - for name, test_func in tests: - try: - result = test_func() - results.append((name, result)) - except Exception as e: - print_error(f"Exception in {name}: {e}") - results.append((name, False)) - - # Summary - print_header("Test Summary") - passed = sum(1 for _, result in results if result) - total = len(results) - - for name, result in results: - if result: - print_success(f"{name}: PASSED") - else: - print_error(f"{name}: FAILED") - - print(f"\nTotal: {passed}/{total} tests passed") - - if passed == total: - print_success("\nAll tests passed! šŸŽ‰") - else: - print_error(f"\n{total - passed} tests failed") - - finally: - # Restore files - print("\nRestoring original files...") - restore_files(backup_dir) - - # Clean up any remaining test changesets - for cs in Path(".changeset").glob("*.md"): - if cs.name not in ["README.md", "wild-rivers-sing.md"]: # Keep the original one - cs.unlink() - - # Clean up archive directory if it exists - archive_dir = Path(".changeset/archive") - if archive_dir.exists(): - shutil.rmtree(archive_dir) - - # Clean up changeset data file - data_file = Path(".changeset/.changeset-data.json") - if data_file.exists(): - data_file.unlink() - - print_success("Cleanup completed") - - -if __name__ == "__main__": - main() \ No newline at end of file From 4b4d4a5ade15560c1ec4a98eb3b620e2f2a0147e Mon Sep 17 00:00:00 2001 From: Roaring <216452114+the-roaring@users.noreply.github.com> Date: Sun, 29 Jun 2025 14:32:44 -0700 Subject: [PATCH 14/19] remove bumpversion --- .bumpversion.cfg | 12 ------------ 1 file changed, 12 deletions(-) delete mode 100644 .bumpversion.cfg diff --git a/.bumpversion.cfg b/.bumpversion.cfg deleted file mode 100644 index d1c662bc..00000000 --- a/.bumpversion.cfg +++ /dev/null @@ -1,12 +0,0 @@ -[bumpversion] -current_version = 0.0.1 -commit = True -tag = True - -[bumpversion:file:pyproject.toml] -search = version = "{current_version}" -replace = version = "{new_version}" - -[bumpversion:file:stagehand/__init__.py] -search = __version__ = "{current_version}" -replace = __version__ = "{new_version}" \ No newline at end of file From f3157f76ee72e2ae31e8df9ba71f90aaddbdf853 Mon Sep 17 00:00:00 2001 From: Roaring <216452114+the-roaring@users.noreply.github.com> Date: Sun, 29 Jun 2025 14:36:14 -0700 Subject: [PATCH 15/19] deprecate old publish workflow --- .github/workflows/publish.yml | 192 ---------------------------------- 1 file changed, 192 deletions(-) delete mode 100644 .github/workflows/publish.yml diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml deleted file mode 100644 index 37bf55e5..00000000 --- a/.github/workflows/publish.yml +++ /dev/null @@ -1,192 +0,0 @@ -name: Publish to PyPI - -on: - workflow_dispatch: - inputs: - release_type: - description: 'Release type (patch, minor, major)' - required: true - default: 'patch' - type: choice - options: - - patch - - minor - - major - create_release: - description: 'Create GitHub Release' - required: true - default: true - type: boolean - -permissions: - contents: write - pull-requests: write - -jobs: - build-and-publish: - runs-on: ubuntu-latest - steps: - - name: Check out repository - uses: actions/checkout@v4 - with: - fetch-depth: 0 - token: ${{ secrets.GITHUB_TOKEN }} - - - name: Set up Python - uses: actions/setup-python@v4 - with: - python-version: '3.11' - - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install build twine wheel setuptools ruff black - pip install -r requirements.txt - if [ -f requirements-dev.txt ]; then pip install -r requirements-dev.txt; fi - # TODO add playwright install for CI pytest - - - name: Run linting and formatting - run: | - # Run linter - black --check --diff stagehand - - # Run Ruff formatter check (without modifying files) - ruff check stagehand - - # TODO: add back as soon as CI is passing - # - name: Run tests - # run: | - # pytest - - - name: Calculate new version - id: version - run: | - # Get current version from pyproject.toml - CURRENT_VERSION=$(python -c "import tomllib; print(tomllib.load(open('pyproject.toml', 'rb'))['project']['version'])") - echo "current_version=$CURRENT_VERSION" >> $GITHUB_OUTPUT - - # Parse version components - IFS='.' read -r MAJOR MINOR PATCH <<< "$CURRENT_VERSION" - - # Calculate new version based on release type - case "${{ github.event.inputs.release_type }}" in - "major") - NEW_MAJOR=$((MAJOR + 1)) - NEW_MINOR=0 - NEW_PATCH=0 - ;; - "minor") - NEW_MAJOR=$MAJOR - NEW_MINOR=$((MINOR + 1)) - NEW_PATCH=0 - ;; - "patch") - NEW_MAJOR=$MAJOR - NEW_MINOR=$MINOR - NEW_PATCH=$((PATCH + 1)) - ;; - esac - - NEW_VERSION="${NEW_MAJOR}.${NEW_MINOR}.${NEW_PATCH}" - echo "new_version=$NEW_VERSION" >> $GITHUB_OUTPUT - echo "Bumping version from $CURRENT_VERSION to $NEW_VERSION" - - - name: Update version files - run: | - CURRENT_VERSION="${{ steps.version.outputs.current_version }}" - NEW_VERSION="${{ steps.version.outputs.new_version }}" - - # Update pyproject.toml - sed -i "s/version = \"$CURRENT_VERSION\"/version = \"$NEW_VERSION\"/" pyproject.toml - - # Update __init__.py - sed -i "s/__version__ = \"$CURRENT_VERSION\"/__version__ = \"$NEW_VERSION\"/" stagehand/__init__.py - - echo "Updated version to $NEW_VERSION in pyproject.toml and __init__.py" - - - name: Configure Git - run: | - git config --local user.email "action@github.com" - git config --local user.name "GitHub Action" - - - name: Create version bump branch and PR - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - NEW_VERSION="${{ steps.version.outputs.new_version }}" - BRANCH_NAME="release/v${NEW_VERSION}" - - # Create and switch to new branch - git checkout -b "$BRANCH_NAME" - - # Commit changes - git add pyproject.toml stagehand/__init__.py - git commit -m "Bump version to v${NEW_VERSION}" - - # Push branch - git push origin "$BRANCH_NAME" - - # Create PR - gh pr create \ - --title "Release v${NEW_VERSION}" \ - --body "Automated version bump to v${NEW_VERSION} for ${{ github.event.inputs.release_type }} release." \ - --base main \ - --head "$BRANCH_NAME" - - - name: Wait for PR to be merged - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - NEW_VERSION="${{ steps.version.outputs.new_version }}" - BRANCH_NAME="release/v${NEW_VERSION}" - - echo "Waiting for PR to be merged..." - - # Wait for PR to be merged (check every 30 seconds for up to 10 minutes) - for i in {1..20}; do - if gh pr view "$BRANCH_NAME" --json state --jq '.state' | grep -q "MERGED"; then - echo "PR has been merged!" - break - elif gh pr view "$BRANCH_NAME" --json state --jq '.state' | grep -q "CLOSED"; then - echo "PR was closed without merging. Exiting." - exit 1 - else - echo "PR is still open. Waiting 30 seconds... (attempt $i/20)" - sleep 30 - fi - - if [ $i -eq 20 ]; then - echo "Timeout waiting for PR to be merged." - exit 1 - fi - done - - - name: Checkout main and create tag - run: | - # Switch back to main and pull latest changes - git checkout main - git pull origin main - - # Create and push tag - NEW_VERSION="${{ steps.version.outputs.new_version }}" - git tag "v${NEW_VERSION}" - git push origin "v${NEW_VERSION}" - - - name: Build package - run: | - python -m build - - - name: Upload to PyPI - env: - TWINE_USERNAME: __token__ - TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }} - run: | - twine upload dist/* - - - name: Create GitHub Release - if: ${{ github.event.inputs.create_release == 'true' }} - uses: softprops/action-gh-release@v1 - with: - tag_name: v${{ steps.version.outputs.new_version }} - name: Release v${{ steps.version.outputs.new_version }} - generate_release_notes: true \ No newline at end of file From 91b620ea2cd50204400d737e26b24e71d2ffa1e2 Mon Sep 17 00:00:00 2001 From: Roaring <216452114+the-roaring@users.noreply.github.com> Date: Sun, 29 Jun 2025 14:36:58 -0700 Subject: [PATCH 16/19] copy in workflows from pychangeset --- .github/workflows/changesets.yml | 140 ++++++++++++++++++++++++++ .github/workflows/check-changeset.yml | 26 +++++ .github/workflows/release.yml | 123 ++++++++++++++++++++++ 3 files changed, 289 insertions(+) create mode 100644 .github/workflows/changesets.yml create mode 100644 .github/workflows/check-changeset.yml create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/changesets.yml b/.github/workflows/changesets.yml new file mode 100644 index 00000000..342dd467 --- /dev/null +++ b/.github/workflows/changesets.yml @@ -0,0 +1,140 @@ +name: Changesets + +on: + push: + branches: + - main + workflow_dispatch: + +jobs: + changesets: + name: Create or Update Release PR + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + + steps: + - name: Checkout main branch + uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.13" + + - name: Install uv + uses: astral-sh/setup-uv@v2 + + - name: Check for changesets + id: check_changesets + run: | + if ls .changeset/*.md 2>/dev/null | grep -v README.md > /dev/null; then + echo "has_changesets=true" >> $GITHUB_OUTPUT + else + echo "has_changesets=false" >> $GITHUB_OUTPUT + fi + + - name: Get PR metadata + if: steps.check_changesets.outputs.has_changesets == 'true' + id: pr_metadata + run: | + # Get the merge commit info + COMMIT_SHA="${{ github.sha }}" + echo "COMMIT_SHA=$COMMIT_SHA" >> $GITHUB_ENV + + # Try to extract PR info from commit message + PR_NUMBER=$(git log -1 --pretty=%B | grep -oP '(?<=#)\d+' | head -1 || echo "") + if [ -n "$PR_NUMBER" ]; then + echo "PR_NUMBER=$PR_NUMBER" >> $GITHUB_ENV + + # Get PR author using GitHub API + PR_AUTHOR=$(gh api repos/${{ github.repository }}/pulls/$PR_NUMBER --jq '.user.login' || echo "") + echo "PR_AUTHOR=$PR_AUTHOR" >> $GITHUB_ENV + fi + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Generate changelogs and PR description + if: steps.check_changesets.outputs.has_changesets == 'true' + run: | + # Generate changelogs and PR description + uvx changeset changelog --output-pr-description pr-description.md + + # Save PR description for later use + echo "PR_DESCRIPTION<> $GITHUB_ENV + cat pr-description.md >> $GITHUB_ENV + echo "EOF" >> $GITHUB_ENV + rm pr-description.md + + - name: Bump versions + if: steps.check_changesets.outputs.has_changesets == 'true' + run: | + uvx changeset version --skip-changelog + + - name: Commit changes + if: steps.check_changesets.outputs.has_changesets == 'true' + id: commit + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + # Add all changes + git add . + + # Commit if there are changes + if ! git diff --cached --quiet; then + git commit -m "Version packages and update changelogs" + echo "has_changes=true" >> $GITHUB_OUTPUT + else + echo "has_changes=false" >> $GITHUB_OUTPUT + fi + + - name: Force push to changeset branch + if: steps.check_changesets.outputs.has_changesets == 'true' && steps.commit.outputs.has_changes == 'true' + run: | + # Force push to the changeset-release branch + git push origin HEAD:changeset-release --force + + - name: Create or update PR + if: steps.check_changesets.outputs.has_changesets == 'true' && steps.commit.outputs.has_changes == 'true' + uses: actions/github-script@v7 + with: + script: | + const { data: prs } = await github.rest.pulls.list({ + owner: context.repo.owner, + repo: context.repo.repo, + head: `${context.repo.owner}:changeset-release`, + base: 'main', + state: 'open' + }); + + const prBody = process.env.PR_DESCRIPTION; + const prTitle = 'šŸš€ Release packages'; + + if (prs.length > 0) { + // Update existing PR + const pr = prs[0]; + await github.rest.pulls.update({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: pr.number, + title: prTitle, + body: prBody + }); + console.log(`Updated PR #${pr.number}`); + } else { + // Create new PR + const { data: pr } = await github.rest.pulls.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title: prTitle, + body: prBody, + head: 'changeset-release', + base: 'main' + }); + console.log(`Created PR #${pr.number}`); + } diff --git a/.github/workflows/check-changeset.yml b/.github/workflows/check-changeset.yml new file mode 100644 index 00000000..e328f661 --- /dev/null +++ b/.github/workflows/check-changeset.yml @@ -0,0 +1,26 @@ +name: Check Changeset + +on: + pull_request: + types: [opened, synchronize] + +jobs: + check-changeset: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.13" + + - name: Install uv + uses: astral-sh/setup-uv@v2 + + - name: Check for changeset + run: | + uvx changeset check-changeset \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..adea3e6d --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,123 @@ +name: Release + +on: + pull_request: + types: [closed] + branches: + - main + +jobs: + release: + name: Release packages + # Only run when changeset PR is merged + if: github.event.pull_request.merged == true && github.event.pull_request.head.ref == 'changeset-release' + runs-on: ubuntu-latest + environment: pypi + permissions: + contents: write + id-token: write # For PyPI trusted publishing + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.13' + + - name: Install uv + uses: astral-sh/setup-uv@v2 + + - name: Build packages + run: | + # Find all packages with pyproject.toml + for pyproject in $(find . -name "pyproject.toml" -not -path "./.venv/*" -not -path "./node_modules/*"); do + dir=$(dirname "$pyproject") + echo "Building package in $dir" + (cd "$dir" && uv build) + done + + - name: Get version info + id: versions + run: | + # Extract version info from PR body + # This is a simplified version - you might want to make it more robust + echo "Extracting version information..." + + # For each package, get its version + RELEASE_TAGS="" + for pyproject in $(find . -name "pyproject.toml" -not -path "./.venv/*" -not -path "./node_modules/*"); do + dir=$(dirname "$pyproject") + # Extract package name and version + PACKAGE_NAME=$(python -c "import tomllib; print(tomllib.load(open('$pyproject', 'rb'))['project']['name'])") + PACKAGE_VERSION=$(python -c "import tomllib; print(tomllib.load(open('$pyproject', 'rb'))['project']['version'])") + + # Add to release tags + TAG="${PACKAGE_NAME}-v${PACKAGE_VERSION}" + RELEASE_TAGS="${RELEASE_TAGS}${TAG} " + + echo "Package: $PACKAGE_NAME @ $PACKAGE_VERSION" + done + + echo "release_tags=$RELEASE_TAGS" >> $GITHUB_OUTPUT + + - name: Publish to PyPI + run: | + # Publish each package + for pyproject in $(find . -name "pyproject.toml" -not -path "./.venv/*" -not -path "./node_modules/*"); do + dir=$(dirname "$pyproject") + echo "Publishing package in $dir" + (cd "$dir" && uv publish) + done + + - name: Create git tags + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + # Create tags for each package + for pyproject in $(find . -name "pyproject.toml" -not -path "./.venv/*" -not -path "./node_modules/*"); do + PACKAGE_NAME=$(python -c "import tomllib; print(tomllib.load(open('$pyproject', 'rb'))['project']['name'])") + PACKAGE_VERSION=$(python -c "import tomllib; print(tomllib.load(open('$pyproject', 'rb'))['project']['version'])") + TAG="${PACKAGE_NAME}-v${PACKAGE_VERSION}" + + # Create and push tag + git tag -a "$TAG" -m "Release $PACKAGE_NAME v$PACKAGE_VERSION" + git push origin "$TAG" + done + + - name: Create GitHub releases + uses: actions/github-script@v7 + with: + script: | + // Get the PR body which contains our changelog + const prBody = context.payload.pull_request.body; + + // Parse the PR body to extract package releases + const releaseRegex = /## (.+)@(.+)\n([\s\S]*?)(?=\n## |$)/g; + let match; + + while ((match = releaseRegex.exec(prBody)) !== null) { + const packageName = match[1]; + const version = match[2]; + const changelog = match[3].trim(); + const tag = `${packageName}-v${version}`; + + try { + await github.rest.repos.createRelease({ + owner: context.repo.owner, + repo: context.repo.repo, + tag_name: tag, + name: `${packageName} v${version}`, + body: changelog, + draft: false, + prerelease: false + }); + console.log(`Created release for ${tag}`); + } catch (error) { + console.error(`Failed to create release for ${tag}:`, error); + } + } From d67b5c31f29153eee9bf0e5249a2746163924074 Mon Sep 17 00:00:00 2001 From: Roaring <216452114+the-roaring@users.noreply.github.com> Date: Sun, 29 Jun 2025 14:40:21 -0700 Subject: [PATCH 17/19] add pychangeset to repo --- .changeset/README.md | 11 +++++++++++ .changeset/config.json | 17 +++++++++++++++++ .changeset/functional-pink-centipede.md | 5 +++++ 3 files changed, 33 insertions(+) create mode 100644 .changeset/README.md create mode 100644 .changeset/config.json create mode 100644 .changeset/functional-pink-centipede.md diff --git a/.changeset/README.md b/.changeset/README.md new file mode 100644 index 00000000..927e0604 --- /dev/null +++ b/.changeset/README.md @@ -0,0 +1,11 @@ +# Changesets + +This directory contains changeset files that track changes. + +## Creating a changeset + +Run `changeset` or `changeset add` to create a new changeset. + +## More info + +See https://github.com/browserbase/pychangeset for more information. diff --git a/.changeset/config.json b/.changeset/config.json new file mode 100644 index 00000000..cd536e07 --- /dev/null +++ b/.changeset/config.json @@ -0,0 +1,17 @@ +{ + "changeTypes": { + "major": { + "description": "Breaking changes", + "emoji": "\ud83d\udca5" + }, + "minor": { + "description": "New features", + "emoji": "\u2728" + }, + "patch": { + "description": "Bug fixes and improvements", + "emoji": "\ud83d\udc1b" + } + }, + "baseBranch": "main" +} \ No newline at end of file diff --git a/.changeset/functional-pink-centipede.md b/.changeset/functional-pink-centipede.md new file mode 100644 index 00000000..8545089e --- /dev/null +++ b/.changeset/functional-pink-centipede.md @@ -0,0 +1,5 @@ +--- +"stagehand": patch +--- + +start using pychangeset to track changes From 476b8850f9d3fc36e88c86fd581b75c55ef14066 Mon Sep 17 00:00:00 2001 From: Roaring <216452114+the-roaring@users.noreply.github.com> Date: Sun, 29 Jun 2025 14:41:08 -0700 Subject: [PATCH 18/19] change version variable to single source from pyproject.toml --- stagehand/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/stagehand/__init__.py b/stagehand/__init__.py index 8f3a5f09..808731d8 100644 --- a/stagehand/__init__.py +++ b/stagehand/__init__.py @@ -20,8 +20,9 @@ ObserveOptions, ObserveResult, ) +from importlib.metadata import version as get_version -__version__ = "0.0.1" +__version__ = get_version("stagehand") __all__ = [ "Stagehand", From 84891b949b55197620e6d2daafc03d9aaa863d6e Mon Sep 17 00:00:00 2001 From: Roaring <216452114+the-roaring@users.noreply.github.com> Date: Sun, 29 Jun 2025 14:42:34 -0700 Subject: [PATCH 19/19] ruff fix --- examples/agent_example.py | 3 +-- examples/example.py | 4 ++-- examples/quickstart.py | 3 ++- stagehand/__init__.py | 3 ++- 4 files changed, 7 insertions(+), 6 deletions(-) diff --git a/examples/agent_example.py b/examples/agent_example.py index 8b214496..c9f0a246 100644 --- a/examples/agent_example.py +++ b/examples/agent_example.py @@ -7,8 +7,7 @@ from rich.panel import Panel from rich.theme import Theme -from stagehand import Stagehand, StagehandConfig, AgentConfig, configure_logging -from stagehand.schemas import AgentExecuteOptions, AgentProvider +from stagehand import Stagehand, StagehandConfig, configure_logging # Create a custom theme for consistent styling custom_theme = Theme( diff --git a/examples/example.py b/examples/example.py index 78219966..c0067929 100644 --- a/examples/example.py +++ b/examples/example.py @@ -1,11 +1,11 @@ import asyncio import logging import os + +from dotenv import load_dotenv from rich.console import Console from rich.panel import Panel from rich.theme import Theme -import json -from dotenv import load_dotenv from stagehand import Stagehand, StagehandConfig from stagehand.utils import configure_logging diff --git a/examples/quickstart.py b/examples/quickstart.py index a441cb33..20daf858 100644 --- a/examples/quickstart.py +++ b/examples/quickstart.py @@ -1,9 +1,10 @@ import asyncio import os + from dotenv import load_dotenv from pydantic import BaseModel, Field -from stagehand import StagehandConfig, Stagehand +from stagehand import Stagehand, StagehandConfig # Load environment variables load_dotenv() diff --git a/stagehand/__init__.py b/stagehand/__init__.py index 808731d8..1134abad 100644 --- a/stagehand/__init__.py +++ b/stagehand/__init__.py @@ -1,5 +1,7 @@ """Stagehand - The AI Browser Automation Framework""" +from importlib.metadata import version as get_version + from .agent import Agent from .config import StagehandConfig, default_config from .handlers.observe_handler import ObserveHandler @@ -20,7 +22,6 @@ ObserveOptions, ObserveResult, ) -from importlib.metadata import version as get_version __version__ = get_version("stagehand")