diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..61a93e3
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,5 @@
+# pyOpenSci packaging template Changelog
+
+## [Unreleased]
+
+* Update release workflow to follow PyPA / PyPI recommended practices (@lwasser, #48)
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..857a206
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,15 @@
+# Contributing to the pyOpenSci Python package template
+
+To work on the template locally, you can call the copier template directly. Note that by default, `copier` uses the latest tag in your commit history. To ensure it uses the latest commit on your current active branch use:
+
+`copier copy -r HEAD /path/to/your/template destination-dir`
+
+If you want to test it against the latest tag in your local commit history, you can use:
+
+`copier copy /path/to/your/template destination-dir`
+
+## Run the tests
+
+You can use Hatch to run all of the tests for the template:
+
+`hatch run test:run`
diff --git a/README.md b/README.md
index 24873f1..a2b4a70 100644
--- a/README.md
+++ b/README.md
@@ -18,11 +18,11 @@ To use this template:
 1. Install copier using [pipx](https://pipx.pypa.io/stable/) or pip preferably with a [virtual environment](https://www.pyopensci.org/python-package-guide/CONTRIBUTING.html#create-a-virtual-environment).
 
     Global Installation:
-    
+
     ```console
     pipx install copier
     ```
-   
+
     or Environment specific installation:
 
     ```console
@@ -37,8 +37,9 @@ To use this template:
     ```console
     copier copy gh:pyopensci/pyos-package-template path/here
     ```
-    
-   The command below will create the package directory in your current working directory. 
+
+   The command below will create the package directory in your current working directory.
+
     ```console
     copier copy gh:pyopensci/pyos-package-template .
     ```
@@ -48,14 +49,13 @@ To use this template:
    as your source. You can read more about generating your project
    in the [copier documentation](https://copier.readthedocs.io/en/stable/generating/).
 
-
 ## Run the template workflow
 
-Once you have installed copier, you are ready to create your Python package template. 
-First, run the command below from your favorite shell. Note that this is copying our template from GitHub so it 
+Once you have installed copier, you are ready to create your Python package template.
+First, run the command below from your favorite shell. Note that this is copying our template from GitHub so it
 will require internet access to run properly.
 
-The command below will create the package directory in your current working directory. 
+The command below will create the package directory in your current working directory.
 
 `copier copy gh:pyopensci/pyos-package-template .`
 
@@ -64,13 +64,13 @@ If you wish to create the package directory in another directory you can specify
 `copier copy gh:pyopensci/pyos-package-template dirname-here`
 
 ## Template overview
-The copier template will ask you a series of questions which you can respond to. The questions will 
-help you customize the template. 
+
+The copier template will ask you a series of questions which you can respond to. The questions will
+help you customize the template.
 
 Below is what the template workflow will look like when you run it. In the example below, you  
 "fully customize" the template.  
 
-
 ```console
 ➜ copier copy gh:pyopensci/pyos-package-template .      
 🎤 Who is the copyright holder, for example, yourself or your organization? Used in the license
diff --git a/copier.yml b/copier.yml
index fb1b199..dc07813 100644
--- a/copier.yml
+++ b/copier.yml
@@ -77,7 +77,7 @@ documentation:
   type: str
   help: "Do you want to include documentation for your project and which framework do you want to use?"
   choices:
-    "Sphinx (https://www.pyopensci.org/pyos-sphinx-theme)": sphinx
+    "Sphinx (https://pydata-sphinx-theme.readthedocs.io/en/stable/index.html)": sphinx
     "mkdocs-material (https://squidfunk.github.io/mkdocs-material)": mkdocs
     No: ""
   default: "{% if use_default != 'minimal' %}sphinx{% else %}{% endif %}"
@@ -111,7 +111,7 @@ license:
   type: str
   help: |
     Which license do you want to use? Includes a LICENSE file in the repository root.
-    For more information, see: 
+    For more information, see:
     - https://www.pyopensci.org/python-package-guide/documentation/repository-files/license-files.html
     - https://opensource.org/licenses
   choices:
diff --git a/template/pyproject.toml.jinja b/template/pyproject.toml.jinja
index 42aea9b..c3f90c4 100644
--- a/template/pyproject.toml.jinja
+++ b/template/pyproject.toml.jinja
@@ -208,7 +208,7 @@ dependencies = [
 detached = true
 
 [tool.hatch.envs.style.scripts]
-docstrings = "pydoclint"
+docstrings = "pydoclint src/ tests/"
 code = "ruff check {args}"
 format = "ruff format {args}"
 check = ["docstrings", "code"]
diff --git a/template/{% if use_git and dev_platform == 'GitHub' %}.github{% endif %}/workflows/release.yml b/template/{% if use_git and dev_platform == 'GitHub' %}.github{% endif %}/workflows/release.yml
index 34fb5af..9b45e8d 100644
--- a/template/{% if use_git and dev_platform == 'GitHub' %}.github{% endif %}/workflows/release.yml	
+++ b/template/{% if use_git and dev_platform == 'GitHub' %}.github{% endif %}/workflows/release.yml	
@@ -1,53 +1,74 @@
-name: CD
+name: Publish to PyPI
 
 on:
-  push:
-    tags:
-    - 'v?[0-9]+.[0-9]+.[0-9]+'
-    - 'v?[0-9]+.[0-9]+.[0-9]+(a|b|rc|post|dev)[0-9]+'
+  release:
+    types: [published]
 
 jobs:
   prerequisites:
     uses: ./.github/workflows/test.yml
-
-  release:
+  # Setup build separate from publish for added security
+  # See https://github.com/pypa/gh-action-pypi-publish/issues/217#issuecomment-1965727093
+  build:
     needs: [prerequisites]
-    strategy:
-      matrix:
-        os: [ubuntu-latest]
-        python-version: ["3.12"]
-    runs-on: ${{ matrix.os }}
-    permissions:
-      # Write permissions are needed to create OIDC tokens.
-      id-token: write
-      # Write permissions are needed to make GitHub releases.
-      contents: write
-
+    runs-on: ubuntu-latest
+    # Environment is encouraged for increased security
     steps:
-    - uses: actions/checkout@v4
-
-    - name: Set up Python ${{ matrix.python-version }}
-      uses: actions/setup-python@v5
-      with:
-        python-version: ${{ matrix.python-version }}
+      - name: Checkout
+        uses: actions/checkout@v4
+        with:
+          # This fetch element is only important if you are use SCM based
+          # versioning (that looks at git tags to gather the version).
+          # setuptools-scm needs tags to form a valid version number
+          fetch-tags: true
 
-    - name: Install hatch
-      uses: pypa/hatch@install
+      - name: Setup Python
+        uses: actions/setup-python@v5
+        with:
+          # You can modify what version of Python you want to use for your release
+          python-version: "3.11"
 
-    - name: Build package
-      run: hatch build
+      # Security recommends we should pin deps. Should we pin the workflow version?
+      - name: Install hatch
+        uses: pypa/hatch@a3c83ab3d481fbc2dc91dd0088628817488dd1d5
 
-    # We rely on a trusted publisher configuration being present on PyPI,
-    # see https://docs.pypi.org/trusted-publishers/.
-    - name: Publish to PyPI
-      uses: pypa/gh-action-pypi-publish@release/v1
+      - name: Build package using Hatch
+        run: |
+          hatch build
+          echo ""
+          echo "Generated files:"
+          ls -lh dist/
 
-    - name: GH release
-      uses: softprops/action-gh-release@v2
-      with:
-        body: >
-          Please see
-          https://github.com/${{ github.repository }}/blob/${{ github.ref_name }}/CHANGELOG.md
-          for the full release notes.
-        draft: false
-        prerelease: false
+      # Store an artifact of the build to use in the publish step below
+      - name: Store the distribution packages
+        uses: actions/upload-artifact@v4
+        with:
+          name: python-package-distributions
+          path: dist/
+          if-no-files-found: error
+  publish:
+    name: >-
+      Publish Python 🐍 distribution 📦 to PyPI
+    # Modify the repo name below to be your project's repo name.
+    if: github.repository_owner == "{{ username }}"
+    needs:
+      - build
+    runs-on: ubuntu-latest
+    # Environment required here for trusted publisher
+    environment:
+      name: pypi
+      # Modify the url to be the name of your package
+      url: https://pypi.org/p/${{ package_name }}
+    permissions:
+      id-token: write  # this permission is mandatory for PyPI publishing
+    steps:
+      - name: Download dists
+        uses: actions/download-artifact@v4
+        with:
+          name: python-package-distributions
+          path: dist/
+          merge-multiple: true
+      - name: Publish package to PyPI
+        # Only publish to real PyPI on release
+        if: github.event_name == 'release' && github.event.action == 'published'
+        uses: pypa/gh-action-pypi-publish@release/v1
diff --git a/tests/conftest.py b/tests/conftest.py
index 888f6dd..659a3e8 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -1,4 +1,4 @@
-"""Ruff is forcing me to write a docstring for conftest.py."""
+"""Provide fixtures to the entire test suite."""
 
 import shutil
 from pathlib import Path
@@ -16,6 +16,7 @@
 COPIER_CONFIG_PATH = Path(__file__).parents[1] / "copier.yml"
 INCLUDES_PATH = Path(__file__).parents[1] / "includes"
 
+
 def _load_copier_config() -> dict:
     yaml = YAML(typ="safe")
     with COPIER_CONFIG_PATH.open("r") as yfile:
@@ -29,6 +30,7 @@ def _load_copier_config() -> dict:
 # pytest hooks
 # --------------------------------------------------
 
+
 def pytest_addoption(parser: "Parser") -> None:
     """Add options to pytest."""
     parser.addoption(
@@ -40,10 +42,12 @@ def pytest_addoption(parser: "Parser") -> None:
         "otherwise, use a temporary directoy and remove it afterwards.",
     )
 
+
 # --------------------------------------------------
 # Fixtures - autouse
 # --------------------------------------------------
 
+
 @pytest.fixture(scope="session", autouse=True)
 def cleanup_hatch_envs(
     pytestconfig: pytest.Config,
@@ -67,10 +71,12 @@ def cleanup_hatch_envs(
     finally:
         shutil.rmtree(hatch_dir, ignore_errors=True)
 
+
 # ---------------------------------------------
 # Fixtures - exports
 # ---------------------------------------------
 
+
 @pytest.fixture(scope="session")
 def monkeypatch_session() -> Generator[MonkeyPatch, None, None]:
     """Monkeypatch you can use with a session scoped fixture."""