diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md deleted file mode 100644 index dd84ea78..00000000 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ /dev/null @@ -1,38 +0,0 @@ ---- -name: Bug report -about: Create a report to help us improve -title: '' -labels: '' -assignees: '' - ---- - -**Describe the bug** -A clear and concise description of what the bug is. - -**To Reproduce** -Steps to reproduce the behavior: -1. Go to '...' -2. Click on '....' -3. Scroll down to '....' -4. See error - -**Expected behavior** -A clear and concise description of what you expected to happen. - -**Screenshots** -If applicable, add screenshots to help explain your problem. - -**Desktop (please complete the following information):** - - OS: [e.g. iOS] - - Browser [e.g. chrome, safari] - - Version [e.g. 22] - -**Smartphone (please complete the following information):** - - Device: [e.g. iPhone6] - - OS: [e.g. iOS8.1] - - Browser [e.g. stock browser, safari] - - Version [e.g. 22] - -**Additional context** -Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 00000000..ba8160f1 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,54 @@ +name: Bug report +description: Something on the printer screen is broken or behaves wrongly +labels: ["bug"] +body: + - type: textarea + id: what-happened + attributes: + label: What happened + description: What went wrong, and what you expected instead. + validations: + required: true + - type: textarea + id: steps + attributes: + label: Steps to reproduce + placeholder: | + 1. Open ... + 2. Tap ... + 3. See ... + validations: + required: true + - type: dropdown + id: branch + attributes: + label: Branch + description: The branch the printer tracks. + options: + - Release-2.0 + - main + - stage + - dev + - Other + validations: + required: true + - type: input + id: commit + attributes: + label: Commit + description: "`git -C ~/BlocksScreen rev-parse --short HEAD` on the printer, if known." + - type: input + id: hardware + attributes: + label: Printer and hardware + placeholder: e.g. RF50, Raspberry Pi 5, AMU attached + - type: textarea + id: logs + attributes: + label: Logs + description: Relevant lines from logs/BlocksScreen.log and klippy.log. + render: shell + - type: textarea + id: context + attributes: + label: Screenshots and context diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 00000000..b1e8ee9d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: true +contact_links: + - name: Report a security vulnerability + url: https://github.com/BlocksTechnology/BlocksScreen/security/advisories/new + about: Report privately instead of opening a public issue. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md deleted file mode 100644 index bbcbbe7d..00000000 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -name: Feature request -about: Suggest an idea for this project -title: '' -labels: '' -assignees: '' - ---- - -**Is your feature request related to a problem? Please describe.** -A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] - -**Describe the solution you'd like** -A clear and concise description of what you want to happen. - -**Describe alternatives you've considered** -A clear and concise description of any alternative solutions or features you've considered. - -**Additional context** -Add any other context or screenshots about the feature request here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 00000000..97c0b661 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,26 @@ +name: Feature request +description: Suggest a new feature or an improvement +labels: ["enhancement"] +body: + - type: textarea + id: problem + attributes: + label: Problem + description: What is hard or impossible today, and who it affects. + validations: + required: true + - type: textarea + id: proposal + attributes: + label: Proposed solution + description: What you would like to happen. + validations: + required: true + - type: textarea + id: alternatives + attributes: + label: Alternatives considered + - type: textarea + id: context + attributes: + label: Screenshots and context diff --git a/.github/PULL_REQUEST_TEMPLATE/pull_request_template.md b/.github/PULL_REQUEST_TEMPLATE/pull_request_template.md deleted file mode 100644 index e65e9dbb..00000000 --- a/.github/PULL_REQUEST_TEMPLATE/pull_request_template.md +++ /dev/null @@ -1,68 +0,0 @@ ---- -name: Pull Request -about: Propose code changes -title: '' -labels: '' -assignees: '' - ---- - -# PR Checklist (Delete the section after checking everything) - -- [ ] PR title follows Conventional Commits: `type: description` or `type(scope): description`, type is one of `feat|fix|docs|refactor|test|chore|perf` (e.g. `fix(updater): retry apt lock on timeout`). Enforced by CI - this is what drives automatic version bumps, so a rejected title means no version credit for this change. -- [ ] Make sure you are requesting to PR **feature/bugfix/refactor branch**. -- [ ] Make sure you are making the pull request against the **dev** branch. -- [ ] Make sure you include labels to the PR -- [ ] Request a reviewer for the current PR -- [ ] After the PR is published, make sure you view the tests conducted by github actions, download artifacts and inspect them. Failure to pass the tests will result in a request for changes. - - - -# Description - -Select the type: -- [ ] Feature -- [ ] Bug fix -- [ ] Code refactor -- [ ] Documentation - -Include a summary of the changes made in this PR and which issue is fixed. - -If the current PR is related to a bug introduced in another PR please insert the reference of the previous PR with **#**. - - -Add a concise checklist of the implemented changes, as exemplified below. - -- Change 1. -- Change 2. -- Change 3. -- ... - - -**Depending on the type of change the current PR relates to, delete sections that are not applicable.** - - -# Motivation - -Include a detailed explanation for the current PR. Delete section if not applicable. - -# Tests -Please describe the conducted tests, and include logs, reports on the tests. - -Also include test configuration. - - -Delete section if not applicable. -# Screenshots - -Include screenshots of the changes. Mostly used for UI changes. - -Delete section if not applicable. - - -# Future work - -Emphasize future tasks and improvements tied directly to this pull request. -Include only non-breaking changes to this pull request - -Delete section if not applicable. \ No newline at end of file diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..03fa5135 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,18 @@ +version: 2 +updates: + # Actions only: pip bumps would break the printers' aarch64 PyQt6 6.7.1 ceiling. + - package-ecosystem: github-actions + directory: / + target-branch: dev + schedule: + interval: monthly + # Skip releases younger than a week, the window most hijacked tags are caught in. + cooldown: + default-days: 7 + labels: ["dependencies"] + groups: + actions: + patterns: ["*"] + commit-message: + prefix: chore + include: scope diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 00000000..906dc4e5 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,35 @@ + + +# PR Checklist + +- [ ] Title follows Conventional Commits, e.g. `fix(updater): retry apt lock on timeout`; CI rejects anything else +- [ ] Opened from a feature, bugfix or refactor branch against **dev** +- [ ] Labels added and a reviewer requested +- [ ] `make check` passes +- [ ] CI results checked once the PR is open; failing checks mean changes requested + +# Description +- [ ] Feature +- [ ] Bug Fix +- [ ] Code Refactor +- [ ] Documentation + + + +## Changes +- + +# Motivation + + +# Tests + +- [ ] Tested on simulator / printer + +# Screenshots + + +# Future work + diff --git a/.github/release.yml b/.github/release.yml new file mode 100644 index 00000000..f630e312 --- /dev/null +++ b/.github/release.yml @@ -0,0 +1,19 @@ +# Categories for GitHub's "Generate release notes"; first matching label wins, so "*" stays last. +changelog: + categories: + - title: Breaking changes + labels: ["breaking"] + - title: Features + labels: ["enhancement"] + - title: Bug fixes + labels: ["bug"] + - title: Performance + labels: ["performance"] + - title: Refactoring + labels: ["refactor"] + - title: Documentation + labels: ["documentation"] + - title: Dependencies + labels: ["dependencies"] + - title: Other changes + labels: ["*"] diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 32b2789d..b8cc5a54 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -1,105 +1,52 @@ -# For most projects, this workflow file will not need changing; you simply need -# to commit it to your repository. -# -# You may wish to alter this file to override the set of languages analyzed, -# or to provide custom queries or build logic. -# -# ******** NOTE ******** -# We have attempted to detect the languages in your repository. Please check -# the `language` matrix defined below to confirm you have the correct set of -# supported CodeQL languages. -# name: "CodeQL Advanced" +# No paths-ignore: code scanning merge protection waits forever for a skipped analysis. on: push: - paths-ignore: - - 'BlocksScreen/lib/ui/**' - - 'extras/**' - - 'scripts/**' + branches: [main, dev, stage, "Release**"] pull_request: - paths-ignore: - - 'BlocksScreen/lib/ui/**' - - 'extras/**' - - 'scripts/**' schedule: - cron: '27 5 * * 0' +permissions: + contents: read + +# Stage's code_scanning rule needs results for the exact SHA, so push runs are never cancelled. +concurrency: + group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.ref || github.sha }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: analyze: name: Analyze (${{ matrix.language }}) - # Runner size impacts CodeQL analysis time. To learn more, please see: - # - https://gh.io/recommended-hardware-resources-for-running-codeql - # - https://gh.io/supported-runners-and-hardware-resources - # - https://gh.io/using-larger-runners (GitHub.com only) - # Consider using larger runners or machines with greater resources for possible analysis time improvements. - runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }} + runs-on: ubuntu-24.04 + timeout-minutes: 30 permissions: - # required for all workflows security-events: write - - # required to fetch internal or private CodeQL packs - packages: read - - # only required for workflows in private repositories - actions: read contents: read strategy: fail-fast: false matrix: include: - - language: python - build-mode: none - # CodeQL supports the following values keywords for 'language': 'actions', 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'rust', 'swift' - # Use `c-cpp` to analyze code written in C, C++ or both - # Use 'java-kotlin' to analyze code written in Java, Kotlin or both - # Use 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both - # To learn more about changing the languages that are analyzed or customizing the build mode for your analysis, - # see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/customizing-your-advanced-setup-for-code-scanning. - # If you are analyzing a compiled language, you can modify the 'build-mode' for that language to customize how - # your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages - steps: - - name: Checkout repository - uses: actions/checkout@v4 + - language: python + build-mode: none + - language: actions + build-mode: none - # Add any setup steps before running the `github/codeql-action/init` action. - # This includes steps like installing compilers or runtimes (`actions/setup-node` - # or others). This is typically only required for manual builds. - # - name: Setup runtime (example) - # uses: actions/setup-example@v1 - - # Initializes the CodeQL tools for scanning. - - name: Initialize CodeQL - uses: github/codeql-action/init@v4 - with: - languages: ${{ matrix.language }} - build-mode: ${{ matrix.build-mode }} - # If you wish to specify custom queries, you can do so here or in a config file. - # By default, queries listed here will override any specified in a config file. - # Prefix the list here with "+" to use these queries and those in the config file. - - # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs - # queries: security-extended,security-and-quality - - # If the analyze step fails for one of the languages you are analyzing with - # "We were unable to automatically build your code", modify the matrix above - # to set the build mode to "manual" for that language. Then modify this step - # to build your code. - # â„šī¸ Command-line programs to run using the OS shell. - # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun - - name: Run manual build steps - if: matrix.build-mode == 'manual' - shell: bash - run: | - echo 'If you are using a "manual" build mode for one or more of the' \ - 'languages you are analyzing, replace this with the commands to build' \ - 'your code, for example:' - echo ' make bootstrap' - echo ' make release' - exit 1 - - - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v4 - with: - category: "/language:${{matrix.language}}" + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Initialize CodeQL + uses: github/codeql-action/init@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2 + with: + languages: ${{ matrix.language }} + build-mode: ${{ matrix.build-mode }} + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2 + with: + category: "/language:${{ matrix.language }}" diff --git a/.github/workflows/dev-ci.yml b/.github/workflows/dev-ci.yml index a7d17075..3f60e942 100644 --- a/.github/workflows/dev-ci.yml +++ b/.github/workflows/dev-ci.yml @@ -1,32 +1,52 @@ name: CI-dev-pipeline +# No paths filter: a required check that never starts leaves the PR pending forever. on: + # A squash merge creates a SHA no PR run tested; stage can only require checks this run reports. + push: + branches: [dev] + # Every base, main included, so a hotfix PR is checked like any other. pull_request: - branches: - - dev - paths-ignore: - - "scripts/**" - - "BlocksScreen/lib/ui/**" - - "extras/**" + +permissions: + contents: read + +# PR runs supersede each other; push runs are grouped per SHA so a fast second merge never cancels the first. +concurrency: + group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.ref || github.sha }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +defaults: + run: + # Explicit bash adds -o pipefail, so `| tee` keeps the tool's exit code. + shell: bash + jobs: ci-checks: - runs-on: ubuntu-latest + # Pinned: ubuntu-26.04 has no Python 3.11.2 build. + runs-on: ubuntu-24.04 + timeout-minutes: 15 strategy: fail-fast: false matrix: + # Debian 12 bookworm python3, what the printers run. python-version: ["3.11.2"] test-type: [ruff, pylint, pytest, docstrcov, security] steps: - name: Checkout repo - uses: actions/checkout@v4 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: ${{ matrix.python-version }} cache: pip - cache-dependency-path: scripts/requirements-dev.txt + cache-dependency-path: | + scripts/requirements.txt + scripts/requirements-dev.txt - name: Install system dependencies if: matrix.test-type == 'pytest' @@ -36,80 +56,65 @@ jobs: - name: Install dependencies run: | - echo "Installing dependencies" - python -m pip install --upgrade pip - pip install -r scripts/requirements-dev.txt + python -m pip install -r scripts/requirements-pip.txt + # --no-deps installs only what is pinned, so pip check fails on any dependency the files forgot. + pip install --no-deps -r scripts/requirements.txt -r scripts/requirements-dev.txt + pip check - - name: Run Test ${{ matrix.test-type }} + - name: Run ${{ matrix.test-type }} + env: + TEST_TYPE: ${{ matrix.test-type }} run: | - echo "Starting test runs" - if [ "${{ matrix.test-type }}" == "ruff" ]; then - echo "Running Formatting Test" - ruff check --output-format=github --target-version=py311 --config=pyproject.toml > ruff-output.txt 2>&1 - ruff format --diff --target-version=py311 --config=pyproject.toml >> ruff-output.txt 2>&1 - echo "Ruff finished" - fi - if [ "${{ matrix.test-type }}" == "pylint" ]; then - echo "Running Pylint Code Test" - pylint -j$(nproc) --recursive=y BlocksScreen/ > pylint-output.txt 2>&1 - echo "Pylint finished" - fi - - if [ "${{ matrix.test-type }}" == "pytest" ]; then - if [ -d "tests/" ] && [ "$(ls -A tests/)" ]; then - echo "Running Python unit tests" - pytest tests/ --doctest-modules --junitxml=junit/test-results.xml --cov=BlocksScreen/ --cov-report=xml --cov-report=html > pytest-output.txt 2>&1 - else - echo "No tests directory no need to proceed with tests" - fi - fi - - if [ "${{ matrix.test-type }}" == "docstrcov" ]; then - echo "Running docstring coverage test" - docstr-coverage BlocksScreen/ --exclude '.*/BlocksScreen/lib/ui/.*?$' --fail-under=80 --skip-magic --skip-init --skip-private --skip-property > docstr-cov-output.txt 2>&1 - fi + case "$TEST_TYPE" in + ruff) + rc=0 + ruff check --output-format=github --target-version=py311 --config=pyproject.toml 2>&1 | tee ruff-output.txt || rc=1 + ruff format --diff --target-version=py311 --config=pyproject.toml 2>&1 | tee -a ruff-output.txt || rc=1 + exit $rc + ;; + pylint) + pylint -j"$(nproc)" --recursive=y BlocksScreen/ 2>&1 | tee pylint-output.txt + ;; + pytest) + pytest tests/ --doctest-modules --junitxml=junit/test-results.xml --cov=BlocksScreen/ --cov-report=xml --cov-report=html 2>&1 | tee pytest-output.txt + ;; + docstrcov) + docstr-coverage BlocksScreen/ --exclude '.*/BlocksScreen/lib/ui/.*?$' --fail-under=80 --skip-magic --skip-init --skip-private --skip-property 2>&1 | tee docstrcov-output.txt + ;; + security) + bandit -c pyproject.toml -r . 2>&1 | tee security-output.txt + ;; + esac - if [ "${{matrix.test-type }}" == "security" ]; then - echo "Running bandit security test" - bandit -c pyproject.toml -r . -f json -o bandit-output.json 2>&1 + # The fleet installs wheels only (sdbus packages aside), so a pin without an aarch64 wheel passes CI yet breaks every box. + # After the scan, not before, so a pin failure never hides bandit's result. + - name: Check pins are complete and have bookworm aarch64 wheels + if: ${{ !cancelled() && matrix.test-type == 'security' }} + run: | + # Bookworm glibc is 2.36; pip does not expand manylinux tags downwards, so list each one. + plats=(--platform manylinux2014_aarch64) + for m in $(seq 17 36); do plats+=(--platform "manylinux_2_${m}_aarch64"); done + pip install --dry-run --quiet --ignore-installed --only-binary :all: "${plats[@]}" \ + --python-version 3.11 --implementation cp --abi cp311 --report "$RUNNER_TEMP/closure.json" \ + -r <(grep -v '^sdbus' scripts/requirements.txt) + # Boxes resolve this file with plain pip, so a dependency missing from it floats to whatever is newest. + unpinned=$(jq -r --rawfile reqs scripts/requirements.txt ' + def norm: ascii_downcase | gsub("[-_.]+"; "-"); + [$reqs | splits("\n") | select(test("^[A-Za-z0-9]")) | split("==")[0] | norm] as $pinned + | .install[].metadata.name | norm | select(IN($pinned[]) | not)' "$RUNNER_TEMP/closure.json") + if [[ -n $unpinned ]]; then + echo "::error file=scripts/requirements.txt::Installed but not pinned with ==: ${unpinned//$'\n'/, }" + exit 1 fi - - name: Upload ruff artifact - if: always() && matrix.test-type == 'ruff' - uses: actions/upload-artifact@v4 - with: - name: ruff-results - path: ruff-output.txt - - - name: Upload Pylint Artifacts - if: always() && matrix.test-type == 'pylint' - uses: actions/upload-artifact@v4 - with: - name: pylint-results - path: pylint-output.txt - - - name: Upload Pytest Artifacts - if: always() && matrix.test-type == 'pytest' - uses: actions/upload-artifact@v4 + - name: Upload ${{ matrix.test-type }} report + if: always() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: - name: pytest-results + name: ${{ matrix.test-type }}-results path: | - pytest-output.txt - junit/test-results.xml + *-output.txt + junit/ coverage.xml htmlcov/ - continue-on-error: true - - - name: Upload docstr coverage report - if: always() && matrix.test-type == 'docstrcov' - uses: actions/upload-artifact@v4 - with: - name: docstr-coverage - path: docstr-cov-output.txt - - - name: Upload bandit security report - if: always() && matrix.test-type == 'security' - uses: actions/upload-artifact@v4 - with: - name: bandit-output - path: bandit-output.json + if-no-files-found: ignore diff --git a/.github/workflows/pr-title-lint.yml b/.github/workflows/pr-title-lint.yml index efbb0b6b..4880e3ee 100644 --- a/.github/workflows/pr-title-lint.yml +++ b/.github/workflows/pr-title-lint.yml @@ -1,17 +1,22 @@ name: Lint PR Title +# pull_request, not pull_request_target: _target runs the default branch's copy and is +# blocked by default on public repos from 2026-11-02. +# Only PRs into dev: their titles become the squash commits; promotion PRs are named freely. on: - pull_request_target: - types: [opened, edited, synchronize] + pull_request: + branches: [dev] + types: [opened, edited, synchronize, reopened] permissions: pull-requests: read jobs: lint-pr-title: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 + timeout-minutes: 5 steps: - - uses: amannn/action-semantic-pull-request@v5 + - uses: amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 # v6.1.1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml deleted file mode 100644 index 58fc3da4..00000000 --- a/.github/workflows/release-please.yml +++ /dev/null @@ -1,20 +0,0 @@ -name: release-please - -on: - push: - branches: - - main - -permissions: - contents: write - pull-requests: write - -jobs: - release-please: - runs-on: ubuntu-latest - steps: - - uses: googleapis/release-please-action@v4 - with: - token: ${{ secrets.RELEASE_PLEASE_PAT }} - config-file: release-please-config.json - manifest-file: .release-please-manifest.json diff --git a/.github/workflows/stage-ci.yml b/.github/workflows/stage-ci.yml deleted file mode 100644 index 6d8de6be..00000000 --- a/.github/workflows/stage-ci.yml +++ /dev/null @@ -1,23 +0,0 @@ -name: stage-ci - -on: - branches: - - stage - paths-ignore: - - "scripts/**" - - "BlocksScreen/lib/ui/**" - - "extras/**" - workflow_run: - workflows: ["dev-test-code"] - types: - - completed - jobs: - ci-stage: - if: ${{ github.event.workflow_run.conclusion == 'success' }} - runs-on: ubuntu-latest - steps: - - name: Checkout repo - uses: actions/checkout@v4 - - - name: Run staging pipeline - run: echo "Running staging integration tests..." \ No newline at end of file diff --git a/.github/workflows/stage-signoff.yml b/.github/workflows/stage-signoff.yml new file mode 100644 index 00000000..e95ce11b --- /dev/null +++ b/.github/workflows/stage-signoff.yml @@ -0,0 +1,133 @@ +name: Stage sign-off + +# Approving this run records a `staging` deployment for the stage tip; the Prod and +# Release Branches rulesets require it before a PR can merge. +# Promote with `git fetch origin && git push origin origin/dev:stage`: never local dev, which can hold unreviewed commits. +on: + push: + branches: [stage] + +permissions: {} + +jobs: + # No environment: it would wait for the approval it announces, so the webhook is a repository secret. + notify: + runs-on: ubuntu-24.04 + timeout-minutes: 5 + permissions: + contents: read + steps: + - name: Send the change list to the Hardware team + env: + GH_TOKEN: ${{ github.token }} + WEBHOOK: ${{ secrets.HW_TEAMS_WEBHOOK }} + run: | + before=$(jq -r .before "$GITHUB_EVENT_PATH") + if [[ $before =~ ^0+$ ]]; then + echo "::notice::stage was created by this push, no previous tip to compare" + exit 0 + fi + # Merge commits and Dependabot bumps are skipped: neither is something to test by hand on a printer. + gh api --paginate "repos/$GITHUB_REPOSITORY/compare/$before...$GITHUB_SHA?per_page=100" \ + --jq '.commits[] | select((.parents | length == 1) and .author.login != "dependabot[bot]") + | {sha, author: (.author.login // .commit.author.name), subject: (.commit.message | split("\n")[0])}' \ + > commits.json + if [[ ! -s commits.json ]]; then + echo "::notice::nothing to test by hand in this push (only merge commits or Dependabot bumps), no email sent" + exit 0 + fi + # Squash subjects are the PR titles; @html because they are free text from PR authors. + # Tables, bgcolor and inline styles only: Outlook for Windows renders with Word. + cat > notify.jq <<'JQ' + def font: "font-family:Segoe UI,Helvetica,Arial,sans-serif;"; + def line: "border-bottom:1px solid #afb8c1;"; + def kind: {feat: 0, fix: 1, perf: 2, refactor: 3, revert: 4}[.type // ""] // 5; + def colour: ["#1a7f37", "#cf222e", "#8250df", "#0969da", "#bc4c00", "#59636e"][.]; + def heading: ["Features", "Fixes", "Performance", "Refactors", "Reverts", "Other changes"][.]; + # Printed on A4: borders and text colour only (print drops backgrounds); 6.35 mm rows fit one handwritten line. + def section: (.[0] | kind) as $k + | "\($k | heading | ascii_upcase) · " + + "\(length)"; + # The author gets its own column so the title alone fills Change: most then fit one line. + def row: "☐\(.ref)" + + (if .scope then "\(.scope | @html): " else "" end) + + (.summary | @html) + (if .breaking then " (breaking)" else "" end) + + "\(.author // "unknown" | @html)" + + " "; + # Fixed layout takes the widths from this header row; auto layout let BY steal space from Change. + def th($label; $width; $pad): "" + + "\($label)"; + def blank($label; $width): "\($label) "; + [inputs | . as $c + | (.subject | capture("^(?.*?)(?: \\(#(?<pr>[0-9]+)\\))?$")) as $s + | ($s.title | capture("^(?<type>[a-z]+)(?:\\((?<scope>[^)]*)\\))?(?<bang>!)?: (?<desc>.+)$") + // {type: null, scope: null, bang: null, desc: $s.title}) as $cc + | {title: $s.title, pr: ($s.pr | if . then tonumber else null end), sha: $c.sha, author: $c.author, + url: (if $s.pr then "\($url)/pull/\($s.pr)" else "\($url)/commit/\($c.sha)" end), + ref: (if $s.pr then "#\($s.pr)" else $c.sha[:7] end), + type: $cc.type, scope: $cc.scope, breaking: ($cc.bang != null), + summary: ($cc.desc | (.[:1] | ascii_upcase) + .[1:])}] + | reverse as $all | ($all | length) as $n | $sha[:8] as $short + | (if $n == 0 then "No changes" elif $n == 1 then "1 change" else "\($n) changes" end) as $count + | {subject: "BlocksScreen stage \($short): \($count) to test", stage: $short, sha: $sha, + items: ($all | map({title, url, pr, author})), + html: ("<div style='display:none;max-height:0;overflow:hidden;mso-hide:all'>\($count) on stage " + + "\($short) to test.</div><table role='presentation' width='100%' cellpadding='0' cellspacing='0' " + + "border='0'><tr><td align='center' style='padding:8px 0'><table role='presentation' width='680' " + + "cellpadding='0' cellspacing='0' border='0' bgcolor='#ffffff' style='width:100%;max-width:680px;" + + "background-color:#ffffff;border-top:4px solid #1f2328'>" + + "<tr><td style='padding:10px 12px 0'><table role='presentation' width='100%' cellpadding='0' " + + "cellspacing='0' border='0'><tr><td style='\(font)font-size:11px;letter-spacing:1px;color:#59636e'>" + + "BLOCKSSCREEN STAGE TEST SHEET</td><td align='right' style='\(font)font-size:11px;color:#59636e'>" + + "\(now | strftime("%Y-%m-%d"))</td></tr></table><p style='\(font)margin:4px 0 0;font-size:18px;" + + "line-height:24px;font-weight:bold;color:#1f2328'>\($count) to test<span style='font-size:12px;" + + "font-weight:normal;color:#59636e'>  stage <a href='\($url)/commit/\($sha)' " + + "style='color:#0969da'>\($short)</a> · pushed by \($actor | @html)</span></p></td></tr>" + + "<tr><td style='padding:6px 12px 0'><table role='presentation' width='100%' cellpadding='0' " + + "cellspacing='0' border='0'><tr>\(blank("Tester"; "38%"))<td width='16'> </td>" + + "\(blank("Date"; "18%"))<td width='16'> </td>\(blank("Printer"; "22%"))</tr></table></td></tr>" + + "<tr><td style='\(font)padding:10px 12px 0;font-size:12px;line-height:16px;color:#1f2328'>" + + "Tick each change once it works on a staging printer and write any problem in Note. " + + "Approve the sign-off run when everything passes.</td></tr>" + + "<tr><td style='padding:4px 12px 0'><table role='presentation' width='100%' cellpadding='0' " + + "cellspacing='0' border='0' style='table-layout:fixed'><thead " + + "style='display:table-header-group;break-inside:avoid'><tr>\(th("✓"; "18"; 0))" + + "\(th("PR"; "50"; 0))\(th("CHANGE"; ""; 0))\(th("BY"; "64"; 0))\(th("NOTE"; "26%"; 8))</tr></thead>" + + "<tbody>\([$all | group_by(kind)[] | section, (.[] | row)] | join(""))</tbody></table></td></tr>" + + "<tr><td style='\(font)padding:10px 12px 12px;font-size:11px;color:#59636e'>" + + "Sent automatically on every stage push.</td></tr></table></td></tr></table>")} + JQ + jq -n -f notify.jq --arg url "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY" \ + --arg sha "$GITHUB_SHA" --arg actor "$GITHUB_ACTOR" commits.json > payload.json + jq -r .html payload.json >> "$GITHUB_STEP_SUMMARY" + if [[ -z $WEBHOOK ]]; then + echo "::warning::HW_TEAMS_WEBHOOK is not set, the Hardware team was not notified" + exit 0 + fi + # Payload {subject, html, stage, sha, items} feeds a Power Automate flow (Teams webhook trigger) that sends the email. + curl --fail-with-body -sS --retry 3 --max-time 30 -H 'Content-Type: application/json' \ + --data-binary @payload.json "$WEBHOOK" + + signoff: + runs-on: ubuntu-24.04 + timeout-minutes: 5 + environment: staging + # Only the newest stage tip needs sign-off; notify stays out so every push reports its own range. + concurrency: + group: stage-signoff + cancel-in-progress: true + steps: + - name: Record staging sign-off + run: echo "Staging printers validated $GITHUB_SHA" diff --git a/.gitignore b/.gitignore index 174738c8..8dddf5a8 100644 --- a/.gitignore +++ b/.gitignore @@ -164,8 +164,6 @@ cython_debug/ *.log # TimedRotatingFileHandler appends a date suffix, so *.log alone leaves logs/ dirty on every box. logs/ -# Bench-only acceptance/diagnostic suite: stays local, never shipped to the fleet. -scripts/healthcheck/ */*Zone.Identifier *:Zone.Identifier diff --git a/.release-please-manifest.json b/.release-please-manifest.json deleted file mode 100644 index 1772e6fa..00000000 --- a/.release-please-manifest.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - ".": "1.0.0" -} diff --git a/Makefile b/Makefile index 300a8f58..d5dc51f9 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ .PHONY: all init init-dev venv run lint format-check security check \ test test-all test-unit test-network test-ui test-integration test-fast \ coverage coverage-all coverage-network clean clean-venv \ - docstrcov rcc rcc-all diagnose help + docstrcov rcc rcc-all pr help .DEFAULT_GOAL := help SHELL := /bin/bash @@ -16,6 +16,12 @@ PYTEST_IGNORE := --ignore=$(TESTS)/network/test_sdbus_integration.py PYTEST_FLAGS ?= -vvv NM_INTEGRATION := NM_INTEGRATION_TESTS=1 +# Tracked and staged .py files only: untracked local scripts never reach CI, so they must not fail a gate. +# wildcard drops files deleted from the working tree but still in the index. +GIT_PY = $(wildcard $(shell git ls-files -- '*.py' 2>/dev/null)) +# Outside a git checkout (a source tarball) GIT_PY is empty and the gates scan the whole tree, as CI does. +PY_SCOPE = $(if $(GIT_PY),$(words $(GIT_PY)) tracked files,the whole tree (not a git checkout)) + PYRCC5 := /usr/bin/pyrcc5 QRC_DIR := BlocksScreen/lib/ui/resources @@ -39,8 +45,8 @@ venv: ## Print venv activation command (source manually — subshells cannot exp init: ## Install production dependencies $(PIP) install -r scripts/requirements.txt -init-dev: ## Install dev + test dependencies - $(PIP) install -r scripts/requirements-dev.txt +init-dev: ## Install dev + test dependencies (PyQt6 pin comes from requirements.txt) + $(PIP) install -r scripts/requirements.txt -r scripts/requirements-dev.txt # ───────────────────────────────────────────────────────────────────────────── ##@ Run @@ -77,17 +83,21 @@ rcc-all: ## Force recompile all .qrc files ##@ Linting & Security # ───────────────────────────────────────────────────────────────────────────── +# Stays on the folder like CI: a file list also lints lib/ and devices/, which CI's recursive run skips (no __init__.py). lint: ## Run pylint $(PYTHON) -m pylint -j$(shell nproc) --recursive=y $(SRC)/ -format-check: ## Verify formatting without modifying files (matches CI exactly) - $(PYTHON) -m ruff check --target-version=py311 --config=pyproject.toml - $(PYTHON) -m ruff format --diff --target-version=py311 --config=pyproject.toml +# --force-exclude: without it ruff ignores its exclude list for paths passed explicitly. +format-check: ## Verify formatting without modifying files (tracked files only) + @echo "ruff check + ruff format --diff on $(PY_SCOPE)" + @$(PYTHON) -m ruff check --force-exclude --target-version=py311 --config=pyproject.toml $(GIT_PY) + @$(PYTHON) -m ruff format --diff --force-exclude --target-version=py311 --config=pyproject.toml $(GIT_PY) -security: ## Run bandit security scan (whole repo, matches CI scope) - $(PYTHON) -m bandit -c pyproject.toml -r . +security: ## Run bandit security scan (tracked files only) + @echo "bandit on $(PY_SCOPE)" + @$(PYTHON) -m bandit -c pyproject.toml $(or $(GIT_PY),-r .) -check: format-check lint security test-fast ## Full pre-push gate (mirrors CI) +check: format-check lint security test-fast docstrcov ## Full pre-push gate (mirrors CI) # ───────────────────────────────────────────────────────────────────────────── ##@ Tests @@ -142,17 +152,12 @@ docstrcov: ## Check docstring coverage (fail-under=80%, matches CI) --fail-under 80 \ --skip-magic --skip-init --skip-private --skip-property - - # ───────────────────────────────────────────────────────────────────────────── -##@ Diagnostics +##@ Contributing # ───────────────────────────────────────────────────────────────────────────── -# scripts/healthcheck/ is git-ignored (bench-only), so degrade with a hint instead of a shell error. -diagnose: ## Deep-check a running machine: health, USB forensics, power (scripts/healthcheck/, local-only, needs sudo) - @test -x scripts/healthcheck/bs-diag.sh \ - || { echo "scripts/healthcheck/ not present on this checkout (bench-only, git-ignored)"; exit 1; } - sudo scripts/healthcheck/bs-diag.sh all +pr: ## Open a PR step by step: title, labels, local gates, review before push + @scripts/create-pr.sh # ───────────────────────────────────────────────────────────────────────────── ##@ Cleanup diff --git a/pyproject.toml b/pyproject.toml index 2de2d62b..86b48757 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "BlocksScreen" -dynamic = ['version'] +dynamic = ['version', 'dependencies', 'optional-dependencies'] description = "GUI for BLOCKS Printers running Klipper" authors = [ { name = "Hugo do Carmo Costa", email = "hugo.santos.costa@gmail.com" }, @@ -9,33 +9,15 @@ maintainers = [ { name = "Guilherme Costa", email = "guilherme.costa@blockstec.com" }, { name = "Roberto Martins ", email = "roberto.martins@blockstec.com" }, ] -dependencies = [ - 'altgraph==0.17.4', - 'certifi==2025.10.5', - 'charset-normalizer==3.4.4', - 'idna==3.15', - 'numpy==2.3.4', - 'pefile==2024.8.26', - 'PyQt6==6.10.0', - 'PyQt6-Qt6==6.10.0', - 'PyQt6_sip==13.10.2', - 'requests>=2.32.5', - 'sdbus==0.14.1', - 'sdbus-networkmanager==2.0.0', - 'typing==3.7.4.3', - 'websocket-client==1.9.0', - 'qrcode==8.2', -] requires-python = "==3.11.2" readme = "README.md" license = { text = "GNU Affero General Public License v3.0 or later" } keywords = ["GUI", "klipper", "BlocksScreen", "BLOCKS"] -[project.optional-dependencies] -dev = ["ruff", "pylint", "pytest", "pytest-cov", "docstr_coverage"] -stage = ["bandit"] -full-dev = ["BlockScreen[dev,stage]"] - +# The requirements files are the only place versions live; a second list here drifted to pins the Pi cannot install. +[tool.setuptools.dynamic] +dependencies = { file = ["scripts/requirements.txt"] } +optional-dependencies.dev = { file = ["scripts/requirements-dev.txt"] } [project.urls] Homepage = "https://blockstec.com" diff --git a/release-please-config.json b/release-please-config.json deleted file mode 100644 index d3a9fa7f..00000000 --- a/release-please-config.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", - "packages": { - ".": { - "release-type": "simple" - } - } -} diff --git a/scripts/bs-bootstrap.sh b/scripts/bs-bootstrap.sh index 2994ad2c..bb4df19a 100755 --- a/scripts/bs-bootstrap.sh +++ b/scripts/bs-bootstrap.sh @@ -97,6 +97,10 @@ if [ ! -f "$SENTINEL" ] || [ "$(cat "$SENTINEL")" != "$REQS_HASH" ]; then fi fi fi +# pip -r never removes a dropped pin; the glob keeps this free on every start once the stale backport is gone. +if compgen -G "$BSENV/lib/python3*/site-packages/typing-*-info" >/dev/null; then + _as_blocks "$BSENV/bin/pip" uninstall --quiet --yes typing || true +fi # 4. Updater stack (local files only - works offline). # Verify each critical artifact: an old-installer box can have the service but no apt helper. diff --git a/scripts/create-pr.sh b/scripts/create-pr.sh new file mode 100755 index 00000000..3179d1e6 --- /dev/null +++ b/scripts/create-pr.sh @@ -0,0 +1,498 @@ +#!/usr/bin/env bash +# make pr: guided pull request creation matching the repo's title lint, labels and PR template. +set -euo pipefail + +if ((BASH_VERSINFO[0] < 4 || (BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] < 4))); then + echo "error: bash 4.4 or newer is required (found $BASH_VERSION)" >&2 + exit 1 +fi +# Without this a failed prompt inside $(...) is ignored and menus loop forever on EOF. +shopt -s inherit_errexit + +usage() { + cat <<'EOF' +Usage: make pr + +Opens a pull request for the current branch, step by step: + 1. Checks gh login, push access, branch, uncommitted changes, base branch, existing PR + 2. Title one line, type(scope)!: summary as enforced by the PR title lint; also the + commit message when you chose to commit your uncommitted changes + 3. Labels area label (type and breaking come from the title), reviewers, draft; you are the assignee + 4. Local gates optional make check before anything leaves your machine + 5. Description the PR template in your editor, Changes prefilled from your commits + 6. Review every commit and setting is shown; nothing is pushed until you type y + +Requires git, the GitHub CLI (gh) logged in, and push access to origin. +EOF +} + +case ${1:-} in + -h | --help) usage; exit 0 ;; + "") ;; + *) usage >&2; exit 2 ;; +esac + +if [[ -t 2 && -z ${NO_COLOR:-} ]]; then + BOLD=$'\e[1m' DIM=$'\e[2m' RED=$'\e[31m' GREEN=$'\e[32m' YELLOW=$'\e[33m' CYAN=$'\e[36m' RESET=$'\e[0m' +else + BOLD="" DIM="" RED="" GREEN="" YELLOW="" CYAN="" RESET="" +fi + +# The menus bind fzf's load event and pos() action, both 0.36+; older fzf gets the numbered lists. +USE_FZF=0 +if [[ -t 0 && -t 2 ]] && command -v fzf >/dev/null && + printf '%s\n' 0.36 "$(fzf --version | cut -d' ' -f1)" | sort -VC; then + USE_FZF=1 +fi + +# All output goes to stderr so that functions can return values on stdout. +say() { printf '%s\n' "$*" >&2; } +ok() { say " ${GREEN}✔${RESET} $*"; } +warn() { say " ${YELLOW}!${RESET} $*"; } +die() { say " ${RED}✖ $*${RESET}"; exit 1; } +step() { say ""; say "${BOLD}[$1/6] $2${RESET}"; } +row() { printf ' %s%-12s%s %s\n' "$DIM" "$1" "$RESET" "$2" >&2; } + +trim() { + local s=$1 + s=${s#"${s%%[![:space:]]*}"} + printf '%s' "${s%"${s##*[![:space:]]}"}" +} + +# ask PROMPT [DEFAULT]: one trimmed line from the user; EOF aborts. +ask() { + local reply hint="" + if [[ -n ${2:-} ]]; then hint=" ${DIM}[$2]${RESET}"; fi + read -rp " ${CYAN}?${RESET} $1$hint: " reply || die "no input, aborting" + reply=$(trim "$reply") + printf '%s' "${reply:-${2:-}}" +} + +# confirm PROMPT DEFAULT(y|n) +confirm() { + local reply hint="y/N" + if [[ $2 == y ]]; then hint="Y/n"; fi + while true; do + read -rp " ${CYAN}?${RESET} $1 ${DIM}[$hint]${RESET} " reply || die "no input, aborting" + case ${reply:-$2} in + [yY] | [yY][eE][sS]) return 0 ;; + [nN] | [nN][oO]) return 1 ;; + esac + say " Answer y or n." + done +} + +# menu PROMPT DEFAULT_INDEX OPTION...: prints the chosen 0-based index; arrow keys via fzf, else a numbered list. +menu() { + local prompt=$1 default=$2 reply i + local -a options=("${@:3}") + if ((USE_FZF)); then + # The index travels hidden in field 1, so the visible label never has to be parsed back. + reply=$(for i in "${!options[@]}"; do printf '%d\t%s\n' "$i" "${options[i]}"; done | + fzf --height=~20 --layout=reverse --no-sort --delimiter='\t' --with-nth=2.. \ + --prompt=" ? $prompt > " --header="↑/↓ move, type to filter, Enter select, Esc abort" \ + --bind "load:pos($((default + 1)))") || die "nothing selected, aborting" + say " ${CYAN}?${RESET} $prompt: ${reply#*$'\t'}" + printf '%s' "${reply%%$'\t'*}" + return + fi + for i in "${!options[@]}"; do + printf ' %s%d)%s %s\n' "$CYAN" $((i + 1)) "$RESET" "${options[i]}" >&2 + done + while true; do + reply=$(ask "$prompt" $((default + 1))) + # 10# keeps "08" from being parsed as an invalid octal number. + if [[ $reply =~ ^[0-9]+$ ]] && ((10#$reply >= 1 && 10#$reply <= ${#options[@]})); then + printf '%s' $((10#$reply - 1)) + return + fi + say " Enter a number from 1 to ${#options[@]}." + done +} + +# pick PROMPT OPTION...: prints the chosen options comma-separated, or nothing; Tab marks several in fzf. +pick() { + local prompt=$1 reply i joined + local -a options=("${@:2}") chosen=() numbers=() + if ((USE_FZF)); then + # Enter with nothing marked returns the line under the cursor, hence the "(none)" line on top. + reply=$(printf '%s\n' "(none)" "${options[@]}" | + fzf --multi --height=~20 --layout=reverse --no-sort \ + --prompt=" ? $prompt > " --header="↑/↓ move, Tab mark, Enter confirm, Esc abort") || + die "nothing selected, aborting" + while read -r i; do + if [[ $i != "(none)" ]]; then chosen+=("$i"); fi + done <<<"$reply" + else + for i in "${!options[@]}"; do + printf ' %s%d)%s %s\n' "$CYAN" $((i + 1)) "$RESET" "${options[i]}" >&2 + done + while true; do + reply=$(ask "$prompt, numbers separated by spaces (Enter for none)") + read -ra numbers <<<"${reply//,/ }" + chosen=() + for i in "${numbers[@]}"; do + if [[ $i =~ ^[0-9]+$ ]] && ((10#$i >= 1 && 10#$i <= ${#options[@]})); then + chosen+=("${options[10#$i - 1]}") + else + say " Enter numbers from 1 to ${#options[@]}." + continue 2 + fi + done + break + done + fi + joined=$(IFS=,; printf '%s' "${chosen[*]}") + say " ${CYAN}?${RESET} $prompt: ${joined:-none}" + printf '%s' "$joined" +} + +# True when Description has text besides its type boxes and HTML comments, which may span lines. +has_summary() { + awk ' + /^#+ / { in_summary = ($0 == "# Description"); next } + in_summary && /^- \[[ xX]\] / { next } + in_summary { + line = $0; text = "" + while (line != "") { + if (in_comment) { + end = index(line, "-->") + if (!end) break + line = substr(line, end + 3); in_comment = 0 + } else { + start = index(line, "<!--") + if (!start) { text = text line; break } + text = text substr(line, 1, start - 1) + line = substr(line, start + 4); in_comment = 1 + } + } + if (text ~ /[^[:space:]]/) found = 1 + } + END { exit !found } + ' "$1" +} + +# set_gates_box FILE: tick the make check box only when this run's gates passed, also in a reused draft. +set_gates_box() { + GATES=$gates awk ' + /^- \[[ xX]\] `make check`/ { sub(/\[[ xX]\]/, ENVIRON["GATES"] == "passed" ? "[x]" : "[ ]") } + { print } + ' "$1" >"$1.tmp" && mv "$1.tmp" "$1" +} + +# apply_meta URL: add the assignee, labels and reviewers saved in $meta; every --add-* is a no-op when already set, so a retry is safe. +apply_meta() { + local -a args + mapfile -t args <"$meta" + gh pr edit "$1" "${args[@]}" >/dev/null + rm -f "$meta" +} + +cd "$(git rev-parse --show-toplevel)" +readonly TEMPLATE=.github/pull_request_template.md +readonly TITLE_MAX=72 +readonly TYPES=(feat fix perf refactor docs test chore) +# The PR title lint's rule: a listed type, optional (scope), optional ! for breaking, then ": summary". +TITLE_RE="^($(IFS='|'; printf '%s' "${TYPES[*]}"))(\(([^()]+)\))?(!)?: [^ ]" +readonly TITLE_RE +declare -rA TYPE_LABEL=( + [feat]=enhancement [fix]=bug [perf]=performance [refactor]=refactor + [docs]=documentation [test]=testing [chore]=chore +) +# The template's Description checkbox each title type ticks; perf, test and chore have none. +declare -rA TYPE_BOX=([feat]=Feature [fix]="Bug Fix" [refactor]="Code Refactor" [docs]=Documentation) +readonly AREAS=(none ui updater network ci dependencies) + +branch="" pushed=0 committed=0 url="" +on_exit() { + local status=$? + if ((status == 0)); then return; fi + if [[ -n $url ]]; then + warn "The pull request is open at $url, but its assignee, labels or reviewers are not all set." + warn "Run make pr again to retry, or set them on GitHub and delete $meta." + elif ((pushed)); then + warn "'$branch' was pushed but the PR was not created. Run make pr again to retry with the same description." + elif ((committed)); then + warn "Aborted. Nothing was pushed; the new commit stays on '$branch' (undo it with: git reset --soft HEAD~1)." + else + warn "Aborted. Nothing was pushed." + fi +} +trap on_exit EXIT +trap 'say ""; exit 130' INT + +# ── 1. Checks ──────────────────────────────────────────────────────────────── +step 1 "Checks" +command -v gh >/dev/null || die "the GitHub CLI is required: https://cli.github.com" +# --hostname: a stale token for another host must not block a valid github.com login. +gh auth status --hostname github.com >/dev/null 2>&1 || die "gh is not logged in; run: gh auth login" + +branch=$(git branch --show-current) +case $branch in + "") die "HEAD is detached; switch to your feature branch first" ;; + main | dev | stage | Release-* | Release/*) die "'$branch' is a shared branch; work on a feature branch (git switch -c fix/short-name)" ;; +esac +# Both live until the PR exists with all its metadata, so a rerun picks up where a failed run stopped. +git_dir=$(git rev-parse --git-dir) +body="$git_dir/PR_BODY-${branch//\//-}.md" +meta="$git_dir/PR_META-${branch//\//-}" + +origin=$(git remote get-url origin 2>/dev/null) || die "this clone has no 'origin' remote" +repo_info=$(gh repo view "$origin" --json nameWithOwner,viewerPermission,isFork,parent \ + --jq '"\(.nameWithOwner) \(.viewerPermission) \(.isFork) \(.parent.owner.login // "")/\(.parent.name // "")"') || + die "cannot read $origin on GitHub" +read -r repo permission is_fork parent <<<"$repo_info" +# gh would open the PR inside the fork, against the fork's own base branch. +if [[ $is_fork == true ]]; then die "origin is a fork of $parent; open the PR on GitHub, or point origin at $parent"; fi +case $permission in + ADMIN | MAINTAIN | WRITE) ;; + *) die "you have no push access to $repo; push to your fork and open the PR on GitHub" ;; +esac +ok "Logged in with push access to $repo" + +existing_pr=$(gh pr list -R "$repo" --head "$branch" --state open --json url --jq '.[0].url // empty') +if [[ -n $existing_pr ]]; then + ok "'$branch' already has an open pull request: $existing_pr" + # Left by a run that opened the PR and then failed: finish its metadata; the draft is already the PR body. + if [[ -s $meta ]]; then + url=$existing_pr + apply_meta "$url" + ok "Assignee, labels and reviewers set" + fi + rm -f "$body" + say " New commits appear there after a plain 'git push'." + exit 0 +fi + +dirty=0 commit_mode=none +if [[ -n $(git status --porcelain --untracked-files=no) ]]; then + dirty=1 + warn "You have uncommitted changes (left column staged, right column not staged):" + git status --short --untracked-files=no | sed 's/^/ /' >&2 + # Untracked files are never added: a stray local file must not end up in a PR by accident. + say " ${DIM}New files are not listed; git add them first if they belong in the PR.${RESET}" + options=() modes=() + if ! git diff --cached --quiet; then + options+=("Commit the staged changes only") + modes+=(staged) + fi + if ! git diff --quiet; then + options+=("Stage every change above and commit it") + modes+=(tracked) + fi + options+=("Leave them out of the PR") + modes+=(none) + index=$(menu "Uncommitted changes" 0 "${options[@]}") + commit_mode=${modes[index]} + if [[ $commit_mode != none ]]; then ok "They are committed in step 2, with the PR title as the message"; fi +fi + +# One fetch for all branches: every call to origin can ask for the SSH key passphrase again. +# git's own error stays visible: a network or auth failure is not a missing branch. +git fetch -q --prune origin || die "could not fetch origin (see the error above)" +# dev is preselected; the rest are stack parents (in HEAD, not yet in dev), and "Other branch" reaches any base. +bases=(dev) +while read -r ref; do + if [[ $ref != HEAD && $ref != dev && $ref != "$branch" ]]; then bases+=("$ref"); fi +done < <(git for-each-ref --merged HEAD --no-merged origin/dev --sort=-committerdate \ + --format='%(refname:strip=3)' refs/remotes/origin/) +bases+=("Other branch...") + +while true; do + index=$(menu "Merge $branch into" 0 "${bases[@]}") + base=${bases[index]} + if ((index == ${#bases[@]} - 1)); then + base=$(ask "Base branch name on origin") + if [[ -z $base || $base == "$branch" ]] || ! git show-ref --verify --quiet "refs/remotes/origin/$base"; then + warn "origin has no branch '$base' to merge $branch into." + continue + fi + fi + case $base in + main | stage | Release-* | Release/*) + warn "'$base' only receives promotions (dev -> stage -> production); feature work targets dev." + confirm "Target '$base' anyway?" n || continue + ;; + esac + break +done + +commit_count=$(git rev-list --count "origin/$base..HEAD") +if [[ $commit_mode != none ]]; then + ok "$branch is $commit_count commit(s) ahead of origin/$base, plus the one you are about to make" +else + ((commit_count > 0)) || die "'$branch' has no commits that are not already on $base; commit your work first" + ok "$branch is $commit_count commit(s) ahead of origin/$base" +fi +behind=$(git rev-list --count "HEAD..origin/$base") +if ((behind > 0)); then warn "It is also $behind commit(s) behind; rebase if GitHub reports conflicts."; fi + +repo_labels=$(gh label list -R "$repo" --limit 500 --json name --jq '.[].name') + +# ── 2. Title ───────────────────────────────────────────────────────────────── +step 2 "Title" +# Prefilled: a one-commit PR reuses its subject, otherwise the type is guessed from the branch name. +initial="" +if [[ $commit_mode == none ]] && ((commit_count == 1)); then + initial=$(git log -1 --format=%s) +else + case ${branch%%/*} in + feat | feature) initial="feat: " ;; + fix | bugfix | hotfix) initial="fix: " ;; + perf) initial="perf: " ;; + refactor | ref) initial="refactor: " ;; + docs | doc) initial="docs: " ;; + test | tests) initial="test: " ;; + chore | ci | build) initial="chore: " ;; + esac +fi +say " ${DIM}type(scope): summary, type is one of: ${TYPES[*]}; a ! before the colon marks a breaking change${RESET}" +if [[ $commit_mode != none ]]; then say " ${DIM}It is also the message of the commit made now.${RESET}"; fi +while true; do + # \001 and \002 tell readline the colour codes take no space, else editing a long line garbles it. + read -erp $' \001'"$CYAN"$'\002?\001'"$RESET"$'\002 Title: ' -i "$initial" title || die "no input, aborting" + title=$(trim "$title") + title=${title%.} + initial=$title + if [[ ! $title =~ $TITLE_RE ]]; then + warn "Use type(scope): summary, e.g. fix(ui): keep the spool weight after a reload" + continue + fi + type=${BASH_REMATCH[1]} scope=${BASH_REMATCH[3],,} breaking=${BASH_REMATCH[4]} + if ((${#title} <= TITLE_MAX)); then break; fi + warn "The title is ${#title} characters; aim for $TITLE_MAX or fewer." + if confirm "Keep it anyway?" n; then break; fi +done + +if [[ $commit_mode != none ]]; then + # Committed here, before the gates, so step 4 tests exactly what gets pushed. + # -a, not git add -u: it stages into a temporary index, so a failed commit keeps the user's staging. + if [[ $commit_mode == tracked ]]; then + git commit -q -a -m "$title" || die "git commit failed (see the error above); nothing was committed" + else + git commit -q -m "$title" || die "git commit failed (see the error above); nothing was committed" + fi + committed=1 + ok "Committed $(git log -1 --format='%h %s')" + commit_count=$(git rev-list --count "origin/$base..HEAD") + dirty=0 + if [[ -n $(git status --porcelain --untracked-files=no) ]]; then dirty=1; fi +fi + +# ── 3. Labels and reviewers ────────────────────────────────────────────────── +step 3 "Labels and reviewers" +default=0 +for i in "${!AREAS[@]}"; do + if [[ ${AREAS[i]} == "$scope" ]]; then default=$i; fi +done +index=$(menu "Area" "$default" "${AREAS[@]}") +labels=("${TYPE_LABEL[$type]}") +if ((index > 0)); then labels+=("${AREAS[index]}"); fi +if [[ -n $breaking ]]; then labels+=(breaking); fi + +# A label missing on the repo would make gh fail after the push, so drop it up front. +apply=() +for label in "${labels[@]}"; do + if grep -qxF "$label" <<<"$repo_labels"; then + apply+=("$label") + else + warn "Label '$label' does not exist on $repo yet; skipped." + fi +done + +me=$(gh api user --jq .login) +# GitHub rejects a review request to the PR author, so you are left off the list. +people=() +if collaborators=$(gh api "repos/$repo/collaborators" --paginate --jq '.[].login'); then + while read -r login; do + if [[ -n $login && ${login,,} != "${me,,}" ]]; then people+=("$login"); fi + done <<<"$collaborators" +else + warn "Could not list collaborators; request reviews on GitHub once the PR is open." +fi +reviewers="" +if ((${#people[@]})); then reviewers=$(pick "Reviewers" "${people[@]}"); fi + +draft=no +if confirm "Open as a draft (not ready for review yet)?" n; then draft=yes; fi + +# ── 4. Local gates ─────────────────────────────────────────────────────────── +step 4 "Local gates" +gates="not run" +if confirm "Run make check now (the CI gates, a few minutes)?" y; then + if make --no-print-directory check; then + # The gates ran on the working tree, so a dirty tree did not test exactly what is pushed. + if ((dirty)); then + gates="passed with uncommitted changes" + warn "Gates passed, but on a tree with uncommitted changes; the PR box stays unticked." + else + gates=passed + ok "All gates passed" + fi + else + gates=failed + warn "Local gates failed; CI will fail the same way." + confirm "Continue anyway?" n || exit 1 + fi +fi + +# ── 5. Description ─────────────────────────────────────────────────────────── +step 5 "Description" +if [[ -s $body ]] && confirm "Reuse the description from your previous attempt?" y; then + ok "Reusing $body" +else + COMMITS=$(git log --reverse --no-merges --format='- %s' "origin/$base..HEAD") BOX=${TYPE_BOX[$type]:-} awk ' + skip && $0 == "-" { skip = 0; next } + { skip = 0 } + ENVIRON["BOX"] != "" && $0 == ("- [ ] " ENVIRON["BOX"]) { $0 = "- [x] " ENVIRON["BOX"] } + { print } + $0 == "## Changes" { print ENVIRON["COMMITS"]; skip = 1 } + ' "$TEMPLATE" >"$body" +fi +set_gates_box "$body" +editor=$(git var GIT_EDITOR) +while true; do + say " Opening $editor; save and close it to continue." + sh -c "$editor \"\$1\"" editor "$body" || die "the editor failed; your draft is kept at $body" + if has_summary "$body"; then break; fi + warn "The Description has no summary; reviewers need the what and the why." + confirm "Open the editor again?" y || exit 1 +done +ok "Description ready" + +# ── 6. Review ──────────────────────────────────────────────────────────────── +step 6 "Review" +labels_text=$(IFS=,; printf '%s' "${apply[*]}") +labels_text=${labels_text//,/, } +row Repository "$repo" +row Title "$title" +row Base "$base <- $branch" +row Labels "${labels_text:-none}" +row Assignee "$me" +row Reviewers "${reviewers:-none}" +row Draft "$draft" +row "Local gates" "$gates" +say "" +say " ${BOLD}Commits in this PR ($commit_count)${RESET}" +git log --reverse --format=' %h %s' "origin/$base..HEAD" >&2 +say "" +# Default no: the push is the one step that cannot be taken back. +confirm "Push '$branch' to origin and open this pull request?" n || exit 1 + +git push -u origin "$branch" || + die "push rejected; if origin/$branch has commits you lack, run 'git pull --rebase' and retry (never force-push a shared branch)" +pushed=1 + +# gh pr create sets metadata after opening the PR, so it only creates; the saved edits are retried by a rerun. +edits=(--add-assignee "$me") +for label in "${apply[@]}"; do edits+=(--add-label "$label"); done +if [[ -n $reviewers ]]; then edits+=(--add-reviewer "$reviewers"); fi +printf '%s\n' "${edits[@]}" >"$meta" + +args=(-R "$repo" --base "$base" --head "$branch" --title "$title" --body-file "$body") +if [[ $draft == yes ]]; then args+=(--draft); fi +url=$(gh pr create "${args[@]}") +rm -f "$body" +apply_meta "$url" +say "" +ok "${BOLD}Pull request opened:${RESET} $url" diff --git a/scripts/install-updater.sh b/scripts/install-updater.sh index 255fcbd6..24ba3f64 100755 --- a/scripts/install-updater.sh +++ b/scripts/install-updater.sh @@ -231,21 +231,30 @@ echo_info "Installing Python requirements ..." export PIP_DISABLE_PIP_VERSION_CHECK=1 # Older runs of this script ran pip as root; hand those files back or every pip-as-blocks run fails. [ -d "$BSENV" ] && sudo find "$BSENV" ! -user "$_BSENV_USER" -exec chown -h "$_BSENV_USER" {} + || true -# Best-effort pip self-update; must not block the requirements install below. -_as_blocks "$BSENV/bin/pip" install --quiet --upgrade pip 2>/dev/null || true +# Pinned so every box runs the pip CI tested; best-effort, the venv's stock pip installs the pins too. +_as_blocks "$BSENV/bin/pip" install --quiet -r "$BS_PATH/scripts/requirements-pip.txt" || + echo_warn "Pinned pip install FAILED; continuing with the venv's current pip" sudo apt-get -o DPkg::Lock::Timeout=60 install -y --quiet libsystemd-dev python3-dev 2>/dev/null || true # xsetroot is used as belt-and-suspenders cursor hiding alongside the Xorg -nocursor server flag. sudo apt-get -o DPkg::Lock::Timeout=60 install -y --quiet x11-xserver-utils 2>/dev/null || true # sdbus pinned; --no-binary needs libsystemd-dev; only-if-needed skips satisfied. -_as_blocks "$BSENV/bin/pip" install --quiet --only-binary :all: --no-binary sdbus,sdbus-networkmanager \ +# A failure must not abort setup, but it must be visible: a silent one hid the typing==3.7.4.3 break. +if _as_blocks "$BSENV/bin/pip" install --quiet --only-binary :all: --no-binary sdbus,sdbus-networkmanager \ --upgrade-strategy=only-if-needed \ - -r "$BS_PATH/scripts/requirements.txt" || true -echo_ok "Python requirements installed" + -r "$BS_PATH/scripts/requirements.txt"; then + echo_ok "Python requirements installed" +else + echo_warn "Python requirements install FAILED; bs-bootstrap retries it on the next boot" +fi # uv: Spoolman's dependency installer (run-from-source); provide it for the hook. echo_info "Ensuring uv is available for Spoolman provisioning ..." -_as_blocks "$BSENV/bin/pip" install --quiet --upgrade uv 2>/dev/null || true -echo_ok "uv ready" +# Pinned: Spoolman tracks master, so the uv version is the one lever we hold over how it resolves. +if _as_blocks "$BSENV/bin/pip" install --quiet uv==0.12.24; then + echo_ok "uv ready" +else + echo_warn "uv install FAILED; Spoolman provisioning fails until it is installed" +fi echo_info "Pre-generating framebuffer splash cache ..." _as_blocks "$BSENV/bin/python3.11" "$SCRIPT_PATH/bs-splash.py" --precompute 2>/dev/null || true diff --git a/scripts/install.sh b/scripts/install.sh index fc327e18..f91071d5 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -236,6 +236,10 @@ function create_virtualenv() { exit 1 fi + # Pinned so a fresh box runs the pip CI tested; on failure the stock pip still installs the requirements. + pip3 --disable-pip-version-check install -r "${BS_PATH}/scripts/requirements-pip.txt" || + echo_info "Could not install the pinned pip, continuing with $(pip3 --version)" + if [[ "$(uname -m)" =~ armv[67]l|aarch64 ]]; then echo_text "Using $(uname -m)! Building sdbus from source, installing PyQt6 + rest from wheels (piwheels)..." grep -v "^sdbus==" "${BS_PATH}/scripts/requirements.txt" >/tmp/bs-requirements.txt @@ -252,14 +256,14 @@ function create_virtualenv() { sudo apt install -y build-essential cmake libsystemd-dev if [[ "$(uname -m)" =~ armv[67]l|aarch64 ]]; then echo_text "Adding piwheels.org as extra index..." - echo_info "Installing with pip setuptools and app requirements" - pip3 install --extra-index-url https://www.piwheels.org/simple --upgrade pip setuptools + echo_info "Installing pinned pip and app requirements" + pip3 install -r "${BS_PATH}/scripts/requirements-pip.txt" pip3 install --use-pep517 --no-binary sdbus --use-feature=no-binary-enable-wheel-cache "$SDBUS_VERSION" && pip3 install --extra-index-url https://www.piwheels.org/simple -r /tmp/bs-requirements.txt pip_status=$? else - echo_info "Upgrading pip and Installing with pip setuptools and app requirements" - pip3 install --upgrade pip setuptools + echo_info "Installing pinned pip and app requirements" + pip3 install -r "${BS_PATH}/scripts/requirements-pip.txt" pip3 install -r "${BS_PATH}"/scripts/requirements.txt pip_status=$? printf "\n" diff --git a/scripts/post-merge b/scripts/post-merge index 9aaa2ac3..a0cda8d8 100755 --- a/scripts/post-merge +++ b/scripts/post-merge @@ -104,7 +104,7 @@ _record_or() { fi } -if echo "$changed" | grep -qE '^scripts/BlocksScreen-updater\.service$|^scripts/BlocksScreen-bootstrap\.service$|^scripts/com\.blockscreen\.Updater\.conf$|^scripts/com\.blockscreen\.Updater\.service$|^scripts/install-updater\.sh$|^scripts/bs-apt-helper\.sh$'; then +if echo "$changed" | grep -qE '^scripts/BlocksScreen-updater\.service$|^scripts/BlocksScreen-bootstrap\.service$|^scripts/com\.blockscreen\.Updater\.conf$|^scripts/com\.blockscreen\.Updater\.service$|^scripts/install-updater\.sh$|^scripts/bs-apt-helper\.sh$|^scripts/requirements-pip\.txt$'; then echo "[post-merge] Updater install files changed - reinstalling updater ..." _record_or install sudo bash "$SCRIPT_PATH/install-updater.sh" elif echo "$changed" | grep -qE '^scripts/bs-splash-holder\.py$'; then diff --git a/scripts/requirements-dev.txt b/scripts/requirements-dev.txt index a43d9bd2..7ed59231 100644 --- a/scripts/requirements-dev.txt +++ b/scripts/requirements-dev.txt @@ -1,18 +1,30 @@ -# CI pipeline (mirrors .github/workflows/dev-ci.yml) +# CI pipeline (mirrors .github/workflows/dev-ci.yml), installed on top of requirements.txt. +# Exact pins so a green `make check` locally means a green CI run. ruff==0.15.7 -pylint -pytest -pytest-cov -pytest-timeout -pytest-qt -pytest-asyncio -PyQt6 -numpy==2.1.0 -docstr_coverage -bandit -requests>=2.32.5 - -# Test suite plugins -pytest-qt -pytest-asyncio - +pylint==4.0.5 +pytest==9.1.1 +pytest-cov==7.0.0 +pytest-timeout==2.4.0 +pytest-qt==4.5.0 +pytest-asyncio==1.3.0 +docstr-coverage==2.3.2 +bandit==1.9.4 +# Transitive deps: a floating astroid or coverage changes lint and coverage results with no repo diff. +astroid==4.0.4 +click==8.5.0 +coverage==7.13.5 +dill==0.4.1 +iniconfig==2.3.0 +isort==8.0.1 +markdown-it-py==4.0.0 +mccabe==0.7.0 +mdurl==0.1.2 +packaging==26.0 +platformdirs==4.9.4 +pluggy==1.6.0 +pygments==2.21.0 +rich==14.3.3 +stevedore==5.7.0 +tomlkit==0.14.0 +tqdm==4.67.3 +typing-extensions==4.15.0 diff --git a/scripts/requirements-pip.txt b/scripts/requirements-pip.txt new file mode 100644 index 00000000..213fc71e --- /dev/null +++ b/scripts/requirements-pip.txt @@ -0,0 +1,2 @@ +# Separate from requirements.txt so pip is never replaced mid-install; BlocksScreen's venv only, never component venvs. +pip==26.2.1 diff --git a/scripts/requirements.txt b/scripts/requirements.txt index bdb2911b..2625b486 100644 --- a/scripts/requirements.txt +++ b/scripts/requirements.txt @@ -1,17 +1,18 @@ +# Exact pins only, transitive deps included: every box must install the set staging tested. altgraph==0.17.4 certifi==2026.5.20 charset-normalizer==3.4.7 idna==3.18 numpy==2.1.0 pefile==2024.8.26 -requests>=2.32.5 +requests==2.34.2 +urllib3==2.8.0 sdbus==0.12.0 sdbus-networkmanager==2.0.0 -typing==3.7.4.3 websocket-client==1.9.0 qrcode==8.2 PyYAML==6.0.3 -pillow>=10.0.0 +pillow==12.3.0 PyQt6==6.7.1 PyQt6-Qt6==6.7.2 PyQt6_sip==13.8.0 diff --git a/tests/scripts/test_requirements_pins.py b/tests/scripts/test_requirements_pins.py index 3ca10bc6..07ffa2fa 100644 --- a/tests/scripts/test_requirements_pins.py +++ b/tests/scripts/test_requirements_pins.py @@ -13,6 +13,8 @@ import re from pathlib import Path +import pytest + _REQS = Path(__file__).resolve().parents[2] / "scripts" / "requirements.txt" # Field-validated on Pi 5 aarch64 (see updater deployment notes). Do NOT bump @@ -25,7 +27,8 @@ "PyQt6_sip": "13.8.0", } -_PIN_RE = re.compile(r"^\s*([A-Za-z0-9_.\-]+)\s*==\s*([^\s#]+)") +# Version must start with a digit and hold no wildcard: "===x" and "==1.*" are not exact pins. +_PIN_RE = re.compile(r"^\s*([A-Za-z0-9_.\-]+)\s*==\s*(\d[^\s#*;]*)\s*(#.*)?$") def _normalize(name: str) -> str: @@ -52,3 +55,24 @@ def test_compiled_pins_match_known_good() -> None: "Bumping a compiled dep needs an on-hardware aarch64 import test first; " "update _KNOWN_GOOD here only after that passes." ) + + +@pytest.mark.parametrize( + "name", ["requirements.txt", "requirements-dev.txt", "requirements-pip.txt"] +) +def test_every_requirement_is_exact_pin(name: str) -> None: + """A range pin lets each box resolve a different version than staging tested.""" + text = (_REQS.parent / name).read_text(encoding="utf-8") + loose = [ + line.strip() + for line in text.splitlines() + if line.strip() + and not line.lstrip().startswith("#") + and not _PIN_RE.match(line) + ] + assert not loose, f"{name}: pin these with == : {loose}" + + +@pytest.mark.parametrize("line", ["pip===26.2.1", "pip==26.*", "pip==", "pip>=26"]) +def test_pin_regex_rejects_non_exact(line: str) -> None: + assert not _PIN_RE.match(line) diff --git a/updater/hooks/BlocksScreen.sh b/updater/hooks/BlocksScreen.sh index 7ad19634..73459ba2 100755 --- a/updater/hooks/BlocksScreen.sh +++ b/updater/hooks/BlocksScreen.sh @@ -78,6 +78,7 @@ if ! git -C "$COMPONENT_PATH" diff --quiet "$PREV_HASH" "$NEW_HASH" \ -- scripts/install-updater.sh scripts/bs-apt-helper.sh \ scripts/BlocksScreen-updater.service scripts/BlocksScreen-bootstrap.service \ scripts/com.blockscreen.Updater.conf scripts/com.blockscreen.Updater.service \ + scripts/requirements-pip.txt \ 2>/dev/null; then echo "[hook:BlocksScreen] install files changed - setting deploy flag" _set_deploy_flag