diff --git a/.github/workflows/cd-publish.yml b/.github/workflows/cd-publish.yml new file mode 100644 index 000000000..d094b1441 --- /dev/null +++ b/.github/workflows/cd-publish.yml @@ -0,0 +1,162 @@ +name: "CD: Build & Publish" + +on: + workflow_dispatch: + inputs: + version: + description: "Version" + required: true + type: string + prerelease: + description: "Mark as prerelease" + required: false + default: false + type: boolean + +jobs: + version: + name: Bump Version & Tag + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: "[INIT] Checkout" + uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - name: "[INIT] Git Config" + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + - name: "[INIT] Install Nix" + uses: cachix/install-nix-action@v31 + with: + nix_path: nixpkgs=channel:nixos-unstable + + - name: "[INIT] Setup Cachix" + uses: cachix/cachix-action@v15 + with: + name: amperser + authToken: ${{ secrets.CACHIX_AUTH_TOKEN }} + + + - name: "[VERSION] Bump & Commit" + run: | + nix develop --command uv version ${{ github.event.inputs.version }} + nix develop --command git-cliff -c cliff.toml \ + --tag v${{ github.event.inputs.version }} \ + -o CHANGELOG.md + + git commit -am "chore: prepare release v${{ github.event.inputs.version }}" || echo "no changes to commit" + + - name: "[GIT] Create tag" + run: | + git tag v${{ github.event.inputs.version }} + git push origin HEAD --tags + + build: + name: Build Artifacts + runs-on: ubuntu-latest + needs: [version] + permissions: + contents: read + id-token: write + attestations: write + steps: + - name: "[INIT] Checkout" + uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - name: "[INIT] Install Nix" + uses: cachix/install-nix-action@v31 + with: + nix_path: nixpkgs=channel:nixos-unstable + + - name: "[INIT] Setup Cachix" + uses: cachix/cachix-action@v15 + with: + name: amperser + authToken: ${{ secrets.CACHIX_AUTH_TOKEN }} + + - name: "[BUILD] Wheel" + run: | + mkdir -p dist + nix build -L .#wheel + cp result/*.whl dist/ + + + - name: "[BUILD] Source dist" + run: | + nix build -L .#sdist + cp result/*.tar.gz dist/ + + - name: "[CHANGELOG] Generate release notes" + run: | + nix develop --command git-cliff -c cliff.toml \ + --unreleased --verbose \ + -o dist/RELEASE_NOTES.md + + - name: "[VERIFY] Provenance" + uses: actions/attest-build-provenance@v3 + with: + subject-path: 'dist/*' + + - name: "[UPLOAD] Artifacts" + uses: actions/upload-artifact@v4 + with: + name: dist-artifacts + path: dist/ + if-no-files-found: error + + github-release: + name: GitHub Release + runs-on: ubuntu-latest + needs: [build] + permissions: + contents: write + steps: + - name: "[INIT] Checkout" + uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - name: "[DOWNLOAD] Artifacts" + uses: actions/download-artifact@v4 + with: + name: dist-artifacts + path: dist/ + + - name: "[INPUT] Get input" + id: input + run: | + echo "tag=v${{ github.event.inputs.version }}" >> $GITHUB_OUTPUT + echo "version=${{ github.event.inputs.version }}" >> $GITHUB_OUTPUT + echo "prerelease=${{ github.event.inputs.prerelease }}" >> $GITHUB_OUTPUT + + - name: "[RELEASE] Create GitHub release" + uses: softprops/action-gh-release@v2 + with: + name: Release ${{ steps.input.outputs.tag }} + tag_name: ${{ steps.input.outputs.tag }} + prerelease: ${{ steps.input.outputs.prerelease }} + body_path: dist/RELEASE_NOTES.md + files: dist/* + + pypi-publish: + name: Publish to PyPI + runs-on: ubuntu-latest + needs: [build] + permissions: + id-token: write + steps: + - name: "[DOWNLOAD] Artifacts" + uses: actions/download-artifact@v4 + with: + name: dist-artifacts + path: dist/ + + - name: "[PUBLISH] PyPI" + uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/.github/workflows/cd-pypi.yml b/.github/workflows/cd-pypi.yml deleted file mode 100644 index a1581652d..000000000 --- a/.github/workflows/cd-pypi.yml +++ /dev/null @@ -1,24 +0,0 @@ -name: "CD: PyPI" -on: - release: - types: [published] -jobs: - release: - name: Release - runs-on: ubuntu-latest - permissions: - id-token: write - defaults: - run: - shell: bash - steps: - - name: "[INIT] Checkout repository" - uses: actions/checkout@v5 - - name: "[INIT] Install uv" - uses: astral-sh/setup-uv@v5 - with: - python-version: "3.10" - - name: "[EXEC] Build project" - run: uv build - - name: "[EXEC] Publish package" - run: uv publish diff --git a/.github/workflows/ci-lint-test.yml b/.github/workflows/ci-lint-test.yml index 063cfb808..bab0d2a7c 100644 --- a/.github/workflows/ci-lint-test.yml +++ b/.github/workflows/ci-lint-test.yml @@ -1,50 +1,82 @@ name: "CI: Lint & Test" + on: [push, pull_request] + jobs: lint: name: Lint if: "!(contains(github.event.head_commit.message, '[skip_ci]'))" runs-on: ubuntu-latest - defaults: - run: - shell: bash + steps: - name: "[INIT] Checkout repository" - uses: actions/checkout@v4 - - name: "[INIT] Install uv" - uses: astral-sh/setup-uv@v5 + uses: actions/checkout@v5 + + - name: "[INIT] Install Nix" + uses: cachix/install-nix-action@v31 with: - python-version: "3.10" - enable-cache: true - - name: "[INIT] Install dependencies" - run: uv sync --locked --all-extras --dev + nix_path: nixpkgs=channel:nixos-unstable + + - name: "[INIT] Setup Cachix" + uses: cachix/cachix-action@v15 + with: + name: amperser + authToken: ${{ secrets.CACHIX_AUTH_TOKEN }} + - name: "[EXEC] Lint" - run: uv run poe lint + run: nix develop --command ruff check proselint tests + test-cover: name: Test & Cover if: "!(contains(github.event.head_commit.message, '[skip_ci]'))" runs-on: ${{ matrix.os }} permissions: id-token: write - defaults: - run: - shell: bash strategy: matrix: python: ["3.9", "3.10", "3.11", "3.12", "3.13"] os: [ubuntu-latest, macos-latest, windows-latest] + steps: - name: "[INIT] Checkout repository" - uses: actions/checkout@v4 - - name: "[INIT] Install uv" - uses: astral-sh/setup-uv@v5 + uses: actions/checkout@v5 + + - name: "[INIT] Install Nix (Unix)" + if: runner.os != 'Windows' + uses: cachix/install-nix-action@v31 + with: + nix_path: nixpkgs=channel:nixos-unstable + + - name: "[INIT] Setup Cachix (Unix)" + if: runner.os != 'Windows' + uses: cachix/cachix-action@v15 + with: + name: amperser + authToken: ${{ secrets.CACHIX_AUTH_TOKEN }} + + - name: "[INIT] Install uv (Windows)" + if: runner.os == 'Windows' + uses: astral-sh/setup-uv@v6 with: python-version: ${{ matrix.python }} enable-cache: true - - name: "[INIT] Install dependencies" + + - name: "[INIT] Install dependencies (Windows)" + if: runner.os == 'Windows' run: uv sync --locked --all-extras --dev --group test - - name: "[EXEC] Test" + + - name: "[EXEC] Test & Coverage (Windows)" + if: runner.os == 'Windows' run: uv run poe test-cover + env: + PYTHON_VERSION: ${{ matrix.python }} + + - name: "[EXEC] Test & Coverage (Unix)" + if: runner.os != 'Windows' + run: nix develop --command uv run poe test-cover + env: + PYTHON_VERSION: ${{ matrix.python }} + - name: "[EXEC] Upload coverage to Codecov" uses: codecov/codecov-action@v5 with: diff --git a/.gitignore b/.gitignore index 0c1f5bf3a..fd69d80d0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,23 +1,36 @@ +# Nix +.direnv/ +.envrc +.result/ +.pre-commit-config.yaml + +# Python +*.egg *.egg-info *.pyc -cached_func_calls/* -profile_output -site/write/* -tests/corpus/newyorker/* -cache/* -proselint/cache/* -build/* -*.egg +*.pstore *.rdb +__pycache__/ +build/ +dist/ +cache/ +proselint/cache/ +cached_func_calls/* + +# Coverage .coverage coverage.lcov -proselint/proselint_develop.sublime-project -proselint/proselint_develop.sublime-workspace +profile_output + +# Tests +.hypothesis/ +tests/corpus/newyorker/* + +# Corpora corpora/* !corpora/README.md -dist/ -*.pstore -.pre-commit-config.yaml -.hypothesis -.direnv/ -.envrc + +# Editor +proselint/proselint_develop.sublime-workspace +.proselint/proselint_develop.sublime-project +.site/write/* diff --git a/cliff.toml b/cliff.toml new file mode 100644 index 000000000..4df04b257 --- /dev/null +++ b/cliff.toml @@ -0,0 +1,101 @@ +[remote.github] +owner = "amperser" +repo = "proselint" + +[changelog] +header = """ +[![animation](https://raw.githubusercontent.com/orhun/git-cliff/main/website/static/img/git-cliff-anim.gif)](https://git-cliff.org)\n +""" + +body = """ +{%- macro remote_url() -%} + https://github.com/{{ remote.github.owner }}/{{ remote.github.repo }} +{%- endmacro -%} + +{% macro print_commit(commit) -%} + - {% if commit.scope %}*({{ commit.scope }})* {% endif %}\ + {% if commit.breaking %}[**breaking**] {% endif %}\ + {{ commit.message | upper_first }} - \ + ([{{ commit.id | truncate(length=7, end="") }}]({{ self::remote_url() }}/commit/{{ commit.id }}))\ +{% endmacro -%} + +{% if version %}\ + {% if previous.version %}\ + ## [{{ version | trim_start_matches(pat="v") }}]\ + ({{ self::remote_url() }}/compare/{{ previous.version }}..{{ version }}) - {{ timestamp | date(format="%Y-%m-%d") }} + {% else %}\ + ## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }} + {% endif %}\ +{% else %}\ + ## [unreleased] +{% endif %}\ + +{% for group, commits in commits | group_by(attribute="group") %} + ### {{ group | striptags | trim | upper_first }} + {% for commit in commits + | filter(attribute="scope") + | sort(attribute="scope") %} + {{ self::print_commit(commit=commit) }} + {%- endfor %} + {% for commit in commits %} + {%- if not commit.scope -%} + {{ self::print_commit(commit=commit) }} + {% endif -%} + {% endfor -%} +{% endfor -%} +{%- if github -%} +{% if github.contributors | filter(attribute="is_first_time", value=true) | length != 0 %} + ## New Contributors โค๏ธ +{% endif %}\ +{% for contributor in github.contributors | filter(attribute="is_first_time", value=true) %} + * @{{ contributor.username }} made their first contribution + {%- if contributor.pr_number %} in \ + [#{{ contributor.pr_number }}]({{ self::remote_url() }}/pull/{{ contributor.pr_number }}) \ + {%- endif %} +{%- endfor -%} +{%- endif %} +""" + +footer = """ + +""" + +trim = true +postprocessors = [ + { pattern = '', replace = "https://github.com/amperser/proselint" }, +] + +[git] +conventional_commits = true +filter_unconventional = true +split_commits = false +commit_preprocessors = [ + { pattern = '\((\w+\s)?#([0-9]+)\)', replace = "([#${2}](/issues/${2}))" }, + { pattern = '.*', replace_command = 'typos --write-changes -' }, +] + +commit_parsers = [ + { message = "^feat", group = "โ›ฐ๏ธ Features" }, + { message = "^fix", group = "๐Ÿ› Bug Fixes" }, + { message = "^doc", group = "๐Ÿ“š Documentation" }, + { message = "^perf", group = "โšก Performance" }, + { message = "^refactor\\(clippy\\)", skip = true }, + { message = "^refactor", group = "๐Ÿšœ Refactor" }, + { message = "^style", group = "๐ŸŽจ Styling" }, + { message = "^test", group = "๐Ÿงช Testing" }, + { message = "^chore\\(release\\): prepare for", skip = true }, + { message = "^chore\\(deps.*\\)", skip = true }, + { message = "^chore\\(pr\\)", skip = true }, + { message = "^chore\\(pull\\)", skip = true }, + { message = "^chore\\(npm\\).*yarn\\.lock", skip = true }, + { message = "^chore|^ci", group = "โš™๏ธ Miscellaneous Tasks" }, + { body = ".*security", group = "๐Ÿ›ก๏ธ Security" }, + { message = "^revert", group = "โ—€๏ธ Revert" }, +] +protect_breaking_commits = false +filter_commits = false +tag_pattern = "v[0-9].*" +skip_tags = "beta|alpha" +ignore_tags = "rc" +topo_order = false +sort_commits = "newest" diff --git a/flake.lock b/flake.lock index de2d6ae5e..a90f21dbe 100644 --- a/flake.lock +++ b/flake.lock @@ -1,5 +1,31 @@ { "nodes": { + "build-systems": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ], + "pyproject-nix": [ + "pyproject" + ], + "uv2nix": [ + "uv" + ] + }, + "locked": { + "lastModified": 1756087852, + "narHash": "sha256-4jc3JDQt75fYXFrglgqyzF6C6zLU0QGLymzian4aP+U=", + "owner": "pyproject-nix", + "repo": "build-system-pkgs", + "rev": "6edb3ae27395cd88be3d64b732d1539957dad59c", + "type": "github" + }, + "original": { + "owner": "pyproject-nix", + "repo": "build-system-pkgs", + "type": "github" + } + }, "flake-compat": { "flake": false, "locked": { @@ -97,9 +123,34 @@ }, "root": { "inputs": { + "build-systems": "build-systems", "hooks": "hooks", "nixpkgs": "nixpkgs", - "pyproject": "pyproject" + "pyproject": "pyproject", + "uv": "uv" + } + }, + "uv": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ], + "pyproject-nix": [ + "pyproject" + ] + }, + "locked": { + "lastModified": 1756973152, + "narHash": "sha256-9JcKAA7T9J98LWdcxbXvmf+amQG3ZErxqQnBjEJI04I=", + "owner": "pyproject-nix", + "repo": "uv2nix", + "rev": "64298e806f4a5f63a51c625edc100348138491aa", + "type": "github" + }, + "original": { + "owner": "pyproject-nix", + "repo": "uv2nix", + "type": "github" } } }, diff --git a/flake.nix b/flake.nix index b5af5997e..922b9a88a 100644 --- a/flake.nix +++ b/flake.nix @@ -1,4 +1,3 @@ -# TODO: use uv2nix { inputs = { nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.05"; @@ -6,6 +5,24 @@ url = "github:cachix/git-hooks.nix"; inputs.nixpkgs.follows = "nixpkgs"; }; + + uv = { + url = "github:pyproject-nix/uv2nix"; + inputs = { + nixpkgs.follows = "nixpkgs"; + pyproject-nix.follows = "pyproject"; + }; + }; + + build-systems = { + url = "github:pyproject-nix/build-system-pkgs"; + inputs = { + uv2nix.follows = "uv"; + nixpkgs.follows = "nixpkgs"; + pyproject-nix.follows = "pyproject"; + }; + }; + pyproject = { url = "github:pyproject-nix/pyproject.nix"; inputs.nixpkgs.follows = "nixpkgs"; @@ -13,51 +30,130 @@ }; outputs = { + uv, self, hooks, nixpkgs, pyproject, + build-systems, ... }: let - forAllSystems = function: - nixpkgs.lib.genAttrs [ + inherit (nixpkgs) lib; + + getPythonVersion = let + val = builtins.getEnv "PYTHON_VERSION"; + result = + if val == "" + then "3.12" + else val; + in + builtins.replaceStrings ["."] [""] result; + + workspace = uv.lib.workspace.loadWorkspace {workspaceRoot = ./.;}; + overlay = workspace.mkPyprojectOverlay {sourcePreference = "wheel";}; + + forAllSystems = f: + lib.genAttrs [ "x86_64-linux" "aarch64-linux" "x86_64-darwin" "aarch64-darwin" - ] (system: function system nixpkgs.legacyPackages.${system}); - project = pyproject.lib.project.loadPyproject {projectRoot = ./.;}; + ] (system: + f rec { + inherit system; + + pkgs = nixpkgs.legacyPackages.${system}; + python = pkgs."python${getPythonVersion}"; + + pythonSet = + (pkgs.callPackage pyproject.build.packages {inherit python;}).overrideScope ( + lib.composeManyExtensions [ + build-systems.overlays.default + overlay + ] + ); + }); in { devShells = - forAllSystems (system: pkgs: let - python = pkgs.python312; - arg = project.renderers.withPackages {inherit python;}; - pyenv = python.withPackages arg; + forAllSystems ({ + pkgs, + system, + python, + pythonSet, + ... + }: let check = self.checks.${system}.pre-commit-check; in { - default = + default = let + editableOverlay = + workspace.mkEditablePyprojectOverlay { + root = "$REPO_ROOT"; + }; + + editablePythonSet = + pythonSet.overrideScope ( + lib.composeManyExtensions [ + editableOverlay + + (final: prev: { + proselint = + prev.proselint.overrideAttrs (old: { + nativeBuildInputs = + old.nativeBuildInputs + ++ final.resolveBuildSystem { + editables = []; + }; + }); + }) + ] + ); + + virtualenv = editablePythonSet.mkVirtualEnv "proselint-env" {proselint = ["test" "dev"];}; + in pkgs.mkShell { - inherit (check) shellHook; - - packages = - check.enabledPackages - ++ [ - pyenv - pkgs.uv - ]; + buildInputs = check.enabledPackages; + + packages = [ + virtualenv + pkgs.git-cliff + pkgs.typos + pkgs.uv + ]; + + env = { + UV_NO_SYNC = "1"; + UV_PYTHON = python.interpreter; + UV_PYTHON_DOWNLOADS = "never"; + }; + + shellHook = + '' + export REPO_ROOT=$(git rev-parse --show-toplevel) + unset PYTHONPATH + '' + + check.shellHook; }; }); packages = - forAllSystems (system: pkgs: let - python = pkgs.python312; - attrs = project.renderers.buildPythonPackage {inherit python;}; - in { - default = python.pkgs.buildPythonPackage attrs; + forAllSystems ({pythonSet, ...}: { + default = pythonSet.mkVirtualEnv "proselint-env" workspace.deps.default; + + wheel = + pythonSet.proselint.override { + pyprojectHook = pythonSet.pyprojectDistHook; + }; + + sdist = + (pythonSet.proselint.override { + pyprojectHook = pythonSet.pyprojectDistHook; + }).overrideAttrs (old: { + env.uvBuildType = "sdist"; + }); }); apps = - forAllSystems (system: _: { + forAllSystems ({system, ...}: { default = { type = "app"; program = "${self.packages.${system}.default}/bin/proselint"; @@ -65,7 +161,11 @@ }); checks = - forAllSystems (system: pkgs: { + forAllSystems ({ + system, + pkgs, + ... + }: { pre-commit-check = hooks.lib.${system}.run { src = ./.; @@ -75,10 +175,12 @@ mixed-line-endings.enable = true; markdownlint.enable = true; ruff.enable = true; - pyright = { + pyright = let + pyright = pkgs.basedpyright; + in { enable = true; - package = pkgs.basedpyright; - entry = "${pkgs.basedpyright}/bin/basedpyright"; + package = pyright; + entry = "${pyright}/bin/basedpyright"; }; convco.enable = true; alejandra.enable = true;