diff --git a/.github/utils/_repo.py b/.github/utils/_repo.py new file mode 100644 index 000000000..ed901bf95 --- /dev/null +++ b/.github/utils/_repo.py @@ -0,0 +1,196 @@ +#!/usr/bin/env python +from __future__ import annotations + +import os +import re +import shlex +from subprocess import PIPE, Popen +from typing import Sequence + +# https://docs.github.com/en/actions/learn-github-actions/variables +# #default-environment-variables +GITHUB_VARS = [ + "GITHUB_REF_NAME", # main, dev, v0.1.0, v0.1.3a1 + "GITHUB_REF_TYPE", # "branch" or "tag" + "GITHUB_REPOSITORY", # has2k1/scikit-misc + "GITHUB_SERVER_URL", # https://github.com + "GITHUB_SHA", # commit shasum + "GITHUB_WORKSPACE", # /home/runner/work/scikit-misc/scikit-misc + "GITHUB_EVENT_NAME", # push, schedule, workflow_dispatch, ... +] + + +count = r"(?:[0-9]|[1-9][0-9]+)" +DESCRIBE_PATTERN = re.compile( + r"^v" + rf"(?P{count}\.{count}\.{count})" + rf"(?P
(a|b|rc|alpha|beta){count})?"
+    r"(-(?P\d+)-g(?P[a-z0-9]+))?"
+    r"(?P-dirty)?"
+    r"$"
+)
+
+# Define a releasable version to be valid according to PEP440
+# and is a semver
+RELEASE_TAG_PATTERN = re.compile(r"^v" rf"{count}\.{count}\.{count}" r"$")
+
+# Prerelease version
+PRE_RELEASE_TAG_PATTERN = re.compile(
+    r"^v"
+    rf"{count}\.{count}\.{count}"
+    r"(?:"
+    rf"(?:a|b|rc|alpha|beta){count}"
+    r")"
+    r"$"
+)
+
+
+def run(cmd: str | Sequence[str]) -> str:
+    if isinstance(cmd, str) and os.name == "posix":
+        cmd = shlex.split(cmd)
+    with Popen(
+        cmd, stdin=PIPE, stderr=PIPE, stdout=PIPE, text=True, encoding="utf-8"
+    ) as p:
+        stdout, _ = p.communicate()
+    return stdout.strip()
+
+
+class Git:
+    @staticmethod
+    def checkout(committish):
+        """
+        Return True if inside a git repo
+        """
+        res = run(f"git checkout {committish}")
+        return res
+
+    @staticmethod
+    def commit_subjects(n=1) -> list[str]:
+        """
+        Return a list n of commit subjects
+        """
+        output = run(
+            f"git log --oneline --no-merges --pretty='format:%s' -{n}"
+        )
+        return output.split("\n")[:n]
+
+    @staticmethod
+    def commit_messages(n=1) -> list[str]:
+        """
+        Return a list n of commit messages
+        """
+        sep = "______ MESSAGE _____"
+        output = run(
+            f"git log --no-merges --pretty='format:%B{sep}' -{n}"
+        ).strip()
+        if output.endswith(sep):
+            output = output[: -len(sep)]
+        return output.split(sep)[:n]
+
+    @staticmethod
+    def commit_subject() -> str:
+        """
+        Commit subject
+        """
+        return Git.commit_subjects(1)[0]
+
+    @staticmethod
+    def commit_message() -> str:
+        """
+        Commit message
+        """
+        return Git.commit_messages(1)[0]
+
+    @staticmethod
+    def is_repo():
+        """
+        Return True if inside a git repo
+        """
+        res = run("git rev-parse --is-inside-work-tree")
+        return res == "return"
+
+    @staticmethod
+    def fetch_tags() -> str:
+        """
+        Fetch all tags
+        """
+        return run("git fetch --tags --force")
+
+    @staticmethod
+    def is_shallow() -> bool:
+        """
+        Return True if current repo is shallow
+        """
+        res = run("git rev-parse --is-shallow-repository")
+        return res == "true"
+
+    @staticmethod
+    def deepen(n: int = 1) -> str:
+        """
+        Fetch n commits beyond the shallow limit
+        """
+        return run(f"git fetch --deepen={n}")
+
+    @staticmethod
+    def describe() -> str:
+        """
+        Git describe to determine version
+        """
+        return run("git describe --dirty --tags --long --match '*[0-9]*'")
+
+    @staticmethod
+    def can_describe() -> bool:
+        """
+        Return True if repo can be "described" from a semver tag
+        """
+        return bool(DESCRIBE_PATTERN.match(Git.describe()))
+
+    @staticmethod
+    def get_tag_at_commit(committish: str) -> str:
+        """
+        Get tag of a given commit
+        """
+        return run(f"git describe --exact-match {committish}")
+
+    @staticmethod
+    def tag_message(tag: str) -> str:
+        """
+        Get the message of a tag
+        """
+        return run(f"git tag -l --format='%(subject)' {tag}")
+
+    @staticmethod
+    def is_annotated(tag: str) -> bool:
+        """
+        Return true if tag is annotated tag
+        """
+        # LHS prints to stderr and returns nothing when
+        # tag is an empty string
+        return run(f"git cat-file -t {tag}") == "tag"
+
+    @staticmethod
+    def shallow_checkout(branch: str, url: str, depth: int = 1) -> str:
+        """
+        Shallow clone upto n commits
+        """
+        _branch = f"--branch={branch}"
+        _depth = f"--depth={depth}"
+        return run(f"git clone {_depth} {_branch} {url} .")
+
+    @staticmethod
+    def is_release():
+        """
+        Return True if event is a release
+        """
+        ref = os.environ.get("GITHUB_REF_NAME", "")
+        ref_type = os.environ.get("GITHUB_REF_TYPE", "")
+        return ref_type == "tag" and bool(RELEASE_TAG_PATTERN.match(ref))
+
+    @staticmethod
+    def branch():
+        """
+        Return event branch
+        """
+        ref = os.environ.get("GITHUB_REF_NAME", "")
+        ref_type = os.environ.get("GITHUB_REF_TYPE", "")
+        return ref if ref_type == "branch" else ""
diff --git a/.github/utils/please.py b/.github/utils/please.py
new file mode 100644
index 000000000..25fb2fbfe
--- /dev/null
+++ b/.github/utils/please.py
@@ -0,0 +1,50 @@
+import os
+import re
+import sys
+from typing import Callable, TypeAlias
+
+from _repo import Git
+
+Ask: TypeAlias = Callable[[], bool | str]
+Do: TypeAlias = Callable[[], str]
+
+
+def can_i_deploy_documentation() -> bool:
+    """
+    Return True if documentation should be deployed
+    """
+    return Git.is_release() and Git.branch() in ("main", "dev")
+
+
+def where_can_i_deploy_documentation() -> str:
+    """
+    Return branch to deploy documentation to
+    """
+    if Git.is_release():
+        return "website"
+    return "gh-pages" if Git.branch() == "main" else "gh-branches"
+
+
+def process_request(arg: str) -> str:
+    if arg in REQUESTS:
+        result = REQUESTS.get(arg, lambda: False)()
+        if not isinstance(result, str):
+            result = str(result).lower()
+    else:
+        result = ACTIONS.get(arg, lambda: "")()
+    return result
+
+
+REQUESTS: dict[str, Ask] = {
+    "can_i_deploy_documentation": can_i_deploy_documentation,
+    "where_can_i_deploy_documentation": where_can_i_deploy_documentation,
+}
+
+
+ACTIONS: dict[str, Do] = {}
+
+
+if __name__ == "__main__":
+    if len(sys.argv) == 2:
+        arg = sys.argv[1]
+        print(process_request(arg))
diff --git a/.github/workflows/documentation.yml b/.github/workflows/documentation.yml
index 4848a81fd..4c2bffcc4 100644
--- a/.github/workflows/documentation.yml
+++ b/.github/workflows/documentation.yml
@@ -2,15 +2,39 @@ name: Documentation
 
 on:
   workflow_dispatch:
-  push:
-    branches: ["main", "dev", "doc", "dev-*"]
-  pull_request:
-  release:
-    types: [published]
+  workflow_call:
 
 jobs:
-  build:
+  parse_commit_info:
     runs-on: ubuntu-latest
+    outputs:
+      can_deploy: ${{ steps.decide.outputs.can_deploy }}
+      deploy_to: ${{ steps.decide.outputs.deploy_to }}
+
+    steps:
+      - name: Checkout Code
+        uses: actions/checkout@v3
+        with:
+          fetch-depth: 0
+
+      - name: Copy build utils
+        run: |
+          cp -r .github/utils ../utils
+
+      - name: Decide Whether to Build and/or Release
+        id: decide
+        run: |
+          set -xe
+          CAN_DEPLOY=$(python ../utils/please.py can_i_deploy_documentation)
+          DEPLOY_TO=$(python ../utils/please.py where_can_i_deploy_documentation)
+
+          echo "can_deploy=$CAN_DEPLOY" >> $GITHUB_OUTPUT
+          echo "deploy_to=$DEPLOY_TO" >> $GITHUB_OUTPUT
+          echo github.ref ${{ github.ref }}
+
+  build-documentation:
+    runs-on: ubuntu-latest
+    needs: parse_commit_info
 
     strategy:
       matrix:
@@ -35,7 +59,7 @@ jobs:
           pip install git+https://github.com/has2k1/mizani.git@main
           python -m pip install ".[doc]"
           pip install -r requirements/doc.txt
-          quarto add --no-prompt has2k1/issuey
+          cd doc; quarto add --no-prompt has2k1/issuey; popd
 
       - name: Environment Information
         shell: bash
@@ -46,9 +70,19 @@ jobs:
 
       - name: Build docs
         run: |
-          make doc
+          cd doc; make doc; popd
+
+      - name: Environment Information
+        shell: bash
+        run: |
+          ls -la doc
+          ls -la doc/reference
+          ls -la doc/examples
+          ls -la doc/tutorials
 
       - name: Deploy to Github Pages
         uses: JamesIves/github-pages-deploy-action@v4
+        if: contains(needs.parse_commit_info.outputs.can_deploy, 'true')
         with:
           folder: doc/_site
+          branch: ${{ needs.parse_commit_info.outputs.deploy_to }}
diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml
index 6eecd0700..77168ce38 100644
--- a/.github/workflows/testing.yml
+++ b/.github/workflows/testing.yml
@@ -123,44 +123,8 @@ jobs:
         shell: bash
         run: make typecheck
 
-  documentation:
-    runs-on: ubuntu-latest
-
-    # We want to run on external PRs, but not on our own internal PRs as they'll be run
-    # by the push to the branch.
-    if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name != github.repository
-
-    strategy:
-      matrix:
-        python-version: [3.11]
-
-    steps:
-      - name: Checkout Code
-        uses: actions/checkout@v3
-
-      - name: Setup Python
-        uses: actions/setup-python@v4
-        with:
-          python-version: ${{ matrix.python-version }}
-
-      - name: Install Quarto
-        uses: quarto-dev/quarto-actions/setup@v2
-        with:
-          version: "1.4.528"
-
-      - name: Install Packages
-        shell: bash
-        run: |
-          sudo apt-get install pandoc
-          pip install git+https://github.com/has2k1/mizani.git@main
-          pip install ".[test,doc]"
-          pip install -r requirements/doc.txt
-          quarto add --no-prompt has2k1/issuey
-
-      - name: Environment Information
-        shell: bash
-        run: pip list
-
-      - name: Build documentation
-        run: |
-          make doc
+  call-build-documentation:
+    if: |
+      github.event_name == 'push' ||
+      github.event.pull_request.head.repo.full_name != github.repository
+    uses: ./.github/workflows/documentation.yml