From 8dd3e6be1cd225c55c64b9bb8ff7e9bc6fcc97a6 Mon Sep 17 00:00:00 2001 From: napalm255 Date: Sat, 3 Oct 2026 01:59:43 -0400 Subject: [PATCH 1/7] ci: standardize quick extension tooling and security Adopt the immutable shared workflows and tooling, generate consistent installation and development docs, and fix findings exposed by strict checks without suppressing them. --- .github/dependabot.yml | 19 +- .github/workflows/ci.yml | 108 +- .github/workflows/pages.yml | 25 + .github/workflows/release.yml | 149 +- .github/workflows/security.yml | 95 +- .github/workflows/sonar.yml | 88 +- .github/workflows/template.yml | 23 + .gitignore | 6 + .gitleaks.toml | 14 - .prettierignore | 5 +- .prettierrc.json | 13 +- AGENTS.md | 135 +- README.md | 146 +- docs/index.html | 482 ++-- docs/project.json | 5 + docs/project.schema.json | 20 + docs/style.css | 56 +- eslint.config.js | 11 +- justfile | 207 +- metadata.json | 16 +- mise.toml | 33 +- package-lock.json | 4844 ++++++++++++++++---------------- package.json | 39 +- playwright.config.js | 22 +- project.just | 12 + pyproject.toml | 6 + quick-project.json | 41 + quick-project.schema.json | 17 + quick-template.lock.json | 5 + quick-template.schema.json | 11 + scripts/build.py | 137 + scripts/clean.py | 16 + scripts/docs.py | 354 +++ scripts/headless-check.sh | 7 +- scripts/pack-check.sh | 86 - scripts/security_source.py | 75 + scripts/sonar_gate.py | 91 + scripts/template-check.sh | 74 - scripts/template.py | 176 ++ scripts/workflow_lint.py | 43 + sonar-project.properties | 14 +- template.list | 29 - template.sha256 | 29 - tests/docs.config.js | 1 + tests/docs.spec.js | 68 +- tests/model.test.js | 12 +- tests/reconnect.test.js | 14 +- tests/settings.test.js | 15 +- tests/stubs/shell-extension.js | 4 +- tests/support/i18n.js | 38 +- tests/test_tooling.py | 194 ++ vitest.config.js | 50 +- 52 files changed, 4548 insertions(+), 3632 deletions(-) create mode 100644 .github/workflows/pages.yml create mode 100644 .github/workflows/template.yml create mode 100644 docs/project.json create mode 100644 docs/project.schema.json create mode 100644 pyproject.toml create mode 100644 quick-project.json create mode 100644 quick-project.schema.json create mode 100644 quick-template.lock.json create mode 100644 quick-template.schema.json create mode 100644 scripts/build.py create mode 100644 scripts/clean.py create mode 100644 scripts/docs.py delete mode 100755 scripts/pack-check.sh create mode 100644 scripts/security_source.py create mode 100644 scripts/sonar_gate.py delete mode 100755 scripts/template-check.sh create mode 100644 scripts/template.py create mode 100644 scripts/workflow_lint.py delete mode 100644 template.list delete mode 100644 template.sha256 create mode 100644 tests/test_tooling.py diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 808f052..daaf48a 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,11 +1,12 @@ version: 2 updates: - - package-ecosystem: npm - directory: / - schedule: - interval: weekly - - - package-ecosystem: github-actions - directory: / - schedule: - interval: weekly + - package-ecosystem: npm + directory: / + open-pull-requests-limit: 0 + schedule: + interval: weekly + - package-ecosystem: github-actions + directory: / + open-pull-requests-limit: 0 + schedule: + interval: weekly diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6813ab9..111813b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,42 +1,76 @@ name: CI - on: - push: - branches: [main] - pull_request: - + push: + branches: [main] + pull_request: + workflow_dispatch: permissions: - contents: read - + contents: read concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - + group: ci-${{ github.ref }} + cancel-in-progress: true jobs: - ci: - runs-on: ubuntu-latest - timeout-minutes: 20 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - # gitleaks scans history; a shallow clone gives it one commit. - fetch-depth: 0 - persist-credentials: false - - # glib-compile-schemas validates the gschema; it is not a mise tool - # because it must match the GLib the target Shell was built against. - - name: Install GLib schema compiler - run: | - sudo apt-get update - sudo apt-get install -y --no-install-recommends libglib2.0-bin - - - uses: jdx/mise-action@c2a87611a18de5b3828c5652fe268e992400cb5c # v4 - - - run: npm ci - - # The docs site's suite runs in real browsers. - - name: Install test browsers - run: npx playwright install --with-deps chromium firefox - - # The same recipe a developer runs: lint, test, test-docs, security, build. - - run: just ci + checks: + name: checks + runs-on: ubuntu-24.04 + timeout-minutes: 30 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + - uses: jdx/mise-action@c2a87611a18de5b3828c5652fe268e992400cb5c # v4.3.0 + with: + version: 2026.9.1 + - name: Install native test tools + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends gjs gnome-shell libglib2.0-bin gir1.2-soup-3.0 gir1.2-secret-1 dbus-daemon + - run: npm ci --ignore-scripts + - run: ./node_modules/.bin/playwright install --with-deps chromium firefox + - run: just ci + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: extension-bundle + path: '*.shell-extension.zip' + if-no-files-found: error + retention-days: 90 + - uses: actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa # v3 + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + with: + path: docs + security: + name: security + uses: $/.github/workflows/security.yml + permissions: + contents: read + security-events: write # Upload CodeQL findings to GitHub code scanning. + actions: read # Inspect workflow runs and download their tested artifacts. + sonar: + name: sonar + uses: $/.github/workflows/sonar.yml + secrets: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + ci: + name: ci + if: always() + needs: [checks, security, sonar] + runs-on: ubuntu-24.04 + timeout-minutes: 2 + steps: + - name: Require every verification job + env: + CHECKS: ${{ needs.checks.result }} + SECURITY: ${{ needs.security.result }} + SONAR: ${{ needs.sonar.result }} + run: test "$CHECKS" = success && test "$SECURITY" = success && test "$SONAR" = success + docs: + name: docs + needs: ci + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + uses: $/.github/workflows/pages.yml + permissions: + contents: read + actions: read # Inspect workflow runs and download their tested artifacts. + pages: write # Publish the tested documentation artifact to Pages. + id-token: write # Authenticate the Pages deployment with GitHub OIDC. diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml new file mode 100644 index 0000000..cd0c32f --- /dev/null +++ b/.github/workflows/pages.yml @@ -0,0 +1,25 @@ +name: Docs +on: + workflow_call: +permissions: + contents: read +concurrency: + group: pages + cancel-in-progress: false +jobs: + deploy: + name: deploy + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + actions: read # Inspect workflow runs and download their tested artifacts. + pages: write # Publish the tested documentation artifact to Pages. + id-token: write # Authenticate the Pages deployment with GitHub OIDC. + environment: + name: github-pages + url: ${{ steps.deploy.outputs.page_url }} + steps: + - uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4 + id: deploy + with: + artifact_name: github-pages diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3d83f1a..c62a5fa 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,84 +1,73 @@ name: Release - on: - push: - tags: ['v*'] - + push: + tags: ['v*'] permissions: - contents: read - + contents: read + actions: read # Inspect workflow runs and download their tested artifacts. +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false jobs: - release: - runs-on: ubuntu-latest - timeout-minutes: 20 - permissions: - contents: write - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - fetch-depth: 0 - persist-credentials: false - - # Before anything is built or published: a tag that disagrees with - # the tree ships a release titled v0.2.0 containing a zip that tells - # GNOME it is 0.1.0, and nothing downstream would notice. - - name: Check the tag matches the version in the tree - env: - TAG: ${{ github.ref_name }} - run: | - version="${TAG#v}" - meta="$(jq -r '."version-name"' metadata.json)" - pkg="$(jq -r .version package.json)" - status=0 - - if [ "$meta" != "$version" ]; then - echo "::error::metadata.json version-name is '$meta', tag is '$version'" - status=1 - fi - - if [ "$pkg" != "$version" ]; then - echo "::error::package.json version is '$pkg', tag is '$version'" - status=1 - fi - - exit "$status" - - - name: Install GLib schema compiler - run: | - sudo apt-get update - sudo apt-get install -y --no-install-recommends libglib2.0-bin - - - uses: jdx/mise-action@c2a87611a18de5b3828c5652fe268e992400cb5c # v4 - with: - # A release publishes a runtime artifact, so it must not restore a - # cache that a pull request could have poisoned. - cache: false - - - run: npm ci - - # The docs site's suite runs in real browsers. - - name: Install test browsers - run: npx playwright install --with-deps chromium firefox - - # Gate the release on the full suite; never publish an untested build. - - run: just ci - - - name: Create the release - env: - GH_TOKEN: ${{ github.token }} - TAG: ${{ github.ref_name }} - run: | - # Derived from metadata.json, like the justfile's, so renaming - # the extension cannot leave this uploading a file that - # `just build` no longer produces. - zip="$(jq -r .uuid metadata.json).shell-extension.zip" - - if [ ! -f "$zip" ]; then - echo "::error::just ci did not produce $zip" - exit 1 - fi - - gh release create "$TAG" \ - --title "$TAG" \ - --generate-notes \ - "$zip" + verify: + name: verify + runs-on: ubuntu-24.04 + timeout-minutes: 10 + outputs: + run: ${{ steps.verify.outputs.run }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + - uses: jdx/mise-action@c2a87611a18de5b3828c5652fe268e992400cb5c # v4.3.0 + with: + version: 2026.9.1 + cache: false + - name: Verify tag and tested commit + id: verify + env: + TAG: ${{ github.ref_name }} + GH_TOKEN: ${{ github.token }} + REPOSITORY: ${{ github.repository }} + run: | + version="${TAG#v}" + test "$version" = "$(jq -r '."version-name"' metadata.json)" + test "$version" = "$(jq -r .version package.json)" + git merge-base --is-ancestor HEAD origin/main + revision="$(git rev-parse HEAD)" + run="$(gh run list --repo "$REPOSITORY" --workflow ci.yml --branch main --event push --commit "$revision" --status success --limit 1 --json databaseId --jq '.[0].databaseId')" + test -n "$run" && test "$run" != null + echo "run=$run" >> "$GITHUB_OUTPUT" + publish: + name: publish + needs: verify + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + contents: write # Create the release and attach its tested ZIP. + actions: read # Inspect workflow runs and download their tested artifacts. + steps: + - uses: jdx/mise-action@c2a87611a18de5b3828c5652fe268e992400cb5c # v4.3.0 + with: + version: 2026.9.1 + cache: false + mise_toml: | + [tools] + gh = "2.99.0" + - uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5 + with: + name: extension-bundle + run-id: ${{ needs.verify.outputs.run }} + github-token: ${{ github.token }} + path: bundle + - name: Publish the tested artifact + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + TAG: ${{ github.ref_name }} + run: | + shopt -s nullglob + files=(bundle/*.shell-extension.zip) + test "${#files[@]}" -eq 1 + gh release create "$TAG" --verify-tag --title "$TAG" --generate-notes "${files[0]}" diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index ff9a77f..dccb6d4 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -1,61 +1,44 @@ name: Security - on: - push: - branches: [main] - pull_request: - workflow_dispatch: - schedule: - # Weekly, so new advisories are caught without needing a code change. - - cron: '17 5 * * 1' - + workflow_call: + workflow_dispatch: + schedule: + - cron: '17 5 * * 1' permissions: - contents: read - + contents: read concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - + group: security-${{ github.ref }}-${{ github.event_name }} + cancel-in-progress: true jobs: - codeql: - name: CodeQL - runs-on: ubuntu-latest - timeout-minutes: 20 - permissions: - contents: read - security-events: write - actions: read - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - - uses: github/codeql-action/init@1c5b675653bb5c22dbe9b12b556ec555138e09fd # v4.38.1 - with: - languages: javascript-typescript - queries: security-extended - - - uses: github/codeql-action/analyze@1c5b675653bb5c22dbe9b12b556ec555138e09fd # v4.38.1 - - scanners: - name: Scanners - # ci.yml runs `just ci`, which already includes `just security`, on every - # push and pull request. Running it again here would scan the same tree - # twice. What this job adds is the weekly schedule: the same scanners - # against unchanged code, catching advisories published since the last - # commit. - if: github.event_name != 'push' && github.event_name != 'pull_request' - runs-on: ubuntu-latest - timeout-minutes: 15 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - # gitleaks scans history; a shallow clone gives it one commit. - fetch-depth: 0 - persist-credentials: false - - - uses: jdx/mise-action@c2a87611a18de5b3828c5652fe268e992400cb5c # v4 - - # Same recipe as `just security` locally: gitleaks, trivy, osv-scanner, - # actionlint and zizmor, all pinned by mise.toml. - - run: just security + codeql: + name: codeql + runs-on: ubuntu-24.04 + timeout-minutes: 20 + permissions: + contents: read + security-events: write # Upload CodeQL findings to GitHub code scanning. + actions: read # Inspect workflow runs and download their tested artifacts. + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: github/codeql-action/init@1c5b675653bb5c22dbe9b12b556ec555138e09fd # v4.38.1 + with: + languages: javascript-typescript,python + queries: security-extended + - uses: github/codeql-action/analyze@1c5b675653bb5c22dbe9b12b556ec555138e09fd # v4.38.1 + scanners: + name: scanners + if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' + runs-on: ubuntu-24.04 + timeout-minutes: 20 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + - uses: jdx/mise-action@c2a87611a18de5b3828c5652fe268e992400cb5c # v4.3.0 + with: + version: 2026.9.1 + - run: npm ci --ignore-scripts + - run: just security diff --git a/.github/workflows/sonar.yml b/.github/workflows/sonar.yml index e89fab1..516a35b 100644 --- a/.github/workflows/sonar.yml +++ b/.github/workflows/sonar.yml @@ -1,53 +1,45 @@ name: Sonar - on: - push: - branches: [main] - pull_request: - + workflow_call: + secrets: + SONAR_TOKEN: + required: true + workflow_dispatch: + schedule: + - cron: '37 5 * * 1' permissions: - contents: read - + contents: read concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - + group: sonar-${{ github.ref }}-${{ github.event_name }} + cancel-in-progress: true jobs: - sonar: - runs-on: ubuntu-latest - timeout-minutes: 15 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - # Sonar uses git history for blame and new-code detection. - fetch-depth: 0 - persist-credentials: false - - - uses: jdx/mise-action@c2a87611a18de5b3828c5652fe268e992400cb5c # v4 - - - run: npm ci - - # Coverage is produced here rather than pulled from the CI workflow, so - # this job stays independent of cross-workflow artifact plumbing. - - run: just coverage - - # A secret cannot be read from a job-level `if`, so the check has - # to happen in a step. Without it this workflow is permanently red - # on a repository whose Sonar project has not been created yet, - # which trains everyone to ignore a failing check — the one habit - # that makes every other check worthless. - - name: Check whether Sonar is configured - id: sonar - env: - SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} - run: echo "configured=${SONAR_TOKEN:+true}" >> "$GITHUB_OUTPUT" - - - if: steps.sonar.outputs.configured == 'true' - uses: SonarSource/sonarqube-scan-action@ba9859eae8dd6bd29e412f25ddbbef3d032000f4 # v8.2.2 - env: - SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} - - - if: steps.sonar.outputs.configured != 'true' - run: | - echo "::notice::Sonar is not configured for this repository." - echo "::notice::Import it at sonarcloud.io and add SONAR_TOKEN to the repository secrets." + sonar: + name: sonar + runs-on: ubuntu-24.04 + timeout-minutes: 25 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + - uses: jdx/mise-action@c2a87611a18de5b3828c5652fe268e992400cb5c # v4.3.0 + with: + version: 2026.9.1 + - name: Require Sonar configuration + env: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + run: test -n "$SONAR_TOKEN" || { echo '::error::SONAR_TOKEN is required; scan cannot be skipped'; exit 1; } + - run: npm ci --ignore-scripts + - run: just coverage + - name: Analyze the full checked branch + env: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + REVIEW: ${{ github.event.pull_request.number }} + BRANCH: ${{ github.ref_name }} + run: | + branch="$BRANCH" + if [ -n "$REVIEW" ]; then branch="review-$REVIEW"; fi + revision="$(git rev-parse HEAD)" + project="Ghost-Assembly_$(jq -r .name package.json)" + sonar-scanner -Dsonar.projectKey="$project" -Dsonar.branch.name="$branch" -Dsonar.scm.revision="$revision" -Dsonar.qualitygate.wait=true + python3 scripts/sonar_gate.py --project "$project" --branch "$branch" --revision "$revision" diff --git a/.github/workflows/template.yml b/.github/workflows/template.yml new file mode 100644 index 0000000..5feedf4 --- /dev/null +++ b/.github/workflows/template.yml @@ -0,0 +1,23 @@ +name: Template freshness +on: + workflow_dispatch: + schedule: + - cron: '47 5 * * 1' +permissions: + contents: read +concurrency: + group: template-${{ github.ref }} + cancel-in-progress: true +jobs: + template: + name: template + runs-on: ubuntu-24.04 + timeout-minutes: 5 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: jdx/mise-action@c2a87611a18de5b3828c5652fe268e992400cb5c # v4.3.0 + with: + version: 2026.9.1 + - run: just template-check template-status diff --git a/.gitignore b/.gitignore index 5d63bce..9563c4d 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,9 @@ schemas/gschemas.compiled *.shell-extension.zip test-results/ playwright-report/ +__pycache__/ +.ruff_cache/ +.scannerwork/ +.env +.env.* +.superpowers/ diff --git a/.gitleaks.toml b/.gitleaks.toml index d6d2c5b..5474857 100644 --- a/.gitleaks.toml +++ b/.gitleaks.toml @@ -1,16 +1,2 @@ -# gitleaks configuration. -# -# `just security` runs `gitleaks detect`, which scans committed history, so a -# false positive here fails CI on every push rather than once. - [extend] useDefault = true - -[[allowlists]] -description = "Sonar project key and organization are public identifiers, not credentials. They are the values SonarQube Cloud assigns when the project is imported and are visible on the public dashboard; the actual credential is SONAR_TOKEN, which lives in GitHub Actions secrets. gitleaks' generic-api-key rule matches them only because the setting is spelled 'projectKey'." -paths = ['''^sonar-project\.properties$'''] -regexes = ['''sonar\.(projectKey|organization)\s*=.*'''] - -[[allowlists]] -description = "Unit tests feed parsers fake password, secret and passphrase keys on purpose: they are what proves a parser drops them. The values are literal placeholders, not credentials." -paths = ['''^tests/.*\.test\.js$'''] diff --git a/.prettierignore b/.prettierignore index f25a2ea..63b90a2 100644 --- a/.prettierignore +++ b/.prettierignore @@ -4,8 +4,5 @@ schemas/gschemas.compiled package-lock.json test-results/ playwright-report/ - -# SDD workspace scratch. Ignored by its own nested .gitignore -# (.superpowers/sdd/.gitignore), which Prettier does not read, so it must be -# named here too or `just lint` fails on any machine where it happens to exist. +.scannerwork/ .superpowers/ diff --git a/.prettierrc.json b/.prettierrc.json index c651b1f..4888c42 100644 --- a/.prettierrc.json +++ b/.prettierrc.json @@ -1,7 +1,10 @@ { - "singleQuote": true, - "printWidth": 88, - "tabWidth": 4, - "semi": true, - "arrowParens": "avoid" + "singleQuote": true, + "printWidth": 88, + "tabWidth": 4, + "semi": true, + "arrowParens": "avoid", + "overrides": [ + { "files": ["*.yml", "*.yaml", "*.json"], "options": { "tabWidth": 2 } } + ] } diff --git a/AGENTS.md b/AGENTS.md index 9573c28..d1bc34a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,16 +7,13 @@ working in this repository must not break. ## What gets published -- A GitHub Release per tag: the installable - `quickts@napalm255.github.io.shell-extension.zip`, built by `just build` - and attached by `.github/workflows/release.yml`, gated on `just ci` - passing first. A tag whose version disagrees with `metadata.json`'s - `version-name` or `package.json`'s `version` is refused. -- The documentation site at `https://ghost-assembly.com/quickts/` (the old - `ghost-assembly.github.io` URL 301s there), served from this repository's - `docs/` folder on `main` through GitHub Pages. -- Nothing is uploaded to extensions.gnome.org from CI — that needs the - account password and goes through human review either way. +- `just build` produces `quickts@napalm255.github.io.shell-extension.zip`. + A `vX.Y.Z` tag triggers `.github/workflows/release.yml`, which verifies + version agreement, main ancestry, and successful CI for the exact commit, + then publishes that tested artifact without rebuilding it. +- Docs at https://ghost-assembly.com/quickts/ are deployed by the Pages + workflow from the tested `docs/` artifact after all required checks pass on main. +- GNOME Extension Store submission and review remain manual. - The one-liner — "Tailscale in Quick Settings: toggle the tailnet, pick an exit node, switch profiles, ping nodes and send or receive Taildrop files." — must stay identical in README.md's opening line, @@ -26,31 +23,35 @@ working in this repository must not break. ## Commands -Table from the shared `justfile` and this repository's own `project.just`. -Run `just ci` before claiming anything done. - -| Recipe | Does | Needs | -| -------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------- | -| `just setup` | `mise install`, `npm ci`, Playwright's browsers, checks for `gjs`, `glib-compile-schemas`, `gnome-shell`, `gnome-extensions`, `rsync`, `zip`, `unzip`, `jq` | — | -| `just fmt` | `prettier --write` + `eslint --fix` | — | -| `just lint` | `template-check`, eslint, prettier `--check`, `glib-compile-schemas --strict --dry-run`, shellcheck | — | -| `just template-check` | Diffs the shared template files against `template.sha256`; `--write` regenerates it | — | -| `just test` | The Vitest unit suite | — | -| `just test-docs` | The docs site in Chromium and Firefox (Playwright + axe) | — | -| `just coverage` | The unit suite with a coverage report | — | -| `just security` | osv-scanner, gitleaks, trivy, actionlint, zizmor | — | -| `just build` | The installable zip | — | -| `just ci` | `lint test test-docs security build` — what CI runs, and the required status check | — | -| `just test-live` | Builds, then `scripts/headless-check.sh`, `scripts/pack-check.sh`, and `live-extra` (`project.just`) | A real headless `gnome-shell`; not run in CI | -| `just localapi-check` | `scripts/localapi-check.sh`: runs `modules/io.js` under plain `gjs` against this machine's `tailscaled` | A reachable `tailscaled`; not run in CI | -| `just pack-check` | Compares the built zip against `gnome-extensions pack`'s output | — | -| `just run` | `gnome-shell --devkit --wayland` in a window | `mutter-devkit`, a real Shell session | -| `just install` / `enable` / `disable` / `prefs` / `logs` | Install into `~/.local/share/gnome-shell/extensions`, toggle it, open preferences, follow its log | A real GNOME Shell session | -| `just docs` | Serves `docs/` on `localhost:8000` | — | -| `just clean` | Removes build and test output | — | - -`just test-live`'s `live-extra` (defined in `project.just`) is -`localapi-check`, so a live daemon check runs as part of `test-live` too. +Tool versions live in `mise.toml`; common commands live in the canonical +`justfile`; project-specific commands and hooks live in `project.just`. +Run `just ci` before claiming a change works. + +| Command | Does | +| ---------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- | +| `just setup` | Install pinned tools, npm development dependencies, and Chromium/Firefox; check host tools | +| `just fmt` | Format JavaScript, Python, configuration, and generated documentation | +| `just lint` | Verify canonical files, generated docs, ESLint, Prettier, Ruff, schemas, and shell scripts | +| `just template-check` | Compare managed files with the immutable GitHub revision in `quick-template.lock.json` | +| `just template-sync SHA` | Synchronize a reviewed canonical revision; then install dependencies and regenerate docs | +| `just test` | Run Vitest, Python tooling tests, and project offline integration tests | +| `just coverage` | Measure all runtime JavaScript, including untested files | +| `just test-docs` | Check docs in Chromium and Firefox, including axe accessibility audits | +| `just security` | Run OSV, source and history secret scans, Trivy, actionlint, and Zizmor | +| `just build` | Build a deterministic runtime-only ZIP with Python's standard library | +| `just pack-check` | Compare every ZIP filename and byte with GNOME's official packer; validate icons | +| `just test-live` | Check packaging, then isolated GNOME lifecycle and project integration hooks | +| `just run` | Run GNOME Shell in a development window | +| `just install` / `enable` / `disable` / `uninstall` / `prefs` / `logs` | Work with the extension in your logged-in session | +| `just docs` | Serve the static site at localhost:8000 | +| `just ci` | Run lint, tests, coverage, docs, security, and packaging; GitHub also requires CodeQL and Sonar | +| `just clean` | Confirm before removing generated build and test output | + +Live checks require an installed GNOME Shell and run outside hosted CI. +Complete the manual checklist and test each declared GNOME version before releasing. + +Project command: `just localapi-check` probes this machine's `tailscaled` +through the real GJS client. `test-live` requires this daemon check to pass. ## Hard constraints @@ -89,31 +90,23 @@ Run `just ci` before claiming anything done. would never see it. - **No JavaScript on the docs pages.** `docs/index.html` ships no `")]), + "

<script>unsafe</script>

", + ) + + def test_missing_or_duplicate_generation_markers_fail(self) -> None: + with self.assertRaises(ValueError): + docs.replace_block("handwritten", "install", "generated") + region = "" + with self.assertRaises(ValueError): + docs.replace_block(region * 2, "install", "generated") + + +if __name__ == "__main__": + unittest.main() diff --git a/vitest.config.js b/vitest.config.js index c1eeb36..b05082c 100644 --- a/vitest.config.js +++ b/vitest.config.js @@ -10,50 +10,18 @@ export default defineConfig({ include: ['tests/**/*.test.js'], coverage: { provider: 'v8', - reporter: ['text', 'lcov'], - // Everything the extension ships, so the denominator is the real - // one. Listing only the modules that happen to be covered would - // measure coverage against a figure chosen to flatter it. - include: ['modules/**/*.js', 'extension.js', 'prefs.js'], - // Two exceptions, both for the same reason: what is left in them - // after the decisions were moved out is toolkit construction, which - // a unit test can only assert against a stub of the toolkit — that - // tests the stub, not the code. - // - // prefs.js Adw and Gtk widget building. The rules it used - // to hold live in modules/shortcuts.js. - // modules/io.js Soup and Gio plumbing. Every URL, body, delay - // and retry decision, whether an answer is JSON - // and what a Gio error or an HTTP status means, - // lives in modules/localapi.js, modules/errors.js, - // modules/timing.js and modules/reconnect.js and - // is tested there. io.js is covered instead by - // scripts/localapi-check.sh, which runs it under - // plain gjs against the real tailscaled — the only - // check that catches Tailscale changing its JSON. - // - // Those two files are exactly sonar.coverage.exclusions, so the - // two tools agree on what counts. - // - // tests/** is listed here and not there. Sonar never counts it, - // because sonar.sources leaves it out, but the `include` above did - // not keep a dynamically imported stub out of this report: - // extension.test.js pulls tests/stubs/shell-extension.js in - // through vi.doMock, and it turned up as production code. A stub - // counted either way is a number that means nothing. - exclude: ['prefs.js', 'modules/io.js', 'tests/**'], + reporter: ['text', 'lcov', 'html'], + include: [ + 'modules/**/*.js', + 'extension.js', + 'prefs.js', + 'scripts/soloist-runner.js', + ], + exclude: ['tests/**'], }, }, - // gnome-shell resolves these at runtime; Node cannot. Pointing them at - // stubs is what makes the actor layer reachable from Vitest at all. The - // stubs live in tests/, so they never ship and are never counted as - // covered code. - // - // gi://Soup is deliberately absent. Nothing under test imports it, because - // only modules/io.js does and that file is excluded above. The day this - // list needs a Soup entry is the day a decision has leaked into the - // transport, and the missing alias is how we find out. + // GNOME imports resolve to recording stubs for offline behavior tests. resolve: { alias: [ { find: 'gi://Clutter', replacement: stub('gi-clutter') }, From 935e69a868ea3c3e5ee00f37a9b9bd7b092030fd Mon Sep 17 00:00:00 2001 From: napalm255 Date: Sat, 3 Oct 2026 15:00:23 -0400 Subject: [PATCH 2/7] fix(ci): install SVG decoding and require full Sonar analysis --- .github/workflows/ci.yml | 2 +- .github/workflows/sonar.yml | 6 +++++- quick-template.lock.json | 2 +- scripts/icon-check.js | 2 +- scripts/sonar_gate.py | 11 +++++++++++ tests/test_tooling.py | 13 +++++++++++++ 6 files changed, 32 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 111813b..080f610 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,7 +25,7 @@ jobs: - name: Install native test tools run: | sudo apt-get update - sudo apt-get install -y --no-install-recommends gjs gnome-shell libglib2.0-bin gir1.2-soup-3.0 gir1.2-secret-1 dbus-daemon + sudo apt-get install -y --no-install-recommends gjs gnome-shell libglib2.0-bin librsvg2-common gir1.2-soup-3.0 gir1.2-secret-1 dbus-daemon - run: npm ci --ignore-scripts - run: ./node_modules/.bin/playwright install --with-deps chromium firefox - run: just ci diff --git a/.github/workflows/sonar.yml b/.github/workflows/sonar.yml index 516a35b..6cb541c 100644 --- a/.github/workflows/sonar.yml +++ b/.github/workflows/sonar.yml @@ -38,7 +38,11 @@ jobs: BRANCH: ${{ github.ref_name }} run: | branch="$BRANCH" - if [ -n "$REVIEW" ]; then branch="review-$REVIEW"; fi + if [ -n "$REVIEW" ]; then + branch="branch-review-$REVIEW" + elif [ "$branch" != main ]; then + branch="branch-$branch" + fi revision="$(git rev-parse HEAD)" project="Ghost-Assembly_$(jq -r .name package.json)" sonar-scanner -Dsonar.projectKey="$project" -Dsonar.branch.name="$branch" -Dsonar.scm.revision="$revision" -Dsonar.qualitygate.wait=true diff --git a/quick-template.lock.json b/quick-template.lock.json index 9267414..3d40bc3 100644 --- a/quick-template.lock.json +++ b/quick-template.lock.json @@ -1,5 +1,5 @@ { "$schema": "quick-template.schema.json", "repository": "Ghost-Assembly/quick-template", - "revision": "effd43bd6c74609886a2574f9a1151eff46b7ff9" + "revision": "2ca95e238eda3966af16ee7c6e876d9618e71696" } diff --git a/scripts/icon-check.js b/scripts/icon-check.js index b0b6ce1..9787e2d 100644 --- a/scripts/icon-check.js +++ b/scripts/icon-check.js @@ -1,6 +1,6 @@ // Assert that one icon file actually decodes. // -// Run by scripts/pack-check.sh over everything in icons/. It exists because an +// Run by just pack-check over everything in icons/. It exists because an // icon that fails to load is completely silent: gnome-shell draws nothing and // logs nothing, so the first report is a person saying "there is no icon". // diff --git a/scripts/sonar_gate.py b/scripts/sonar_gate.py index 0162547..ca39904 100644 --- a/scripts/sonar_gate.py +++ b/scripts/sonar_gate.py @@ -40,6 +40,15 @@ def request(endpoint: str, parameters: dict[str, str]) -> dict: connection.close() +def validate_branch(branches: list[dict], expected_name: str) -> None: + """Require overall-code analysis rather than new-code-only short branches.""" + branch = next((item for item in branches if item.get("name") == expected_name), None) + if branch is None: + raise ValueError("No Sonar analysis exists for the checked branch") + if branch.get("type") != "LONG": + raise ValueError("Sonar must analyze overall code on a long-lived branch") + + def validate(measures: list[dict], analyzed_revision: str, expected_revision: str) -> None: """Reject stale, incomplete, or nonzero results, including rounded duplication.""" if analyzed_revision != expected_revision: @@ -60,6 +69,8 @@ def main() -> None: parser.add_argument("--revision", required=True) parser.add_argument("--branch", default="main") args = parser.parse_args() + branches = request("project_branches/list", {"project": args.project})["branches"] + validate_branch(branches, args.branch) analyses = request( "project_analyses/search", {"project": args.project, "branch": args.branch, "ps": "1"} )["analyses"] diff --git a/tests/test_tooling.py b/tests/test_tooling.py index 4fe34f5..0fb8578 100644 --- a/tests/test_tooling.py +++ b/tests/test_tooling.py @@ -133,6 +133,19 @@ def measures(self) -> list[dict]: def test_current_zero_results_pass(self) -> None: self.assertIsNone(gate.validate(self.measures(), "current", "current")) + def test_short_branch_results_cannot_approve_overall_code(self) -> None: + branches = [ + {"name": "main", "type": "LONG"}, + {"name": "branch-review-1", "type": "LONG"}, + {"name": "review-1", "type": "SHORT"}, + ] + for name in ["main", "branch-review-1"]: + with self.subTest(branch=name): + self.assertIsNone(gate.validate_branch(branches, name)) + for name in ["review-1", "missing"]: + with self.subTest(branch=name), self.assertRaises(ValueError): + gate.validate_branch(branches, name) + def test_every_quality_category_and_exact_duplication_are_required(self) -> None: for name in gate.METRICS: measures = self.measures() From e7f94060cf9b70bb245316ec438a087add35314b Mon Sep 17 00:00:00 2001 From: napalm255 Date: Sat, 3 Oct 2026 15:05:47 -0400 Subject: [PATCH 3/7] fix(ci): scope release artifact access to verification jobs --- .github/workflows/release.yml | 4 +++- quick-template.lock.json | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c62a5fa..27e8b3e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,7 +4,6 @@ on: tags: ['v*'] permissions: contents: read - actions: read # Inspect workflow runs and download their tested artifacts. concurrency: group: release-${{ github.ref }} cancel-in-progress: false @@ -13,6 +12,9 @@ jobs: name: verify runs-on: ubuntu-24.04 timeout-minutes: 10 + permissions: + contents: read + actions: read # Inspect workflow runs and download their tested artifacts. outputs: run: ${{ steps.verify.outputs.run }} steps: diff --git a/quick-template.lock.json b/quick-template.lock.json index 3d40bc3..1b9c5ce 100644 --- a/quick-template.lock.json +++ b/quick-template.lock.json @@ -1,5 +1,5 @@ { "$schema": "quick-template.schema.json", "repository": "Ghost-Assembly/quick-template", - "revision": "2ca95e238eda3966af16ee7c6e876d9618e71696" + "revision": "d6fa407ce8886a5841e6766bef2969c56e222266" } From 5dedb63ebfb41e8c2989e4df003b85aaa524e98a Mon Sep 17 00:00:00 2001 From: napalm255 Date: Sat, 3 Oct 2026 15:36:14 -0400 Subject: [PATCH 4/7] docs: leave submission version numbering to GNOME --- README.md | 2 +- docs/index.html | 8 ++++---- quick-template.lock.json | 2 +- scripts/docs.py | 5 +++-- 4 files changed, 9 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index c83e30d..f396578 100644 --- a/README.md +++ b/README.md @@ -123,7 +123,7 @@ just pack-check compares both filenames and file contents with GNOME's official -Run just ci, just test-live, and the project manual checklist. Set metadata.json version-name and package.json version to the same new version and increment metadata.json version for the GNOME Extension Store. Update the npm lockfile, regenerate the docs, and commit the reviewed changes to main through a passing pull request. +Run just ci, just test-live, and the project manual checklist. Set metadata.json version-name and package.json version to the same new version. The GNOME Extensions website assigns the numeric metadata.json version during submission. Update the npm lockfile, regenerate the docs, and commit the reviewed changes to main through a passing pull request. Create and push a v-prefixed tag for that version. The release workflow verifies the version, main ancestry, and successful required checks for the tagged commit, then attaches its tested ZIP to a GitHub release. It does not upload to extensions.gnome.org; that submission and its review remain manual. diff --git a/docs/index.html b/docs/index.html index 70dcea5..a8468c3 100644 --- a/docs/index.html +++ b/docs/index.html @@ -1842,10 +1842,10 @@

Releasing

Run just ci, just test-live, and the project manual checklist. Set metadata.json version-name and package.json version to the - same new version and increment metadata.json version for the - GNOME Extension Store. Update the npm lockfile, regenerate the - docs, and commit the reviewed changes to main through a passing - pull request. + same new version. The GNOME Extensions website assigns the + numeric metadata.json version during submission. Update the npm + lockfile, regenerate the docs, and commit the reviewed changes + to main through a passing pull request.

Create and push a v-prefixed tag for that version. The release diff --git a/quick-template.lock.json b/quick-template.lock.json index 1b9c5ce..09995d0 100644 --- a/quick-template.lock.json +++ b/quick-template.lock.json @@ -1,5 +1,5 @@ { "$schema": "quick-template.schema.json", "repository": "Ghost-Assembly/quick-template", - "revision": "d6fa407ce8886a5841e6766bef2969c56e222266" + "revision": "3a46437ad001ece2fdbfcba1b615363331df45fb" } diff --git a/scripts/docs.py b/scripts/docs.py index 552581c..bd49f6c 100644 --- a/scripts/docs.py +++ b/scripts/docs.py @@ -156,8 +156,9 @@ def blocks(metadata: dict, project: dict) -> dict[str, list[tuple[str, str]]]: ( "Run just ci, just test-live, and the project manual checklist. Set " "metadata.json version-name and package.json version to the same new " - "version and increment metadata.json version for the GNOME Extension " - "Store. Update the npm lockfile, regenerate the docs, and commit the " + "version. The GNOME Extensions website assigns the numeric metadata.json " + "version during submission. Update the npm lockfile, regenerate the docs, " + "and commit the " "reviewed changes to main through a passing pull request." ), ), From 699c4d6013144d2a51915d300f4903efbc54f398 Mon Sep 17 00:00:00 2001 From: napalm255 Date: Sat, 3 Oct 2026 17:35:58 -0400 Subject: [PATCH 5/7] fix: adopt the public Sonar project identifier correction --- .gitleaks.toml | 10 ++++++++ quick-template.lock.json | 2 +- tests/test_tooling.py | 53 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 64 insertions(+), 1 deletion(-) diff --git a/.gitleaks.toml b/.gitleaks.toml index 5474857..e6c5ec4 100644 --- a/.gitleaks.toml +++ b/.gitleaks.toml @@ -1,2 +1,12 @@ [extend] useDefault = true + +[[rules]] +id = "generic-api-key" + +[[rules.allowlists]] +description = "Recognize QuickTiler's historical public Sonar project identifier" +condition = "AND" +regexTarget = "match" +paths = ['''^sonar-project\.properties$'''] +regexes = ['''^sonar\.projectKey=napalm255_tiler$'''] diff --git a/quick-template.lock.json b/quick-template.lock.json index 09995d0..20a37f0 100644 --- a/quick-template.lock.json +++ b/quick-template.lock.json @@ -1,5 +1,5 @@ { "$schema": "quick-template.schema.json", "repository": "Ghost-Assembly/quick-template", - "revision": "3a46437ad001ece2fdbfcba1b615363331df45fb" + "revision": "f05201194d77ce2d4669630d1ae4643b610bac17" } diff --git a/tests/test_tooling.py b/tests/test_tooling.py index 0fb8578..8f30881 100644 --- a/tests/test_tooling.py +++ b/tests/test_tooling.py @@ -1,5 +1,6 @@ """Behavior regressions for canonical tooling and publication boundaries.""" +import hashlib import importlib.util import io import json @@ -32,6 +33,58 @@ def load(name: str): security = load("security_source") +class GitleaksTests(unittest.TestCase): + def test_public_project_identity_cannot_exempt_credentials_or_other_contexts(self) -> None: + public_id = "napalm255_tiler" + fixture = hashlib.sha256(b"nonfunctional scanner regression fixture").hexdigest()[:40] + vendor_fixture = "squ_" + fixture + cases = [ + ("public identity", "sonar-project.properties", "sonar.projectKey", public_id, 0), + ("other identity", "sonar-project.properties", "sonar.projectKey", fixture, 1), + ("credential", "sonar-project.properties", "sonar.token", fixture, 1), + ("public value as credential", "sonar-project.properties", "token", public_id, 1), + ("other file", "other.properties", "sonar.projectKey", public_id, 1), + ( + "vendor credential", + "sonar-project.properties", + "sonar.projectKey", + vendor_fixture, + 1, + ), + ( + "appended credential", + "sonar-project.properties", + "sonar.projectKey", + public_id + " token=" + fixture, + 1, + ), + ] + for name, path, property_name, value, expected in cases: + with self.subTest(case=name), tempfile.TemporaryDirectory() as work: + root = Path(work) + (root / path).write_text(f"{property_name}={value}\n") + + result = subprocess.run( + [ + "/usr/bin/env", + "gitleaks", + "dir", + "--redact", + "--no-banner", + "--no-color", + ".", + ], + cwd=root, + env={**os.environ, "GITLEAKS_CONFIG": str(ROOT / ".gitleaks.toml")}, + capture_output=True, + text=True, + check=False, + timeout=30, + ) + + self.assertEqual(result.returncode, expected, result.stderr) + + class SecuritySourceTests(unittest.TestCase): def test_scan_includes_untracked_source_but_not_private_ignored_files(self) -> None: with tempfile.TemporaryDirectory() as work: From a086686904f4ca01ed2781cba5b5edfe97465c91 Mon Sep 17 00:00:00 2001 From: napalm255 Date: Sat, 3 Oct 2026 18:18:35 -0400 Subject: [PATCH 6/7] fix: align Sonar checks with the Free plan Analyze PR changes and the full main branch using exact revisions. Report Python tooling coverage without excluding first-party files. --- .github/workflows/sonar.yml | 21 ++++++++------- AGENTS.md | 4 +-- README.md | 2 +- docs/index.html | 3 ++- justfile | 4 ++- mise.toml | 2 ++ pyproject.toml | 6 +++++ quick-template.lock.json | 2 +- scripts/docs.py | 1 + scripts/sonar_gate.py | 44 ++++++++++++++++++++++--------- sonar-project.properties | 1 + tests/test_tooling.py | 52 +++++++++++++++++++++++++++++++++++++ 12 files changed, 115 insertions(+), 27 deletions(-) diff --git a/.github/workflows/sonar.yml b/.github/workflows/sonar.yml index 6cb541c..1df5a12 100644 --- a/.github/workflows/sonar.yml +++ b/.github/workflows/sonar.yml @@ -31,19 +31,22 @@ jobs: run: test -n "$SONAR_TOKEN" || { echo '::error::SONAR_TOKEN is required; scan cannot be skipped'; exit 1; } - run: npm ci --ignore-scripts - run: just coverage - - name: Analyze the full checked branch + - name: Analyze the checked pull request or main branch env: SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} REVIEW: ${{ github.event.pull_request.number }} BRANCH: ${{ github.ref_name }} + SOURCE_BRANCH: ${{ github.head_ref }} + TARGET_BRANCH: ${{ github.base_ref }} run: | - branch="$BRANCH" - if [ -n "$REVIEW" ]; then - branch="branch-review-$REVIEW" - elif [ "$branch" != main ]; then - branch="branch-$branch" - fi revision="$(git rev-parse HEAD)" project="Ghost-Assembly_$(jq -r .name package.json)" - sonar-scanner -Dsonar.projectKey="$project" -Dsonar.branch.name="$branch" -Dsonar.scm.revision="$revision" -Dsonar.qualitygate.wait=true - python3 scripts/sonar_gate.py --project "$project" --branch "$branch" --revision "$revision" + if [ -n "$REVIEW" ]; then + test "$TARGET_BRANCH" = main || { echo '::error::Free-plan PR analysis must target main'; exit 1; } + sonar-scanner -Dsonar.projectKey="$project" -Dsonar.pullrequest.key="$REVIEW" -Dsonar.pullrequest.branch="$SOURCE_BRANCH" -Dsonar.pullrequest.base="$TARGET_BRANCH" -Dsonar.scm.revision="$revision" -Dsonar.qualitygate.wait=true + python3 scripts/sonar_gate.py --project "$project" --pull-request "$REVIEW" --revision "$revision" + else + test "$BRANCH" = main || { echo '::error::Dispatch main for overall-code analysis on the Free plan'; exit 1; } + sonar-scanner -Dsonar.projectKey="$project" -Dsonar.branch.name=main -Dsonar.scm.revision="$revision" -Dsonar.qualitygate.wait=true + python3 scripts/sonar_gate.py --project "$project" --branch main --revision "$revision" + fi diff --git a/AGENTS.md b/AGENTS.md index d1bc34a..3234766 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -35,7 +35,7 @@ Run `just ci` before claiming a change works. | `just template-check` | Compare managed files with the immutable GitHub revision in `quick-template.lock.json` | | `just template-sync SHA` | Synchronize a reviewed canonical revision; then install dependencies and regenerate docs | | `just test` | Run Vitest, Python tooling tests, and project offline integration tests | -| `just coverage` | Measure all runtime JavaScript, including untested files | +| `just coverage` | Measure runtime JavaScript and Python tooling, including untested files | | `just test-docs` | Check docs in Chromium and Firefox, including axe accessibility audits | | `just security` | Run OSV, source and history secret scans, Trivy, actionlint, and Zizmor | | `just build` | Build a deterministic runtime-only ZIP with Python's standard library | @@ -98,7 +98,7 @@ through the real GJS client. `test-live` requires this daemon check to pass. Write the failing test first (RED → GREEN). Layers: - `just test` uses Vitest with recording GNOME stubs; `just coverage` - measures all runtime JavaScript, including untested files. Native and live + measures runtime JavaScript and Python tooling, including untested files. Native and live integration checks remain separate from that coverage report. - **`just localapi-check`** (`scripts/localapi-check.js`, under plain `gjs`) — exercises `modules/io.js` against this machine's `tailscaled`. diff --git a/README.md b/README.md index f396578..77b7b73 100644 --- a/README.md +++ b/README.md @@ -151,7 +151,7 @@ just template-check # verify the pinned canonical template just template-status # report a newer approved template revision ``` -GitHub requires local verification, security analysis, and completed Sonar analysis. The shared Sonar policy requires zero security, reliability, and maintainability issues and zero duplicated lines. Missing configuration fails instead of silently skipping analysis. Pages publishes the tested docs only after the required checks pass on main. +GitHub requires local verification, security analysis, and completed Sonar analysis. The shared Sonar policy requires zero security, reliability, and maintainability issues and zero duplicated lines. PR checks cover changed code; main checks cover the entire project. Missing configuration fails instead of silently skipping analysis. Pages publishes the tested docs only after the required checks pass on main. Common tooling and these instructions are generated from a pinned canonical template. Change that source and synchronize its approved revision; do not edit generated sections or locally bless drift. Extension-specific behavior belongs in project configuration and project.just. diff --git a/docs/index.html b/docs/index.html index a8468c3..5ca2f98 100644 --- a/docs/index.html +++ b/docs/index.html @@ -1893,7 +1893,8 @@

Development

GitHub requires local verification, security analysis, and completed Sonar analysis. The shared Sonar policy requires zero security, reliability, and maintainability issues and zero - duplicated lines. Missing configuration fails instead of + duplicated lines. PR checks cover changed code; main checks + cover the entire project. Missing configuration fails instead of silently skipping analysis. Pages publishes the tested docs only after the required checks pass on main.

diff --git a/justfile b/justfile index 2ed9e2c..8b0b20e 100644 --- a/justfile +++ b/justfile @@ -58,9 +58,11 @@ test *args: python3 -m unittest discover -s tests -p 'test_*.py' -v just test-extra -# Measure all JavaScript runtime source +# Measure all JavaScript runtime source and Python tooling coverage: ./node_modules/.bin/vitest run --coverage + coverage run -m unittest discover -s tests -p 'test_*.py' -v + coverage xml -o coverage/python.xml # Test static documentation in Chromium and Firefox test-docs *args: diff --git a/mise.toml b/mise.toml index e445bd2..06797f4 100644 --- a/mise.toml +++ b/mise.toml @@ -2,6 +2,8 @@ [tools] node = "24.21.0" python = "3.14.7" +uv = "0.12.9" +"pipx:coverage" = "7.16.2" just = "1.58.0" ruff = "0.16.9" actionlint = "1.7.12" diff --git a/pyproject.toml b/pyproject.toml index bae54d0..396c824 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,3 +4,9 @@ line-length = 100 [tool.ruff.lint] select = ["E", "F", "I", "UP", "B", "S", "SIM", "RUF"] + +[tool.coverage.run] +branch = true +relative_files = true +source = ["scripts"] +data_file = "coverage/.coverage" diff --git a/quick-template.lock.json b/quick-template.lock.json index 20a37f0..e097ffc 100644 --- a/quick-template.lock.json +++ b/quick-template.lock.json @@ -1,5 +1,5 @@ { "$schema": "quick-template.schema.json", "repository": "Ghost-Assembly/quick-template", - "revision": "f05201194d77ce2d4669630d1ae4643b610bac17" + "revision": "8373d5d02b200bc644fa7420f0e5e3de4a0172fb" } diff --git a/scripts/docs.py b/scripts/docs.py index bd49f6c..cf066c4 100644 --- a/scripts/docs.py +++ b/scripts/docs.py @@ -191,6 +191,7 @@ def blocks(metadata: dict, project: dict) -> dict[str, list[tuple[str, str]]]: "GitHub requires local verification, security analysis, and completed " "Sonar analysis. The shared Sonar policy requires zero security, " "reliability, and maintainability issues and zero duplicated lines. " + "PR checks cover changed code; main checks cover the entire project. " "Missing configuration fails instead of silently skipping analysis. Pages" " publishes the tested docs only after the required checks pass on main." ), diff --git a/scripts/sonar_gate.py b/scripts/sonar_gate.py index ca39904..b0da449 100644 --- a/scripts/sonar_gate.py +++ b/scripts/sonar_gate.py @@ -49,6 +49,16 @@ def validate_branch(branches: list[dict], expected_name: str) -> None: raise ValueError("Sonar must analyze overall code on a long-lived branch") +def pull_request_revision(pull_requests: list[dict], key: str) -> str: + """Read the checked PR's analyzed revision, without logging account metadata.""" + review = next((item for item in pull_requests if item.get("key") == key), None) + if review is None: + raise ValueError("No Sonar analysis exists for the checked pull request") + if review.get("base") != "main": + raise ValueError("Sonar pull request analysis must target main") + return review.get("commit", {}).get("sha", "") + + def validate(measures: list[dict], analyzed_revision: str, expected_revision: str) -> None: """Reject stale, incomplete, or nonzero results, including rounded duplication.""" if analyzed_revision != expected_revision: @@ -67,25 +77,34 @@ def main() -> None: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--project", required=True) parser.add_argument("--revision", required=True) - parser.add_argument("--branch", default="main") + scope = parser.add_mutually_exclusive_group() + scope.add_argument("--branch", default="main") + scope.add_argument("--pull-request") args = parser.parse_args() - branches = request("project_branches/list", {"project": args.project})["branches"] - validate_branch(branches, args.branch) - analyses = request( - "project_analyses/search", {"project": args.project, "branch": args.branch, "ps": "1"} - )["analyses"] - if not analyses: - raise ValueError("No Sonar analysis exists for this branch") + if args.pull_request: + selection = {"pullRequest": args.pull_request} + reviews = request("project_pull_requests/list", {"project": args.project})["pullRequests"] + revision = pull_request_revision(reviews, args.pull_request) + else: + selection = {"branch": args.branch} + branches = request("project_branches/list", {"project": args.project})["branches"] + validate_branch(branches, args.branch) + analyses = request( + "project_analyses/search", {"project": args.project, **selection, "ps": "1"} + )["analyses"] + if not analyses: + raise ValueError("No Sonar analysis exists for this branch") + revision = analyses[0].get("revision", "") measures = request( "measures/component", - {"component": args.project, "branch": args.branch, "metricKeys": ",".join(METRICS)}, + {"component": args.project, **selection, "metricKeys": ",".join(METRICS)}, )["component"]["measures"] - validate(measures, analyses[0].get("revision", ""), args.revision) + validate(measures, revision, args.revision) dismissed = request( "issues/search", { "componentKeys": args.project, - "branch": args.branch, + **selection, "issueStatuses": "ACCEPTED,FALSE_POSITIVE", "ps": "1", }, @@ -93,7 +112,8 @@ def main() -> None: if dismissed["total"]: raise ValueError("Sonar findings were dismissed instead of fixed") print( - "PASS: current revision has zero security, reliability, maintainability, " + f"PASS: current {'PR changes' if args.pull_request else 'overall code'} " + "have zero security, reliability, maintainability, " "hotspots, and duplicated lines" ) diff --git a/sonar-project.properties b/sonar-project.properties index 36054eb..1d11226 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -3,5 +3,6 @@ sonar.sources=. sonar.tests=tests sonar.exclusions=tests/**,node_modules/**,coverage/**,test-results/**,playwright-report/**,.scannerwork/** sonar.javascript.lcov.reportPaths=coverage/lcov.info +sonar.python.coverage.reportPaths=coverage/python.xml sonar.qualitygate.wait=true sonar.qualitygate.timeout=600 diff --git a/tests/test_tooling.py b/tests/test_tooling.py index 8f30881..3c9e16e 100644 --- a/tests/test_tooling.py +++ b/tests/test_tooling.py @@ -6,6 +6,7 @@ import json import os import subprocess +import sys import tempfile import unittest import zipfile @@ -186,6 +187,57 @@ def measures(self) -> list[dict]: def test_current_zero_results_pass(self) -> None: self.assertIsNone(gate.validate(self.measures(), "current", "current")) + def test_pull_request_results_must_match_the_review_and_revision(self) -> None: + reviews = [{"key": "3", "base": "main", "commit": {"sha": "current"}}] + revision = gate.pull_request_revision(reviews, "3") + self.assertIsNone(gate.validate(self.measures(), revision, "current")) + with self.assertRaises(ValueError): + gate.validate(self.measures(), revision, "stale") + with self.assertRaises(ValueError): + gate.pull_request_revision(reviews, "4") + with self.assertRaises(ValueError): + gate.pull_request_revision([{"key": "3", "base": "other"}], "3") + + def test_cli_checks_the_requested_scope_and_rejects_dismissed_findings(self) -> None: + def responder(selection: dict[str, str], responses: dict): + def respond(endpoint: str, parameters: dict[str, str]) -> dict: + if endpoint in {"measures/component", "issues/search"}: + self.assertEqual( + {k: v for k, v in parameters.items() if k in {"pullRequest", "branch"}}, + selection, + ) + return responses[endpoint] + + return respond + + for review in [False, True]: + for dismissed in [0, 1]: + selection = {"pullRequest": "3"} if review else {"branch": "main"} + responses = { + "project_pull_requests/list": { + "pullRequests": [{"key": "3", "base": "main", "commit": {"sha": "current"}}] + }, + "project_branches/list": {"branches": [{"name": "main", "type": "LONG"}]}, + "project_analyses/search": {"analyses": [{"revision": "current"}]}, + "measures/component": {"component": {"measures": self.measures()}}, + "issues/search": {"total": dismissed}, + } + + argv = ["sonar_gate.py", "--project", "fixture", "--revision", "current"] + if review: + argv.extend(["--pull-request", "3"]) + with ( + self.subTest(review=review, dismissed=dismissed), + patch.object(gate, "request", side_effect=responder(selection, responses)), + patch.object(sys, "argv", argv), + patch("sys.stdout", new=io.StringIO()), + ): + if dismissed: + with self.assertRaises(ValueError): + gate.main() + else: + gate.main() + def test_short_branch_results_cannot_approve_overall_code(self) -> None: branches = [ {"name": "main", "type": "LONG"}, From 581b33474c125bdb53a4129fbf4b37f46dbb1e4c Mon Sep 17 00:00:00 2001 From: napalm255 Date: Sat, 3 Oct 2026 18:29:46 -0400 Subject: [PATCH 7/7] test: adopt verified shared tooling coverage Pin the merged canonical revision and cover publication boundaries. Report all runtime JavaScript and Python tooling without exclusions. Install native packaging tools for Python tests in the Sonar job. --- .github/workflows/sonar.yml | 4 + README.md | 4 +- docs/index.html | 8 +- quick-template.lock.json | 2 +- scripts/clean.py | 26 ++-- scripts/docs.py | 4 +- tests/test_tooling.py | 270 +++++++++++++++++++++++++++++++++++- 7 files changed, 299 insertions(+), 19 deletions(-) diff --git a/.github/workflows/sonar.yml b/.github/workflows/sonar.yml index 1df5a12..7c0ec87 100644 --- a/.github/workflows/sonar.yml +++ b/.github/workflows/sonar.yml @@ -30,6 +30,10 @@ jobs: SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} run: test -n "$SONAR_TOKEN" || { echo '::error::SONAR_TOKEN is required; scan cannot be skipped'; exit 1; } - run: npm ci --ignore-scripts + - name: Install native test tools + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends gjs gnome-shell libglib2.0-bin librsvg2-common gir1.2-soup-3.0 gir1.2-secret-1 dbus-daemon - run: just coverage - name: Analyze the checked pull request or main branch env: diff --git a/README.md b/README.md index 77b7b73..597aa5a 100644 --- a/README.md +++ b/README.md @@ -94,7 +94,7 @@ From a clone, just uninstall performs the same steps. Disabling with just disabl -just test runs the JavaScript suite with Vitest, the shared tooling tests, and any project-specific offline suites. just coverage reports the JavaScript coverage universe, including untested runtime files. Test stubs and generated reports are not runtime source. +just test runs the JavaScript suite with Vitest, the shared tooling tests, and any project-specific offline suites. just coverage reports runtime JavaScript and Python tooling coverage, including untested files. Test stubs and generated reports are not runtime source. just test-docs runs Playwright and axe in Chromium and Firefox: dark and light accessibility checks, keyboard navigation, mobile layout, reduced motion, links, metadata, local assets, and no page JavaScript. Automated accessibility checks still require human review of reading and focus order. @@ -139,7 +139,7 @@ just setup # install pinned tools, dependencies, and browsers just fmt # format source and configuration just lint # verify template, generated docs, source, and schemas just test # JavaScript, Python, and project offline tests -just coverage # report JavaScript coverage without source exclusions +just coverage # report JavaScript and Python coverage without source exclusions just test-docs # Chromium and Firefox documentation checks just security # dependencies, secrets, and workflow checks just build # build the runtime-only extension ZIP diff --git a/docs/index.html b/docs/index.html index 5ca2f98..26af6d2 100644 --- a/docs/index.html +++ b/docs/index.html @@ -1771,9 +1771,9 @@

Testing

just test runs the JavaScript suite with Vitest, the shared tooling tests, and any project-specific offline suites. just - coverage reports the JavaScript coverage universe, including - untested runtime files. Test stubs and generated reports are not - runtime source. + coverage reports runtime JavaScript and Python tooling coverage, + including untested files. Test stubs and generated reports are + not runtime source.

just test-docs runs Playwright and axe in Chromium and Firefox: @@ -1878,7 +1878,7 @@

Development

just fmt # format source and configuration just lint # verify template, generated docs, source, and schemas just test # JavaScript, Python, and project offline tests -just coverage # report JavaScript coverage without source exclusions +just coverage # report JavaScript and Python coverage without source exclusions just test-docs # Chromium and Firefox documentation checks just security # dependencies, secrets, and workflow checks just build # build the runtime-only extension ZIP diff --git a/quick-template.lock.json b/quick-template.lock.json index e097ffc..ab690bc 100644 --- a/quick-template.lock.json +++ b/quick-template.lock.json @@ -1,5 +1,5 @@ { "$schema": "quick-template.schema.json", "repository": "Ghost-Assembly/quick-template", - "revision": "8373d5d02b200bc644fa7420f0e5e3de4a0172fb" + "revision": "40e31d2739e0c94162c707adb6851cc207ce6d2c" } diff --git a/scripts/clean.py b/scripts/clean.py index 71f9e91..1b329b6 100644 --- a/scripts/clean.py +++ b/scripts/clean.py @@ -5,12 +5,20 @@ from pathlib import Path ROOT = Path(__file__).resolve().parent.parent -for name in ["coverage", "test-results", "playwright-report", ".scannerwork"]: - target = ROOT / name - if target.is_symlink(): - target.unlink() - elif target.is_dir(): - shutil.rmtree(target) -for artifact in ROOT.glob("*.shell-extension.zip"): - artifact.unlink() -(ROOT / "schemas/gschemas.compiled").unlink(missing_ok=True) + + +def main(root: Path = ROOT) -> None: + """Keep source and user data while removing the checkout's generated output.""" + for name in ["coverage", "test-results", "playwright-report", ".scannerwork"]: + target = root / name + if target.is_symlink(): + target.unlink() + elif target.is_dir(): + shutil.rmtree(target) + for artifact in root.glob("*.shell-extension.zip"): + artifact.unlink() + (root / "schemas/gschemas.compiled").unlink(missing_ok=True) + + +if __name__ == "__main__": + main() diff --git a/scripts/docs.py b/scripts/docs.py index cf066c4..c014e1b 100644 --- a/scripts/docs.py +++ b/scripts/docs.py @@ -14,7 +14,7 @@ just fmt # format source and configuration just lint # verify template, generated docs, source, and schemas just test # JavaScript, Python, and project offline tests -just coverage # report JavaScript coverage without source exclusions +just coverage # report JavaScript and Python coverage without source exclusions just test-docs # Chromium and Firefox documentation checks just security # dependencies, secrets, and workflow checks just build # build the runtime-only extension ZIP @@ -107,7 +107,7 @@ def blocks(metadata: dict, project: dict) -> dict[str, list[tuple[str, str]]]: ( "just test runs the JavaScript suite with Vitest, the shared tooling " "tests, and any project-specific offline suites. just coverage reports " - "the JavaScript coverage universe, including untested runtime files. Test" + "runtime JavaScript and Python tooling coverage, including untested files. Test" " stubs and generated reports are not runtime source." ), ), diff --git a/tests/test_tooling.py b/tests/test_tooling.py index 3c9e16e..56bea78 100644 --- a/tests/test_tooling.py +++ b/tests/test_tooling.py @@ -11,7 +11,7 @@ import unittest import zipfile from pathlib import Path -from unittest.mock import patch +from unittest.mock import Mock, patch ROOT = Path(__file__).resolve().parents[1] @@ -32,6 +32,7 @@ def load(name: str): docs = load("docs") workflow = load("workflow_lint") security = load("security_source") +clean = load("clean") class GitleaksTests(unittest.TestCase): @@ -120,6 +121,28 @@ def test_source_symlink_cannot_read_outside_the_repository(self) -> None: class WorkflowTests(unittest.TestCase): + def test_cli_preserves_real_actionlint_failures(self) -> None: + with tempfile.TemporaryDirectory() as work: + root = Path(work) + directory = root / ".github/workflows" + directory.mkdir(parents=True) + with ( + patch.object(sys, "argv", ["workflow_lint.py", work]), + patch("sys.stdout", new=io.StringIO()), + ): + with self.assertRaisesRegex(ValueError, "No workflows found"): + workflow.main() + path = directory / "ci.yml" + path.write_text( + "name: Fixture\non: workflow_dispatch\njobs:\n" + " check:\n runs-on: ubuntu-24.04\n steps:\n" + " - run: echo fixture\n" + ) + workflow.main() + path.write_text(path.read_text().replace("runs-on: ubuntu-24.04", "invalid: true")) + with self.assertRaises(subprocess.CalledProcessError): + workflow.main() + def test_only_self_repository_uses_are_adapted(self) -> None: source = " uses: $/.github/workflows/ci.yml\n run: echo '$/unchanged'\n" self.assertEqual( @@ -133,6 +156,104 @@ def test_invalid_workflow_content_is_preserved_for_the_linter(self) -> None: class TemplateTests(unittest.TestCase): + def test_sync_check_and_release_status_preserve_project_identity(self) -> None: + sha = "a" * 40 + files = { + "justfile": b"canonical commands", + "scripts/template.py": b"canonical helper", + "package.json": b'{"name":"template","version":"1.0.0"}', + "package-lock.json": b'{"name":"template","version":"1.0.0","packages":{"":{}}}', + } + with tempfile.TemporaryDirectory() as work: + root = Path(work) + identity = {"name": "quickfixture", "version": "2.0.0"} + (root / "package.json").write_text(json.dumps(identity)) + (root / "quick-template.lock.json").write_text( + json.dumps({"repository": template.REPOSITORY, "revision": sha}) + ) + with ( + patch.object(template, "ROOT", root), + patch.object(template, "source_files", return_value=files), + patch("sys.stdout", new=io.StringIO()), + ): + with patch.object(sys, "argv", ["template.py", "sync", sha]): + template.main() + self.assertEqual(json.loads((root / "package.json").read_text()), identity) + lock = json.loads((root / "package-lock.json").read_text()) + self.assertEqual(lock["packages"][""], identity) + self.assertEqual(template.compare(root, template.expected_files(root, files)), []) + with patch.object(sys, "argv", ["template.py", "check"]): + template.main() + (root / "justfile").write_text("local drift") + with self.assertRaisesRegex(SystemExit, "Template drift"): + template.main() + with ( + patch.object(sys, "argv", ["template.py", "status"]), + patch.object( + template, + "download", + return_value=json.dumps({"target_commitish": sha}).encode(), + ), + ): + template.main() + with ( + patch.object(sys, "argv", ["template.py", "status"]), + patch.object( + template, + "download", + return_value=json.dumps({"target_commitish": "b" * 40}).encode(), + ), + self.assertRaisesRegex(SystemExit, "Template update available"), + ): + template.main() + + def test_sync_cannot_write_through_a_symlink(self) -> None: + with tempfile.TemporaryDirectory() as work: + root = Path(work) / "repo" + root.mkdir() + (root / "package.json").write_text('{"name":"quickfixture","version":"1.0.0"}') + outside = Path(work) / "outside" + outside.write_text("private fixture") + (root / "alias").symlink_to(outside) + files = { + "alias": b"replacement", + "package.json": b'{"name":"template","version":"1.0.0"}', + "package-lock.json": b'{"name":"template","version":"1.0.0","packages":{"":{}}}', + } + with self.assertRaises(ValueError): + template.synchronize(root, "a" * 40, files) + self.assertEqual(outside.read_text(), "private fixture") + + def test_local_source_override_is_rejected_in_ci(self) -> None: + with tempfile.TemporaryDirectory() as work: + payload = Path(work) / "template" + payload.mkdir() + (payload / "justfile").write_text("local commands") + (payload / "__pycache__").mkdir() + (payload / "__pycache__/cache.pyc").write_bytes(b"generated") + with patch.dict(os.environ, {"QUICK_TEMPLATE_SOURCE": work, "CI": ""}): + self.assertEqual(template.source_files("a" * 40), {"justfile": b"local commands"}) + with patch.dict(os.environ, {"CI": "true"}), self.assertRaises(ValueError): + template.source_files("a" * 40) + + def test_archive_rejects_paths_that_escape_the_payload_and_incomplete_content(self) -> None: + sha = "a" * 40 + for names in [["../outside"], ["justfile"]]: + data = io.BytesIO() + with zipfile.ZipFile(data, "w") as archive: + for name in names: + archive.writestr(f"quick-template-{sha}/template/{name}", b"fixture") + with ( + self.subTest(names=names), + tempfile.TemporaryDirectory() as work, + patch.dict( + os.environ, {"XDG_CACHE_HOME": work, "QUICK_TEMPLATE_SOURCE": "", "CI": "true"} + ), + patch.object(template, "download", return_value=data.getvalue()), + self.assertRaises(ValueError), + ): + template.source_files(sha) + def test_corrupt_archive_cache_is_recovered_and_reused(self) -> None: sha = "a" * 40 buffer = io.BytesIO() @@ -265,7 +386,91 @@ def test_missing_metrics_and_stale_analysis_fail(self) -> None: gate.validate(self.measures(), "previous", "current") +class TransportTests(unittest.TestCase): + def test_https_clients_fail_closed_and_close_connections(self) -> None: + def fetch(client): + if client is gate: + return gate.request("fixture", {"project": "fixture with space"}) + return template.download("codeload.github.com", "/fixture") + + credential = hashlib.sha256(b"nonfunctional HTTP credential fixture").hexdigest() + for module, limit in [(gate, 4 * 1024 * 1024), (template, 8 * 1024 * 1024)]: + for status, data, error in [ + (200, b'{"fixture":true}', None), + (403, b"denied", RuntimeError), + (200, b"x" * (limit + 1), ValueError), + ]: + connection = Mock() + response = connection.getresponse.return_value + response.status = status + response.read.return_value = data + with ( + self.subTest( + client=module.__name__, status=status, oversized=len(data) > limit + ), + patch.dict(os.environ, {"SONAR_TOKEN": credential}), + patch.object(module.http.client, "HTTPSConnection", return_value=connection), + ): + if error: + with self.assertRaises(error): + fetch(module) + elif module is gate: + self.assertEqual(fetch(module), {"fixture": True}) + path = connection.request.call_args.args[1] + self.assertIn("project=fixture+with+space", path) + self.assertNotIn(credential, path) + else: + self.assertEqual(fetch(module), data) + connection.close.assert_called_once() + with patch.dict(os.environ, {"SONAR_TOKEN": ""}), self.assertRaises(ValueError): + gate.request("fixture", {}) + with self.assertRaises(ValueError): + template.download("untrusted.example.test", "/fixture") + + class PackagingTests(unittest.TestCase): + def test_packages_are_deterministic_and_detect_content_or_inventory_drift(self) -> None: + with tempfile.TemporaryDirectory() as work: + root = Path(work) + files = { + "metadata.json": json.dumps({"uuid": "fixture@example.test"}).encode(), + "extension.js": b"runtime extension", + "prefs.js": b"runtime preferences", + "LICENSE": b"fixture license", + "modules/model.js": b"runtime model", + } + for name, content in files.items(): + path = root / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(content) + (root / ".env").write_text("private fixture") + (root / "quick-project.json").write_text(json.dumps({"runtimeFiles": list(files)})) + + def pack(command: list[str], **kwargs) -> None: + if command[0] == "/usr/bin/gnome-extensions": + destination = kwargs["cwd"] / "packed" / bundle.artifact_name(root) + with zipfile.ZipFile(destination, "w") as archive: + for name, content in files.items(): + archive.writestr(name, content) + + with patch.object(bundle.subprocess, "run", side_effect=pack): + artifact = bundle.build(root) + first = artifact.read_bytes() + self.assertEqual(bundle.build(root).read_bytes(), first) + with zipfile.ZipFile(artifact) as archive: + self.assertEqual(set(archive.namelist()), set(files)) + self.assertEqual(archive.read("modules/model.js"), files["modules/model.js"]) + with patch("sys.stdout", new=io.StringIO()): + bundle.check(root) + for name in ["modules/model.js", "unexpected.js"]: + with zipfile.ZipFile(artifact, "w") as archive: + for path, content in files.items(): + archive.writestr(path, b"changed" if path == name else content) + if name not in files: + archive.writestr(name, b"unexpected") + with self.subTest(name=name), self.assertRaises(ValueError): + bundle.check(root) + def test_unsafe_runtime_paths_are_rejected_before_packaging(self) -> None: with tempfile.TemporaryDirectory() as work: root = Path(work) @@ -283,6 +488,44 @@ def test_invalid_uuid_cannot_choose_an_output_path(self) -> None: class DocumentationTests(unittest.TestCase): + def test_generation_preserves_project_content_and_detects_stale_docs(self) -> None: + with tempfile.TemporaryDirectory() as work: + root = Path(work) + (root / "docs").mkdir() + modules = ROOT / "node_modules" + if not modules.exists(): + modules = ROOT.parent / "node_modules" + (root / "node_modules").symlink_to(modules, target_is_directory=True) + metadata = {"uuid": "fixture@example.test", "shell-version": ["49", "50"]} + project = {"repository": "quickspot", "requirements": "Fixture ."} + (root / "metadata.json").write_text(json.dumps(metadata)) + (root / "docs/project.json").write_text(json.dumps(project)) + regions = "\n".join( + f"" + for name in [*docs.SECTIONS, "badges"] + ) + (root / "README.md").write_text("# Project prose\n" + regions) + (root / "docs/index.html").write_text( + "
Project prose\n" + regions + "
" + ) + with patch.object(docs, "ROOT", root), patch("sys.stdout", new=io.StringIO()): + with patch.object(sys, "argv", ["docs.py"]): + docs.main() + readme = (root / "README.md").read_text() + page = (root / "docs/index.html").read_text() + self.assertIn("# Project prose", readme) + self.assertIn("Fixture <dependency>.", page) + self.assertIn("fixture@example.test", page) + self.assertIn("systemctl --user disable --now quickspot-soloist.service", readme) + self.assertIn("Security issues", readme) + with patch.object(sys, "argv", ["docs.py", "--check"]): + docs.main() + (root / "README.md").write_text( + readme.replace("just test-docs", "stale command") + ) + with self.assertRaisesRegex(SystemExit, "Stale generated docs"): + docs.main() + def test_install_restart_precedes_enable(self) -> None: sections = docs.blocks( {"uuid": "fixture@example.test", "shell-version": ["50"]}, @@ -308,5 +551,30 @@ def test_missing_or_duplicate_generation_markers_fail(self) -> None: docs.replace_block(region * 2, "install", "generated") +class CleanupTests(unittest.TestCase): + def test_cleanup_preserves_source_and_external_symlink_targets(self) -> None: + with tempfile.TemporaryDirectory() as work: + root = Path(work) / "repo" + root.mkdir() + outside = Path(work) / "outside" + outside.mkdir() + (outside / "private.txt").write_text("private fixture") + (root / "coverage").symlink_to(outside, target_is_directory=True) + (root / "test-results").mkdir() + (root / "test-results/output.json").write_text("generated") + (root / "schemas").mkdir() + (root / "schemas/gschemas.compiled").write_bytes(b"generated") + (root / "fixture.shell-extension.zip").write_bytes(b"generated") + (root / "source.js").write_text("source") + clean.main(root) + clean.main(root) + self.assertEqual((outside / "private.txt").read_text(), "private fixture") + self.assertEqual((root / "source.js").read_text(), "source") + self.assertFalse((root / "coverage").is_symlink()) + self.assertFalse((root / "test-results").exists()) + self.assertFalse((root / "schemas/gschemas.compiled").exists()) + self.assertEqual(list(root.glob("*.shell-extension.zip")), []) + + if __name__ == "__main__": unittest.main()