diff --git a/.config/.copier-answers.yml b/.config/.copier-answers.yml index 2258014db..f8d9cee4b 100644 --- a/.config/.copier-answers.yml +++ b/.config/.copier-answers.yml @@ -1,5 +1,5 @@ # Changes here will be overwritten by Copier -_commit: v0.2.0 +_commit: v0.2.0-19-g06a3a062 _src_path: gh:LabAutomationAndScreening/copier-base-template.git copier_answers_directory: .config description: A web app that is hosted within a local intranet. Nuxt frontend, python diff --git a/.config/pyrefly.toml b/.config/pyrefly.toml index 40fffc162..9a47eeea3 100644 --- a/.config/pyrefly.toml +++ b/.config/pyrefly.toml @@ -40,6 +40,7 @@ matches = "../**/tests/**" [sub-config.errors] implicitly-defined-attribute = "ignore" # doesn't make any sense for test cases. See https://github.com/microsoft/pyright/discussions/4834#discussioncomment-8496931 +uninitialized-instance-variable = "ignore" # test classes assign their attributes in pytest fixtures rather than __init__, so the class-level declarations those fixtures fill are never initialized where pyrefly looks # Tests are self-asserting: a value that isn't what the test assumed fails the very next assertion, # pointing at the exact line. The unknowns these rules flag also come almost entirely from mocking, where the value # genuinely is `Any` and no upstream typing fix is possible --- `spy_return` is set dynamically on the mock, diff --git a/.config/taskfiles/copier-template.yaml b/.config/taskfiles/copier-template.yaml index cba155e35..1ca330876 100644 --- a/.config/taskfiles/copier-template.yaml +++ b/.config/taskfiles/copier-template.yaml @@ -28,6 +28,8 @@ tasks: internal: true cmds: - copier update --answers-file .config/.copier-answers.yml --trust --conflict rej --defaults {{.REF_FLAG}} + - | + echo "Reviewers should be able to skim the update commit and study the fixes. So one commit holds copier's output, the resolved .rej conflicts and every mechanical fix: lock files, formatting, the devcontainer hash (--no-verify if hooks still fail). Each hand-made fix for a remaining hook failure goes in a later commit." prune-copier-rejects: desc: Delete .rej files whose target file is unchanged, failing if any real conflict is left to resolve diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index afe8ffbaa..a597b36d4 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -30,7 +30,7 @@ // Python "-ms-python.vscode-pylance", // pyrefly is the language server for this project; ms-python.python auto-installs Pylance via its extension pack, and Pylance then warns on every boot that it's disabled "ms-python.python@2026.5.2026052901", - "meta.pyrefly@1.3.2", + "meta.pyrefly@1.4.0", "ms-vscode-remote.remote-containers@0.414.0", "charliermarsh.ruff@2026.84.0", @@ -67,5 +67,5 @@ "initializeCommand": "sh .devcontainer/initialize-command.sh", "onCreateCommand": "sh .devcontainer/on-create-command.sh", "postStartCommand": "sh .devcontainer/post-start-command.sh" - // Devcontainer context hash (do not manually edit this, it's managed by a pre-commit hook): 55943b35 # spellchecker:disable-line + // Devcontainer context hash (do not manually edit this, it's managed by a pre-commit hook): a1013bcc # spellchecker:disable-line } diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index ac50221e1..eef920a86 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -19,8 +19,8 @@ +that they ran: CI runs every suite a change can affect. Re-running a suite only counts somewhere CI can't (a Raspberry +Pi, a lab PC). Don't list test names from the diff. --> ## Other diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 013d0bcc6..8ea87f64a 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -272,11 +272,14 @@ jobs: if: ${{ github.event_name == 'pull_request' || github.event_name == 'merge_group' }} env: GH_TOKEN: ${{ github.token }} + # A status belongs to a commit: the PR's head commit on pull_request events, the merge group's head on + # merge_group events, whose payload carries no pull_request at all. + COMMIT_SHA: ${{ github.event_name == 'merge_group' && github.event.merge_group.head_sha || github.event.pull_request.head.sha }} run: | gh api \ -X POST -H "Accept: application/vnd.github.v3+json" \ - "${{ github.event.pull_request.statuses_url }}" \ - -f state=success -f context="required-check" -f description="✅ All required checks passed in the job triggered by pull_request" \ + "/repos/${{ github.repository }}/statuses/${COMMIT_SHA}" \ + -f state=success -f context="required-check" -f description="✅ All required checks passed in the job triggered by ${{ github.event_name }}" \ -f target_url="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" - name: Mark updated dependabot hash commit as succeeded diff --git a/extensions/context.py b/extensions/context.py index 7e31db287..bb0bc303f 100644 --- a/extensions/context.py +++ b/extensions/context.py @@ -38,7 +38,7 @@ def hook( # noqa: PLR0915 # yes, this is a lot of statements, but it's all just context["pulumi_aws_version"] = ">=7.48.0" context["pulumi_aws_native_version"] = ">=1.81.0" context["pulumi_command_version"] = ">=1.2.1" - context["pulumi_github_version"] = ">=6.14.0" + context["pulumi_github_version"] = ">=6.15.0" context["pulumi_okta_version"] = ">=6.6.0" context["boto3_version"] = ">=1.43.88" context["ephemeral_pulumi_deploy_version"] = ">=0.0.8" @@ -67,7 +67,7 @@ def hook( # noqa: PLR0915 # yes, this is a lot of statements, but it's all just context["pytest_reserial_version"] = ">=0.6.1" context["python_faker_version"] = ">=40.41.0" context["mutmut_version"] = ">=3.8.0" - context["pyrefly_version"] = ">=1.3.2" + context["pyrefly_version"] = "==1.4.0" context["vacuum_openapi_version"] = "0.30.0" context["default_node_version"] = "24.11.1" @@ -124,6 +124,7 @@ def hook( # noqa: PLR0915 # yes, this is a lot of statements, but it's all just context["gha_action_gh_release"] = "v3.0.3" context["gha_codecov"] = "v7.1.0" context["gha_mutex"] = "1ebad517141198e08d47cf72f3c0975316620a65 # v1.0.0-alpha.10" + context["gha_paths_filter"] = "ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3" context["gha_pypi_publish"] = "v1.14.2" context["gha_sleep"] = "v2.0.4" context["gha_absaoss_k3d"] = "v2.4.0" diff --git a/pyproject.toml b/pyproject.toml index 74b34c731..710e79b31 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,7 +20,7 @@ dependencies = [ "pytest-cov>=7.1.0", "pytest-randomly>=5.0.0", "faker>=40.41.0", - "pyrefly>=1.3.2", + "pyrefly==1.4.0", "copier==9.18.2", "copier-template-extensions==0.3.3" ] diff --git a/src/copier_tasks/ensure_pnpm_minimum_release_age_exclude.py b/src/copier_tasks/ensure_pnpm_minimum_release_age_exclude.py index 964a2572e..afe9ca89c 100644 --- a/src/copier_tasks/ensure_pnpm_minimum_release_age_exclude.py +++ b/src/copier_tasks/ensure_pnpm_minimum_release_age_exclude.py @@ -38,8 +38,9 @@ def _existing_patterns(*, workspace_dir: Path) -> list[str]: parsed: object = json.loads(get_result.stdout) if not isinstance(parsed, list): return [] + entries: list[object] = parsed patterns: list[str] = [] - for entry in parsed: + for entry in entries: if not isinstance(entry, str): continue patterns.append(entry) diff --git a/template/.config/pyrefly.toml b/template/.config/pyrefly.toml index 89dcb1275..debec9ca1 100644 --- a/template/.config/pyrefly.toml +++ b/template/.config/pyrefly.toml @@ -42,6 +42,7 @@ matches = "../**/tests/**" [sub-config.errors] implicitly-defined-attribute = "ignore" # doesn't make any sense for test cases. See https://github.com/microsoft/pyright/discussions/4834#discussioncomment-8496931 +uninitialized-instance-variable = "ignore" # test classes assign their attributes in pytest fixtures rather than __init__, so the class-level declarations those fixtures fill are never initialized where pyrefly looks # Tests are self-asserting: a value that isn't what the test assumed fails the very next assertion, # pointing at the exact line. The unknowns these rules flag also come almost entirely from mocking, where the value # genuinely is `Any` and no upstream typing fix is possible --- `spy_return` is set dynamically on the mock, diff --git a/template/.config/taskfiles/copier-template.yaml b/template/.config/taskfiles/copier-template.yaml index cba155e35..1ca330876 100644 --- a/template/.config/taskfiles/copier-template.yaml +++ b/template/.config/taskfiles/copier-template.yaml @@ -28,6 +28,8 @@ tasks: internal: true cmds: - copier update --answers-file .config/.copier-answers.yml --trust --conflict rej --defaults {{.REF_FLAG}} + - | + echo "Reviewers should be able to skim the update commit and study the fixes. So one commit holds copier's output, the resolved .rej conflicts and every mechanical fix: lock files, formatting, the devcontainer hash (--no-verify if hooks still fail). Each hand-made fix for a remaining hook failure goes in a later commit." prune-copier-rejects: desc: Delete .rej files whose target file is unchanged, failing if any real conflict is left to resolve diff --git a/template/.devcontainer/devcontainer.json.jinja b/template/.devcontainer/devcontainer.json.jinja index 175971f2b..6e33e7ac4 100644 --- a/template/.devcontainer/devcontainer.json.jinja +++ b/template/.devcontainer/devcontainer.json.jinja @@ -43,7 +43,7 @@ // Python "-ms-python.vscode-pylance", // pyrefly is the language server for this project; ms-python.python auto-installs Pylance via its extension pack, and Pylance then warns on every boot that it's disabled "ms-python.python@2026.5.2026052901", - "meta.pyrefly@1.3.2", + "meta.pyrefly@1.4.0", "ms-vscode-remote.remote-containers@0.414.0", "charliermarsh.ruff@2026.84.0",{% endraw %}{% endif %}{% raw %} {% endraw %}{% if is_child_of_copier_base_template is not defined and template_uses_vuejs is defined and template_uses_vuejs is sameas(true) %}{% raw %} diff --git a/template/.github/path-filters.yaml.jinja b/template/.github/path-filters.yaml.jinja new file mode 100644 index 000000000..e9b06e9c5 --- /dev/null +++ b/template/.github/path-filters.yaml.jinja @@ -0,0 +1,32 @@ +# Path classes for the `changes` job in .github/workflows/ci.yaml. Every top-level key becomes a boolean in that job's +# `classes` output, read as fromJSON(needs.changes.outputs.classes)., true when the change touched a matching +# path. Globs follow dorny/paths-filter (picomatch); a leading `!` excludes. +# +# `code` is every path a build, test or packaging job may depend on, so exclude only paths no such job reads. A code +# path that no other class claims switches every class on, which means an unlisted path can never cause a job to be +# skipped. A project that builds its documentation excludes the sources here and claims them in a class of its own +# that gates the docs job. Project classes go below `code`. +code: + - '**' + - '!**/*.md' + - '!.coderabbit.yaml' + - '!**/*.mmd' + - '!*.code-workspace' + - '!.github/ISSUE_TEMPLATE/**' + - '!LICENSE' + # unit-level tests never reach a build artifact; their own classes still run them{% if has_backend %} + - '!backend/tests/unit/**'{% endif %} + - '!frontend/tests/unit/**' + - '!frontend/tests/compiled/**' +frontend: + - 'frontend/**' + - 'pnpm-lock.yaml' + - 'pnpm-workspace.yaml'{% if has_backend %} + - 'backend/tests/unit/__snapshots__/test_basic_server_functionality/test_openapi_schema.json'{% if frontend_uses_graphql %} + - 'backend/src/backend_api/graphql/schema.graphql'{% endif %} +backend: + - 'backend/**' + - 'pytest.ini'{% endif %}{% if install_as_windows_service %} +installer: + - 'installer/**' + - '!installer/**/*.md'{% endif %} diff --git a/template/.github/pull_request_template.md b/template/.github/pull_request_template.md index ac50221e1..eef920a86 100644 --- a/template/.github/pull_request_template.md +++ b/template/.github/pull_request_template.md @@ -19,8 +19,8 @@ +that they ran: CI runs every suite a change can affect. Re-running a suite only counts somewhere CI can't (a Raspberry +Pi, a lab PC). Don't list test names from the diff. --> ## Other diff --git a/template/.github/workflows/ci.yaml.jinja b/template/.github/workflows/ci.yaml.jinja index 164c5aff7..e2f5a1ce0 100644 --- a/template/.github/workflows/ci.yaml.jinja +++ b/template/.github/workflows/ci.yaml.jinja @@ -6,6 +6,7 @@ on: - 'gh-readonly-queue/**' # don't run (again) when on these special branches created during merge groups; the `on: merge_group` already triggers it. merge_group: pull_request: + workflow_dispatch: # a manual run executes every job for the chosen branch, as information only: it posts no required-check env: PYTHONUNBUFFERED: True @@ -37,6 +38,78 @@ jobs: - id: check uses: ./.github/actions/check-skip-duplicates + changes: + name: Classify changed paths + runs-on: {% endraw %}{{ gha_linux_runner }}{% raw %} + timeout-minutes: {% endraw %}{{ gha_short_timeout_minutes }}{% raw %} + permissions: + contents: read + pull-requests: read # dorny/paths-filter reads a PR's file list through the API + outputs: + # One boolean per class in .github/path-filters.yaml, as a JSON object: + # fromJSON(needs.changes.outputs.classes). + classes: ${{ steps.resolve.outputs.classes }} + steps: + - name: Decide whether the diff matters at all + id: policy + env: + # The default branch always runs everything, so a class that was wrong on a feature branch or in the merge + # queue is still caught there. A manual run is the way to get that full run for one branch. + FORCED_BY: ${{ github.ref == format('refs/heads/{0}', github.event.repository.default_branch) && 'the default branch' || github.event_name == 'workflow_dispatch' && 'a manual run' || '' }} + run: | + echo "forced_by=$FORCED_BY" >> "$GITHUB_OUTPUT" + if [ -n "$FORCED_BY" ]; then + echo "run_everything=true" >> "$GITHUB_OUTPUT" + else + echo "run_everything=false" >> "$GITHUB_OUTPUT" + fi + + - name: Checkout code + uses: actions/checkout@{% endraw %}{{ gha_checkout }}{% raw %} + with: + persist-credentials: false + # On push and merge_group events dorny/paths-filter compares against a base commit through git. Without + # persisted credentials it cannot fetch that commit from a private repository, so the full history is cloned + # up front and the base is already present locally. + # TODO: a full clone gets slow once a repository's history is large. Consider fetching only the base commit + # instead: resolve it (merge_group.base_sha, or the merge base from the compare API on push), fetch it at + # depth 1 with a one-off auth header that is never persisted, and pass it to the filter as `base`. + fetch-depth: 0 + + - name: Match the changed files against the path classes + if: steps.policy.outputs.run_everything != 'true' + id: filter + uses: dorny/paths-filter@{% endraw %}{{ gha_paths_filter }}{% raw %} + with: + filters: .github/path-filters.yaml + predicate-quantifier: some-with-excludes + list-files: json + + - name: Resolve the class outputs + id: resolve + env: + RUN_EVERYTHING: ${{ steps.policy.outputs.run_everything }} + FORCED_BY: ${{ steps.policy.outputs.forced_by }} + # Holds file names from the PR, so it only ever travels through env and jq, never a shell expansion + FILTER_OUTPUTS: ${{ toJSON(steps.filter.outputs) }} + run: | + # A code file that no other class claims forces every class on: an unclassified path can affect anything, so + # nothing may be skipped because of it. + classes=$(yq 'keys | .[]' .github/path-filters.yaml) + jq -n --arg run_everything "$RUN_EVERYTHING" --arg forced_by "$FORCED_BY" --argjson outputs "$FILTER_OUTPUTS" --arg classes "$classes" ' + def files($class): $outputs[$class + "_files"] // "[]" | fromjson; + ($classes | split("\n") | map(select(length > 0))) as $classes + | (files("code") - ([$classes[] | select(. != "code") | files(.)] | add // [])) as $unclassified + | ($run_everything == "true" or ($unclassified | length) > 0) as $force + | { + forced_by: $forced_by, + unclassified: $unclassified, + classes: ([$classes[] | {key: ., value: ($force or $outputs[.] == "true")}] | from_entries) + } + ' > "$RUNNER_TEMP/resolved.json" + echo "classes=$(jq -c .classes "$RUNNER_TEMP/resolved.json")" >> "$GITHUB_OUTPUT" + jq -r '"classes: \(.classes)", (if .forced_by != "" then "every class forced on by \(.forced_by)" elif (.unclassified | length) > 0 then "every class forced on by unclassified paths:", .unclassified[] else empty end)' "$RUNNER_TEMP/resolved.json" | tee -a "$GITHUB_STEP_SUMMARY" + lint: needs: - get-values @@ -53,24 +126,30 @@ jobs: node-version: {% endraw %}{{ node_version }}{% raw %} unit-test-frontend: - name: Unit Test Frontend + name: Unit Test Frontend${{ fromJSON(needs.changes.outputs.classes).frontend != true && ' (skipped, no frontend changes)' || '' }} timeout-minutes: {% endraw %}{{ gha_medium_timeout_minutes }}{% raw %} runs-on: {% endraw %}{{ gha_linux_runner }}{% raw %} needs: - lint - check-skip-duplicate + - changes if: needs.check-skip-duplicate.outputs.should-run == 'true' + env: + RUN_JOB: ${{ fromJSON(needs.changes.outputs.classes).frontend }} steps: - name: Checkout code + if: env.RUN_JOB == 'true' uses: actions/checkout@{% endraw %}{{ gha_checkout }}{% raw %} - name: Install latest versions of packages + if: env.RUN_JOB == 'true' uses: ./.github/actions/install_deps with: node-version: {% endraw %}{{ node_version }}{% raw %} skip-installing-ssm-plugin-manager: true - name: Unit test + if: env.RUN_JOB == 'true' run: pnpm --dir=frontend test-unit - name: Upload test coverage on failure @@ -84,12 +163,15 @@ jobs: retention-days: 5{% endraw %}{% if has_backend %}{% raw %} unit-test-backend: - name: Unit Test Backend + name: Unit Test Backend${{ fromJSON(needs.changes.outputs.classes).backend != true && ' (skipped, no backend changes)' || '' }} timeout-minutes: {% endraw %}{{ gha_medium_timeout_minutes }}{% raw %} needs: - lint - check-skip-duplicate + - changes if: needs.check-skip-duplicate.outputs.should-run == 'true' + env: + RUN_JOB: ${{ fromJSON(needs.changes.outputs.classes).backend }} strategy: matrix: os: @@ -98,21 +180,24 @@ jobs: runs-on: ${{ matrix.os }} steps:{% endraw %}{% if use_windows_in_ci %} - name: Allow long file paths - if: runner.os == 'Windows' + if: env.RUN_JOB == 'true' && runner.os == 'Windows' # Some tooling (e.g. vcrpy, syrupy) generates file names past the 260 character limit Windows # defaults to. This has to happen before the checkout that writes those files. run: git config --global core.longpaths true {% endif %}{% raw %} - name: Checkout code + if: env.RUN_JOB == 'true' uses: actions/checkout@{% endraw %}{{ gha_checkout }}{% raw %} - name: Install latest versions of python packages + if: env.RUN_JOB == 'true' uses: ./.github/actions/install_deps with: python-version: {% endraw %}{{ python_version }}{% raw %} skip-installing-ssm-plugin-manager: true - name: Unit test + if: env.RUN_JOB == 'true' run: uv --directory=backend run pytest tests/unit --durations=5 - name: Upload test coverage on failure @@ -130,8 +215,9 @@ jobs: - unit-test-frontend{% endraw %}{% if has_backend %}{% raw %} - unit-test-backend{% endraw %}{% endif %}{% raw %} - check-skip-duplicate - if: needs.check-skip-duplicate.outputs.should-run == 'true' -{% endraw %}{% if not deploy_as_executable %}{% raw %} uses: ./.github/workflows/build-docker-image.yaml + - changes +{% endraw %}{% if not deploy_as_executable %}{% raw %} if: needs.check-skip-duplicate.outputs.should-run == 'true' && fromJSON(needs.changes.outputs.classes).code + uses: ./.github/workflows/build-docker-image.yaml permissions: contents: write # needed for mutex id-token: write # needed to assume OIDC roles (e.g. for downloading from CodeArtifact) @@ -144,22 +230,30 @@ jobs: repository: {% endraw %}{{ frontend_ecr_base_url }}{% raw %} image_name: {% endraw %}{{ frontend_image_name }}{% raw %} {% endraw %}{% if push_to_ecr %}{% raw %} push-role-name: {% endraw %}{{ frontend_ecr_push_role_name }}{% endif %}{% else %}{% raw %} + name: Build Frontend${{ fromJSON(needs.changes.outputs.classes).code != true && ' (skipped, no code changes)' || '' }} + if: needs.check-skip-duplicate.outputs.should-run == 'true' + env: + RUN_JOB: ${{ fromJSON(needs.changes.outputs.classes).code }} timeout-minutes: {% endraw %}{{ gha_medium_timeout_minutes }}{% raw %} runs-on: {% endraw %}{{ gha_linux_runner }}{% raw %} steps: - name: Checkout code + if: env.RUN_JOB == 'true' uses: actions/checkout@{% endraw %}{{ gha_checkout }}{% raw %} - name: Install latest versions of node packages + if: env.RUN_JOB == 'true' uses: ./.github/actions/install_deps with: node-version: {% endraw %}{{ node_version }}{% raw %} skip-installing-ssm-plugin-manager: true - name: Build frontend + if: env.RUN_JOB == 'true' run: task build-frontend - name: Upload build artifact + if: env.RUN_JOB == 'true' uses: actions/upload-artifact@{% endraw %}{{ gha_upload_artifact }}{% raw %} with: name: frontend-static-assets @@ -176,7 +270,8 @@ jobs: - unit-test-frontend - unit-test-backend - check-skip-duplicate - if: needs.check-skip-duplicate.outputs.should-run == 'true' + - changes + if: needs.check-skip-duplicate.outputs.should-run == 'true' && fromJSON(needs.changes.outputs.classes).code uses: ./.github/workflows/build-docker-image.yaml permissions: contents: write # needed for mutex @@ -195,7 +290,11 @@ jobs: - unit-test-backend - build-frontend - check-skip-duplicate + - changes + name: Build Backend${{ fromJSON(needs.changes.outputs.classes).code != true && ' (skipped, no code changes)' || '' }} if: needs.check-skip-duplicate.outputs.should-run == 'true' + env: + RUN_JOB: ${{ fromJSON(needs.changes.outputs.classes).code }} strategy: matrix: os: @@ -206,31 +305,36 @@ jobs: steps:{% endraw %}{% if use_windows_in_ci %} - name: Allow long file paths - if: runner.os == 'Windows' + if: env.RUN_JOB == 'true' && runner.os == 'Windows' # Some tooling (e.g. vcrpy, syrupy) generates file names past the 260 character limit Windows # defaults to. This has to happen before the checkout that writes those files. run: git config --global core.longpaths true {% endif %}{% raw %} - name: Checkout code + if: env.RUN_JOB == 'true' uses: actions/checkout@{% endraw %}{{ gha_checkout }}{% raw %} - name: Download Frontend Static Assets + if: env.RUN_JOB == 'true' uses: actions/download-artifact@{% endraw %}{{ gha_download_artifact }}{% raw %} with: name: frontend-static-assets path: backend/src/static - name: Install latest versions of python packages + if: env.RUN_JOB == 'true' uses: ./.github/actions/install_deps with: python-version: {% endraw %}{{ python_version }}{% raw %} skip-installing-ssm-plugin-manager: true - name: Build application + if: env.RUN_JOB == 'true' run: task bundle-executable-from-staged-static - name: Tar files to preserve permissions on Linux during artifact upload # https://github.com/actions/upload-artifact?tab=readme-ov-file#permission-loss + if: env.RUN_JOB == 'true' run: tar -cvf app.tar backend/dist/{% endraw %}{{ repo_name }}{% raw %} - name: Upload application artifact (Windows) - if: runner.os == 'Windows' + if: env.RUN_JOB == 'true' && runner.os == 'Windows' uses: actions/upload-artifact@{% endraw %}{{ gha_upload_artifact }}{% raw %} with: name: built-{% endraw %}{{ repo_name }}{% raw %}-${{ matrix.os }} @@ -238,7 +342,7 @@ jobs: if-no-files-found: error retention-days: 5 - name: Upload application artifact (Linux) - if: runner.os == 'Linux' + if: env.RUN_JOB == 'true' && runner.os == 'Linux' uses: actions/upload-artifact@{% endraw %}{{ gha_upload_artifact }}{% raw %} with: name: built-{% endraw %}{{ repo_name }}{% raw %}-${{ matrix.os }} @@ -248,66 +352,80 @@ jobs: test-compiled-frontend-dev: - name: Test the compiled Frontend for pnpm dev mode + name: Test the compiled Frontend for pnpm dev mode${{ fromJSON(needs.changes.outputs.classes).frontend != true && ' (skipped, no frontend changes)' || '' }} timeout-minutes: {% endraw %}{{ gha_medium_timeout_minutes }}{% raw %} runs-on: {% endraw %}{{ gha_linux_runner }}{% raw %} env: + RUN_JOB: ${{ fromJSON(needs.changes.outputs.classes).frontend }} INSTALL_PLAYWRIGHT_BROWSERS: "1" # createPage() drives a real browser needs: - build-frontend{% endraw %}{% if has_backend %}{% raw %} - build-backend{% endraw %}{% endif %}{% raw %} - check-skip-duplicate + - changes if: needs.check-skip-duplicate.outputs.should-run == 'true' steps: - name: Checkout code + if: env.RUN_JOB == 'true' uses: actions/checkout@{% endraw %}{{ gha_checkout }}{% raw %} - name: Install latest versions of node packages + if: env.RUN_JOB == 'true' uses: ./.github/actions/install_deps with: node-version: {% endraw %}{{ node_version }}{% raw %} skip-installing-ssm-plugin-manager: true - name: Test + if: env.RUN_JOB == 'true' run: pnpm --dir=frontend test-compiled-dev{% endraw %}{% if is_circuit_python_driver %}{% raw %} e2e-test-k3d: - name: End-to-end Testing on k3d + name: End-to-end Testing on k3d${{ fromJSON(needs.changes.outputs.classes).code != true && ' (skipped, no code changes)' || '' }} timeout-minutes: {% endraw %}{{ gha_xlong_timeout_minutes }}{% raw %} runs-on: "{% endraw %}{{ gha_linux_runner }}{% raw %}" needs: - build-frontend - build-backend - check-skip-duplicate + - changes if: needs.check-skip-duplicate.outputs.should-run == 'true' + env: + RUN_JOB: ${{ fromJSON(needs.changes.outputs.classes).code }} steps: - name: Checkout code + if: env.RUN_JOB == 'true' uses: actions/checkout@{% endraw %}{{ gha_checkout }}{% raw %} - name: Download Backend Artifact + if: env.RUN_JOB == 'true' uses: actions/download-artifact@{% endraw %}{{ gha_download_artifact }}{% raw %} with: name: ${{ needs.build-backend.outputs.artifact-name }} path: deployment-package-folder - name: Download Frontend Artifact + if: env.RUN_JOB == 'true' uses: actions/download-artifact@{% endraw %}{{ gha_download_artifact }}{% raw %} with: name: {% endraw %}{{ frontend_image_name | replace('/', '-') }}{% raw %} path: deployment-package-folder - name: Load docker images + if: env.RUN_JOB == 'true' run: | ls deployment-package-folder -a # load all images in the folder ls -1 deployment-package-folder/*.tar | xargs --no-run-if-empty -L 1 docker load -i - name: Retag loaded images to stable local names + if: env.RUN_JOB == 'true' run: | docker tag ${{ needs.build-frontend.outputs.full-image-tag }} {% endraw %}{{ frontend_image_name }}{% raw %}:local docker tag ${{ needs.build-backend.outputs.full-image-tag }} {% endraw %}{{ backend_image_name }}{% raw %}:local - name: Create k3d cluster + if: env.RUN_JOB == 'true' # k3d runs k3s inside Docker containers. NodePort iptables rules only handle traffic # arriving via Docker port publishing (host → container), not inter-container traffic. # --port with @server:0 uses Docker port publishing so localhost:30060/30061 on the @@ -324,28 +442,34 @@ jobs: --port {% endraw %}{{ backend_nodeport_number }}{% raw %}:{% endraw %}{{ backend_nodeport_number }}{% raw %}@server:0 - name: Setup Helm + if: env.RUN_JOB == 'true' uses: Azure/setup-helm@{% endraw %}{{ gha_azure_setup_helm }}{% raw %} with: version: {% endraw %}{{ helm_version }}{% raw %} - name: Setup kubectl + if: env.RUN_JOB == 'true' uses: Azure/setup-kubectl@{% endraw %}{{ gha_azure_setup_kubectl }}{% raw %} with: version: {% endraw %}{{ kubectl_version }}{% raw %} - name: Get runner IP + if: env.RUN_JOB == 'true' run: | RUNNER_IP=$(hostname -I | awk '{print $1}') echo "Runner IP: $RUNNER_IP" echo "RUNNER_IP=$RUNNER_IP" >> $GITHUB_ENV - name: Create app namespace + if: env.RUN_JOB == 'true' run: kubectl create ns app - name: Wait for k3d node ready + if: env.RUN_JOB == 'true' run: kubectl wait --for=condition=Ready node --all --timeout=60s - name: Import images into k3d + if: env.RUN_JOB == 'true' run: | k3d image import {% endraw %}{{ frontend_image_name }}{% raw %}:local {% endraw %}{% if has_backend %}{{ backend_image_name }}{% raw %}:local{% endraw %}{% endif %}{% raw %} -c e2e # k3d image import exits 0 even when the internal `ctr images import` exec inside the node @@ -359,6 +483,7 @@ jobs: docker exec k3d-e2e-server-0 ctr -n k8s.io images ls | grep -Fq -- {% endraw %}{{ backend_image_name }}{% raw %}:local{% endraw %}{% endif %}{% raw %} - name: Helm install + if: env.RUN_JOB == 'true' run: | helm upgrade --install e2e ./deployment/helm \ --namespace app \ @@ -374,6 +499,7 @@ jobs: node-version: {% endraw %}{{ node_version }}{% raw %} - name: Wait for rollout + if: env.RUN_JOB == 'true' id: rollout run: kubectl -n app rollout status deploy/e2e-{% endraw %}{{ repo_name }}{% raw %} --timeout=180s @@ -391,6 +517,7 @@ jobs: run: kubectl -n app logs deploy/e2e-{% endraw %}{{ repo_name }}{% raw %} --all-containers --tail=100 --ignore-errors || true - name: E2E Test # TODO: add more than just smoke test + if: env.RUN_JOB == 'true' run: | # since there's no gap after the rollout to install node dependencies, sometimes the NodePorts themselves aren't ready yet, so do a few retries curl -s --show-error --fail-with-body --retry 10 --retry-delay 2 --retry-connrefused --retry-all-errors http://localhost:{% endraw %}{{ backend_nodeport_number }}{% raw %}/api/healthcheck @@ -398,7 +525,7 @@ jobs: curl -s --show-error --fail-with-body --retry 10 --retry-delay 2 --retry-connrefused --retry-all-errors http://localhost:{% endraw %}{{ frontend_nodeport_number }}{% raw %}/{% endraw %}{% endif %}{% raw %}{% endraw %}{% if run_backend_e2e_in_ci %}{% raw %} e2e-test-backend: - name: End-to-end Testing of the Backend + name: End-to-end Testing of the Backend${{ (fromJSON(needs.changes.outputs.classes).code && fromJSON(needs.changes.outputs.classes).backend) != true && ' (skipped, no backend code changes)' || '' }} timeout-minutes: {% endraw %}{{ gha_xlong_timeout_minutes }}{% raw %} strategy: matrix: @@ -408,72 +535,89 @@ jobs: needs: - build-backend - check-skip-duplicate + - changes if: needs.check-skip-duplicate.outputs.should-run == 'true' + env: + RUN_JOB: ${{ fromJSON(needs.changes.outputs.classes).code && fromJSON(needs.changes.outputs.classes).backend }} steps: - name: Checkout code + if: env.RUN_JOB == 'true' uses: actions/checkout@{% endraw %}{{ gha_checkout }}{% raw %} - name: Install latest versions of python packages + if: env.RUN_JOB == 'true' uses: ./.github/actions/install_deps with: python-version: {% endraw %}{{ python_version }}{% raw %} skip-installing-ssm-plugin-manager: true - name: Download Backend Artifact + if: env.RUN_JOB == 'true' uses: actions/download-artifact@{% endraw %}{{ gha_download_artifact }}{% raw %} with: name: ${{ needs.build-backend.outputs.artifact-name }} - path: deployment-package-folder{% endraw %}{% if auth_to_ecr_for_backend_e2e_in_ci %}{% raw %} - - - name: ECR Auth to pull images for E2E tests - uses: ./.github/actions/ecr-auth - with: - aws-region: {% endraw %}{{ auth_to_ecr_for_backend_e2e_in_ci_region }}{% raw %} - role-arn: {% endraw %}{{ auth_to_ecr_for_backend_e2e_in_ci_role_arn }}{% raw %}{% endraw %}{% endif %}{% raw %} + path: deployment-package-folder - name: Load docker images + if: env.RUN_JOB == 'true' run: | ls deployment-package-folder -a # load all images in the folder ls -1 deployment-package-folder/*.tar | xargs --no-run-if-empty -L 1 docker load -i - name: Retag loaded backend image to stable local name - run: docker tag ${{ needs.build-backend.outputs.full-image-tag }} {% endraw %}{{ backend_image_name }}{% raw %}:local + if: env.RUN_JOB == 'true' + run: docker tag ${{ needs.build-backend.outputs.full-image-tag }} {% endraw %}{{ backend_image_name }}{% raw %}:local{% endraw %}{% if auth_to_ecr_for_backend_e2e_in_ci %}{% raw %} + + - name: ECR Auth to pull images for E2E tests + if: env.RUN_JOB == 'true' + uses: ./.github/actions/ecr-auth + with: + aws-region: {% endraw %}{{ auth_to_ecr_for_backend_e2e_in_ci_region }}{% raw %} + role-arn: {% endraw %}{{ auth_to_ecr_for_backend_e2e_in_ci_role_arn }}{% raw %}{% endraw %}{% endif %}{% raw %} - name: E2E test + if: env.RUN_JOB == 'true' run: uv --directory=backend run pytest tests/e2e --durations=5{% endraw %}{% endif %}{% raw %}{% endraw %}{% if install_as_windows_service %}{% raw %} e2e-test-windows-service: - name: Windows Service Smoke Tests + name: Windows Service Smoke Tests${{ (fromJSON(needs.changes.outputs.classes).code && fromJSON(needs.changes.outputs.classes).backend) != true && ' (skipped, no backend code changes)' || '' }} timeout-minutes: {% endraw %}{{ gha_long_timeout_minutes }}{% raw %} runs-on: {% endraw %}{{ gha_windows_runner }}{% raw %} needs: - build-backend - check-skip-duplicate + - changes if: needs.check-skip-duplicate.outputs.should-run == 'true' + env: + RUN_JOB: ${{ fromJSON(needs.changes.outputs.classes).code && fromJSON(needs.changes.outputs.classes).backend }} steps: - name: Allow long file paths - if: runner.os == 'Windows' + if: env.RUN_JOB == 'true' && runner.os == 'Windows' # Some tooling (e.g. vcrpy, syrupy) generates file names past the 260 character limit Windows # defaults to. This has to happen before the checkout that writes those files. run: git config --global core.longpaths true - name: Checkout code + if: env.RUN_JOB == 'true' uses: actions/checkout@{% endraw %}{{ gha_checkout }}{% raw %} - name: Install latest versions of python packages + if: env.RUN_JOB == 'true' uses: ./.github/actions/install_deps with: python-version: {% endraw %}{{ python_version }}{% raw %} skip-installing-ssm-plugin-manager: true - name: Download Built executable + if: env.RUN_JOB == 'true' uses: actions/download-artifact@{% endraw %}{{ gha_download_artifact }}{% raw %} with: name: built-{% endraw %}{{ repo_name }}{% raw %}-{% endraw %}{{ gha_windows_runner }}{% raw %} path: backend/dist/{% endraw %}{{ repo_name }}{% raw %} - name: Windows service smoke tests + if: env.RUN_JOB == 'true' run: uv --directory=backend run pytest tests/windows_service --durations=5 --basetemp=dist/pytest-tmp - name: Dump service crash logs on failure @@ -503,7 +647,8 @@ jobs: needs: - build-backend - check-skip-duplicate - if: needs.check-skip-duplicate.outputs.should-run == 'true' + - changes + if: needs.check-skip-duplicate.outputs.should-run == 'true' && fromJSON(needs.changes.outputs.classes).code permissions: contents: read actions: read # build-installer.yaml downloads the built bundle artifact @@ -513,9 +658,10 @@ jobs: e2e-test: - name: End-to-end Testing + name: End-to-end Testing${{ fromJSON(needs.changes.outputs.classes).code != true && ' (skipped, no code changes)' || '' }} timeout-minutes: {% endraw %}{{ gha_xlong_timeout_minutes }}{% raw %} env: + RUN_JOB: ${{ fromJSON(needs.changes.outputs.classes).code }} INSTALL_PLAYWRIGHT_BROWSERS: "1" strategy: matrix: @@ -527,61 +673,71 @@ jobs: - build-frontend{% endraw %}{% if has_backend %}{% raw %} - build-backend{% endraw %}{% endif %}{% raw %} - check-skip-duplicate + - changes if: needs.check-skip-duplicate.outputs.should-run == 'true' steps:{% endraw %}{% if use_windows_in_ci %} - name: Allow long file paths - if: runner.os == 'Windows' + if: env.RUN_JOB == 'true' && runner.os == 'Windows' # Some tooling (e.g. vcrpy, syrupy) generates file names past the 260 character limit Windows # defaults to. This has to happen before the checkout that writes those files. run: git config --global core.longpaths true {% endif %}{% raw %} - name: Checkout code + if: env.RUN_JOB == 'true' uses: actions/checkout@{% endraw %}{{ gha_checkout }}{% raw %} - name: Install latest versions of node packages + if: env.RUN_JOB == 'true' uses: ./.github/actions/install_deps with: node-version: {% endraw %}{{ node_version }}{% raw %}{% endraw %}{% if not deploy_as_executable %}{% raw %} {% endraw %}{% if has_backend %}{% raw %} - name: Download Backend Artifact + if: env.RUN_JOB == 'true' uses: actions/download-artifact@{% endraw %}{{ gha_download_artifact }}{% raw %} with: name: ${{ needs.build-backend.outputs.artifact-name }} path: deployment-package-folder{% endraw %}{% endif %}{% raw %} - name: Download Frontend Artifact + if: env.RUN_JOB == 'true' uses: actions/download-artifact@{% endraw %}{{ gha_download_artifact }}{% raw %} with: name: {% endraw %}{{ frontend_image_name | replace('/', '-') }}{% raw %} path: deployment-package-folder - name: Load docker images + if: env.RUN_JOB == 'true' run: | ls deployment-package-folder -a # load all images in the folder ls -1 deployment-package-folder/*.tar | xargs --no-run-if-empty -L 1 docker load -i - name: Retag loaded images to stable local names + if: env.RUN_JOB == 'true' run: | docker tag ${{ needs.build-frontend.outputs.full-image-tag }} {% endraw %}{{ frontend_image_name }}{% raw %}:local{% endraw %}{% if has_backend %}{% raw %} docker tag ${{ needs.build-backend.outputs.full-image-tag }} {% endraw %}{{ backend_image_name }}{% raw %}:local{% endraw %}{% endif %}{% raw %} {% endraw %}{% else %}{% raw %} - name: Download Built executable + if: env.RUN_JOB == 'true' uses: actions/download-artifact@{% endraw %}{{ gha_download_artifact }}{% raw %} with: name: built-{% endraw %}{{ repo_name }}{% raw %}-${{ matrix.os }} path: backend/dist/{% endraw %}{{ repo_name }}{% raw %} - name: Untar the artifact (on Linux) - if: runner.os == 'Linux' + if: env.RUN_JOB == 'true' && runner.os == 'Linux' run: tar -xvf backend/dist/{% endraw %}{{ repo_name }}{% raw %}/app.tar -C . {% endraw %}{% endif %}{% raw %}{% endraw %}{% if auth_to_ecr_for_e2e_in_ci %}{% raw %} - name: ECR Auth to pull images for E2E tests + if: env.RUN_JOB == 'true' uses: ./.github/actions/ecr-auth with: aws-region: {% endraw %}{{ auth_to_ecr_for_e2e_in_ci_region }}{% raw %} role-arn: {% endraw %}{{ auth_to_ecr_for_e2e_in_ci_role_arn }}{% raw %}{% endraw %}{% endif %}{% raw %} - name: E2E test + if: env.RUN_JOB == 'true' run: pnpm --dir=frontend test-e2e - name: Upload Playwright report on failure @@ -596,36 +752,44 @@ jobs: retention-days: 5 {% endraw %}{% if create_docker_image_tar_artifact %}{% raw %} package-images: - name: Package Images + name: Package Images${{ fromJSON(needs.changes.outputs.classes).code != true && ' (skipped, no code changes)' || '' }} timeout-minutes: {% endraw %}{{ gha_medium_timeout_minutes }}{% raw %} runs-on: {% endraw %}{{ gha_linux_runner }}{% raw %} needs: - build-frontend{% endraw %}{% if has_backend %}{% raw %} - build-backend{% endraw %}{% endif %}{% raw %} - check-skip-duplicate + - changes if: needs.check-skip-duplicate.outputs.should-run == 'true' + env: + RUN_JOB: ${{ fromJSON(needs.changes.outputs.classes).code }} steps: - name: Checkout repository + if: env.RUN_JOB == 'true' uses: actions/checkout@{% endraw %}{{ gha_checkout }}{% raw %} - name: Prepare docker-compose file + if: env.RUN_JOB == 'true' run: | mkdir -p deployment-package-folder cp deployment/docker-compose.yaml deployment-package-folder/docker-compose.yaml sed -i '/image:/s/:latest/:${{ github.sha }}/g' deployment-package-folder/docker-compose.yaml{% endraw %}{% if has_backend %}{% raw %} - name: Download Backend Artifact + if: env.RUN_JOB == 'true' uses: actions/download-artifact@{% endraw %}{{ gha_download_artifact }}{% raw %} with: name: ${{ needs.build-backend.outputs.artifact-name }} path: deployment-package-folder{% endraw %}{% endif %}{% raw %} - name: Download Frontend Artifact + if: env.RUN_JOB == 'true' uses: actions/download-artifact@{% endraw %}{{ gha_download_artifact }}{% raw %} with: name: ${{ needs.build-frontend.outputs.artifact-name }} path: deployment-package-folder - name: Upload deployment package artifact + if: env.RUN_JOB == 'true' uses: actions/upload-artifact@{% endraw %}{{ gha_upload_artifact }}{% raw %} with: name: {% endraw %}{{ repo_name }}{% raw %}-deployment-package @@ -634,40 +798,50 @@ jobs: retention-days: 5{% endraw %}{% endif %}{% raw %}{% endraw %}{% if has_circuit_python_backend_template_been_instantiated %}{% raw %} package-firmware: - name: Package Firmware + name: Package Firmware${{ fromJSON(needs.changes.outputs.classes).code != true && ' (skipped, no code changes)' || '' }} timeout-minutes: {% endraw %}{{ gha_short_timeout_minutes }}{% raw %} runs-on: {% endraw %}{{ gha_linux_runner }}{% raw %} needs: - build-frontend - build-backend - check-skip-duplicate + - changes if: needs.check-skip-duplicate.outputs.should-run == 'true' + env: + RUN_JOB: ${{ fromJSON(needs.changes.outputs.classes).code }} steps: - name: Checkout repository + if: env.RUN_JOB == 'true' uses: actions/checkout@{% endraw %}{{ gha_checkout }}{% raw %} - name: Install latest versions of python packages + if: env.RUN_JOB == 'true' uses: ./.github/actions/install_deps with: python-version: {% endraw %}{{ python_version }}{% raw %} skip-installing-ssm-plugin-manager: true - name: Extract firmware version + if: env.RUN_JOB == 'true' id: get-version run: | VERSION=$(uv --directory=backend run python -c "from backend_api.firmware.src.code_lib import VERSION; print(VERSION)") echo "version=$VERSION" >> $GITHUB_OUTPUT - name: Install firmware dependencies + if: env.RUN_JOB == 'true' run: uv --directory=backend run python scripts/install_firmware_dependencies.py - name: Verify CircuitPython libraries installed + if: env.RUN_JOB == 'true' run: uv --directory=backend run circup --path=./src/backend_api/firmware/src/ freeze - name: Clean __pycache__ directories + if: env.RUN_JOB == 'true' run: find ./backend/src/backend_api/firmware/src -type d -name '__pycache__' -exec rm -rf {} + - name: Upload firmware artifact + if: env.RUN_JOB == 'true' uses: actions/upload-artifact@{% endraw %}{{ gha_upload_artifact }}{% raw %} with: name: {% endraw %}{{ repo_name.removesuffix('-driver') }}{% raw %}-firmware-v${{ steps.get-version.outputs.version }} @@ -685,6 +859,7 @@ jobs: needs: - get-values - check-skip-duplicate + - changes - lint - unit-test-frontend - build-frontend{% endraw %}{% if has_backend %}{% raw %} @@ -722,11 +897,14 @@ jobs: if: ${{ github.event_name == 'pull_request' || github.event_name == 'merge_group' }} env: GH_TOKEN: ${{ github.token }} + # A status belongs to a commit: the PR's head commit on pull_request events, the merge group's head on + # merge_group events, whose payload carries no pull_request at all. + COMMIT_SHA: ${{ github.event_name == 'merge_group' && github.event.merge_group.head_sha || github.event.pull_request.head.sha }} run: | gh api \ -X POST -H "Accept: application/vnd.github.v3+json" \ - "${{ github.event.pull_request.statuses_url }}" \ - -f state=success -f context="required-check" -f description="✅ All required checks passed in the job triggered by pull_request" \ + "/repos/${{ github.repository }}/statuses/${COMMIT_SHA}" \ + -f state=success -f context="required-check" -f description="✅ All required checks passed in the job triggered by ${{ github.event_name }}" \ -f target_url="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" - name: Mark updated dependabot hash commit as succeeded diff --git a/template/{% if has_backend %}backend{% endif %}/src/backend_api/entrypoint/{% if install_as_windows_service %}win_service.py{% endif %} b/template/{% if has_backend %}backend{% endif %}/src/backend_api/entrypoint/{% if install_as_windows_service %}win_service.py{% endif %} index 62fec994e..d26bcb365 100644 --- a/template/{% if has_backend %}backend{% endif %}/src/backend_api/entrypoint/{% if install_as_windows_service %}win_service.py{% endif %} +++ b/template/{% if has_backend %}backend{% endif %}/src/backend_api/entrypoint/{% if install_as_windows_service %}win_service.py{% endif %} @@ -8,10 +8,10 @@ from pathlib import Path from typing import override # pywin32 is win32-only, so the per-import missing-import ignores are required in CI/Linux but appear unnecessary on Windows dev machines. -import servicemanager # pyrefly: ignore[missing-import,missing-source,unused-ignore] # pywin32 only installs on Windows; its types-pywin32 stubs type it on every platform -import win32service # pyrefly: ignore[missing-import,missing-source,unused-ignore] # pywin32 only installs on Windows; its types-pywin32 stubs type it on every platform -import win32serviceutil # pyrefly: ignore[missing-import,missing-source,unused-ignore] # pywin32 only installs on Windows; its types-pywin32 stubs type it on every platform -import winerror # pyrefly: ignore[missing-import,missing-source,unused-ignore] # pywin32 only installs on Windows; its types-pywin32 stubs type it on every platform +import servicemanager # pyrefly: ignore[missing-source] # pywin32 only installs on Windows; its types-pywin32 stubs type it on every platform +import win32service # pyrefly: ignore[missing-source] # pywin32 only installs on Windows; its types-pywin32 stubs type it on every platform +import win32serviceutil # pyrefly: ignore[missing-source] # pywin32 only installs on Windows; its types-pywin32 stubs type it on every platform +import winerror # pyrefly: ignore[missing-source] # pywin32 only installs on Windows; its types-pywin32 stubs type it on every platform from .. import app_runner from ..jinja_constants import APP_NAME diff --git a/uv.lock b/uv.lock index 241c8f672..d6b4b8905 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 3 +revision = 5 requires-python = ">=3.12.7" [[package]] @@ -63,7 +63,7 @@ requires-dist = [ { name = "copier", specifier = "==9.18.2" }, { name = "copier-template-extensions", specifier = "==0.3.3" }, { name = "faker", specifier = ">=40.41.0" }, - { name = "pyrefly", specifier = ">=1.3.2" }, + { name = "pyrefly", specifier = "==1.4.0" }, { name = "pytest", specifier = ">=9.1.1" }, { name = "pytest-cov", specifier = ">=7.1.0" }, { name = "pytest-randomly", specifier = ">=5.0.0" }, @@ -411,21 +411,21 @@ wheels = [ [[package]] name = "pyrefly" -version = "1.3.2" -source = { registry = "https://pypi.org/simple/" } -sdist = { url = "https://files.pythonhosted.org/packages/4d/78/b6ec12acb586606618c71ebe1008af59e1ca50e667895f1946fa7b656619/pyrefly-1.3.2.tar.gz", hash = "sha256:30a68ff5429a1546ec08a2d913dfe442c9553a2940d274de18833d8eda939b5e", size = 6679591, upload-time = "2026-09-28T22:10:06.292Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/24/01/144a78839822abad55628881bcb45f8173e6d8b87976897c9105884b5a6b/pyrefly-1.3.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:626dbaee16ae1169a10a4ba3293583b5ab0320102f35efaad9e03457a2fc646c", size = 15088488, upload-time = "2026-09-28T22:09:40.743Z" }, - { url = "https://files.pythonhosted.org/packages/23/aa/599756bd42e6533ee9261ebd288f846ab453b00debaef2b4789893967809/pyrefly-1.3.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:2d5fc704dbf0e990dc4f15cb1ead64f1a325ae27383a50c76cfcaf0387f926bb", size = 14429996, upload-time = "2026-09-28T22:09:43.316Z" }, - { url = "https://files.pythonhosted.org/packages/90/0c/4b0678889136b1514ecd25cec11974d5bfdaeaaf9f5fe8a766ca45ec4ab5/pyrefly-1.3.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:89dbe8a33e735fa96023c33ce5dfd81af9b0bdd2ee771e8d69b2ebffc7e0fbc7", size = 14871810, upload-time = "2026-09-28T22:09:45.42Z" }, - { url = "https://files.pythonhosted.org/packages/a6/4e/a96236391c6bb0d8b50af85e0ea6c0c56206f739931ac3031f9e000325ee/pyrefly-1.3.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3ec0c31715d48bff279f548b85a93b5bc43d2c7bd1492a8cd703a9185cb089e5", size = 16113462, upload-time = "2026-09-28T22:09:47.619Z" }, - { url = "https://files.pythonhosted.org/packages/ad/c3/d4d0966aa7008ebd8481a6d05aa53202ad40c9597705eeee87ba6e0e2025/pyrefly-1.3.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3b3de468bed691e40dc6271284c8dcc05e649d7ed45280ef3d07332741efe34c", size = 16043341, upload-time = "2026-09-28T22:09:50.083Z" }, - { url = "https://files.pythonhosted.org/packages/4f/d1/52d9ea1b7048c7316d9ff7594c170b1c06d37547a75d140c8deea7b9ddfd/pyrefly-1.3.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cb94365fe5f854cb4fe82f600c4e2453160d328610b12981004851ee0b6e8aeb", size = 15437183, upload-time = "2026-09-28T22:09:52.56Z" }, - { url = "https://files.pythonhosted.org/packages/08/ec/24f83e4a397fa9d5c416bb4d378ee1bb18fe48bdd5698fe541636f5352f3/pyrefly-1.3.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:4f730d2e515ac25827fc68f343a7aa02b1072e0062df6a430346a4ffdfb5db2f", size = 14892976, upload-time = "2026-09-28T22:09:54.916Z" }, - { url = "https://files.pythonhosted.org/packages/29/c7/a473f8e9ec31ba0682ebe44f4e51167a22a4cbdc8bfd87365b9aca23be20/pyrefly-1.3.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:60f440e1626bd5cc4a41ee90cecba80796ed01ebd6c15d9f933efb8d07ffe2b8", size = 15469689, upload-time = "2026-09-28T22:09:57.541Z" }, - { url = "https://files.pythonhosted.org/packages/d0/df/779092365e8e469fc8e7349e1c26c35b8a73df9e6be80a00265fc26a2b2b/pyrefly-1.3.2-py3-none-win32.whl", hash = "sha256:b31684189de44a25a5bf00bdf3ad16be44a7b14654702d6018bce1043357202c", size = 14135550, upload-time = "2026-09-28T22:09:59.757Z" }, - { url = "https://files.pythonhosted.org/packages/9d/20/466333e7f2891f8c357b5c226559c39ba8dde4f90be09189ecc771a086f5/pyrefly-1.3.2-py3-none-win_amd64.whl", hash = "sha256:693960cdb247d1f76b9e09b3f2554dae20034f7d442ccd4e9b3e8962a3622363", size = 15067523, upload-time = "2026-09-28T22:10:02.099Z" }, - { url = "https://files.pythonhosted.org/packages/18/32/ee66df92c692c0686d82c9ecef3876ccb0386aa318bf507016f0121a8f78/pyrefly-1.3.2-py3-none-win_arm64.whl", hash = "sha256:f7424b21331670522980ff13e08bd143659879a4d92f4832282c7d7c41a9b5d6", size = 14348271, upload-time = "2026-09-28T22:10:04.253Z" }, +version = "1.4.0" +source = { registry = "https://pypi.org/simple/" } +sdist = { url = "https://files.pythonhosted.org/packages/b3/3e/ea6893be167fa14307d7f9a924bed9ecbbbd91b01953890b9b3c58ed5359/pyrefly-1.4.0.tar.gz", hash = "sha256:bb57c004a4842cfe93f93aebb5203ca6209a5f0c8bda47e8bc289e70940fd910", size = 6962062, upload-time = "2026-10-15T16:54:11.819Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/17/dfce6611acc00cb26667e5e1011438cc510f3067f961e0f07f7a0c68f416/pyrefly-1.4.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:460cec39bc8743915e9c2c8c10ba926ad0b29b099d89c1859fb97e0059f42fa7", size = 15705624, upload-time = "2026-10-15T16:53:43.412Z" }, + { url = "https://files.pythonhosted.org/packages/a6/78/a7b96442123fa6ed733de0d1ea933e35d6fc6b7c9cf16e05279abb493493/pyrefly-1.4.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:5a6370d686b6cbbac8869832f09a52012888371956fbc68d446a349bc85ccd43", size = 14978669, upload-time = "2026-10-15T16:53:46.546Z" }, + { url = "https://files.pythonhosted.org/packages/23/97/dfcfec6a9d8948ab4b2746f7e87558a82bffabb7a54c22e7d3ce4bdc1932/pyrefly-1.4.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f1b16923a6583a6764e8aec07787e2ef4df644c7030294cba71339783a9451c", size = 15438858, upload-time = "2026-10-15T16:53:48.904Z" }, + { url = "https://files.pythonhosted.org/packages/31/12/6fa414aa87e398d6ddc7145a5d993b0975fc3511198dbb72c47873653ab6/pyrefly-1.4.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:688ad4cb4d6e711d67026fdcfb37b599f86061efe11ba5704287330e7041a69e", size = 16852241, upload-time = "2026-10-15T16:53:51.496Z" }, + { url = "https://files.pythonhosted.org/packages/a8/73/2d7312262cf6481d6ad46eed55b8f2592c3cff60f95cc2f30a114cd1484e/pyrefly-1.4.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f5f3c9dddb6f3128beb49cf4e0f5cb88d4d17f039008d8e0c2be653966acd234", size = 16751917, upload-time = "2026-10-15T16:53:54.39Z" }, + { url = "https://files.pythonhosted.org/packages/2c/65/b44a1cb23667bcbcdc88558cbef79fa9e07a1152c2a1da148036b1a2e0d1/pyrefly-1.4.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b6951f51687304caebfedf13dd1a943aeafdbc95abcc4e24a683b97467a77007", size = 16066345, upload-time = "2026-10-15T16:53:57.436Z" }, + { url = "https://files.pythonhosted.org/packages/a6/07/7ca6ff0c53e3a90ecdab05790d33f430fca5c48687613a0b182744166d98/pyrefly-1.4.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:838de4d4a8b9b8ed39bedd0b8b55629c69661d73a6fa3a3a4adff24b9dbeb399", size = 15461223, upload-time = "2026-10-15T16:54:00.364Z" }, + { url = "https://files.pythonhosted.org/packages/61/be/ec7b2f38a898ce5b2219dda730e87d31fc8cfb2108a3d5844f9d590f1281/pyrefly-1.4.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:afb9ce2e0ae3e6106fb45f6d47536ac92b6423e601e08401cdefa850066444f5", size = 16119099, upload-time = "2026-10-15T16:54:02.664Z" }, + { url = "https://files.pythonhosted.org/packages/6e/16/1b9549168b38a575d9c168e17b4b4ee30028d602ec0f7b07c198ef265dbe/pyrefly-1.4.0-py3-none-win32.whl", hash = "sha256:839db536d0ac29f1b23e2d974fb911075c2283e3e623755c45d826aafac175bc", size = 14858880, upload-time = "2026-10-15T16:54:05.114Z" }, + { url = "https://files.pythonhosted.org/packages/03/02/18f075d4de0e255f184f42fc28c1f0ecc9f49dd528678479fc90cf12246c/pyrefly-1.4.0-py3-none-win_amd64.whl", hash = "sha256:7aff37c2b711c8240ed650da0c6148237b327bf7e5dfacee65884f1adea22305", size = 15726220, upload-time = "2026-10-15T16:54:07.452Z" }, + { url = "https://files.pythonhosted.org/packages/92/62/92947076681550904c72a9dd03b24e92694ede9a48548f5e487c849e66ea/pyrefly-1.4.0-py3-none-win_arm64.whl", hash = "sha256:88879f0cb749e5fea81d9ff4c2d5431a70349c458bcdf0b61de72c79214d90a5", size = 14943690, upload-time = "2026-10-15T16:54:09.689Z" }, ] [[package]]