Repository: canonical/operator
Pull request #1432 (2024-10-11 01:51:46)

ci: adjust the release process to handle publishing ops and ops-scenario
Adds new publish workflows for ops-scenario (one for main PyPI, one for test.pypi.org), modelled on the ops workflows. These differ significantly from the old ops-scenario publish workflow (now deleted), which would automatically create a GitHub release and tag and publish to PyPI (with API credentials) on a push to main.

To avoid confusion, the publish workflows for `ops` are renamed (and while this is being done, the extension is changed from `yml` to `yaml` to match all the other files).

The publish workflows are trigged by new tags - `2.*` for `ops` (rather than a release being published) and `scenario-7.*` for `ops-scenario`. This allows releases that only include publishing one of the packages (for example, in a month that had no changes to one, or when a patch release is required).

The HACKING.md documentation is updated to reflect the changes in the release process.

A CHANGES.md is added for Scenario, backdated to 7.0.0 (although 7.0.0 is not entirely complete, but it does list the main changes).

Once this is merged, trusted publishing will need to be configured for the `ops-scenario` package, and the config for `ops` will need to be adjusted to refer to the new workflow filename.

Drive-bys:
 * The smoke test workflow had incorrect indentation for the `schedule` trigger.
 * Clarify when `(testing)` and `(harness)` scopes are used in conventional commits, now that we have Scenario in the repo.
 * Note the use of the various `.. juju` directives in documentation.
 * Minor clarifications in the release process based on comments from @james-garner-canonical when he did his first release.


Patch:
diff --git a/.github/workflows/build_scenario_wheels.yaml b/.github/workflows/build_scenario_wheels.yaml
deleted file mode 100644
index 8783384..0000000
--- a/.github/workflows/build_scenario_wheels.yaml
+++ /dev/null
@@ -1,61 +0,0 @@
-name: Build ops-scenario wheels
-
-# TODO: adjust this workflow to properly build ops-scenario from the operator repo
-# and then this should be adjusted to run when appropriate (not on push to main
-# any more, but as part of the releasing workflow we agree on).
-on:
-  workflow_dispatch
-#  push:
-#    branches:
-#      - main
-
-jobs:
-  build_wheel:
-    name: Build wheel on ubuntu (where else???)
-    runs-on: ubuntu-latest
-
-    steps:
-      - uses: actions/checkout@v4
-
-      - uses: actions/setup-python@v5
-
-      - name: Install build
-        run: pip install build
-
-      - name: Build wheel
-        run: python -m build
-
-      - uses: actions/upload-artifact@v3
-        with:
-          path: ./dist/*.whl
-
-      - name: Get the version
-        id: get_version
-        run: echo "VERSION=$(sed -n 's/^ *version.*=.*"\([^"]*\)".*/\1/p' pyproject.toml)" >> $GITHUB_OUTPUT
-
-      - name: release
-        uses: actions/create-release@v1
-        id: create_release
-        with:
-          draft: false
-          prerelease: false
-          tag_name: ${{ steps.get_version.outputs.VERSION }}
-          release_name: ${{ steps.get_version.outputs.VERSION }}
-
-        env:
-          GITHUB_TOKEN: ${{ github.token }}
-
-      - name: upload wheel
-        uses: actions/upload-release-asset@v1
-        env:
-          GITHUB_TOKEN: ${{ github.token }}
-        with:
-          upload_url: ${{ steps.create_release.outputs.upload_url }}
-          asset_path: ./dist/ops_scenario-${{ steps.get_version.outputs.VERSION }}-py3-none-any.whl
-          asset_name: ops_scenario-${{ steps.get_version.outputs.VERSION }}-py3-none-any.whl
-          asset_content_type: application/wheel
-
-      - name: Publish to PyPI
-        uses: pypa/gh-action-pypi-publish@release/v1
-        with:
-          password: ${{ secrets.PYPI_API_TOKEN }}
diff --git a/.github/workflows/charmcraft-pack.yaml b/.github/workflows/charmcraft-pack.yaml
index 1c8c24b..6bca4a4 100644
--- a/.github/workflows/charmcraft-pack.yaml
+++ b/.github/workflows/charmcraft-pack.yaml
@@ -37,7 +37,7 @@ jobs:
           cat requirements.txt
 
       - name: Set up LXD
-        uses: canonical/setup-lxd@8fb85546a934dfb994becf81341dd387ffe6aabb
+        uses: canonical/setup-lxd@54a5806e490d92e207b57183cf111ed54bbdeff4
         with:
           channel: 5.0/stable
 
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish-ops-scenario.yaml
similarity index 84%
rename from .github/workflows/publish.yml
rename to .github/workflows/publish-ops-scenario.yaml
index 05c1527..1566491 100644
--- a/.github/workflows/publish.yml
+++ b/.github/workflows/publish-ops-scenario.yaml
@@ -1,8 +1,8 @@
 name: Publish
 on:
-  release:
-    types:
-      - published
+  push:
+    tags:
+      - 'scenario-7.*'
 
 jobs:
   framework-tests:
@@ -12,7 +12,7 @@ jobs:
   hello-charm-tests:
     uses: ./.github/workflows/hello-charm-tests.yaml
   build-n-publish:
-    name: Build and Publish to PyPI
+    name: Build and Publish ops-scenario to PyPI
     runs-on: ubuntu-latest
     permissions:
       id-token: write
@@ -27,9 +27,10 @@ jobs:
         run: pip install wheel build
       - name: Build
         run: python -m build
+        working-directory: ./testing
       - name: Attest build provenance
         uses: actions/attest-build-provenance@v1.4.3
         with:
-          subject-path: 'dist/*'
+          subject-path: 'testing/dist/*'
       - name: Publish
         uses: pypa/gh-action-pypi-publish@release/v1
diff --git a/.github/workflows/publish-ops.yaml b/.github/workflows/publish-ops.yaml
new file mode 100644
index 0000000..c391664
--- /dev/null
+++ b/.github/workflows/publish-ops.yaml
@@ -0,0 +1,39 @@
+name: Publish
+on:
+  push:
+    tags:
+      # TODO: When we come to ops v3.x we will need to reconsider what to do
+      # here (we might want to start prefixing with 'v' for one thing), and we
+      # will likely be publishing at least three packages: ops, ops-scenario,
+      # and ops-harness.
+      - '2.*'
+  
+jobs:
+  framework-tests:
+    uses: ./.github/workflows/framework-tests.yaml
+  observability-charm-tests:
+    uses: ./.github/workflows/observability-charm-tests.yaml
+  hello-charm-tests:
+    uses: ./.github/workflows/hello-charm-tests.yaml
+  build-n-publish:
+    name: Build and Publish ops to PyPI
+    runs-on: ubuntu-latest
+    permissions:
+      id-token: write
+      attestations: write
+      contents: read
+    needs: [framework-tests, observability-charm-tests, hello-charm-tests]
+    steps:
+      - uses: actions/checkout@v4
+      - name: Setup Python
+        uses: actions/setup-python@v5
+      - name: Install build dependencies
+        run: pip install wheel build
+      - name: Build
+        run: python -m build
+      - name: Attest build provenance
+        uses: actions/attest-build-provenance@v1.4.3
+        with:
+          subject-path: 'dist/*'
+      - name: Publish
+        uses: pypa/gh-action-pypi-publish@release/v1
diff --git a/.github/workflows/smoke.yaml b/.github/workflows/smoke.yaml
index 3f69d1e..f339254 100644
--- a/.github/workflows/smoke.yaml
+++ b/.github/workflows/smoke.yaml
@@ -2,8 +2,8 @@ name: ops Smoke Tests
 
 on:
   workflow_dispatch:
-    schedule:
-      - cron:  '0 7 25 * *'
+  schedule:
+    - cron:  '0 7 25 * *'
 
 jobs:
   test:
@@ -24,7 +24,7 @@ jobs:
       # LXD is required for charmcraft to pack, even if it's not used as the
       # Juju cloud.
       - name: Set up LXD
-        uses: canonical/setup-lxd@8fb85546a934dfb994becf81341dd387ffe6aabb
+        uses: canonical/setup-lxd@54a5806e490d92e207b57183cf111ed54bbdeff4
         with:
           channel: 5.0/stable
 
diff --git a/.github/workflows/tiobe.yaml b/.github/workflows/tiobe.yaml
index 6c79c22..c038573 100644
--- a/.github/workflows/tiobe.yaml
+++ b/.github/workflows/tiobe.yaml
@@ -19,10 +19,6 @@ jobs:
       - name: Generate coverage report
         run: |
           tox -e coverage
-          # Annoyingly, the coverage.xml file needs to be in a .coverage folder.
-          rm .coverage
-          mkdir .coverage
-          mv coverage.xml .coverage/
 
       - name: TICS GitHub Action
         uses: tiobe/tics-github-action@v3
diff --git a/.github/workflows/validate-pr-title.yaml b/.github/workflows/validate-pr-title.yaml
index 483a928..104ba8c 100644
--- a/.github/workflows/validate-pr-title.yaml
+++ b/.github/workflows/validate-pr-title.yaml
@@ -10,7 +10,7 @@ permissions:
   pull-requests: read
 
 concurrency:
-  group: ${{ github.workflow }}-${{ github.ref }}
+  group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
   cancel-in-progress: true
 
 jobs:
diff --git a/.gitignore b/.gitignore
index 1744684..167e84a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,6 +8,7 @@ venv
 .vscode
 .coverage
 coverage.xml
+.coverage.data
 /.tox
 .*.swp
 
diff --git a/HACKING.md b/HACKING.md
index 0ba4ffb..2873ede 100644
--- a/HACKING.md
+++ b/HACKING.md
@@ -179,14 +179,17 @@ Pull requests should have a short title that follows the
 * revert
 * test
 
-If the PR is limited to changes in ops.testing (Harness), also include the scope
-`(harness)` in the title. At present, we do not add a scope in any other cases.
+At present, we only add a scope in these cases:
+
+* If the PR is limited to changes in ops/_private/harness.py, also include the scope `(harness)`
+* If the PR is limited to changes in testing/, also include the scope `(testing)`
 
 For example:
 
 * feat: add the ability to observe change-updated events
 * fix!: correct the type hinting for config data
 * docs(harness): clarify the types of exceptions that Harness.add_user_secret may raise
+* ci(testing): adjust the workflow that publishes ops-scenario
 
 Note that the commit messages to the PR's branch do not need to follow the
 conventional commit format, as these will be squashed into a single commit to `main`
@@ -202,10 +205,10 @@ The copyright information in existing files does not need to be updated when tho
 
 In general, new functionality
 should always be accompanied by user-focused documentation that is posted to
-https://juju.is/docs/sdk.  The content for this site is written and hosted on
-https://discourse.charmhub.io/c/doc.  New documentation should get a new
+https://juju.is/docs/sdk. The content for this site is written and hosted on
+https://discourse.charmhub.io/c/doc. New documentation should get a new
 topic/post on this Discourse forum and then should be linked into the main
-docs navigation page(s) as appropriate.  The ops library's SDK page
+docs navigation page(s) as appropriate. The ops library's SDK page
 content is pulled from
 [the corresponding Discourse topic](https://discourse.charmhub.io/t/the-charmed-operator-software-development-kit-sdk-docs/4449).
 Each page on [juju.is](https://juju.is/docs/sdk) has a link at the bottom that
@@ -221,13 +224,21 @@ Currently we don't publish separate versions of documentation for separate relea
 next to the relevant content (e.g. headings, etc.).
 
 The ops library's API reference is automatically built and published to
-[ops.readthedocs.io](https://ops.readthedocs.io/en/latest/).  Please be complete with
+[ops.readthedocs.io](https://ops.readthedocs.io/en/latest/). Please be complete with
 docstrings and keep them informative for _users_. The published docs are always
 for the in-development (main branch) of ops, and do not include any notes
-indicating changes or additions across versions - we encourage all charmers to
+indicating changes or additions across ops versions - we encourage all charmers to
 promptly upgrade to the latest version of ops, and to refer to the release notes
 and changelog for learning about changes.
 
+We do note when features behave differently when using different Juju versions.
+Use the `.. jujuadded:: x.y` directive to indicate that the feature is only
+available when using version x.y (or higher) of Juju, `..jujuchanged:: x.y`
+when the feature's behaviour _in ops_ changes, and `..jujuremoved:: x.y` when
+the feature will be available in ops but not in that version (or later) of Juju.
+Unmarked features are assumed to work and be available in the current LTS
+version of Juju.
+
 During the release process, changes also get a new entry in [CHANGES.md](CHANGES.md).
 These are grouped into the same groupings as
 [commit messages](https://www.conventionalcommits.org/en/)
@@ -307,47 +318,155 @@ the build frontend is [build](https://pypi.org/project/build/).
 
 # Publishing a Release
 
-To make a release of the ops library, do the following:
-
-1. Visit the [releases page on GitHub](https://github.com/canonical/operator/releases).
-2. Click "Draft a new release"
-3. The "Release Title" is simply the full version number, in the form `<major>.<minor>.<patch>`
-   and a brief summary of the main changes in the release
-   E.g. 2.3.12 Bug fixes for the Juju foobar feature when using Python 3.12
-4. Use the "Generate Release Notes" button to get a copy of the changes into the
-   notes field.
-5. Group the changes by the commit type (feat, fix, etc.) and use full names (e.g., "Features",
-   not "feat") for group headings. Strip the commit type prefix from the bullet point. Strip the
-   username (who did each commit) if the author is a member of the Charm Tech team.
-6. Where appropriate, collapse multiple tightly related bullet points into a
-   single point that refers to multiple commits.
-7. Create a new branch, and copy this text to the [CHANGES.md](CHANGES.md) file,
-   stripping out links, who did each commit, the new contributor list, and the
-   link to the full changelog.
-8. Change [version.py](ops/version.py)'s `version` to the
-   [appropriate string](https://semver.org/).
-9. Check if there's a `chore: update charm pins` auto-generated PR in the queue. If it looks
-   good, merge it and check that tests still pass. If needed, you can re-trigger the
-   `Update Charm Pins` workflow manually to ensure latest charms and ops get tested.
-10. Add, commit, and push, and open a PR to get the changelog and version bump
+To make a release of the `ops` and/or `ops-scenario` packages, do the following:
+
+1. Check if there's a `chore: update charm pins` auto-generated PR in the queue.
+   If it looks good, merge it and check that tests still pass. If needed, you
+   can re-trigger the `Update Charm Pins` workflow manually to ensure latest
+   charms and ops get tested.
+2. Visit the [releases page on GitHub](https://github.com/canonical/operator/releases).
+3. Click "Draft a new release"
+4. The "Release Title" is the full version numbers of ops and/or ops-scenario,
+   in the form `ops <major>.<minor>.<patch> and ops-scenario <major>.<minor>.<patch>`
+   and a brief summary of the main changes in the release.
+   For example: `ops 2.3.12 Bug fixes for the Juju foobar feature when using Python 3.12`
+5. If the last release was for both `ops` and `ops-scenario`, leave the previous
+   tag choice on `auto`. If the last release was for only one package, change
+   the previous tag to be the last time the same package(s) were being released.
+6. Have the release create a new tag, in the form `<major>.<minor>.<patch>` for
+   `ops` and `scenario-<major>.<minor>.<patch>` for `ops-scenario`. If releasing
+   both packages, use the ops tag.
+7. Use the "Generate Release Notes" button to get a copy of the changes into the
+   notes field. The 'Release Documentation' section below details the form that
+   the release notes and changelog should take.
+8. For `ops`, change [version.py](ops/version.py)'s `version` to the
+   appropriate string. For `ops-scenario`, change the version in
+   [testing/pyproject.toml](testing/pyproject.toml). Both packages use
+   [semantic versioning]](https://semver.org/), and adjust independently
+   (that is: ops 2.18 doesn't imply ops-scenario 2.18, or any other number).
+9. Add, commit, and push, and open a PR to get the changelogs and version bumps
    into main (and get it merged).
-11. Back in the GitHub releases page, tweak the release notes - for example,
-   you might want to have a short paragraph at the intro on particularly
-   noteworthy changes.
-12. Have someone else in the Charm-Tech team proofread the release notes.
-13. When you are ready, click "Publish". (If you are not ready, click "Save as Draft".)
+10. Save the release notes as a draft, and have someone else in the Charm-Tech
+   team proofread the release notes.
+11. If the release includes both `ops` and `ops-scenario` packages, then push a
+   new tag in the form `scenario-<major>.<minor>.<patch>`. This is done by
+   executing `git tag scenario-x.y.z`, then `git push upstream tag scenario-x.y.z` locally
+   (assuming you have configured `canonical/operator` as a remote named
+   `upstream`).
+12. When you are ready, click "Publish". GitHub will create the additional tag.
+
+Pushing the tags will trigger automatic builds for the Python packages and
+publish them to PyPI ([ops](https://pypi.org/project/ops/) and
+[ops-scenario](https://pypi.org/project/ops-scenario)) (authorisation is handled
+via a [Trusted Publisher](https://docs.pypi.org/trusted-publishers/) relationship).
+Note that it sometimes take a bit of time for the new releases to show up.
+
+See [.github/workflows/publish-ops.yaml](.github/workflows/publish-ops.yaml) and
+[.github/workflows/publish-ops-scenario.yaml](.github/workflows/publish-ops-scenario.yaml) for details.
+(Note that the versions in the YAML refer to versions of the GitHub actions, not the versions of the ops library.)
 
-This will trigger an automatic build for the Python package and publish it to
-[PyPI](https://pypi.org/project/ops/)) (authorisation is handled via a
-[Trusted Publisher](https://docs.pypi.org/trusted-publishers/) relationship).
-Note that it sometimes take a bit of time for the new release to show up.
+You can troubleshoot errors on the [Actions Tab](https://github.com/canonical/operator/actions).
 
-See [.github/workflows/publish.yml](.github/workflows/publish.yml) for details. (Note that the versions in publish.yml refer to versions of the GitHub actions, not the versions of the ops library.)
+13. Announce the release on [Discourse](https://discourse.charmhub.io/c/framework/42) and [Matrix](https://matrix.to/#/#charmhub-charmdev:ubuntu.com).
 
-You can troubleshoot errors on the [Actions Tab](https://github.com/canonical/operator/actions).
+14. Open a PR to change the version strings to the expected
+   next version, with ".dev0" appended (for example, if 3.14.1 is the next
+   expected version, use `'3.14.1.dev0'`).
+
+## Release Documentation
+
+We produce several pieces of documentation for `ops` and `ops-scenario`
+releases, each serving a separate purpose and covering a different level.
+
+Avoid using the word "Scenario", preferring "unit testing API" or "state
+transition testing". Users should install `ops-scenario` with
+`pip install ops[testing]` rather than using the `ops-scenario` package name
+directly.
+
+### `git log`
+
+`git log` is used to see every change since a previous release. Obviously, no
+special work needs to be done so that this is available. A link to the GitHub
+view of the log will be included at the end of the GitHub release notes when
+the "Generate Release Notes" button is used, in the form:
+
+```
+**Full Changelog**: https://github.com/canonical/operator/compare/2.17.0...2.18.0
+```
+
+These changes include both `ops` and `ops-scenario`. If someone needs to see
+changes only for one of the packages, then the `/testing/` folder can be
+filtered in/out.
+
+### CHANGES.md
+
+A changelog is kept in version control that simply lists the changes in each
+release, other than chores like bumping version numbers. The changelog for `ops`
+is at the top level, in [CHANGES.md](CHANGES.md), and the changelog for
+`ops-scenario` is in the `/testing` folder, [CHANGES.md](testing/CHANGES.md).
+There will be overlap between the two files, as many PRs will include changes to
+common infrastructure, or will adjust both `ops` and also the testing API in
+`ops-scenario`.
+
+Adding the changes is done in preparation for a release. Use the "Generate
+Release Notes" button in the GitHub releases page, and copy the text to the
+CHANGES.md files.
+
+* Group the changes by the commit type (feat, fix, and so on) and use full names
+  ("Features", not "feat", "Fixes", not "fix") for group headings.
+* Remove any bullets that do not apply to the package (`ops` only changes for
+  `ops-scenario`, and `ops-scenario` only changes for `ops`).
+* Strip the commit type prefix from the bullet point, and capitalise the first
+  word.
+* Strip the username (who did each commit) if the author is a member of the
+  Charm Tech team.
+* Replace the link to the pull request with the PR number in parentheses.
+* Where appropriate, collapse multiple tightly related bullet points into a
+  single point that refers to multiple commits.
+
+For example: the PR
+
+```
+* docs: clarify where StoredState is stored by @benhoyt in https://github.com/canonical/operator/pull/2006
+```
 
-13. Announce the release on [Discourse](https://discourse.charmhub.io/c/framework/42) and [Matrix](https://matrix.to/#/#charmhub-charmdev:ubuntu.com)
+is added to the "Documentation" section as:
+
+```
+* Clarify where StoredState is stored (#2006)
+```
+
+### GitHub Release Notes
+
+The GitHub release notes include the list of changes found in the changelogs,
+but:
+
+* If both `ops` and `ops-scenario` packages are being released, include all the
+  changes in the same set of release notes. If only one package is being
+  released, remove any bullets that apply only to the other package.
+* The links to the PRs are left in full.
+* Add a section above the list of changes that briefly outlines any key changes
+  in the release.
+
+### Discourse Release Announcement
+
+Post to the [framework category](https://discourse.charmhub.io/c/framework/42)
+with a subject matching the GitHub release title.
+
+The post should resemble this:
+
+```
+The Charm Tech team has just released version x.y.z of ops!
+
+It’s available from PyPI by using `pip install ops`, and `pip install ops[testing]`,
+which will pick up the latest version. Upgrade by running `pip install --upgrade ops`.
+
+The main improvements in this release are ...
+
+Read more in the [full release notes on GitHub](link to the GitHub release).
+```
 
-14. Open a PR to change [version.py](ops/version.py)'s `version` to the expected
-   next version, with ".dev0" appended (for example, if 3.14.1 is the next expected version, use
-   `'3.14.1.dev0'`).
+In the post, outline the key improvements both in `ops` and `ops-scenario` - 
+the point here is to encourage people to check out the full notes and to upgrade
+promptly, so ensure that you entice them with the best that the new versions
+have to offer.
diff --git a/docs/requirements.txt b/docs/requirements.txt
index 0c4e6f1..55cf2d1 100644
--- a/docs/requirements.txt
+++ b/docs/requirements.txt
@@ -151,7 +151,7 @@ sphinxcontrib-serializinghtml==2.0.0
     # via sphinx
 sphinxext-opengraph==0.9.1
     # via ops (pyproject.toml)
-starlette==0.38.2
+starlette==0.40.0
     # via sphinx-autobuild
 uc-micro-py==1.0.3
     # via linkify-it-py
diff --git a/ops/_private/harness.py b/ops/_private/harness.py
index 2be1e45..61c2ee0 100644
--- a/ops/_private/harness.py
+++ b/ops/_private/harness.py
@@ -39,6 +39,7 @@ from typing import (
     AnyStr,
     BinaryIO,
     Callable,
+    ClassVar,
     Dict,
     Generic,
     Iterable,
@@ -2225,7 +2226,7 @@ def _copy_docstrings(source_cls: Any):
 class _TestingConfig(Dict[str, Union[str, int, float, bool]]):
     """Represents the Juju Config."""
 
-    _supported_types = {
+    _supported_types: ClassVar[Dict[str, Any]] = {
         'string': str,
         'boolean': bool,
         'int': int,
@@ -2838,9 +2839,7 @@ class _TestingModelBackend:
         unit_secret = secret.owner_name == self.unit_name
         app_secret = secret.owner_name == self.app_name
 
-        if unit_secret or (app_secret and self.is_leader()):
-            return True
-        return False
+        return unit_secret or (app_secret and self.is_leader())
 
     def secret_info_get(
         self, *, id: Optional[str] = None, label: Optional[str] = None
diff --git a/ops/charm.py b/ops/charm.py
index 42c94b2..d58b8ad 100644
--- a/ops/charm.py
+++ b/ops/charm.py
@@ -22,6 +22,7 @@ import warnings
 from typing import (
     TYPE_CHECKING,
     Any,
+    ClassVar,
     Dict,
     List,
     Literal,
@@ -1678,7 +1679,7 @@ class RelationMeta:
     ``metadata.yaml`` and used by the charm code if appropriate.
     """
 
-    VALID_SCOPES = ['global', 'container']
+    VALID_SCOPES: ClassVar[List[str]] = ['global', 'container']
 
     def __init__(self, role: RelationRole, relation_name: str, raw: '_RelationMetaDict'):
         assert isinstance(
diff --git a/ops/framework.py b/ops/framework.py
index 06dc5c5..a7c4ff6 100644
--- a/ops/framework.py
+++ b/ops/framework.py
@@ -716,7 +716,7 @@ class Framework(Object):
             parent_path = parent.path
 
         kind_: str = kind or cls.handle_kind
-        self._type_registry[(parent_path, kind_)] = cls
+        self._type_registry[parent_path, kind_] = cls
         self._type_known.add(cls)
 
     def save_snapshot(self, value: Union['StoredStateData', 'EventBase']):
diff --git a/ops/model.py b/ops/model.py
index fc75230..a86c2db 100644
--- a/ops/model.py
+++ b/ops/model.py
@@ -39,6 +39,7 @@ from typing import (
     Any,
     BinaryIO,
     Callable,
+    ClassVar,
     Dict,
     Generator,
     Iterable,
@@ -1963,7 +1964,7 @@ class StatusBase:
     directly use the child class such as :class:`ActiveStatus` to indicate their status.
     """
 
-    _statuses: Dict[StatusName, Type['StatusBase']] = {}
+    _statuses: ClassVar[Dict[StatusName, Type['StatusBase']]] = {}
 
     # Subclasses must provide this attribute
     name: StatusName
@@ -2023,7 +2024,7 @@ class StatusBase:
             )
         cls._statuses[child.name] = child
 
-    _priorities = {
+    _priorities: ClassVar[Dict[str, Any]] = {
         'error': 5,
         'blocked': 4,
         'maintenance': 3,
@@ -2661,7 +2662,7 @@ class Container:
                     if info.type is pebble.FileType.DIRECTORY:
                         self.make_dir(dstpath, make_parents=True)
                         continue
-                    with open(info.path) as src:
+                    with open(info.path, 'rb') as src:
                         self.push(
                             dstpath,
                             src,
@@ -3715,7 +3716,7 @@ class _ModelBackend:
             args.extend(['--label', label])
         result = self._run_for_secret('secret-info-get', *args, return_output=True, use_json=True)
         info_dicts = typing.cast(Dict[str, Any], result)
-        id = list(info_dicts)[0]  # Juju returns dict of {secret_id: {info}}
+        id = next(iter(info_dicts))  # Juju returns dict of {secret_id: {info}}
         return SecretInfo.from_dict(
             id, typing.cast(Dict[str, Any], info_dicts[id]), model_uuid=self.model_uuid
         )
diff --git a/ops/pebble.py b/ops/pebble.py
index 6144519..f17e1cf 100644
--- a/ops/pebble.py
+++ b/ops/pebble.py
@@ -2173,7 +2173,7 @@ class Client:
         timeout: float = 30.0,
         delay: float = 0.1,
     ) -> ChangeID:
-        """Stop services by name and wait (poll) for them to be started.
+        """Stop services by name and wait (poll) for them to be stopped.
 
         Args:
             services: Non-empty list of services to stop.
@@ -3216,7 +3216,7 @@ class _FilesParser:
         self._parser.feed(data)
 
     def _prepare_tempfile(self, filename: str):
-        tf = tempfile.NamedTemporaryFile(delete=False)
+        tf = tempfile.NamedTemporaryFile(delete=False)  # noqa: SIM115
         self._files[filename] = tf  # type: ignore # we have a custom protocol for it
         self.current_filename = filename
 
diff --git a/pyproject.toml b/pyproject.toml
index 024bb94..0c77fea 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -146,17 +146,9 @@ ignore = [
     # Missing docstring in `__init__`
     "D107",
 
-    # Manual list comprehension.
-    "PERF401",
     # Manual dict comprehension.
     "PERF403",
 
-    # Convert Dict[X] to dict[x], which we cannot do while we support Python 3.8
-    "UP006",
-
-    # Convert Union[X, Y] to X | Y, which we cannot do while we support Python 3.8
-    "UP007",
-
     # Convert {} from `TypedDict` functional to class syntax
     # Note that since we have some `TypedDict`s that cannot use the class
     # syntax, we're currently choosing to be consistent in syntax even though
@@ -167,14 +159,6 @@ ignore = [
 
     # `subprocess` call: check for execution of untrusted input
     "S603",
-    # Prefer `next(iter(info_dicts))` over single element slice
-    "RUF015",
-    # Mutable class attributes should be annotated with `typing.ClassVar`
-    "RUF012",
-    # Unnecessary dict comprehension for iterable; use `dict.fromkeys` instead
-    "RUF025",
-    # Unnecessary `tuple` call (rewrite as a literal)
-    "C408",
 ]
 
 [tool.ruff.lint.pyupgrade]
@@ -211,6 +195,7 @@ keep-runtime-typing = true
     "E266",  # Too many leading `#` before block comment
     "I001",  # Import block is un-sorted or un-formatted
     "RUF003",  # Comment contains ambiguous unicode characters (EN DASH, RIGHT SINGLE QUOTATION MARK)
+    "RUF012",  # Mutable class attributes should be annotated with `typing.ClassVar`
     "UP031",  # Use format specifiers instead of percent format
 ]
 
diff --git a/tox.ini b/tox.ini
index 7854dfe..ef9ee7f 100644
--- a/tox.ini
+++ b/tox.ini
@@ -60,14 +60,14 @@ commands =
 [testenv:fmt]
 description = Apply coding style standards to code
 deps =
-    ruff==0.4.5
+    ruff==0.7.0
 commands =
     ruff format --preview
 
 [testenv:lint]
 description = Check code against coding style standards
 deps =
-    ruff==0.4.5
+    ruff==0.7.0
     codespell==2.3.0
 commands =
     ruff check --preview
@@ -80,7 +80,7 @@ allowlist_externals = cp
 deps =
     PyYAML==6.*
     websocket-client==1.*
-    pyright==1.1.377
+    pyright==1.1.385
     pytest~=7.2
     typing_extensions~=4.2
     -e .
@@ -107,6 +107,7 @@ commands =
 
 [testenv:coverage]
 description = Run unit tests with coverage
+allowlist_externals = mkdir
 passenv =
     RUN_REAL_PEBBLE_TESTS
     PEBBLE
@@ -120,10 +121,12 @@ deps =
     -e .
     -e testing
 commands =
-    coverage run --source={[vars]src_path} \
+    mkdir -p .coverage
+    coverage run --source={[vars]src_path},testing/src/scenario \
+             --data-file=.coverage.data \
              -m pytest --ignore={[vars]tst_path}smoke -v --tb native {posargs}
-    coverage xml
-    coverage report
+    coverage xml --data-file=.coverage.data -o .coverage/coverage.xml
+    coverage report --data-file=.coverage.data
 
 [testenv:pebble]
 description = Run real pebble tests


Test patch:
diff --git a/.github/workflows/db-charm-tests.yaml b/.github/workflows/db-charm-tests.yaml
index 4d938f1..b6f1e56 100644
--- a/.github/workflows/db-charm-tests.yaml
+++ b/.github/workflows/db-charm-tests.yaml
@@ -15,13 +15,13 @@ jobs:
       matrix:
         include:
           - charm-repo: canonical/postgresql-operator
-            commit: 7219d1ed1a118abbfca23aeacf3b7bf67e81a937  # rev480 rev479 2024-09-23T16:12:27Z
+            commit: 283463b6df9fd6c81c3221668cbcd4de9927a58d  # rev503 rev502 2024-10-22T11:52:52Z
           - charm-repo: canonical/postgresql-k8s-operator
-            commit: 5c2ae046546a7d163b9d040d6a12d1abc0ca1f70  # rev398 rev397 2024-09-24T19:23:37Z
+            commit: fb0276647d1409c67e12459f8b64acca881d0b4a  # 2024-10-24T11:26:42Z
           - charm-repo: canonical/mysql-operator
-            commit: 9fe60aa9f0a00636e06902d0849d36e1e1525df2  # 2024-09-22
+            commit: 30d63e8d96f8730b5189d95a3f4c0214351801c0  # 2024-10-18T15:50:23Z
           - charm-repo: canonical/mysql-k8s-operator
-            commit: 61db833ba8318773de565d7d9b56031baf3e75f7  # 2024-09-19T11:48:20Z
+            commit: b9da8fccd108780bdb551b6dbcac7017f7908990  # 2024-10-18T15:49:54Z
     steps:
       - name: Checkout the ${{ matrix.charm-repo }} repository
         uses: actions/checkout@v4
diff --git a/.github/workflows/framework-tests.yaml b/.github/workflows/framework-tests.yaml
index f00ff2f..c159365 100644
--- a/.github/workflows/framework-tests.yaml
+++ b/.github/workflows/framework-tests.yaml
@@ -30,6 +30,8 @@ jobs:
       - uses: actions/checkout@v4
       - name: Set up Python 3
         uses: actions/setup-python@v5
+        with:
+          python-version: '3.11'
 
       - name: Install tox
         run: pip install tox~=4.2
@@ -79,6 +81,8 @@ jobs:
         uses: actions/setup-go@v5
         with:
           go-version: "1.22"
+          # To suppress the "Restore cache failed" error, since there is no go.sum file here.
+          cache: false
 
       - name: Install tox
         run: pip install tox~=4.2
@@ -116,7 +120,6 @@ jobs:
         uses: actions/setup-python@v5
         with:
           python-version: ${{ matrix.python-version }}
-          allow-prereleases: true
 
       - name: Install build dependencies
         run: pip install wheel build
diff --git a/.github/workflows/observability-charm-tests.yaml b/.github/workflows/observability-charm-tests.yaml
index 8acf27a..d604ade 100644
--- a/.github/workflows/observability-charm-tests.yaml
+++ b/.github/workflows/observability-charm-tests.yaml
@@ -16,15 +16,13 @@ jobs:
       matrix:
         include:
           - charm-repo: canonical/alertmanager-k8s-operator
-            commit: 7518ec8343941ca91248614fd8f2d50fdd9e068c  # rev135 2024-09-06T12:10:02Z
+            commit: fe233592f9324a6cf4aa9dbc71577ee170cf9eb3  # rev138 2024-10-18T12:11:02Z
           # Waiting for an upstream PR:
           # https://github.com/canonical/prometheus-k8s-operator/pull/639
           #- charm-repo: canonical/prometheus-k8s-operator
           #  commit: 7518ec8343941ca91248614fd8f2d50fdd9e068c  # rev135 2024-09-06T12:10:02Z
-          # Waiting for an upstream PR:
-          # https://github.com/canonical/grafana-k8s-operator/pull/347
-          #- charm-repo: canonical/grafana-k8s-operator
-          #  commit: 1bd9e7fd96a9f5a91443e5932667c58a92c1ce5e  # 2024-09-03T14:57:42Z
+          - charm-repo: canonical/grafana-k8s-operator
+            commit: 07a52a65f272e115ab786f30cdb3b53be6b34bb7
     steps:
       - name: Checkout the ${{ matrix.charm-repo }} repository
         uses: actions/checkout@v4
diff --git a/.github/workflows/published-charms-tests.yaml b/.github/workflows/published-charms-tests.yaml
index d7430de..c614e42 100644
--- a/.github/workflows/published-charms-tests.yaml
+++ b/.github/workflows/published-charms-tests.yaml
@@ -59,17 +59,11 @@ jobs:
         with:
           repository: ${{ matrix.charm-repo }}
 
-      - name: Checkout the operator repository
-        uses: actions/checkout@v4
-        with:
-          path: myops
-
       - name: Install patch dependencies
         run: pip install poetry~=1.6
 
       - name: Update 'ops' dependency in test charm to latest
         run: |
-          rm -rf myops/test
           if [ -e "test-requirements.txt" ]; then
             sed -i -e "/^ops[ ><=]/d" -e "/canonical\/operator/d" -e "/#egg=ops/d" test-requirements.txt
             echo -e "\ngit+$GITHUB_SERVER_URL/$GITHUB_REPOSITORY@$GITHUB_SHA#egg=ops" >> test-requirements.txt
@@ -82,8 +76,7 @@ jobs:
             sed -i -e "/^ops[ ><=]/d" -e "/canonical\/operator/d" -e "/#egg=ops/d" requirements.txt
             echo -e "\ngit+$GITHUB_SERVER_URL/$GITHUB_REPOSITORY@$GITHUB_SHA#egg=ops" >> requirements.txt
           elif [ -e "poetry.lock" ]; then
-            sed -i -e "s/^ops[ ><=].*/ops = {path = \"myops\"}/" pyproject.toml
-            poetry lock --no-update
+            poetry add git+$GITHUB_SERVER_URL/$GITHUB_REPOSITORY@$GITHUB_SHA --lock
           else
             echo "Error: No requirements.txt or poetry.lock file found"
             exit 1
@@ -111,14 +104,8 @@ jobs:
       - name: Charmcraft init
         run: charmcraft init --profile=${{ matrix.profile }} --author=charm-tech
 
-      - name: Checkout the operator repository
-        uses: actions/checkout@v4
-        with:
-          path: myops
-
       - name: Update 'ops' dependency in test charm to latest
         run: |
-          rm -rf myops/test
           if [ -e "requirements.txt" ]; then
             sed -i -e "/^ops[ ><=]/d" -e "/canonical\/operator/d" -e "/#egg=ops/d" requirements.txt
             echo -e "\ngit+$GITHUB_SERVER_URL/$GITHUB_REPOSITORY@$GITHUB_SHA#egg=ops" >> requirements.txt
@@ -129,3 +116,6 @@ jobs:
 
       - name: Run the charm's unit tests
         run: tox -vve unit
+
+      - name: Run the charm's static tests
+        run: tox -vve static
diff --git a/.github/workflows/test-publish-ops-scenario.yaml b/.github/workflows/test-publish-ops-scenario.yaml
new file mode 100644
index 0000000..398d5af
--- /dev/null
+++ b/.github/workflows/test-publish-ops-scenario.yaml
@@ -0,0 +1,35 @@
+name: Test Publish
+on: [workflow_dispatch, workflow_call]
+
+jobs:
+  framework-tests:
+    uses: ./.github/workflows/framework-tests.yaml
+  observability-charm-tests:
+    uses: ./.github/workflows/observability-charm-tests.yaml
+  hello-charm-tests:
+    uses: ./.github/workflows/hello-charm-tests.yaml
+  build-n-publish:
+    name: Build and Publish ops-scenario to Test PyPI
+    runs-on: ubuntu-latest
+    permissions:
+      id-token: write
+      attestations: write
+      contents: read
+    needs: [framework-tests, observability-charm-tests, hello-charm-tests]
+    steps:
+      - uses: actions/checkout@v4
+      - name: Setup Python
+        uses: actions/setup-python@v5
+      - name: Install build dependencies
+        run: pip install wheel build
+      - name: Build
+        run: python -m build
+        working-directory: ./testing
+      - name: Attest build provenance
+        uses: actions/attest-build-provenance@v1.4.3
+        with:
+          subject-path: 'testing/dist/*'
+      - name: Publish to test.pypi.org
+        uses: pypa/gh-action-pypi-publish@release/v1
+        with:
+          repository-url: https://test.pypi.org/legacy/
diff --git a/.github/workflows/test-publish.yml b/.github/workflows/test-publish-ops.yaml
similarity index 95%
rename from .github/workflows/test-publish.yml
rename to .github/workflows/test-publish-ops.yaml
index da6fb55..dcfcb3b 100644
--- a/.github/workflows/test-publish.yml
+++ b/.github/workflows/test-publish-ops.yaml
@@ -9,7 +9,7 @@ jobs:
   hello-charm-tests:
     uses: ./.github/workflows/hello-charm-tests.yaml
   build-n-publish:
-    name: Build and Publish to Test PyPI
+    name: Build and Publish ops to Test PyPI
     runs-on: ubuntu-latest
     permissions:
       id-token: write
diff --git a/test/test_main.py b/test/test_main.py
index 6aea9ee..2ce6162 100644
--- a/test/test_main.py
+++ b/test/test_main.py
@@ -1231,7 +1231,7 @@ class _TestMainWithDispatch(_TestMain):
         request: pytest.FixtureRequest,
         fake_script: FakeScript,
     ):
-        self.stdout = self.stderr = tempfile.TemporaryFile()
+        self.stdout = self.stderr = tempfile.TemporaryFile()  # noqa: SIM115
         request.addfinalizer(self.stdout.close)
 
         fake_script_hooks = FakeScript(request, self.hooks_dir)
@@ -1383,7 +1383,7 @@ class TestMainWithDispatch(_TestMainWithDispatch):
     @pytest.mark.usefixtures('setup_charm')
     def test_crash_action(self, request: pytest.FixtureRequest, fake_script: FakeScript):
         self._prepare_actions()
-        self.stderr = tempfile.TemporaryFile('w+t')
+        self.stderr = tempfile.TemporaryFile('w+t')  # noqa: SIM115
         request.addfinalizer(self.stderr.close)
         fake_script.write('action-get', "echo '{}'")
         with pytest.raises(subprocess.CalledProcessError):
diff --git a/test/test_model.py b/test/test_model.py
index 7eb2ffa..541eeb1 100644
--- a/test/test_model.py
+++ b/test/test_model.py
@@ -1369,8 +1369,8 @@ def test_recursive_push_and_pull(case: PushPullCase):
     for file in case.files:
         fpath = os.path.join(push_src.name, file[1:])
         os.makedirs(os.path.dirname(fpath), exist_ok=True)
-        with open(fpath, 'w') as f:
-            f.write('hello')
+        with open(fpath, 'wb') as f:
+            f.write(b'push \xc3\x28')  # invalid UTF-8 to ensure binary works
     if case.dirs:
         for directory in case.dirs:
             fpath = os.path.join(push_src.name, directory[1:])
@@ -1402,6 +1402,8 @@ def test_recursive_push_and_pull(case: PushPullCase):
     ), f'push_path gave wrong expected errors: want {case.errors}, got {errors}'
     for fpath in case.want:
         assert c.exists(fpath), f'push_path failed: file {fpath} missing at destination'
+        content = c.pull(fpath, encoding=None).read()
+        assert content == b'push \xc3\x28'
     for fdir in case.want_dirs:
         assert c.isdir(fdir), f'push_path failed: dir {fdir} missing at destination'
 
@@ -2808,7 +2810,7 @@ class TestModelBackend:
         version: str,
     ):
         # on 2.7.0+, things proceed as expected
-        t = tempfile.NamedTemporaryFile()
+        t = tempfile.NamedTemporaryFile()  # noqa: SIM115
         try:
             fake_script.write(
                 'relation-set',
diff --git a/test/test_pebble.py b/test/test_pebble.py
index 004ddc6..602fe31 100644
--- a/test/test_pebble.py
+++ b/test/test_pebble.py
@@ -1359,7 +1359,7 @@ class MockClient(pebble.Client):
             raise resp
         if callable(resp):
             resp = resp()
-        return resp
+        return resp  # type: ignore
 
     def _request_raw(
         self,
@@ -3691,9 +3691,9 @@ class TestExec:
         assert io_ws.sends == []
 
     def test_wait_file_io(self, client: MockClient):
-        fin = tempfile.TemporaryFile(mode='w+', encoding='utf-8')
-        out = tempfile.TemporaryFile(mode='w+', encoding='utf-8')
-        err = tempfile.TemporaryFile(mode='w+', encoding='utf-8')
+        fin = tempfile.TemporaryFile(mode='w+', encoding='utf-8')  # noqa: SIM115
+        out = tempfile.TemporaryFile(mode='w+', encoding='utf-8')  # noqa: SIM115
+        err = tempfile.TemporaryFile(mode='w+', encoding='utf-8')  # noqa: SIM115
         try:
             fin.write('foo\n')
             fin.seek(0)
diff --git a/test/test_real_pebble.py b/test/test_real_pebble.py
index a788f72..4e7d170 100644
--- a/test/test_real_pebble.py
+++ b/test/test_real_pebble.py
@@ -123,7 +123,7 @@ class TestRealPebble:
         # After two retries the "bad" check should go down
         for _ in range(5):
             checks = client.get_checks()
-            bad_check = [c for c in checks if c.name == 'bad'][0]
+            bad_check = next(c for c in checks if c.name == 'bad')
             if bad_check.status == pebble.CheckStatus.DOWN:
                 break
             time.sleep(0.06)
@@ -131,7 +131,7 @@ class TestRealPebble:
             assert False, 'timed out waiting for "bad" check to go down'
         assert bad_check.failures == 2
         assert bad_check.threshold == 2
-        good_check = [c for c in checks if c.name == 'good'][0]
+        good_check = next(c for c in checks if c.name == 'good')
         assert good_check.status == pebble.CheckStatus.UP
 
         # And /v1/health should return "unhealthy" (with status HTTP 502)
diff --git a/test/test_storage.py b/test/test_storage.py
index 2df43df..6c5a842 100644
--- a/test/test_storage.py
+++ b/test/test_storage.py
@@ -441,7 +441,7 @@ class TestJujuStateBackend:
         assert fake_script.calls(clear=True) == []
 
     def test_set_encodes_args(self, fake_script: FakeScript):
-        t = tempfile.NamedTemporaryFile()
+        t = tempfile.NamedTemporaryFile()  # noqa: SIM115
         try:
             fake_script.write(
                 'state-set',
@@ -478,7 +478,7 @@ class TestJujuStateBackend:
         ]
 
     def test_set_and_get_complex_value(self, fake_script: FakeScript):
-        t = tempfile.NamedTemporaryFile()
+        t = tempfile.NamedTemporaryFile()  # noqa: SIM115
         try:
             fake_script.write(
                 'state-set',
diff --git a/test/test_testing.py b/test/test_testing.py
index 8321427..1d636c7 100644
--- a/test/test_testing.py
+++ b/test/test_testing.py
@@ -5113,7 +5113,7 @@ class PebbleStorageAPIsTestMixin:
         }
 
         # Let's pull the first file again and check its details
-        file = [f for f in files if f.path == f'{pebble_dir}/file1'][0]
+        file = next(f for f in files if f.path == f'{pebble_dir}/file1')
         assert file.name == 'file1'
         assert file.type == pebble.FileType.FILE
         assert file.size == 4
@@ -5275,8 +5275,8 @@ class PebbleStorageAPIsTestMixin:
         client.make_dir(f'{pebble_dir}/dir2', permissions=0o777)
 
         files = client.list_files(f'{pebble_dir}/', pattern='dir*')
-        assert [f for f in files if f.path == f'{pebble_dir}/dir1'][0].permissions == 0o700
-        assert [f for f in files if f.path == f'{pebble_dir}/dir2'][0].permissions == 0o777
+        assert next(f for f in files if f.path == f'{pebble_dir}/dir1').permissions == 0o700
+        assert next(f for f in files if f.path == f'{pebble_dir}/dir2').permissions == 0o777
 
         # If permissions are outside of the range 0o000 through 0o777, an exception should be
         # raised.
@@ -5432,8 +5432,8 @@ class TestPebbleStorageAPIsUsingMocks(PebbleStorageAPIsTestMixin):
         assert not c1.exists(c1_fpath)
 
     def _select_testing_user_group(self):
-        user = [u for u in pwd.getpwall() if u.pw_uid != os.getuid()][0]
-        group = [g for g in grp.getgrall() if g.gr_gid != os.getgid()][0]
+        user = next(u for u in pwd.getpwall() if u.pw_uid != os.getuid())
+        group = next(g for g in grp.getgrall() if g.gr_gid != os.getgid())
         return user, group
 
     def test_push_with_ownership(
diff --git a/testing/CHANGES.md b/testing/CHANGES.md
new file mode 100644
index 0000000..b60094e
--- /dev/null
+++ b/testing/CHANGES.md
@@ -0,0 +1,43 @@
+# 7.0.5 - 20 Sep 2024
+
+## Features
+
+* Use a slightly more strict type for `AnyJson`
+
+# 7.0.4 - 18 Sep 2024
+
+## Chores
+
+* Add a `py.typed` file
+
+# 7.0.3 - 18 Sep 2024
+
+## Fixes
+
+* `ops.Model.get_relation` should not raise when a relation with the specified ID does not exist
+
+# 7.0.2 - 13 Sep 2024
+
+## Refactor
+
+* Adjustments to handle the upcoming release of ops 2.17
+
+# 7.0.1 - 9 Sep 2024
+
+## Fixes
+
+* Fix broken Python 3.8 compatibility.
+
+# 7.0.0 - 9 Sep 2024
+
+## Features
+
+* Support for testing Pebble check events
+* Container exec mocking can match against a command prefix
+* Inspect a list of the commands that a charm has `exec`'d in a container
+* Add consistency checks for `StoredState`
+* Specifying your event is now done via `ctx.on` attributes
+* The context manager is accessed via the `Context` object
+* State collections are frozensets instead of lists
+* Most classes now expect at least some arguments to be passed as keywords
+* Secret tests are much simpler - particularly, revision numbers do not need to be managed