Repository: pyscaffold/pyscaffold (language: python)

Issue:
Add Github Actions CI/CD
## Describe your use-case
Github Actions seems to become more and more adapted for CI/CD, it thus could be nice to have besides `--gitlab` and `--cirrus` also a `--github` flag.


Discussion / hints:
Relevant link/example: https://github.com/jaraco/skeleton/blob/main/.github/workflows/main.yml
i would like to work on this if it's still open
Please @GowthamChowdary that would be amazing!

Hopefully  https://pyscaffold.org/en/stable/dev-guide.html can help you in the journey.

Fix patch:
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index 8319b6570..a09737055 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -10,6 +10,14 @@ Changelog
     Version 4.X.X, 2022-XX-XX
     -------------------------
 
+Development version
+===================
+
+Version 4.2, 2022-XX-XX
+-----------------------
+
+- Added ``--github-actions`` extension, :pr:`619`
+
 
 Current versions
 ================
diff --git a/setup.cfg b/setup.cfg
index 029abb956..9ef5e9831 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -110,8 +110,9 @@ pyscaffold.cli =
     no_skeleton = pyscaffold.extensions.no_skeleton:NoSkeleton
     pre_commit = pyscaffold.extensions.pre_commit:PreCommit
     no_tox = pyscaffold.extensions.no_tox:NoTox
-    gitlab = pyscaffold.extensions.gitlab_ci:GitLab
     cirrus = pyscaffold.extensions.cirrus:Cirrus
+    gitlab = pyscaffold.extensions.gitlab_ci:GitLab
+    github_actions = pyscaffold.extensions.github_actions:GithubActions
     no_pyproject = pyscaffold.extensions.no_pyproject:NoPyProject
 
 [tool:pytest]
diff --git a/src/pyscaffold/extensions/github_actions.py b/src/pyscaffold/extensions/github_actions.py
new file mode 100644
index 000000000..f65cd5f5c
--- /dev/null
+++ b/src/pyscaffold/extensions/github_actions.py
@@ -0,0 +1,48 @@
+"""Extension that generates configuration for GitHub Actions."""
+from argparse import ArgumentParser
+from typing import List
+
+from .. import structure
+from ..actions import Action, ActionParams, ScaffoldOpts, Structure
+from ..operations import no_overwrite
+from ..templates import get_template
+from . import Extension, include
+from .pre_commit import PreCommit
+
+TEMPLATE_FILE = "github_ci_workflow"
+
+
+class GithubActions(Extension):
+    """Add configuration file for GitHub Actions (includes `--pre-commit`)"""
+
+    def augment_cli(self, parser: ArgumentParser):
+        """Augments the command-line interface parser.
+        See :obj:`~pyscaffold.extension.Extension.augment_cli`.
+        """
+        parser.add_argument(
+            self.flag, help=self.help_text, nargs=0, action=include(PreCommit(), self)
+        )
+        return self
+
+    def activate(self, actions: List[Action]) -> List[Action]:
+        """Activate extension, see :obj:`~pyscaffold.extension.Extension.activate`."""
+        return self.register(actions, add_files, after="define_structure")
+
+
+def add_files(struct: Structure, opts: ScaffoldOpts) -> ActionParams:
+    """Add .github/workflows/ci.yml to the file structure
+
+    Args:
+        struct: project representation as (possibly) nested :obj:`dict`.
+        opts: given options, see :obj:`create_project` for an extensive list.
+
+    Returns:
+        struct, opts: updated project representation and options
+    """
+    ci_workflow = get_template(TEMPLATE_FILE).template  # no substitutions
+
+    files: Structure = {
+        ".github": {"workflows": {"ci.yml": (ci_workflow, no_overwrite())}}
+    }
+
+    return structure.merge(struct, files), opts
diff --git a/src/pyscaffold/templates/github_ci_workflow.template b/src/pyscaffold/templates/github_ci_workflow.template
new file mode 100644
index 000000000..c0cdfe978
--- /dev/null
+++ b/src/pyscaffold/templates/github_ci_workflow.template
@@ -0,0 +1,114 @@
+# GitHub Actions configuration **EXAMPLE**,
+# MODIFY IT ACCORDING TO YOUR NEEDS!
+# Reference: https://docs.github.com/en/actions
+
+name: tests
+
+on:
+  push:
+    # Avoid using all the resources/limits available by checking only
+    # relevant branches and tags. Other branches can be checked via PRs.
+    branches: [main]
+    tags: ['v*']  # Push events to matching v*, i.e. v1.0, v20.15.10
+  pull_request:  # Run in every PR
+  workflow_dispatch:  # Allow manually triggering the workflow
+  schedule:
+    # Run roughly every 15 days at 00:00 UTC
+    # (useful to check if updates on dependencies break the package)
+    - cron: '0 0 1,16 * *'
+
+concurrency:
+  group: >-
+    ${{ github.workflow }}-${{ github.ref_type }}-
+    ${{ github.event.pull_request.number || github.sha }}
+  cancel-in-progress: true
+
+jobs:
+  prepare:
+    runs-on: ubuntu-latest
+    outputs:
+      wheel-distribution: ${{ steps.wheel-distribution.outputs.path }}
+    steps:
+      - uses: actions/checkout@v2
+        with: {fetch-depth: 0}  # deep clone for setuptools-scm
+      - uses: actions/setup-python@v2
+        with: {python-version: "3.10"}
+      - name: Lint code
+        run: pipx run pre-commit run --all-files --show-diff-on-failure
+      - name: Build package distribution files
+        run: pipx run tox -e clean,build
+      - name: Record the path of wheel distribution
+        id: wheel-distribution
+        run: echo "::set-output name=path::$(ls dist/*.whl)"
+      - name: Store the distribution files for use in other stages
+        # `tests` and `publish` will use the same pre-built distributions,
+        # so we make sure to release the exact same package that was tested
+        uses: actions/upload-artifact@v2
+        with:
+          name: python-distribution-files
+          path: dist/
+          retention-days: 1
+
+  test:
+    needs: prepare
+    strategy:
+      matrix:
+        python:
+        - 3.7  # oldest Python supported by PSF
+        - "3.10"  # newest Python that is stable
+        platform:
+        - ubuntu-latest
+        - macos-latest
+        - windows-latest
+    runs-on: ${{ matrix.platform }}
+    steps:
+      - uses: actions/checkout@v2
+      - uses: actions/setup-python@v2
+        with:
+          python-version: ${{ matrix.python }}
+      - name: Retrieve pre-built distribution files
+        uses: actions/download-artifact@v2
+        with: {name: python-distribution-files, path: dist/}
+      - name: Run tests
+        run: >-
+          pipx run tox
+          --installpkg '${{ needs.prepare.outputs.wheel-distribution }}'
+          -- -rFEx --durations 10 --color yes
+      - name: Generate coverage report
+        run: pipx run coverage lcov -o coverage.lcov
+      - name: Upload partial coverage report
+        uses: coverallsapp/github-action@master
+        with:
+          path-to-lcov: coverage.lcov
+          github-token: ${{ secrets.github_token }}
+          flag-name: ${{ matrix.platform }} - py${{ matrix.python }}
+          parallel: true
+
+  finalize:
+    needs: test
+    runs-on: ubuntu-latest
+    steps:
+      - name: Finalize coverage report
+        uses: coverallsapp/github-action@master
+        with:
+          github-token: ${{ secrets.GITHUB_TOKEN }}
+          parallel-finished: true
+
+  publish:
+    needs: finalize
+    if: ${{ github.event_name == 'push' && contains(github.ref, 'refs/tags/') }}
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v2
+      - uses: actions/setup-python@v2
+        with: {python-version: "3.10"}
+      - name: Retrieve pre-built distribution files
+        uses: actions/download-artifact@v2
+        with: {name: python-distribution-files, path: dist/}
+      - name: Publish Package
+        env:
+          # See: https://pypi.org/help/#apitoken
+          TWINE_REPOSITORY: pypi
+          TWINE_USERNAME: __token__
+          TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }}
+        run: pipx run tox -e publish
diff --git a/src/pyscaffold/templates/tox_ini.template b/src/pyscaffold/templates/tox_ini.template
index 27e2a29bd..de5939185 100644
--- a/src/pyscaffold/templates/tox_ini.template
+++ b/src/pyscaffold/templates/tox_ini.template
@@ -20,6 +20,19 @@ commands =
     pytest {posargs}
 
 
+# # To run `tox -e lint` you need to make sure you have a
+# # `.pre-commit-config.yaml` file. See https://pre-commit.com
+# [testenv:lint]
+# description = Perform static analysis and style checks
+# skip_install = True
+# deps = pre-commit
+# passenv =
+#     HOMEPATH
+#     PROGRAMDATA
+# commands =
+#     pre-commit run --all-files {posargs:--show-diff-on-failure}
+
+
 [testenv:{build,clean}]
 description =
     build: Build the package in isolation according to PEP517, see https://github.com/pypa/build
@@ -30,7 +43,8 @@ changedir = {toxinidir}
 deps =
     build: build[virtualenv]
 commands =
-    clean: python -c 'from shutil import rmtree; rmtree("build", True); rmtree("dist", True)'
+    clean: python -c 'import shutil; [shutil.rmtree(p, True) for p in ("build", "dist", "docs/_build")]'
+    clean: python -c 'import pathlib, shutil; [shutil.rmtree(p, True) for p in pathlib.Path("src").glob("*.egg-info")]'
     build: python -m build {posargs}
 
 
@@ -60,10 +74,11 @@ description =
 skip_install = True
 changedir = {toxinidir}
 passenv =
+    # See: https://twine.readthedocs.io/en/latest/
     TWINE_USERNAME
     TWINE_PASSWORD
     TWINE_REPOSITORY
 deps = twine
 commands =
     python -m twine check dist/*
-    python -m twine upload {posargs:--repository testpypi} dist/*
+    python -m twine upload {posargs:--repository {env:TWINE_REPOSITORY:testpypi}} dist/*
diff --git a/tox.ini b/tox.ini
index 85b7fd850..5bfdc23d2 100644
--- a/tox.ini
+++ b/tox.ini
@@ -43,6 +43,17 @@ commands =
     pytest -x -n auto -m "not slow and not system" {posargs}
 
 
+[testenv:lint]
+description = Perform static analysis and style checks
+skip_install = True
+deps = pre-commit
+passenv =
+    HOMEPATH
+    PROGRAMDATA
+commands =
+    pre-commit run --all-files {posargs:--show-diff-on-failure}
+
+
 [testenv:{build,clean}]
 description =
     build: Build the package in isolation according to PEP517, see https://github.com/pypa/build
@@ -53,7 +64,8 @@ changedir = {toxinidir}
 deps =
     build: build[virtualenv]
 commands =
-    clean: python -c 'from shutil import rmtree; rmtree("build", True); rmtree("dist", True)'
+    clean: python -c 'import shutil; [shutil.rmtree(p, True) for p in ("build", "dist", "docs/_build")]'
+    clean: python -c 'import pathlib, shutil; [shutil.rmtree(p, True) for p in pathlib.Path("src").glob("*.egg-info")]'
     build: python -m build {posargs}
 
 


Test patch:
diff --git a/tests/extensions/test_github_actions.py b/tests/extensions/test_github_actions.py
new file mode 100644
index 000000000..f5fca6948
--- /dev/null
+++ b/tests/extensions/test_github_actions.py
@@ -0,0 +1,67 @@
+#!/usr/bin/env python
+import sys
+from os.path import exists as path_exists
+
+from pyscaffold.api import create_project
+from pyscaffold.cli import run
+from pyscaffold.extensions.github_actions import GithubActions
+
+
+def test_create_project_with_github_actions(tmpfolder):
+    # Given options with the GithubActions extension,
+    opts = dict(project_path="proj", extensions=[GithubActions()])
+
+    # when the project is created,
+    create_project(opts)
+
+    # then files from GithubActions extension should exist
+    assert path_exists("proj/.github/workflows/ci.yml")
+
+
+def test_create_project_without_github_actions(tmpfolder):
+    # Given options without the GithubActions extension,
+    opts = dict(project_path="proj")
+
+    # when the project is created,
+    create_project(opts)
+
+    # then GithubActions files should not exist
+    assert not path_exists("proj/.github/workflows/ci.yml")
+
+
+def test_cli_with_github_actions(tmpfolder):
+    # Given the command line with the GithubActions option,
+    sys.argv = ["pyscaffold", "--github-actions", "proj"]
+
+    # when pyscaffold runs,
+    run()
+
+    # then files from GithubActions and other extensions automatically added should
+    # exist
+    assert path_exists("proj/.github/workflows/ci.yml")
+    assert path_exists("proj/tox.ini")
+    assert path_exists("proj/.pre-commit-config.yaml")
+
+
+def test_cli_with_github_actions_and_pretend(tmpfolder):
+    # Given the command line with the GithubActions and pretend options
+    sys.argv = ["pyscaffold", "--pretend", "--github-actions", "proj"]
+
+    # when pyscaffold runs,
+    run()
+
+    # then GithubActions files should not exist
+    assert not path_exists("proj/.github/workflows/ci.yml")
+    # (or the project itself)
+    assert not path_exists("proj")
+
+
+def test_cli_without_github_actions(tmpfolder):
+    # Given the command line without the GithubActions option,
+    sys.argv = ["pyscaffold", "proj"]
+
+    # when pyscaffold runs,
+    run()
+
+    # then GithubActions files should not exist
+    assert not path_exists("proj/.github/workflows/ci.yml")