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..080f610 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 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 + - 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..27e8b3e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,84 +1,75 @@ name: Release - on: - push: - tags: ['v*'] - + push: + tags: ['v*'] permissions: - contents: read - + contents: read +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 + permissions: + contents: read + actions: read # Inspect workflow runs and download their tested artifacts. + 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..7c0ec87 100644 --- a/.github/workflows/sonar.yml +++ b/.github/workflows/sonar.yml @@ -1,53 +1,56 @@ 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 + - 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: + 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: | + revision="$(git rev-parse HEAD)" + project="Ghost-Assembly_$(jq -r .name package.json)" + 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/.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..e6c5ec4 100644 --- a/.gitleaks.toml +++ b/.gitleaks.toml @@ -1,16 +1,12 @@ -# 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*=.*'''] +[[rules]] +id = "generic-api-key" -[[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$'''] +[[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/.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..3234766 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 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 | +| `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") + + +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() 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') },