diff --git a/.github/workflows/aur.yml b/.github/workflows/aur.yml new file mode 100644 index 0000000..8d8d899 --- /dev/null +++ b/.github/workflows/aur.yml @@ -0,0 +1,102 @@ +name: AUR Checks + +on: + pull_request: + paths: + - ".github/workflows/aur.yml" + - "packaging/aur/**" + - "scripts/render-aur-pkgbuild.sh" + - "scripts/verify-aur-source-package.sh" + - "scripts/verify-aur-git-package.sh" + - "scripts/ci-impact.rb" + - "scripts/test-ci-impact.rb" + - "scripts/test-ci-workflow-paths.rb" + - "!**/*.md" + push: + branches: [master, main] + paths: + - ".github/workflows/aur.yml" + - "packaging/aur/**" + - "scripts/render-aur-pkgbuild.sh" + - "scripts/verify-aur-source-package.sh" + - "scripts/verify-aur-git-package.sh" + - "scripts/ci-impact.rb" + - "scripts/test-ci-impact.rb" + - "scripts/test-ci-workflow-paths.rb" + - "apps/desktop/**" + - "packages/**" + - "packaging/common/**" + - "scripts/package-release.sh" + - "scripts/restore-background.mjs" + - "scripts/detect-audio-backends.mjs" + - "package.json" + - "pnpm-lock.yaml" + - "pnpm-workspace.yaml" + - "tsconfig.base.json" + - ".npmrc" + - "!**/*.md" + workflow_dispatch: + +permissions: + contents: read + +jobs: + changes: + name: Check affected inputs + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + run: ${{ steps.scope.outputs.run }} + steps: + - uses: actions/checkout@v7.0.0 + with: + fetch-depth: 0 + persist-credentials: false + - id: scope + name: Check paths and dependency impact + run: ruby scripts/ci-impact.rb application .github/workflows/aur.yml + + validate-aur-source: + name: Build source AUR packages on Arch Linux + needs: changes + if: needs.changes.outputs.run == 'true' + concurrency: + group: aur-checks-${{ github.ref }} + cancel-in-progress: true + runs-on: ubuntu-latest + timeout-minutes: 90 + steps: + - name: Checkout + uses: actions/checkout@v7.0.0 + with: + fetch-depth: 0 + + - name: Build and inspect source package + env: + LOOPWIRE_DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + run: | + set -euo pipefail + release_tag="$(git describe --tags --abbrev=0 --match 'v[0-9]*')" + export LOOPWIRE_AUR_VERSION="${release_tag#v}" + export LOOPWIRE_AUR_GIT_VERSION="$( + git describe --long --tags --abbrev=7 "origin/$LOOPWIRE_DEFAULT_BRANCH" | + sed 's/^v//;s/\([^-]*-g\)/r\1/;s/-/./g' + )" + docker run --rm \ + --env LOOPWIRE_AUR_VERSION \ + --env LOOPWIRE_AUR_GIT_VERSION \ + --env LOOPWIRE_DEFAULT_BRANCH \ + --volume "$GITHUB_WORKSPACE:/workspace:ro" \ + archlinux@sha256:84cd9ef000b3cff245ec028e87965b84724f4bf1cc63fc2741ba927b88515ed6 \ + bash -ceu ' + pacman -Syu --noconfirm --needed \ + curl git namcap nodejs pipewire pnpm rust webkit2gtk-4.1 wireplumber + useradd --create-home aur-builder + runuser -u aur-builder -- env HOME=/home/aur-builder \ + bash /workspace/scripts/verify-aur-source-package.sh \ + --version "$LOOPWIRE_AUR_VERSION" + runuser -u aur-builder -- env HOME=/home/aur-builder \ + bash /workspace/scripts/verify-aur-git-package.sh \ + --version "$LOOPWIRE_AUR_GIT_VERSION" \ + --default-branch "$LOOPWIRE_DEFAULT_BRANCH" + ' diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 67deeba..ed5efb2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,21 +2,86 @@ name: CI on: pull_request: + paths: + - ".github/workflows/ci.yml" + - "apps/desktop/**" + - "packages/**" + - "packaging/**" + - "scripts/**" + - "vm/**" + - "flake.nix" + - "flake.lock" + - ".cargo/**" + - "rust-toolchain*" + - "package.json" + - "pnpm-lock.yaml" + - "pnpm-workspace.yaml" + - "tsconfig.base.json" + - ".npmrc" + - "!**/*.md" + - "!scripts/*docs*" + - "!scripts/build-static-site.mjs" + - "!scripts/verify-static-site.mjs" + - "!scripts/e2e-site-install.mjs" + - "!scripts/verify-github-workflows.sh" + - "!scripts/verify-requirements.sh" + - "!scripts/setup-github-actions.mjs" + - "!scripts/test-setup-github-actions.mjs" push: - branches: - - master - - main + branches: [master, main] + paths: + - ".github/workflows/ci.yml" + - "apps/desktop/**" + - "packages/**" + - "packaging/**" + - "scripts/**" + - "vm/**" + - "flake.nix" + - "flake.lock" + - ".cargo/**" + - "rust-toolchain*" + - "package.json" + - "pnpm-lock.yaml" + - "pnpm-workspace.yaml" + - "tsconfig.base.json" + - ".npmrc" + - "!**/*.md" + - "!scripts/*docs*" + - "!scripts/build-static-site.mjs" + - "!scripts/verify-static-site.mjs" + - "!scripts/e2e-site-install.mjs" + - "!scripts/verify-github-workflows.sh" + - "!scripts/verify-requirements.sh" + - "!scripts/setup-github-actions.mjs" + - "!scripts/test-setup-github-actions.mjs" + workflow_dispatch: permissions: contents: read -concurrency: - group: ci-${{ github.ref }} - cancel-in-progress: true - jobs: + changes: + name: Check affected inputs + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + run: ${{ steps.scope.outputs.run }} + steps: + - uses: actions/checkout@v7.0.0 + with: + fetch-depth: 0 + persist-credentials: false + - id: scope + name: Check paths and dependency impact + run: ruby scripts/ci-impact.rb application .github/workflows/ci.yml + validate: name: Validate workspace + needs: changes + if: needs.changes.outputs.run == 'true' + concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true runs-on: ubuntu-latest timeout-minutes: 30 steps: @@ -59,43 +124,3 @@ jobs: - name: Run workspace checks run: pnpm check - - validate-aur-source: - name: Build source AUR packages on Arch Linux - runs-on: ubuntu-latest - timeout-minutes: 90 - steps: - - name: Checkout - uses: actions/checkout@v7.0.0 - with: - fetch-depth: 0 - - - name: Build and inspect source package - env: - LOOPWIRE_DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} - run: | - set -euo pipefail - release_tag="$(git describe --tags --abbrev=0 --match 'v[0-9]*')" - export LOOPWIRE_AUR_VERSION="${release_tag#v}" - export LOOPWIRE_AUR_GIT_VERSION="$( - git describe --long --tags --abbrev=7 "origin/$LOOPWIRE_DEFAULT_BRANCH" | - sed 's/^v//;s/\([^-]*-g\)/r\1/;s/-/./g' - )" - docker run --rm \ - --env LOOPWIRE_AUR_VERSION \ - --env LOOPWIRE_AUR_GIT_VERSION \ - --env LOOPWIRE_DEFAULT_BRANCH \ - --volume "$GITHUB_WORKSPACE:/workspace:ro" \ - archlinux@sha256:84cd9ef000b3cff245ec028e87965b84724f4bf1cc63fc2741ba927b88515ed6 \ - bash -ceu ' - pacman -Syu --noconfirm --needed \ - curl git namcap nodejs pipewire pnpm rust webkit2gtk-4.1 wireplumber - useradd --create-home aur-builder - runuser -u aur-builder -- env HOME=/home/aur-builder \ - bash /workspace/scripts/verify-aur-source-package.sh \ - --version "$LOOPWIRE_AUR_VERSION" - runuser -u aur-builder -- env HOME=/home/aur-builder \ - bash /workspace/scripts/verify-aur-git-package.sh \ - --version "$LOOPWIRE_AUR_GIT_VERSION" \ - --default-branch "$LOOPWIRE_DEFAULT_BRANCH" - ' diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml index cf6dc7c..49119ce 100644 --- a/.github/workflows/deploy-docs.yml +++ b/.github/workflows/deploy-docs.yml @@ -8,6 +8,26 @@ on: - main tags: - "v*" + paths: + - ".github/workflows/deploy-docs.yml" + - "apps/site/**" + - "apps/docs/**" + - "assets/product-screenshot.png" + - "package.json" + - "pnpm-lock.yaml" + - "pnpm-workspace.yaml" + - "tsconfig.base.json" + - ".npmrc" + - "scripts/install.sh" + - "scripts/build-static-site.mjs" + - "scripts/verify-static-site.mjs" + - "scripts/verify-docs.sh" + - "scripts/verify-support-matrix.mjs" + - "scripts/verify-vm-evidence.sh" + - "vm/targets.tsv" + - "scripts/deploy-docs-bunny.sh" + - "scripts/verify-docs-deployment-manifest.mjs" + - "scripts/verify-docs-live.sh" permissions: contents: read @@ -60,7 +80,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 20 environment: docs-production - if: > + if: >- ${{ github.event_name == 'workflow_dispatch' || github.ref == 'refs/heads/main' || diff --git a/.github/workflows/web.yml b/.github/workflows/web.yml new file mode 100644 index 0000000..69684d9 --- /dev/null +++ b/.github/workflows/web.yml @@ -0,0 +1,100 @@ +name: Web and Docs + +on: + pull_request: + paths: + - ".github/workflows/web.yml" + - "apps/site/**" + - "apps/docs/**" + - "assets/product-screenshot.png" + - "README.md" + - "packaging/README.md" + - "package.json" + - "pnpm-lock.yaml" + - "pnpm-workspace.yaml" + - "tsconfig.base.json" + - ".npmrc" + - "scripts/ci-impact.rb" + - "scripts/test-ci-impact.rb" + - "scripts/test-ci-workflow-paths.rb" + - "scripts/install.sh" + - "scripts/build-static-site.mjs" + - "scripts/verify-static-site.mjs" + - "scripts/e2e-site-install.mjs" + - "scripts/verify-docs.sh" + - "scripts/verify-support-matrix.mjs" + - "scripts/verify-vm-evidence.sh" + - "vm/targets.tsv" + push: + branches: [master, main] + paths: + - ".github/workflows/web.yml" + - "apps/site/**" + - "apps/docs/**" + - "assets/product-screenshot.png" + - "README.md" + - "packaging/README.md" + - "package.json" + - "pnpm-lock.yaml" + - "pnpm-workspace.yaml" + - "tsconfig.base.json" + - ".npmrc" + - "scripts/ci-impact.rb" + - "scripts/test-ci-impact.rb" + - "scripts/test-ci-workflow-paths.rb" + - "scripts/install.sh" + - "scripts/build-static-site.mjs" + - "scripts/verify-static-site.mjs" + - "scripts/e2e-site-install.mjs" + - "scripts/verify-docs.sh" + - "scripts/verify-support-matrix.mjs" + - "scripts/verify-vm-evidence.sh" + - "vm/targets.tsv" + workflow_dispatch: + +permissions: + contents: read + +jobs: + changes: + name: Check affected inputs + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + run: ${{ steps.scope.outputs.run }} + steps: + - uses: actions/checkout@v7.0.0 + with: + fetch-depth: 0 + persist-credentials: false + - id: scope + name: Check paths and dependency impact + run: ruby scripts/ci-impact.rb web .github/workflows/web.yml + + validate-web: + name: Validate website and docs + needs: changes + if: needs.changes.outputs.run == 'true' + concurrency: + group: web-${{ github.ref }} + cancel-in-progress: true + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v7.0.0 + - uses: pnpm/action-setup@v6.0.9 + with: + version: 11.3.0 + - uses: actions/setup-node@v6.4.0 + with: + node-version: 22.23.1 + cache: pnpm + - run: pnpm install --frozen-lockfile + - name: Check website and docs types + run: pnpm --filter @loopwire/site --filter @loopwire/docs typecheck + - name: Check documentation contracts + run: pnpm verify:docs && pnpm verify:requirements + - name: Test documentation + run: pnpm --filter @loopwire/docs test + - name: Build and verify the static site + run: pnpm build:web && pnpm verify:site diff --git a/.github/workflows/workflow-checks.yml b/.github/workflows/workflow-checks.yml new file mode 100644 index 0000000..f013a00 --- /dev/null +++ b/.github/workflows/workflow-checks.yml @@ -0,0 +1,60 @@ +name: Workflow Contracts + +on: + pull_request: + paths: + - ".github/workflows/**" + - "scripts/ci-impact.rb" + - "scripts/test-ci-impact.rb" + - "scripts/test-ci-workflow-paths.rb" + - "scripts/*native-package-proof-snapshot.mjs" + - "scripts/verify-github-workflows.sh" + - "scripts/verify-requirements.sh" + - "scripts/*docs*" + - "scripts/setup-github-actions.mjs" + - "scripts/test-setup-github-actions.mjs" + - ".planning/REQUIREMENTS.md" + push: + branches: [master, main] + paths: + - ".github/workflows/**" + - "scripts/ci-impact.rb" + - "scripts/test-ci-impact.rb" + - "scripts/test-ci-workflow-paths.rb" + - "scripts/*native-package-proof-snapshot.mjs" + - "scripts/verify-github-workflows.sh" + - "scripts/verify-requirements.sh" + - "scripts/*docs*" + - "scripts/setup-github-actions.mjs" + - "scripts/test-setup-github-actions.mjs" + - ".planning/REQUIREMENTS.md" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: workflow-contracts-${{ github.ref }} + cancel-in-progress: true + +jobs: + verify-contracts: + name: Validate workflow and automation contracts + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v7.0.0 + with: + fetch-depth: 0 + - uses: pnpm/action-setup@v6.0.9 + with: + version: 11.3.0 + - uses: actions/setup-node@v6.4.0 + with: + node-version: 22.23.1 + cache: pnpm + - run: pnpm install --frozen-lockfile + - name: Check workflow routing and contracts + run: pnpm verify:workflows && pnpm verify:requirements + - name: Test automation with isolated fixtures + run: pnpm test:setup-github && pnpm verify:scripts diff --git a/.planning/STATE.md b/.planning/STATE.md index be9f4e4..b822c41 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -3,7 +3,7 @@ gsd_state_version: 1.0 milestone: v0.5 milestone_name: GitHub Operator Setup status: Ready for Review -last_updated: "2026-09-05T10:59:12.430Z" +last_updated: "2026-09-05T11:55:11.036Z" last_activity: 2026-09-05 progress: total_phases: 1 @@ -27,7 +27,7 @@ See: .planning/PROJECT.md (updated 2026-07-03) Phase: 19 of 19 complete Plan: 19.1 — Hardened GitHub Actions Setup Status: Ready for review in PR #9 -Last activity: 2026-09-05 - completed quick task 260905-hia: minimal landing identity and GSAP reactions, PR #40 +Last activity: 2026-09-05 - completed quick task 260905-i4l: CI input scopes and full release-validation evidence ## Blockers / Concerns @@ -92,6 +92,7 @@ Last activity: 2026-09-05 - completed quick task 260905-hia: minimal landing ide | 260902-tfw | Fail docs deployment when Bunny configuration is absent | 2026-09-02 | 2d1e697 | [260902-tfw-make-docs-deployment-fail-when-bunny-con](./quick/260902-tfw-make-docs-deployment-fail-when-bunny-con/) | | 260905-fld | Default platform installer and homepage tabs; native install/reinstall proof | 2026-09-05 | c415386 | [260905-fld-homepage-platform-installer](./quick/260905-fld-homepage-platform-installer/) | | 260905-hia | Minimal landing identity, GSAP reactions and screenshot proofs in PR #40 | 2026-09-05 | 6e84a9a | [260905-hia-landing-identity](./quick/260905-hia-landing-identity/) | +| 260905-i4l | Scope CI to affected files while retaining release validation | 2026-09-05 | f9bc0d8 | [260905-i4l-scope-ci](./quick/260905-i4l-scope-ci/) | ## Accumulated Context diff --git a/.planning/quick/260905-i4l-scope-ci/260905-i4l-PLAN.md b/.planning/quick/260905-i4l-scope-ci/260905-i4l-PLAN.md new file mode 100644 index 0000000..565082d --- /dev/null +++ b/.planning/quick/260905-i4l-scope-ci/260905-i4l-PLAN.md @@ -0,0 +1,49 @@ +--- +status: complete +--- + +# Run validation only for affected surfaces + +Issue: https://github.com/sandwichfarm/loopwire/issues/41 + +## Plan and boundaries + +1. Preserve the full local `pnpm check` command. Scope its native CI workflow to application/runtime/build/packaging + inputs and remove web/docs-only inputs from its triggers. +2. Move source AUR verification into its own filtered workflow. On PRs it consumes local AUR templates/tools and + remote tag/default-branch code; on default-branch pushes include inputs to the rolling package build. +3. Add lightweight web/docs validation and workflow-contract validation with their own paths. Limit docs deployment + to its actual build/deploy inputs. Preserve manual/scheduled/release-tag behavior and VM metadata checks. +4. Use an existing-tooling Ruby helper to read each workflow's own path policy and compare Git event ranges. When + the only matching file is pnpm-lock.yaml, compare the reachable importer/package/snapshot projection for that + surface. Unknown formats/missing history run conservatively. No duplicated path-routing configuration or new action. +5. Add regression tests before implementation, including real #40 changes, shared vs web-only lock changes, app and + AUR inputs, docs/README/planning exclusions, rename/deletion, merge-base and initial-push behavior. Verify YAML/job + wiring, syntax and applicable repository gates; document scope/coverage and open a PR. + +## Verification + +- Source-preservation: existing native/AUR commands, permissions, release and schedule entrypoints remain intact. +- Positive/negative routing table: app source, recipes, scripts, site, docs, README, planning/proofs, workflow edits, + package manifests and transitive dependencies select only the intended lanes. +- Shared lock: web-only GSAP changes from #40 skip application/AUR work; app/shared transitive changes run relevant checks. +- Full Git diff semantics: three-dot PRs, before/after pushes, new branches, renames/deletions, and conservative fallbacks. +- Workflow contract verifier, Ruby tests/syntax, actionlint if available, docs validation and lint/typecheck/tests. +- Inspect the opened PR and Actions job selection when available; do not claim skipped local tests ran remotely. + +## Constraints + +No branch protection/ruleset changes (live master is unprotected and rulesets are empty). Path-filtered workflows +must not be made individually required later without an always-reporting aggregate. GitHub path-diff limits still +apply. No release publication, deployment, host audio, packaging recipe or application changes. + +## Follow-up: native proof freshness + +The full workspace job in run 33964723801 fails because the native proof snapshot compares the entire shared +pnpm lockfile with its tested commit. The only critical-path difference is the website-only GSAP addition already +present on master. Reuse the application lock projection for this one comparison, retaining all snapshot, ancestry, +and non-lock input checks. Any changed application/root dependency or uncertain comparison must reject the proof. + +Add an explicit strict comparison mode to the existing helper, exercise the actual snapshot verifier with temporary +Git fixtures, rerun the failed gate and full workspace checks, then push the correction to PR #42 and inspect CI. +Do not modify the recorded VM evidence or describe dependency equivalence as a new VM execution. diff --git a/.planning/quick/260905-i4l-scope-ci/260905-i4l-SUMMARY.md b/.planning/quick/260905-i4l-scope-ci/260905-i4l-SUMMARY.md new file mode 100644 index 0000000..a61dae8 --- /dev/null +++ b/.planning/quick/260905-i4l-scope-ci/260905-i4l-SUMMARY.md @@ -0,0 +1,94 @@ +--- +status: complete +--- + +# CI follows the files it validates + +Issue: [#41](https://github.com/sandwichfarm/loopwire/issues/41) +Implementation: `f9bc0d8` on `ci/41-scope-workflows`. + +## Result + +Application/native CI, AUR checks, web/docs validation and workflow contracts now have separate path policies. +Website and README PRs avoid native builds. Planning notes and screenshot proofs select no automatic workflow; +requirements metadata remains an explicit contract input. The existing VM policy is retained. + +The impact helper reads each workflow's own paths and uses Git event ranges. For a shared-lockfile-only match it +compares reachable application/web importer, snapshot and package metadata. Web-only GSAP changes skip native +validation; shared and transitive changes select affected checks. Uncertain schemas/metadata/history run +conservatively. Ruby/YAML was already required by the repository; there are no new project dependencies/actions. + +Docs deployment has web/build/deploy path filters and retains manual/tag runs, its production environment and +serialized concurrency. Its shared-lockfile trigger intentionally remains conservative because deployment installs +the workspace. Validation concurrency occurs after impact selection, avoiding cancellation by irrelevant lockfile +checks. Workflow-contract runs cancel superseded runs of their own lane. + +The release audit now requires an actually successful workspace job and check step. A successful scope-only workflow +cannot count as full validation; absent/skipped/failed evidence prints a manual CI dispatch command. Legacy successful +CI remains accepted. Release and AUR publication workflows retain their independent validation gates. + +## Changed files and simplifications + +- `.github/workflows/ci.yml`: scoped triggers and guarded native validation; original validation steps retained. +- `.github/workflows/aur.yml`, `web.yml`, `workflow-checks.yml`: focused validation surfaces using existing actions. +- `.github/workflows/deploy-docs.yml`: scoped push inputs; corrected the folded condition's trailing newline. +- `scripts/ci-impact.rb`: Git/path/dependency impact decisions, with conservative fallback. +- `scripts/test-ci-impact.rb`, `test-ci-workflow-paths.rb`, `verify-github-workflows.sh`: routing and wiring regression gates. +- `scripts/verify-docs.sh`, `verify-packaging.sh`: three packaging README prose checks moved into docs validation. +- `scripts/audit-final-release-state.sh`, `verify-scripts.sh`: full-CI evidence guard and release-audit regressions. +- Developer CI/architecture/release documentation, unreleased notes and these GSD records: ownership and operator guidance. + +No application, audio/backend, package recipe, release publication, branch protection or dependency changes. +The full local `pnpm check` entrypoint remains unchanged. + +## Evidence + +- 38 temporary-Git/lockfile scenarios pass, including peers/aliases, optional/transitive dependencies, moved base + branches, initial pushes, renames/deletions, malformed input and unknown-schema fallback. +- 80 committed workflow path/event cases pass, plus job-output, concurrency and operator/deployment wiring checks. +- Actual PR #40 replay through the new policy: application `run=false`, AUR `run=false`, web `run=true`. +- Original native and AUR validation steps are equal as parsed YAML before/after the workflow split. +- 10 release-audit modes pass. The scope-only case failed before the fix because the old audit accepted it. +- The new job-evidence validator accepted real legacy metadata from CI run + [33961080924](https://github.com/sandwichfarm/loopwire/actions/runs/33961080924). +- `pnpm verify:workflows`, `pnpm verify:requirements`, `pnpm verify:docs`, `pnpm verify:scripts`, and the 11 GitHub + setup transport tests pass. Actionlint 1.7.12 validates all workflow files; Ruby/Bash syntax and whitespace checks pass. +- `pnpm lint`/typechecks, all 295 workspace tests, exact web-lane type/test commands, production web/docs builds and + static-site verification pass. +- Review found the missing web requirements check; a failing policy regression was added before fixing the web lane. + Targeted re-review approved the fix. The deployment condition now uses `>-` and has a whitespace regression guard. + +## Limits and operator notes + +At initial delivery, native/AUR builds were not rerun locally because their execution steps were unchanged. GitHub event routing is +covered by actual-revision replay and fixture tests; hosted runs will exercise the published workflow definitions. +This CI-definition change itself legitimately selects native/AUR checks. No release, deployment or merge was performed. +GitHub path-diff limits still apply, and globally required path-filtered checks need an appropriate aggregate policy. + +## Native proof freshness correction + +The full workspace job in [run 33964723801](https://github.com/sandwichfarm/loopwire/actions/runs/33964723801) +exposed a native snapshot freshness failure inherited from master. Comparing the verifier's critical inputs with +the recorded VM-tested commit `70eee4ec433bb7d967931357cf77bd0c28056a35` found only nine added lockfile lines for +the website's GSAP dependency. The application projection was identical; the web projection differed. + +The snapshot verifier now delegates only the lockfile comparison to the existing application dependency projection. +The helper's explicit `--verify-lockfile` mode returns 0 for equal inputs, 1 for changed inputs and 2 for uncertainty, +and never writes GitHub workflow outputs. Every other critical-path comparison, snapshot check and ancestry check +is retained. The original proof files and tested commit are unchanged; this does not represent a new VM execution. +Standalone snapshot verification now explicitly requires the existing Ruby/YAML tooling. + +Validation of the correction: + +- The production snapshot check reproduced the original failure, then verified all four targets after the fix. +- The new 27-case suite runs the real verifier against isolated Git fixtures. The committed old verifier failed its + GSAP case; the repaired verifier passes. Native/root/shared/transitive dependencies, global settings, unknown or + malformed locks, missing history, source changes and corrupted/unrelated evidence remain rejected. +- Strict CLI cases verify exit codes and that absent/prefilled `GITHUB_OUTPUT` files remain untouched. +- Existing 38 impact scenarios and the expanded 84 workflow path/event cases pass. The native-proof test and verifier + are explicit Workflow Contracts inputs, and the suite runs under `pnpm verify:workflows`. +- `pnpm verify:packaging` passes, including native fixture packages and the previously failing snapshot check. +- Node/Ruby syntax, actionlint and whitespace checks pass. A separate review found no path that accepts uncertainty. + +The PR records the subsequent full workspace and hosted validation results. No application, recipe or dependency +versions changed, and no stored VM evidence was regenerated or relabeled. diff --git a/apps/docs/docs/developer/architecture.md b/apps/docs/docs/developer/architecture.md index 96732cd..b0ebdad 100644 --- a/apps/docs/docs/developer/architecture.md +++ b/apps/docs/docs/developer/architecture.md @@ -118,3 +118,5 @@ Current host mutation primitive: `pactl unload-module`, `pactl list short sinks`, `pactl list sink-inputs`, `pactl move-sink-input`, `pactl set-sink-input-volume`, `pactl set-sink-input-mute`, and `pactl load-module module-loopback` through an injected command runner. + +See [CI by affected files](./ci.md) for workflow ownership, shared-lockfile handling and validation commands. diff --git a/apps/docs/docs/developer/ci.md b/apps/docs/docs/developer/ci.md new file mode 100644 index 0000000..9dd292a --- /dev/null +++ b/apps/docs/docs/developer/ci.md @@ -0,0 +1,105 @@ +# CI by affected files + +Automatic validation is split by the inputs it checks. A website or documentation PR no longer starts Tauri checks +or Arch package builds. The full local `pnpm check` command remains available and unchanged. + +## Workflow ownership + +| Workflow | Automatic inputs | Work performed | +| --- | --- | --- | +| CI | Desktop/core/audio source, runtime and packaging scripts/data, app build configuration | Existing full workspace, native and packaging checks | +| AUR Checks | On PRs: AUR recipes, license, renderer/verifiers and their workflow. On default-branch pushes: also rolling-package application/build inputs | Existing stable and rolling source-package builds | +| Web and Docs | Site/docs sources, product screenshot, README files, web scripts, relevant dependency/config inputs | Web/docs types, documentation requirements/contracts, docs tests and production static build | +| Workflow Contracts | Workflow definitions, routing helpers/tests, automation/requirements verifiers and docs/GitHub setup automation | Workflow policy, requirements, GitHub setup tests and isolated automation fixtures | +| VM Matrix | Existing VM metadata/scripts and support-matrix documentation | VM metadata and handoff checks | +| Deploy Docs | Default-branch changes to website/docs build or deployment inputs | Existing protected static-site build and Bunny deployment | + +The exact policies live beside the `pull_request` and `push` triggers in each workflow. There is no second list of +path ownership in the selector. Root README and packaging README edits select web validation; packaging README prose +assertions now belong to the docs verifier. Ordinary planning notes, screenshot proofs, agent instructions and +browser scratch files select no automatic workflow. `.planning/REQUIREMENTS.md` is an explicit contract input. + +AUR verification on a PR uses the current recipes/tools but downloads tagged/default-branch application source. +It does not compile PR application edits, so those edits alone do not select AUR on PRs. After merging application +changes, default-branch push validation also exercises the rolling package. Application edits still receive the +native workspace checks on the PR itself. + +## Shared dependencies + +A shared `pnpm-lock.yaml` edit can start a short impact-check job. Before the application, AUR or web validation +job starts, `scripts/ci-impact.rb` reads that workflow's own event paths. If the lockfile is the only matching file, +it compares the relevant pnpm v9 dependency graph between the two revisions: + +- Application: root tools, the desktop importer and packages workspaces, including workspace links. +- Web: root tools, site and docs importers, including their workspace links. +- Each projection includes reachable snapshots, peer-qualified/aliased versions, optional dependencies, package + resolution metadata and shared lock settings. + +A web-only GSAP change therefore skips native work. An application dependency selects application checks; a shared +TypeScript/Vitest or transitive dependency selects every affected surface. Formatting or unreachable lock entries +do not force a rebuild. Unknown schemas, unsupported references/metadata, missing Git history or an unreadable +lockfile run validation conservatively and print a warning. + +The parser uses Ruby's existing YAML support and standard library, with no new action or package dependency. +Current workflow patterns use literal paths, `*`, `**` and ordered `!` exclusions. More elaborate patterns need a +matching helper/test update; unsupported syntax conservatively runs validation instead of silently skipping it. + +Native package proof freshness uses the same application projection when comparing `pnpm-lock.yaml` with the +recorded VM-tested commit. The snapshot verifier still checks every recorded result, commit ancestry and all other +package/proof inputs exactly. Website-only dependencies can change without invalidating the recorded native proof; +root tools, application dependencies, shared/transitive metadata and global lock settings must remain equivalent. +Unsupported lock data, missing history or a missing Ruby runtime reject verification. This comparison requires Ruby +and its standard YAML library for standalone snapshot verification as well as for CI; it does not rerun the VMs or +change the recorded tested commit. The helper's strict `--verify-lockfile` mode returns a failure on uncertainty and +never writes workflow outputs. + +Deploy Docs retains a conservative shared-lockfile trigger because both deployment jobs install the workspace. +Its existing workflow-wide cancellation/production serialization is preserved. Workflows with an impact selector +apply concurrency only to selected validation jobs, so an unrelated lockfile check cannot cancel an active relevant build. + +## Events and required checks + +PR comparisons use the merge base with the PR head, matching GitHub's cumulative PR semantics. Default-branch +pushes compare the supplied before/after commits. Renames are treated as removal plus addition, and initial pushes +inspect all current files. Adding a docs-only commit to a PR that already changes application code still validates +the whole PR; this is not a last-commit-only shortcut. + +Manual runs remain explicit overrides. Scheduled diagnostics and release/operator workflows retain their existing +entrypoints. Release-tag deployments still run: GitHub does not apply path filters to tag pushes. + +Release auditing still requires an executed full workspace check, not merely a successful impact-check workflow. +It inspects the successful CI run's `Validate workspace` job and `Run workspace checks` step. A skipped, missing or +failed job/step is rejected. If a future release commit was filtered out, deliberately run CI at that release ref: + +```sh +gh workflow run ci.yml --repo sandwichfarm/loopwire --ref vX.Y.Z +``` + +Replace `vX.Y.Z` with the release tag, then wait for completion before repeating the release audit. Existing successful legacy CI runs still qualify. +The Release workflow continues to run the full `pnpm check` independently; path filtering does not bypass it. + +No branch protection or rulesets were changed. The live repository had neither configured when this change was +prepared. If protection is added, do not require every path-filtered workflow unconditionally: GitHub can leave +an absent required check pending. Use an appropriate always-reporting aggregate policy if a universal required +check is desired. See [GitHub's path-filter and diff documentation](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#onpushpull_requestpull_request_targetpathspaths-ignore) +for skipped-check behavior and hosted diff-size limits. + +## Verification + +`pnpm verify:workflows` runs the YAML/workflow contracts, routing suites and native-proof freshness regressions: + +```sh +ruby scripts/test-ci-impact.rb +ruby scripts/test-ci-workflow-paths.rb +node scripts/test-native-package-proof-snapshot.mjs +``` + +The first uses temporary Git repositories and real event-shaped payloads to exercise dependency changes, moving +base branches, initial pushes, deletions/renames and conservative fallbacks. The second checks the committed YAML's +path/event routing, helper outputs, job guards, concurrency, retained operator entrypoints and web-only setup. +The native-proof suite uses isolated Git repositories and copied evidence to verify that website-only lock changes +remain acceptable while relevant dependencies, critical source changes, malformed input and invalid evidence fail. + +The actual changes from merged PR #40 were replayed through the selector: native application work was false, +AUR work was false, and web validation was true. This CI-scoping PR itself changes native/AUR workflow definitions, +so those validations are expected to run for its review. diff --git a/apps/docs/docs/developer/release.md b/apps/docs/docs/developer/release.md index f6ac2fc..1a2e3aa 100644 --- a/apps/docs/docs/developer/release.md +++ b/apps/docs/docs/developer/release.md @@ -898,3 +898,11 @@ bash scripts/setup-github-secrets.sh \ --remote-prefix loopwire \ --dry-run ``` + +## Scoped CI evidence + +[CI path selection](./ci.md) can omit native checks for documentation or website changes. The release audit therefore +checks that the commit-scoped CI run actually completed `Validate workspace` and its `Run workspace checks` step; +a successful impact-check-only run is insufficient. Follow the audit's manual `ci.yml` dispatch command at the +release tag when a full run is missing, wait for completion, and repeat the audit. Legacy successful full CI runs +remain valid. Release builds themselves still run the full workspace checks regardless of ordinary path filters. diff --git a/apps/docs/docs/release-notes/unreleased.md b/apps/docs/docs/release-notes/unreleased.md index 3e1a9c2..de2643e 100644 --- a/apps/docs/docs/release-notes/unreleased.md +++ b/apps/docs/docs/release-notes/unreleased.md @@ -7,6 +7,18 @@ These notes describe source-tree progress. They are not a public release announc - The signed installer is now live at `https://loopwire.app/install.sh`; AppImage, deb, and RPM artifacts are available from the `v0.1.0` GitHub Release, with native files documented as direct downloads rather than distro repositories. +## CI selection + +- Automatic checks now follow application, AUR, web/docs and workflow-contract path scopes. Site, documentation + and README changes avoid unrelated native builds; a dependency-aware lockfile check handles shared pnpm inputs. +- Docs deployment now has explicit web/build/deploy path filters while retaining manual and release-tag runs. + Scheduled diagnostics and operator release workflows retain their entrypoints. +- Release auditing now checks the actual successful workspace job and check step, rejecting scope-only or skipped CI + success and explaining how to request a full run at the release ref. +- Packaging README assertions moved to lightweight docs validation. The full local `pnpm check` command is unchanged. +- Native proof freshness now compares application lockfile inputs, so website-only dependencies do not invalidate + recorded package evidence. Relevant or unverifiable dependency changes still fail the check. + ## Landing page - The root page now uses a flat Sora wordmark and signal-path identity, with the product screenshot integrated into diff --git a/scripts/audit-final-release-state.sh b/scripts/audit-final-release-state.sh index 84c21f8..5241ed5 100755 --- a/scripts/audit-final-release-state.sh +++ b/scripts/audit-final-release-state.sh @@ -625,12 +625,56 @@ check_published_release_evidence_archive() { --require-no-release-blockers } +check_ci_workspace_validation() { + local run_id + local jobs + run_id="$(workflow_run_id_from_json "$1")" + if [[ ! "$run_id" =~ ^[1-9][0-9]*$ ]]; then + echo "CI run is missing a valid databaseId for workspace validation lookup" >&2 + return 1 + fi + if ! jobs="$(gh run view "$run_id" --repo "$repo" --json jobs 2>&1)"; then + echo "Could not inspect CI workspace validation jobs:" >&2 + printf '%s\n' "$jobs" | indent >&2 + return 1 + fi + + node - "$jobs" <<'NODE' +const fail = (message) => { + console.error(message); + process.exit(1); +}; +let parsed; +try { + parsed = JSON.parse(process.argv[2]); +} catch (error) { + fail(`CI jobs lookup did not return JSON: ${error.message}`); +} +const job = Array.isArray(parsed?.jobs) + ? parsed.jobs.find((entry) => entry?.name === "Validate workspace") + : undefined; +if (!job) fail("CI run is missing the Validate workspace job."); +if (job.status !== "completed" || job.conclusion !== "success") { + fail(`CI Validate workspace job did not complete successfully: status=${job.status ?? "unknown"}, conclusion=${job.conclusion ?? "unknown"}.`); +} +const step = Array.isArray(job.steps) + ? job.steps.find((entry) => entry?.name === "Run workspace checks") + : undefined; +if (!step) fail("CI Validate workspace job is missing the Run workspace checks step."); +if (step.status !== "completed" || step.conclusion !== "success") { + fail(`CI Run workspace checks step did not complete successfully: status=${step.status ?? "unknown"}, conclusion=${step.conclusion ?? "unknown"}.`); +} +console.log("workspace validation job and checks step verified"); +NODE +} + run_workflow_probe() { local label="$1" local expected_head="$2" shift 2 local output local validation + local workspace_validation if [ "$skip_gh" = "true" ]; then echo "==> $label" @@ -717,6 +761,17 @@ NODE return 1 fi + if [ "$label" = "commit-scoped CI workflow run" ]; then + if ! workspace_validation="$(check_ci_workspace_validation "$output" 2>&1)"; then + echo "blocked: $label" >&2 + [ -z "$workspace_validation" ] || printf '%s\n' "$workspace_validation" | indent >&2 + printf 'next: dispatch full CI at the release ref: gh workflow run ci.yml --repo %q --ref %q\n' "$repo" "$tag" >&2 + echo >&2 + return 1 + fi + printf '%s\n' "$workspace_validation" | indent + fi + echo "ok: $label" if [ "$label" = "commit-scoped Deploy Docs workflow run" ] || [ "$label" = "Deploy Docs workflow run" ]; then latest_docs_deployment_run_id="$(workflow_run_id_from_json "$output")" diff --git a/scripts/ci-impact.rb b/scripts/ci-impact.rb new file mode 100644 index 0000000..b9961af --- /dev/null +++ b/scripts/ci-impact.rb @@ -0,0 +1,270 @@ +#!/usr/bin/env ruby +# Refine each workflow's own path filter when a shared pnpm lockfile is its only matching input. +require 'json' +require 'open3' +require 'pathname' +require 'yaml' + +class UncertainImpact < StandardError; end + +def mapping(value, label) + raise UncertainImpact, "#{label} must be a mapping" unless value.is_a?(Hash) + + value +end + +def supported_keys(value, keys, label) + unknown = mapping(value, label).keys - keys + raise UncertainImpact, "unsupported #{label} fields: #{unknown.inspect}" unless unknown.empty? +end + +def git_output(*args) + output, _error, status = Open3.capture3('git', *args) + raise UncertainImpact, "Git #{args.first} failed; required history may be missing" unless status.success? + + output +end + +def commit_sha(value) + unless value.is_a?(String) && value.match?(/\A(?:[a-f0-9]{40}|[a-f0-9]{64})\z/i) + raise UncertainImpact, 'event commit is not a full hexadecimal SHA' + end + git_output('rev-parse', '--verify', "#{value}^{commit}").strip +end + +def changed_files(event_name, event) + if event_name == 'pull_request' + pr = mapping(event.fetch('pull_request'), 'pull request event') + head = commit_sha(pr.fetch('head').fetch('sha')) + base = commit_sha(pr.fetch('base').fetch('sha')) + ancestors = git_output('merge-base', '--all', base, head).lines.map(&:strip) + raise UncertainImpact, 'pull request has no unique merge-base' unless ancestors.length == 1 + + before = commit_sha(ancestors.first) + else + head = commit_sha(event.fetch('after')) + before = event.fetch('before') + if before.is_a?(String) && before.match?(/\A(?:0{40}|0{64})\z/) + return [git_output('ls-tree', '-r', '--name-only', '-z', head).split("\0"), nil, head] + end + before = commit_sha(before) + end + files = git_output('diff', '--name-only', '--no-renames', '-z', before, head, '--').split("\0") + [files, before, head] +end + +def path_pattern(pattern) + # GitHub's ?, +, character classes and escapes have special semantics. Reject them rather than guess. + unless pattern.is_a?(String) && !pattern.empty? && !pattern.match?(/[?+\[\]{}\\]/) + raise UncertainImpact, "unsupported workflow path pattern: #{pattern.inspect}" + end + negative = pattern.start_with?('!') + pattern = pattern.delete_prefix('!') + raise UncertainImpact, 'empty or unsupported negated path pattern' if pattern.empty? || pattern.include?('!') + + expression = +'' + index = 0 + while index < pattern.length + if pattern[index, 3] == '**/' + expression << '(?:.*/)?' + index += 3 + elsif pattern[index, 2] == '**' + expression << '.*' + index += 2 + elsif pattern[index] == '*' + expression << '[^/]*' + index += 1 + else + expression << Regexp.escape(pattern[index]) + index += 1 + end + end + [negative, Regexp.new("\\A#{expression}\\z", Regexp::MULTILINE)] +end + +def workflow_patterns(path, event_name) + workflow = mapping(YAML.safe_load_file(path), 'workflow') + events = mapping(workflow['on'] || workflow[true], 'workflow events') + config = events.fetch(event_name) + return nil if config.nil? + + mapping(config, 'workflow event configuration') + raise UncertainImpact, 'paths-ignore filters are unsupported' if config.key?('paths-ignore') + return nil unless config.key?('paths') + + paths = config['paths'] + raise UncertainImpact, 'workflow paths must be a nonempty list' unless paths.is_a?(Array) && !paths.empty? + + patterns = paths.map { |pattern| path_pattern(pattern) } + raise UncertainImpact, 'workflow paths need a positive pattern' if patterns.all?(&:first) + + patterns +end + +def path_matches?(path, patterns) + patterns.reduce(false) { |matched, (negative, pattern)| pattern.match?(path) ? !negative : matched } +end + +class LockProjection + DEPENDENCIES = %w[dependencies devDependencies optionalDependencies].freeze + GLOBALS = %w[lockfileVersion settings overrides patchedDependencies packageExtensionsChecksum catalogs + ignoredOptionalDependencies pnpmfileChecksum].freeze + IMPORTER_FIELDS = (DEPENDENCIES + %w[dependenciesMeta publishDirectory]).freeze + SNAPSHOT_FIELDS = (DEPENDENCIES + %w[optional transitivePeerDependencies]).freeze + PACKAGE_FIELDS = %w[resolution engines cpu os libc hasBin peerDependencies peerDependenciesMeta dependenciesMeta + bundledDependencies deprecated optional requiresBuild].freeze + + def initialize(content, surface) + @lock = mapping(YAML.safe_load(content), 'lockfile') + supported_keys(@lock, GLOBALS + %w[importers packages snapshots], 'lockfile') + raise UncertainImpact, 'only pnpm lockfile version 9.0 is supported' unless @lock['lockfileVersion'].to_s == '9.0' + + @importers = mapping(@lock.fetch('importers'), 'lockfile importers') + @packages = mapping(@lock.fetch('packages'), 'lockfile packages') + @snapshots = mapping(@lock.fetch('snapshots'), 'lockfile snapshots') + @selected_importers = {} + @selected_packages = {} + @selected_snapshots = {} + @importer_queue = surface == 'web' ? ['.', 'apps/site', 'apps/docs'] : ['.', 'apps/desktop'] + if surface == 'application' + @importer_queue.concat(@importers.keys.select { |key| key.is_a?(String) && key.match?(%r{\Apackages/[^/]+\z}) }) + end + @snapshot_queue = [] + end + + def projection + until @importer_queue.empty? && @snapshot_queue.empty? + visit_importer(@importer_queue.shift) until @importer_queue.empty? + visit_snapshot(@snapshot_queue.shift) until @snapshot_queue.empty? + end + { + 'globals' => @lock.reject { |key, _| %w[importers packages snapshots].include?(key) }, + 'importers' => @selected_importers, 'packages' => @selected_packages, 'snapshots' => @selected_snapshots + } + end + + def visit_importer(name) + return if @selected_importers.key?(name) + + record = mapping(@importers.fetch(name), "importer #{name}") + supported_keys(record, IMPORTER_FIELDS, 'importer') + @selected_importers[name] = record + DEPENDENCIES.each do |kind| + mapping(record.fetch(kind, {}), kind).each do |dependency, details| + supported_keys(details, %w[specifier version], 'importer dependency') + unless details['specifier'].is_a?(String) && details['version'].is_a?(String) + raise UncertainImpact, 'importer dependency needs string specifier and version' + end + add_dependency(dependency, details.fetch('version'), importer: name) + end + end + end + + def visit_snapshot(key) + return if @selected_snapshots.key?(key) + + snapshot = mapping(@snapshots.fetch(key), "snapshot #{key}") + supported_keys(snapshot, SNAPSHOT_FIELDS, 'snapshot') + package_key = key.split('(', 2).first + metadata = mapping(@packages.fetch(package_key), "package #{package_key}") + supported_keys(metadata, PACKAGE_FIELDS, 'package metadata') + mapping(metadata.fetch('resolution'), 'package resolution') + @selected_snapshots[key] = snapshot + @selected_packages[package_key] = metadata + DEPENDENCIES.each do |kind| + mapping(snapshot.fetch(kind, {}), kind).each { |name, reference| add_dependency(name, reference) } + end + end + + def add_dependency(name, reference, importer: nil) + unless name.is_a?(String) && reference.is_a?(String) + raise UncertainImpact, 'dependency name and reference must be strings' + end + if reference.start_with?('link:') + raise UncertainImpact, 'snapshot workspace links are unsupported' unless importer + + relative = reference.delete_prefix('link:') + raise UncertainImpact, 'absolute or empty workspace link' if relative.empty? || Pathname.new(relative).absolute? + + target = Pathname.new(File.join(importer, relative)).cleanpath.to_s + raise UncertainImpact, 'workspace link leaves repository' if target == '..' || target.start_with?('../') + + @importer_queue << target + return + end + reference = reference.delete_prefix('npm:') + key = reference.match?(/\A\d/) ? "#{name}@#{reference}" : reference + # Exact snapshot lookup retains peer suffixes and npm aliases; local/tarball/Git refs fail closed. + unless key.match?(%r{\A(?:@[a-zA-Z0-9._-]+/)?[a-zA-Z0-9._-]+@\d+\.\d+\.\d+[-+a-zA-Z0-9.]*(?:\(\S+\))*\z}) + raise UncertainImpact, "unsupported dependency reference: #{reference.inspect}" + end + @snapshot_queue << key + end +end + +def lock_inputs_equal?(surface, before, after) + previous = LockProjection.new(git_output('show', "#{before}:pnpm-lock.yaml"), surface).projection + current = LockProjection.new(git_output('show', "#{after}:pnpm-lock.yaml"), surface).projection + previous == current +end + +def decide_impact(surface, workflow_path, env) + raise UncertainImpact, 'surface must be application or web' unless %w[application web].include?(surface) + + event_name = env.fetch('GITHUB_EVENT_NAME') + if %w[workflow_dispatch schedule].include?(event_name) || + (event_name == 'push' && env.fetch('GITHUB_REF', '').start_with?('refs/tags/')) + return [true, 'manual, scheduled, or tag execution is always enabled'] + end + raise UncertainImpact, "unsupported event: #{event_name}" unless %w[pull_request push].include?(event_name) + + patterns = workflow_patterns(workflow_path, event_name) + return [true, 'workflow event has no path filter'] unless patterns + + event = mapping(JSON.parse(File.read(env.fetch('GITHUB_EVENT_PATH'))), 'GitHub event') + files, before, after = changed_files(event_name, event) + matching = files.select { |path| path_matches?(path, patterns) } + return [false, 'no changed files match this workflow event'] if matching.empty? + return [true, 'changed files match this workflow beyond the shared lockfile'] if matching.any? { |path| path != 'pnpm-lock.yaml' } + + raise UncertainImpact, 'initial push has no previous lockfile' unless before + + if lock_inputs_equal?(surface, before, after) + [false, "shared lockfile changed outside the #{surface} dependency graph"] + else + [true, "shared lockfile changed the #{surface} dependency graph"] + end +end + +if $PROGRAM_NAME == __FILE__ + # Proof verification must reject uncertainty; workflow selection below instead runs extra checks. + if ARGV.first == '--verify-lockfile' + begin + unless ARGV.length == 4 && %w[application web].include?(ARGV[1]) + raise UncertainImpact, 'usage: ruby scripts/ci-impact.rb --verify-lockfile application|web ' + end + before, after = ARGV.drop(2).map { |value| commit_sha(value) } + unless lock_inputs_equal?(ARGV[1], before, after) + warn "ci-impact: #{ARGV[1]} lockfile inputs changed" + exit 1 + end + puts "ci-impact: #{ARGV[1]} lockfile inputs are unchanged" + exit 0 + rescue StandardError => error + warn "ci-impact: cannot verify lockfile inputs (#{error.message.gsub(/[\r\n]/, ' ')})" + exit 2 + end + end + + begin + raise UncertainImpact, 'usage: ruby scripts/ci-impact.rb application|web ' unless ARGV.length == 2 + + run, reason = decide_impact(ARGV[0], ARGV[1], ENV) + rescue StandardError => error + run = true + reason = "WARNING: impact is uncertain; running conservatively (#{error.message.gsub(/[\r\n]/, ' ')})" + end + puts "ci-impact: #{reason}" + puts "run=#{run}" + File.open(ENV['GITHUB_OUTPUT'], 'a') { |output| output.puts "run=#{run}" } if ENV['GITHUB_OUTPUT'] +end diff --git a/scripts/test-ci-impact.rb b/scripts/test-ci-impact.rb new file mode 100644 index 0000000..639a32c --- /dev/null +++ b/scripts/test-ci-impact.rb @@ -0,0 +1,389 @@ +#!/usr/bin/env ruby +# Standard-library-only integration tests: every decision runs the public CLI in a real Git repository. +require 'fileutils' +require 'json' +require 'open3' +require 'rbconfig' +require 'tmpdir' +require 'yaml' + +HELPER = File.expand_path('ci-impact.rb', __dir__) + +def git(repo, *args) + stdout, stderr, status = Open3.capture3('git', '-C', repo, *args) + raise "git #{args.first}: #{stderr}" unless status.success? + + stdout.strip +end + +def write_file(repo, name, content) + path = File.join(repo, name) + FileUtils.mkdir_p(File.dirname(path)) + File.write(path, content) +end + +def commit(repo) + git(repo, 'add', '--all') + git(repo, '-c', 'user.name=CI test', '-c', 'user.email=ci@example.invalid', '-c', 'commit.gpgsign=false', + 'commit', '--quiet', '-m', 'fixture') + git(repo, 'rev-parse', 'HEAD') +end + +def fixture_lock + importer = lambda do |dependencies| + { 'dependencies' => dependencies.transform_values { |version| { 'specifier' => version, 'version' => version } } } + end + { + 'lockfileVersion' => '9.0', + 'settings' => { 'autoInstallPeers' => true, 'excludeLinksFromLockfile' => false }, + 'importers' => { + '.' => { 'devDependencies' => { 'typescript' => { 'specifier' => '6.0.3', 'version' => '6.0.3' } } }, + 'apps/desktop' => importer.call('desktop' => '1.0.0(shared@1.0.0)', '@loopwire/core' => 'link:../../packages/core'), + 'apps/site' => importer.call('gsap' => '3.14.0', 'astro' => '7.0.0'), + 'apps/docs' => importer.call('vitepress' => '1.6.4'), + 'packages/core' => importer.call('core-only' => '1.0.0'), + 'packages/unlinked' => importer.call('unlinked-only' => '1.0.0') + }, + 'packages' => %w[typescript@6.0.3 desktop@1.0.0 gsap@3.14.0 astro@7.0.0 vitepress@1.6.4 shared@1.0.0 + app-transitive@1.0.0 web-transitive@1.0.0 core-only@1.0.0 unlinked-only@1.0.0].to_h do |key| + [key, { 'resolution' => { 'integrity' => "sha512-#{key}" } }] + end, + 'snapshots' => { + 'typescript@6.0.3' => {}, + 'desktop@1.0.0(shared@1.0.0)' => { 'dependencies' => { 'shared' => '1.0.0', 'app-transitive' => '1.0.0' } }, + 'gsap@3.14.0' => {}, + 'astro@7.0.0' => { 'dependencies' => { 'shared' => '1.0.0', 'web-transitive' => '1.0.0' } }, + 'vitepress@1.6.4' => { 'optionalDependencies' => { 'shared' => '1.0.0' } }, + 'shared@1.0.0' => {}, + 'app-transitive@1.0.0' => {}, + 'web-transitive@1.0.0' => {}, + 'core-only@1.0.0' => {}, + 'unlinked-only@1.0.0' => {} + } + } +end + +def with_repo(paths: ['apps/desktop/**', 'packages/**', 'pnpm-lock.yaml']) + Dir.mktmpdir('ci-impact-test-') do |repo| + git(repo, 'init', '--quiet') + # Keep on unquoted to exercise Ruby YAML's boolean-key interpretation. + workflow = "on:\n pull_request:\n paths:\n" + workflow += paths.map { |path| " - #{path.to_json}\n" }.join + workflow += " push:\n paths:\n" + workflow += paths.map { |path| " - #{path.to_json}\n" }.join + write_file(repo, '.github/workflows/test.yml', workflow) + write_file(repo, 'pnpm-lock.yaml', YAML.dump(fixture_lock)) + write_file(repo, 'README.md', 'base') + write_file(repo, 'apps/desktop/old.ts', 'base') + yield repo, commit(repo) + end +end + +def check(repo, expected, surface: 'application', event: 'push', before: nil, after: nil, ref: 'refs/heads/main', payload: nil) + payload ||= if event == 'pull_request' + { 'pull_request' => { 'base' => { 'sha' => before }, 'head' => { 'sha' => after } } } + else + { 'before' => before, 'after' => after } + end + event_file = File.join(repo, '.git', 'event.json') + output_file = File.join(repo, '.git', 'action-output') + File.write(event_file, JSON.generate(payload)) + File.write(output_file, '') + env = { 'GITHUB_EVENT_NAME' => event, 'GITHUB_EVENT_PATH' => event_file, 'GITHUB_REF' => ref, + 'GITHUB_OUTPUT' => output_file } + stdout, stderr, status = Open3.capture3(env, RbConfig.ruby, HELPER, surface, '.github/workflows/test.yml', chdir: repo) + raise "CLI failed: #{stdout} #{stderr}" unless status.success? + raise "Expected run=#{expected}, got: #{stdout} #{stderr}" unless stdout.lines.include?("run=#{expected}\n") + raise "Missing Actions output: #{File.read(output_file)}" unless File.read(output_file) == "run=#{expected}\n" + raise 'Missing readable decision reason' unless stdout.lines.length >= 2 + + stdout + stderr +end + +def mutate_lock(repo) + lock = YAML.safe_load_file(File.join(repo, 'pnpm-lock.yaml')) + yield lock + write_file(repo, 'pnpm-lock.yaml', YAML.dump(lock)) + commit(repo) +end + +def test(name) + yield + puts "PASS #{name}" +rescue StandardError => error + warn "FAIL #{name}: #{error.message}" + exit 1 +end + +test('web-only lock update skips application and selects web') do + with_repo do |repo, base| + head = mutate_lock(repo) do |lock| + lock['importers']['apps/site']['dependencies']['gsap'] = { 'specifier' => '3.15.0', 'version' => '3.15.0' } + lock['packages']['gsap@3.15.0'] = lock['packages'].delete('gsap@3.14.0') + lock['snapshots']['gsap@3.15.0'] = lock['snapshots'].delete('gsap@3.14.0') + end + check(repo, false, before: base, after: head) + check(repo, true, surface: 'web', before: base, after: head) + end +end + +{ + 'root tools' => ['typescript@6.0.3', true, true], + 'peer-qualified direct app package' => ['desktop@1.0.0', true, false], + 'app transitive integrity' => ['app-transitive@1.0.0', true, false], + 'shared transitive integrity' => ['shared@1.0.0', true, true], + 'web transitive integrity' => ['web-transitive@1.0.0', false, true], + 'workspace link dependency' => ['core-only@1.0.0', true, false], + 'unlinked packages root dependency' => ['unlinked-only@1.0.0', true, false] +}.each do |name, (package, application, web)| + test(name) do + with_repo do |repo, base| + head = mutate_lock(repo) { |lock| lock['packages'][package]['resolution']['integrity'] = 'sha512-changed' } + check(repo, application, before: base, after: head) + check(repo, web, surface: 'web', before: base, after: head) + end + end +end + +test('shared settings affect both surfaces') do + with_repo do |repo, base| + head = mutate_lock(repo) { |lock| lock['settings']['autoInstallPeers'] = false } + %w[application web].each { |surface| check(repo, true, surface: surface, before: base, after: head) } + end +end + +test('root direct dependency upgrades affect both surfaces') do + with_repo do |repo, base| + head = mutate_lock(repo) do |lock| + lock['importers']['.']['devDependencies']['typescript'] = { 'specifier' => '6.0.4', 'version' => '6.0.4' } + lock['packages']['typescript@6.0.4'] = lock['packages'].delete('typescript@6.0.3') + lock['snapshots']['typescript@6.0.4'] = lock['snapshots'].delete('typescript@6.0.3') + end + %w[application web].each { |surface| check(repo, true, surface: surface, before: base, after: head) } + end +end + +test('lockfile formatting and key order do not affect either projection') do + with_repo do |repo, base| + lock = YAML.safe_load_file(File.join(repo, 'pnpm-lock.yaml')) + lock['snapshots'] = lock['snapshots'].to_a.reverse.to_h + write_file(repo, 'pnpm-lock.yaml', "# regenerated\n#{YAML.dump(lock.to_a.reverse.to_h)}") + head = commit(repo) + %w[application web].each { |surface| check(repo, false, surface: surface, before: base, after: head) } + end +end + +test('selected importer metadata changes select its surface') do + with_repo do |repo, base| + head = mutate_lock(repo) { |lock| lock['importers']['apps/desktop']['dependencies']['desktop']['specifier'] = '^1.0.0' } + check(repo, true, before: base, after: head) + check(repo, false, surface: 'web', before: base, after: head) + end +end + +test('new transitive and optional dependencies are traversed') do + with_repo do |repo, base| + head = mutate_lock(repo) do |lock| + lock['snapshots']['desktop@1.0.0(shared@1.0.0)']['optionalDependencies'] = { 'web-transitive' => '1.0.0' } + end + check(repo, true, before: base, after: head) + end +end + +test('aliases resolve by their real snapshot key') do + with_repo do |repo, _base| + base = mutate_lock(repo) do |lock| + lock['importers']['apps/desktop']['dependencies']['alias'] = { 'specifier' => 'npm:shared@1.0.0', 'version' => 'shared@1.0.0' } + end + head = mutate_lock(repo) { |lock| lock['packages']['gsap@3.14.0']['resolution']['integrity'] = 'changed' } + check(repo, false, before: base, after: head) + end +end + +test('scoped names and nested peer suffixes retain exact snapshots') do + with_repo do |repo, _base| + base = mutate_lock(repo) do |lock| + version = '1.0.0(shared@1.0.0(@types/node@26.1.0))' + lock['importers']['apps/desktop']['dependencies']['desktop']['version'] = version + snapshot = lock['snapshots'].delete('desktop@1.0.0(shared@1.0.0)') + snapshot['dependencies']['shared'] = '1.0.0(@types/node@26.1.0)' + lock['snapshots']["desktop@#{version}"] = snapshot + lock['snapshots']['shared@1.0.0(@types/node@26.1.0)'] = { 'dependencies' => { '@types/node' => '26.1.0' } } + lock['snapshots']['@types/node@26.1.0'] = {} + lock['packages']['@types/node@26.1.0'] = { 'resolution' => { 'integrity' => 'sha512-node' } } + end + head = mutate_lock(repo) { |lock| lock['packages']['gsap@3.14.0']['resolution']['integrity'] = 'changed' } + check(repo, false, before: base, after: head) + node_head = mutate_lock(repo) { |lock| lock['packages']['@types/node@26.1.0']['resolution']['integrity'] = 'changed' } + check(repo, true, before: head, after: node_head) + end +end + +test('workspace links can bring an importer from outside the selected roots') do + with_repo do |repo, _base| + base = mutate_lock(repo) do |lock| + lock['importers']['apps/desktop']['dependencies']['site'] = { 'specifier' => 'workspace:*', 'version' => 'link:../site' } + end + head = mutate_lock(repo) { |lock| lock['packages']['gsap@3.14.0']['resolution']['integrity'] = 'changed' } + check(repo, true, before: base, after: head) + end +end + +{ + 'future lock schema' => ->(lock) { lock['lockfileVersion'] = '10.0' }, + 'missing reachable snapshot' => ->(lock) { lock['snapshots'].delete('shared@1.0.0') }, + 'missing package metadata' => ->(lock) { lock['packages'].delete('shared@1.0.0') }, + 'unknown snapshot metadata' => ->(lock) { lock['snapshots']['typescript@6.0.3']['futureDependencies'] = {} }, + 'unknown package metadata' => ->(lock) { lock['packages']['typescript@6.0.3']['futureDependencies'] = {} }, + 'unknown importer metadata' => ->(lock) { lock['importers']['.']['futureDependencies'] = {} }, + 'unknown dependency metadata' => ->(lock) { lock['importers']['.']['devDependencies']['typescript']['future'] = 'x' }, + 'unsupported dependency ref' => ->(lock) { lock['importers']['.']['devDependencies']['typescript']['version'] = 'file:../tool' }, + 'missing required importer' => ->(lock) { lock['importers'].delete('.') } +}.each do |name, mutation| + test("conservative fallback: #{name}") do + with_repo do |repo, base| + head = mutate_lock(repo, &mutation) + output = check(repo, true, before: base, after: head) + raise 'Missing conservative warning' unless output.include?('WARNING') + end + end +end + +test('irrelevant changes and no changes skip') do + with_repo do |repo, base| + write_file(repo, 'README.md', 'updated') + head = commit(repo) + check(repo, false, before: base, after: head) + check(repo, false, before: head, after: head) + end +end + +test('ordered path exclusions, reinclusion and zero-directory globstar') do + paths = ['**', '!**/*.md', '!apps/site/**', 'apps/site/keep.md'] + with_repo(paths: paths) do |repo, base| + write_file(repo, 'README.md', 'changed') + head = commit(repo) + check(repo, false, before: base, after: head) + write_file(repo, 'apps/site/nested/new.ts', 'changed') + next_head = commit(repo) + check(repo, false, before: head, after: next_head) + write_file(repo, 'apps/site/keep.md', 'changed') + check(repo, true, before: next_head, after: commit(repo)) + end +end + +test('single star does not cross directories') do + with_repo(paths: ['apps/*/package.json']) do |repo, base| + write_file(repo, 'apps/desktop/nested/package.json', '{}') + head = commit(repo) + check(repo, false, before: base, after: head) + write_file(repo, 'apps/site/package.json', '{}') + check(repo, true, before: head, after: commit(repo)) + end +end + +test('irrelevant files alongside an unrelated lock change still skip') do + with_repo do |repo, base| + write_file(repo, 'README.md', 'updated') + head = mutate_lock(repo) { |lock| lock['packages']['gsap@3.14.0']['resolution']['integrity'] = 'changed' } + check(repo, false, before: base, after: head) + write_file(repo, 'apps/desktop/new.ts', 'changed') + check(repo, true, before: base, after: commit(repo)) + end +end + +test('PR uses merge-base rather than changes on a moving base branch') do + with_repo do |repo, base| + write_file(repo, 'apps/desktop/base-only.ts', 'base branch work') + moving_base = commit(repo) + git(repo, 'checkout', '--quiet', '--detach', base) + write_file(repo, 'README.md', 'PR documentation') + head = commit(repo) + check(repo, false, event: 'pull_request', before: moving_base, after: head) + write_file(repo, 'apps/desktop/pr.ts', 'PR app work') + check(repo, true, event: 'pull_request', before: moving_base, after: commit(repo)) + end +end + +test('push uses the supplied before and after, not checkout HEAD') do + with_repo do |repo, base| + write_file(repo, 'apps/desktop/new.ts', 'app work') + app_head = commit(repo) + write_file(repo, 'README.md', 'docs work') + docs_head = commit(repo) + check(repo, true, before: base, after: app_head) + check(repo, false, before: app_head, after: docs_head) + end +end + +test('initial pushes inspect all files at the event commit') do + with_repo do |repo, base| + check(repo, true, before: '0' * 40, after: base) + end + with_repo(paths: ['not-present/**']) do |repo, base| + check(repo, false, before: '0' * 40, after: base) + end +end + +test('rename out of scope, deletion and newline filenames remain visible') do + with_repo do |repo, base| + FileUtils.mv(File.join(repo, 'apps/desktop/old.ts'), File.join(repo, 'removed-from-scope.ts')) + renamed = commit(repo) + check(repo, true, before: base, after: renamed) + write_file(repo, "apps/desktop/new\nfile.ts", 'newline') + added = commit(repo) + check(repo, true, before: renamed, after: added) + File.delete(File.join(repo, "apps/desktop/new\nfile.ts")) + check(repo, true, before: added, after: commit(repo)) + end +end + +test('missing Git history and invalid SHAs run conservatively') do + with_repo do |repo, base| + ['f' * 40, '--help', 'HEAD', "#{base}\n"].each do |before| + output = check(repo, true, before: before, after: base) + raise 'Missing warning' unless output.include?('WARNING') + end + end +end + +test('missing and unsafe lockfiles run conservatively') do + with_repo do |repo, base| + File.delete(File.join(repo, 'pnpm-lock.yaml')) + check(repo, true, before: base, after: commit(repo)) + write_file(repo, 'pnpm-lock.yaml', '--- !ruby/object:Object {}') + check(repo, true, before: base, after: commit(repo)) + end +end + +test('manual, scheduled, and tag events run without diff metadata') do + with_repo do |repo, _base| + %w[workflow_dispatch schedule].each { |event| check(repo, true, event: event, payload: {}) } + check(repo, true, event: 'push', ref: 'refs/tags/v1.0.0', payload: {}) + check(repo, true, event: 'unknown', payload: {}) + end +end + +test('unsupported workflow patterns run conservatively') do + ['apps/[ab]/**', 'apps/file?.ts', 'apps/{site,docs}/**'].each do |pattern| + with_repo(paths: [pattern]) do |repo, base| + write_file(repo, 'README.md', 'changed') + output = check(repo, true, before: base, after: commit(repo)) + raise 'Missing warning' unless output.include?('WARNING') + end + end +end + +test('each event reads its own workflow path policy') do + with_repo do |repo, base| + workflow = YAML.safe_load_file(File.join(repo, '.github/workflows/test.yml')) + workflow.fetch(true).fetch('push')['paths'] = ['README.md'] + write_file(repo, '.github/workflows/test.yml', YAML.dump(workflow)) + write_file(repo, 'README.md', 'changed') + head = commit(repo) + check(repo, true, before: base, after: head) + check(repo, false, event: 'pull_request', before: base, after: head) + end +end + +puts 'ci-impact: all integration tests passed' diff --git a/scripts/test-ci-workflow-paths.rb b/scripts/test-ci-workflow-paths.rb new file mode 100644 index 0000000..7b73470 --- /dev/null +++ b/scripts/test-ci-workflow-paths.rb @@ -0,0 +1,115 @@ +#!/usr/bin/env ruby +# Exercise committed workflow policies, independently of the lockfile/Git fixture tests. +require_relative 'ci-impact' + +ROOT = File.expand_path('..', __dir__) +WORKFLOWS = { + 'app' => 'ci.yml', 'aur' => 'aur.yml', 'web' => 'web.yml', 'contracts' => 'workflow-checks.yml', + 'vm' => 'vm-matrix.yml', 'deploy' => 'deploy-docs.yml' +}.freeze + +parsed = WORKFLOWS.transform_values { |file| YAML.safe_load_file(File.join(ROOT, '.github/workflows', file)) } + +def check(condition, message) + raise message unless condition +end + +def selected_paths(path, event, parsed) + WORKFLOWS.filter_map do |name, file| + events = parsed[name]['on'] || parsed[name][true] + next unless events.key?(event) + + patterns = workflow_patterns(File.join(ROOT, '.github/workflows', file), event) + check(patterns, "#{file} #{event} must have a path filter") + name if path_matches?(path, patterns) + end.sort +end + +cases = { + 'apps/site/src/pages/index.astro' => [%w[web], %w[deploy web]], + 'apps/site/package.json' => [%w[web], %w[deploy web]], + 'apps/docs/docs/guide/install.md' => [%w[web], %w[deploy web]], + 'README.md' => [%w[web], %w[web]], + 'packaging/README.md' => [%w[web], %w[web]], + '.planning/STATE.md' => [[], []], + '.planning/quick/example/proofs/desktop.png' => [[], []], + '.planning/REQUIREMENTS.md' => [%w[contracts], %w[contracts]], + '.playwright-mcp/screenshot.yml' => [[], []], + 'AGENTS.md' => [[], []], + 'assets/product-screenshot.png' => [%w[web], %w[deploy web]], + 'apps/desktop/src/App.svelte' => [%w[app], %w[app aur]], + 'apps/desktop/src-tauri/Cargo.lock' => [%w[app], %w[app aur]], + 'packages/core/src/runtime.ts' => [%w[app], %w[app aur]], + 'packaging/aur/loopwire/PKGBUILD.in' => [%w[app aur], %w[app aur]], + 'packaging/aur/loopwire-git/PKGBUILD.in' => [%w[app aur], %w[app aur]], + 'packaging/aur/LICENSE-MIT' => [%w[app aur], %w[app aur]], + 'packaging/common/loopwire.desktop' => [%w[app], %w[app aur]], + 'scripts/render-aur-pkgbuild.sh' => [%w[app aur], %w[app aur]], + 'scripts/package-release.sh' => [%w[app], %w[app aur]], + 'scripts/install.sh' => [%w[app web], %w[app deploy web]], + 'scripts/deploy-docs-bunny.sh' => [%w[contracts], %w[contracts deploy]], + 'scripts/verify-docs.sh' => [%w[contracts web], %w[contracts deploy web]], + 'scripts/verify-github-workflows.sh' => [%w[contracts], %w[contracts]], + 'scripts/setup-github-actions.mjs' => [%w[contracts], %w[contracts]], + 'scripts/test-ci-workflow-paths.rb' => [%w[app aur contracts web], %w[app aur contracts web]], + 'scripts/verify-native-package-proof-snapshot.mjs' => [%w[app contracts], %w[app contracts]], + 'scripts/test-native-package-proof-snapshot.mjs' => [%w[app contracts], %w[app contracts]], + 'vm/targets.tsv' => [%w[app vm web], %w[app deploy vm web]], + 'apps/docs/docs/guide/support-matrix.md' => [%w[vm web], %w[deploy vm web]], + 'apps/docs/docs/developer/vm-matrix.md' => [%w[vm web], %w[deploy vm web]], + 'package.json' => [%w[app web], %w[app aur deploy web]], + # Lockfile entries are candidates here; the separate Git tests prove dependency-sensitive job skipping. + 'pnpm-lock.yaml' => [%w[app web], %w[app aur deploy web]], + 'pnpm-workspace.yaml' => [%w[app web], %w[app aur deploy web]], + 'tsconfig.base.json' => [%w[app web], %w[app aur deploy web]], + '.npmrc' => [%w[app web], %w[app aur deploy web]], + '.github/workflows/ci.yml' => [%w[app contracts], %w[app contracts]], + '.github/workflows/aur.yml' => [%w[aur contracts], %w[aur contracts]], + '.github/workflows/web.yml' => [%w[contracts web], %w[contracts web]], + '.github/workflows/deploy-docs.yml' => [%w[contracts], %w[contracts deploy]], + '.github/workflows/vm-matrix.yml' => [%w[contracts vm], %w[contracts vm]], + '.github/workflows/release.yml' => [%w[contracts], %w[contracts]] +} +cases.each do |path, expected| + %w[pull_request push].each_with_index do |event, index| + actual = selected_paths(path, event, parsed) + check(actual == expected[index].sort, "#{event} #{path}: expected #{expected[index].sort}, got #{actual}") + end +end + +{ 'app' => ['validate', 'application'], 'aur' => ['validate-aur-source', 'application'], 'web' => ['validate-web', 'web'] }.each do |name, info| + job_id, surface = info + workflow = parsed.fetch(name) + jobs = workflow.fetch('jobs') + check(!workflow.key?('concurrency'), "#{name}: an irrelevant lockfile check must not cancel an active relevant validation") + changes = jobs.fetch('changes') + check(changes.dig('outputs', 'run') == '${{ steps.scope.outputs.run }}', "#{name}: missing scope output") + checkout = changes.fetch('steps').find { |step| step.fetch('uses', '').start_with?('actions/checkout@') } + check(checkout.dig('with', 'fetch-depth') == 0, "#{name}: impact calculation needs full history") + scope = changes.fetch('steps').find { |step| step['id'] == 'scope' } + expected_command = "ruby scripts/ci-impact.rb #{surface} .github/workflows/#{WORKFLOWS[name]}" + check(scope['run'] == expected_command, "#{name}: helper must read this workflow's own policy") + job = jobs.fetch(job_id) + check(job['needs'] == 'changes' && job['if'] == "needs.changes.outputs.run == 'true'", "#{name}: job bypasses scope output") + check(job.dig('concurrency', 'cancel-in-progress') == true, "#{name}: preserve cancellation for actual validation jobs") + check(workflow.dig('permissions', 'contents') == 'read', "#{name}: CI must remain read-only") +end + +check(parsed['web']['jobs']['validate-web'].to_s.include?('pnpm build:web'), 'web: missing production web build') +check(parsed['web']['jobs']['validate-web'].to_s.include?('pnpm verify:requirements'), 'web: missing website requirements checks') +check(!parsed['web'].to_s.match?(/apt-get|pacman|tauri:build/), 'web: unexpected native build setup') +check(!parsed['contracts'].to_s.match?(/apt-get|pacman|tauri:build/), 'contracts: unexpected native build setup') +check(parsed['contracts'].dig('concurrency', 'cancel-in-progress') == true, 'contracts: cancel superseded validation runs') +deploy_events = parsed['deploy']['on'] || parsed['deploy'][true] +check(deploy_events.key?('workflow_dispatch') && deploy_events.dig('push', 'tags') == ['v*'], 'retain manual/release deployment') +check(parsed['deploy'].dig('concurrency', 'group') == 'deploy-docs', 'preserve serialized production deployment') +check(parsed['deploy'].dig('jobs', 'deploy-bunny', 'environment') == 'docs-production', 'preserve deployment environment') +condition = parsed['deploy'].dig('jobs', 'deploy-bunny', 'if') +check(condition == condition.strip, 'deployment condition must not become an always-true string with trailing whitespace') +%w[release final-release-proof publish-aur continuous-tests].each do |name| + workflow = YAML.safe_load_file(File.join(ROOT, '.github/workflows', "#{name}.yml")) + events = workflow['on'] || workflow[true] + check(!events.key?('pull_request'), "#{name}: deliberate operator workflows must not gain PR triggers") + check(events.key?('workflow_dispatch'), "#{name}: retain explicit operator entrypoint") +end +puts "CI workflow policy passed: #{cases.length * 2} path/event cases and selection/deployment wiring." diff --git a/scripts/test-native-package-proof-snapshot.mjs b/scripts/test-native-package-proof-snapshot.mjs new file mode 100644 index 0000000..41ad49a --- /dev/null +++ b/scripts/test-native-package-proof-snapshot.mjs @@ -0,0 +1,214 @@ +#!/usr/bin/env node +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const repository = dirname(dirname(fileURLToPath(import.meta.url))); +const verifier = join(repository, "scripts/verify-native-package-proof-snapshot.mjs"); +const lockHelper = join(repository, "scripts/ci-impact.rb"); +const snapshot = "vm/native-package-proof"; +const manifest = "packaging/vm/native-package-targets.tsv"; +const targets = readdirSync(join(repository, snapshot)).filter((name) => name !== "README.md"); +const recordedCommit = readFileSync(join(repository, snapshot, targets[0], "git-head.txt"), "utf8").trim(); +const testedLock = command("git", ["show", `${recordedCommit}:pnpm-lock.yaml`], repository); +const currentLock = readFileSync(join(repository, "pnpm-lock.yaml"), "utf8"); +const fixture = mkdtempSync(join(tmpdir(), "loopwire-native-proof-test-")); +let passed = 0; + +try { + git("init", "--quiet"); + git("config", "user.name", "Native proof test"); + git("config", "user.email", "native-proof@example.invalid"); + git("config", "commit.gpgsign", "false"); + write("pnpm-lock.yaml", testedLock); + write("apps/desktop/src/proof-fixture.ts", "export const value = 1;\n"); + mkdirSync(join(fixture, dirname(manifest)), { recursive: true }); + cpSync(join(repository, manifest), join(fixture, manifest)); + const testedCommit = commit(); + cpSync(join(repository, snapshot), join(fixture, snapshot), { recursive: true }); + setEvidenceCommit(testedCommit); + const baseline = commit(); + + function test(name, action) { + git("reset", "--hard", baseline); + git("clean", "-fd"); + action(); + passed += 1; + console.log(`PASS ${name}`); + } + + test("unchanged native proof passes", () => verify(true)); + test("real website GSAP lockfile addition preserves native proof", () => { + write("pnpm-lock.yaml", currentLock); + commit(); + verify(true); + }); + test("web-only package integrity changes preserve native proof", () => { + write("pnpm-lock.yaml", currentLock); + mutateLock("lock.fetch('packages').fetch('gsap@3.15.0').fetch('resolution')['integrity'] = 'sha512-web-only'"); + verify(true); + }); + + for (const [name, packageKey] of [ + ["root TypeScript integrity", "typescript@6.0.3"], + ["native direct dependency integrity", "@tauri-apps/api@2.11.1"], + ["shared build dependency integrity", "vite@8.1.3"], + ["native transitive dependency integrity", "@jridgewell/sourcemap-codec@1.5.5"] + ]) { + test(`${name} invalidates native proof`, () => { + mutateLock(`lock.fetch('packages').fetch(${JSON.stringify(packageKey)}).fetch('resolution')['integrity'] = 'sha512-changed'`); + verify(false); + }); + } + + for (const [name, mutation] of [ + ["native importer dependency", "lock.fetch('importers').fetch('apps/desktop').fetch('dependencies').delete('svelte')"], + ["native transitive dependency", "lock.fetch('snapshots').fetch('svelte@5.56.4').fetch('dependencies').delete('acorn')"], + ["global installation settings", "lock.fetch('settings')['autoInstallPeers'] = false"], + ["unknown lockfile schema", "lock['lockfileVersion'] = '10.0'"], + ["unknown reachable dependency metadata", "lock.fetch('packages').fetch('typescript@6.0.3')['futureDependencies'] = {}"], + ["missing reachable package", "lock.fetch('packages').delete('typescript@6.0.3')"] + ]) { + test(`${name} invalidates native proof`, () => { + mutateLock(mutation); + verify(false); + }); + } + + test("malformed lockfile invalidates native proof", () => { + write("pnpm-lock.yaml", "lockfileVersion: [\n"); + commit(); + verify(false); + }); + test("missing current lockfile invalidates native proof", () => { + rmSync(join(fixture, "pnpm-lock.yaml")); + commit(); + verify(false); + }); + test("missing historical lockfile invalidates native proof", () => { + rmSync(join(fixture, "pnpm-lock.yaml")); + const missingLockCommit = commit(); + setEvidenceCommit(missingLockCommit); + write("pnpm-lock.yaml", testedLock); + commit(); + verify(false); + }); + test("changed native source still invalidates native proof", () => { + write("apps/desktop/src/proof-fixture.ts", "export const value = 2;\n"); + commit(); + verify(false, /package or proof-critical inputs changed/); + }); + test("corrupt snapshot still fails before freshness checks", () => { + const file = `${snapshot}/${targets[0]}/summary.tsv`; + write(file, readFileSync(join(fixture, file), "utf8").replace("gui_launch\tpass", "gui_launch\tfail")); + commit(); + verify(false, /gui_launch must be pass/); + }); + test("missing tested commit still invalidates native proof", () => { + setEvidenceCommit("f".repeat(40)); + commit(); + verify(false, /tested commit is not an ancestor/); + }); + test("existing but unrelated tested commit still invalidates native proof", () => { + git("checkout", "--quiet", "--orphan", "unrelated"); + commit(); + verify(false, /tested commit is not an ancestor/); + }); + + test("proof CLI returns 0 for equal application dependencies without Actions output", () => { + write("pnpm-lock.yaml", currentLock); + verifyLock(["application", testedCommit, commit()], 0); + }); + test("proof CLI returns 1 for changed application dependencies without Actions output", () => { + mutateLock("lock.fetch('packages').fetch('typescript@6.0.3').fetch('resolution')['integrity'] = 'sha512-changed'"); + verifyLock(["application", testedCommit, git("rev-parse", "HEAD")], 1); + }); + test("proof CLI returns 2 for malformed lockfiles without Actions output", () => { + write("pnpm-lock.yaml", "lockfileVersion: [\n"); + verifyLock(["application", testedCommit, commit()], 2); + }); + test("proof CLI returns 2 for missing before revisions without Actions output", () => { + verifyLock(["application", "f".repeat(40), baseline], 2); + }); + test("proof CLI returns 2 for missing after revisions without Actions output", () => { + verifyLock(["application", testedCommit, "f".repeat(40)], 2); + }); + test("proof CLI returns 2 for wrong argument counts without Actions output", () => { + verifyLock(["application", testedCommit], 2); + verifyLock(["application", testedCommit, baseline, "extra"], 2); + }); + test("proof CLI returns 2 for unsupported surfaces without Actions output", () => { + verifyLock(["unsupported", testedCommit, baseline], 2); + }); + + console.log(`Native package proof snapshot regression tests passed (${passed} cases).`); +} finally { + rmSync(fixture, { recursive: true, force: true }); +} + +function command(executable, args, cwd = fixture) { + const result = spawnSync(executable, args, { cwd, encoding: "utf8" }); + assert.equal(result.status, 0, `${executable} ${args.join(" ")}: ${result.error ?? ""}${result.stderr}`); + return result.stdout; +} + +function git(...args) { + return command("git", args).trim(); +} + +function write(file, content) { + mkdirSync(join(fixture, dirname(file)), { recursive: true }); + writeFileSync(join(fixture, file), content); +} + +function commit() { + git("add", "--all"); + git("commit", "--quiet", "--allow-empty", "-m", "Native proof fixture"); + return git("rev-parse", "HEAD"); +} + +function setEvidenceCommit(value) { + for (const target of targets) { + const file = `${snapshot}/${target}/summary.tsv`; + write(file, readFileSync(join(fixture, file), "utf8").replace(/^git_head\t[^\n]+/m, `git_head\t${value}`)); + write(`${snapshot}/${target}/git-head.txt`, `${value}\n`); + } +} + +function mutateLock(mutation) { + const script = `lock = YAML.safe_load_file('pnpm-lock.yaml'); ${mutation}; File.write('pnpm-lock.yaml', YAML.dump(lock))`; + command("ruby", ["-ryaml", "-e", script]); + commit(); +} + +function verifyLock(args, expectedStatus) { + const outputFile = join(fixture, ".git", "proof-mode-actions-output"); + const sentinel = "existing-output=must-remain-unchanged\n"; + for (const existsBefore of [false, true]) { + rmSync(outputFile, { force: true }); + if (existsBefore) writeFileSync(outputFile, sentinel); + const result = spawnSync("ruby", [lockHelper, "--verify-lockfile", ...args], { + cwd: fixture, + encoding: "utf8", + env: { ...process.env, GITHUB_OUTPUT: outputFile } + }); + const output = `${result.stdout}${result.stderr}`; + assert.equal(result.error, undefined, String(result.error)); + assert.equal(result.status, expectedStatus, output); + assert.match(output, /ci-impact:/); + assert.equal(existsSync(outputFile), existsBefore, "proof mode must not create GITHUB_OUTPUT"); + if (existsBefore) assert.equal(readFileSync(outputFile, "utf8"), sentinel, "proof mode must not modify GITHUB_OUTPUT"); + } +} + +function verify(expectedPass, expectedFailure) { + const result = spawnSync(process.execPath, [verifier], { cwd: fixture, encoding: "utf8" }); + const output = `${result.stdout}${result.stderr}`; + assert.equal(result.error, undefined, String(result.error)); + assert.equal(result.status === 0, expectedPass, output); + if (expectedPass) assert.match(output, /Native package proof snapshot verified: 4 targets/); + else assert.match(output, expectedFailure ?? /verify-native-package-proof-snapshot:/); +} diff --git a/scripts/verify-docs.sh b/scripts/verify-docs.sh index a8149d6..c9c9746 100644 --- a/scripts/verify-docs.sh +++ b/scripts/verify-docs.sh @@ -75,6 +75,9 @@ assert_contains "apps/docs/docs/guide/basic-usage.md" "Pass-Thru" assert_contains "apps/docs/docs/guide/basic-usage.md" "Ctrl+," assert_contains "apps/docs/docs/guide/basic-usage.md" "Browser" assert_contains "apps/docs/docs/guide/basic-usage.md" "preview mode" +assert_contains "packaging/README.md" "same release artifacts" +assert_contains "packaging/README.md" "loopwire-dsp-provider" +assert_contains "packaging/README.md" "loopwire-jack-ports" assert_contains "README.md" "assets/product-screenshot.png" assert_contains "README.md" "Loopwire is a Linux virtual audio routing app" assert_contains "README.md" "Basic usage / first route" diff --git a/scripts/verify-github-workflows.sh b/scripts/verify-github-workflows.sh index a658490..42d6f90 100755 --- a/scripts/verify-github-workflows.sh +++ b/scripts/verify-github-workflows.sh @@ -95,6 +95,9 @@ fi workflows=( ".github/workflows/ci.yml" + ".github/workflows/web.yml" + ".github/workflows/aur.yml" + ".github/workflows/workflow-checks.yml" ".github/workflows/continuous-tests.yml" ".github/workflows/deploy-docs.yml" ".github/workflows/final-release-proof.yml" @@ -119,9 +122,9 @@ assert_contains ".github/workflows/ci.yml" "fetch-depth: 0" assert_contains ".github/workflows/ci.yml" "libwebkit2gtk-4.1-dev" assert_contains ".github/workflows/ci.yml" "xauth" assert_contains ".github/workflows/ci.yml" "xvfb" -assert_contains ".github/workflows/ci.yml" "validate-aur-source:" -assert_contains ".github/workflows/ci.yml" "scripts/verify-aur-source-package.sh" -assert_contains ".github/workflows/ci.yml" "scripts/verify-aur-git-package.sh" +assert_contains ".github/workflows/aur.yml" "validate-aur-source:" +assert_contains ".github/workflows/aur.yml" "scripts/verify-aur-source-package.sh" +assert_contains ".github/workflows/aur.yml" "scripts/verify-aur-git-package.sh" assert_contains "package.json" '"verify:tauri": "bash scripts/verify-tauri.sh"' assert_contains "package.json" "pnpm verify:tauri" @@ -325,4 +328,8 @@ assert_contains ".github/workflows/vm-matrix.yml" "scripts/verify-support-matrix assert_contains ".github/workflows/vm-matrix.yml" "node scripts/verify-support-matrix.mjs" assert_contains ".github/workflows/vm-matrix.yml" "apps/docs/docs/guide/support-matrix.md" +ruby "$root/scripts/test-ci-impact.rb" +ruby "$root/scripts/test-ci-workflow-paths.rb" +node "$root/scripts/test-native-package-proof-snapshot.mjs" + echo "GitHub workflow contract verification passed." diff --git a/scripts/verify-native-package-proof-snapshot.mjs b/scripts/verify-native-package-proof-snapshot.mjs index c1f1200..840df49 100755 --- a/scripts/verify-native-package-proof-snapshot.mjs +++ b/scripts/verify-native-package-proof-snapshot.mjs @@ -3,6 +3,7 @@ import { execFileSync } from "node:child_process"; import { lstat, readFile, readdir } from "node:fs/promises"; import path from "node:path"; +import { fileURLToPath } from "node:url"; function fail(message) { console.error(`verify-native-package-proof-snapshot: ${message}`); @@ -153,7 +154,6 @@ const proofCriticalPaths = [ "packaging/vm/Dockerfile.qemu", "packaging/vm/guest-native-package-smoke.sh", "packaging/vm/native-package-targets.tsv", - "pnpm-lock.yaml", "scripts/build-deb-package.sh", "scripts/build-portable-linux-binary.sh", "scripts/build-rpm-package.sh", @@ -170,4 +170,18 @@ try { fail(`package or proof-critical inputs changed after tested commit: ${sharedCommit}`); } +// Web-only lockfile changes do not alter the native artifact's dependency inputs. +// The shared projection rejects unknown formats, missing history, and relevant dependency changes. +try { + const head = execFileSync("git", ["rev-parse", "HEAD"], { encoding: "utf8" }).trim(); + execFileSync("ruby", [ + fileURLToPath(new URL("./ci-impact.rb", import.meta.url)), + "--verify-lockfile", "application", sharedCommit, head, + ], { stdio: ["ignore", "ignore", "pipe"] }); +} catch (error) { + if (error.code === "ENOENT") fail("ruby is required to verify native dependency inputs"); + if (error.stderr) process.stderr.write(error.stderr); + fail(`native dependency inputs changed or could not be verified after tested commit: ${sharedCommit}`); +} + console.log(`Native package proof snapshot verified: ${manifest.length} targets at ${sharedCommit}`); diff --git a/scripts/verify-packaging.sh b/scripts/verify-packaging.sh index 94d7ffb..aa62d23 100755 --- a/scripts/verify-packaging.sh +++ b/scripts/verify-packaging.sh @@ -59,9 +59,6 @@ require_contains scripts/verify-nix-release-package.sh "--skip-build-if-missing- require_contains scripts/verify-nix-release-package.sh "--render-only" require_contains scripts/verify-nix-release-package.sh "--repo OWNER/REPO" require_contains scripts/verify-nix-release-package.sh "nix build" -require_contains packaging/README.md "same release artifacts" -require_contains packaging/README.md "loopwire-dsp-provider" -require_contains packaging/README.md "loopwire-jack-ports" require_contains packaging/vm/native-package-targets.tsv "ubuntu-24.04" require_contains packaging/vm/native-package-targets.tsv "debian-13" require_contains packaging/vm/native-package-targets.tsv "fedora-44" diff --git a/scripts/verify-scripts.sh b/scripts/verify-scripts.sh index 67cafb7..fa6b693 100755 --- a/scripts/verify-scripts.sh +++ b/scripts/verify-scripts.sh @@ -1379,6 +1379,12 @@ case "${1:-}" in esac ;; view) + if [ "${3:-}" = "111" ] && [ "${*: -1}" = "jobs" ]; then + printf '%s\n' \ + '{"jobs":[{"name":"Validate workspace","status":"completed","conclusion":"success",' \ + '"steps":[{"name":"Run workspace checks","status":"completed","conclusion":"success"}]}]}' + exit 0 + fi [ "${3:-}" = "222" ] || { echo "unexpected run view id: ${3:-}" >&2 exit 1 @@ -6065,9 +6071,13 @@ NODE "run view") run_id="${3:?missing fake run id}" shift 3 + json_fields="" while [ "$#" -gt 0 ]; do case "$1" in --repo | --json) + if [ "$1" = "--json" ]; then + json_fields="${2:?missing fake json fields}" + fi shift 2 ;; *) @@ -6076,6 +6086,47 @@ NODE ;; esac done + if [ "$json_fields" = "jobs" ]; then + [ "$run_id" = "123456" ] || exit 64 + if [ -n "${LOOPWIRE_FAKE_GH_TRACE:-}" ]; then + printf '%s\t%s\t%s\n' "run view" "$run_id" "$json_fields" >>"$LOOPWIRE_FAKE_GH_TRACE" + fi + if [ "${LOOPWIRE_FAKE_GH_CI_JOBS_MODE:-legacy}" = "lookup-failed" ]; then + echo "CI jobs lookup denied" >&2 + exit 42 + fi + node - "${LOOPWIRE_FAKE_GH_CI_JOBS_MODE:-legacy}" <<'NODE' +const mode = process.argv[2]; +const workspace = { + name: "Validate workspace", + status: "completed", + conclusion: "success", + steps: [{ name: "Run workspace checks", status: "completed", conclusion: "success" }] +}; +let jobs = [workspace]; +switch (mode) { + case "legacy": break; + case "native": + jobs.unshift({ name: "Check affected inputs", status: "completed", conclusion: "success", steps: [] }); + jobs.push({ name: "Build source AUR packages on Arch Linux", status: "completed", conclusion: "skipped", steps: [] }); + break; + case "scope-only": + workspace.conclusion = "skipped"; + workspace.steps = []; + jobs.unshift({ name: "Check affected inputs", status: "completed", conclusion: "success", steps: [] }); + break; + case "missing-job": jobs = []; break; + case "failed-job": workspace.conclusion = "failure"; break; + case "unfinished-job": workspace.status = "in_progress"; break; + case "missing-step": workspace.steps = []; break; + case "skipped-step": workspace.steps[0].conclusion = "skipped"; break; + case "failed-step": workspace.steps[0].conclusion = "failure"; break; + default: throw new Error(`unexpected CI jobs mode: ${mode}`); +} +console.log(JSON.stringify({ jobs })); +NODE + exit 0 + fi case "${LOOPWIRE_FAKE_GH_RUN_MODE:-empty}" in empty) exit 1 @@ -7265,6 +7316,44 @@ grep -F "commit-scoped CI workflow run commit-scoped completed run did not succe echo "verify-scripts: release status did not block a failed CI workflow run" >&2 exit 1 } +for ci_jobs_mode in scope-only missing-job failed-job unfinished-job missing-step skipped-step failed-step lookup-failed legacy native; do + release_status_ci_jobs_log="$tmp_dir/release-status-ci-jobs-${ci_jobs_mode}.log" + release_status_ci_jobs_trace="$tmp_dir/release-status-ci-jobs-${ci_jobs_mode}.trace" + LOOPWIRE_FAKE_GH_RELEASE_MODE=ok \ + LOOPWIRE_FAKE_GH_RUN_MODE=success \ + LOOPWIRE_FAKE_GH_CI_JOBS_MODE="$ci_jobs_mode" \ + LOOPWIRE_FAKE_GH_TRACE="$release_status_ci_jobs_trace" \ + PATH="$fake_gh_dir:$PATH" \ + bash scripts/audit-final-release-state.sh \ + --repo sandwichfarm/loopwire \ + --tag v0.1.0 \ + --git-head 0123456789abcdef0123456789abcdef01234567 \ + --secret-list-file "$secret_list_all_final" >"$release_status_ci_jobs_log" 2>&1 || true + case "$ci_jobs_mode" in + legacy | native) + grep -F "ok: commit-scoped CI workflow run" "$release_status_ci_jobs_log" >/dev/null && + grep -F "workspace validation job and checks step verified" "$release_status_ci_jobs_log" >/dev/null || { + echo "verify-scripts: release status rejected completed workspace checks: $ci_jobs_mode" >&2 + exit 1 + } + ;; + *) + if grep -F "ok: commit-scoped CI workflow run" "$release_status_ci_jobs_log" >/dev/null; then + echo "verify-scripts: release status accepted unverified workspace checks: $ci_jobs_mode" >&2 + exit 1 + fi + grep -F "blocked: commit-scoped CI workflow run" "$release_status_ci_jobs_log" >/dev/null && + grep -F "gh workflow run ci.yml --repo sandwichfarm/loopwire --ref v0.1.0" "$release_status_ci_jobs_log" >/dev/null || { + echo "verify-scripts: release status did not explain how to rerun full CI: $ci_jobs_mode" >&2 + exit 1 + } + ;; + esac + grep -F $'run view\t123456\tjobs' "$release_status_ci_jobs_trace" >/dev/null || { + echo "verify-scripts: release status did not inspect the verified CI run jobs: $ci_jobs_mode" >&2 + exit 1 + } +done release_status_stale_workflow_log="$tmp_dir/release-status-stale-workflow.log" if LOOPWIRE_FAKE_GH_RELEASE_MODE=ok \ LOOPWIRE_FAKE_GH_RUN_MODE=success \