diff --git a/.agents/building-and-testing.md b/.agents/building-and-testing.md index adaca297d..f8fdb8279 100644 --- a/.agents/building-and-testing.md +++ b/.agents/building-and-testing.md @@ -62,3 +62,12 @@ The Office text-input tests inspect actual PDF glyphs with Poppler’s `pdftotext`. Install `poppler-utils` on Ubuntu/Debian or `poppler` with Homebrew before running `go test ./addons/office/...` or the main Go suites. Missing PDF inspection tools are test failures, not skipped checks. + +### Lightweight CI workflow checks + +Run `make test-ci-scripts`, `python3 scripts/ci/test-native-paths.py` (PyYAML), +`python3 scripts/ci/test-fixtures.py`, and +`sh scripts/ci/test-prepare-build.sh` without building the product. Run +`sh scripts/addons/prerequisites_test.sh` for the original readiness regressions. +See [ci-caching.md](ci-caching.md#native-validation-paths) for path maintenance, +required-check migration and same-run production fixture constraints. diff --git a/.agents/ci-caching.md b/.agents/ci-caching.md index aaac05f2d..53eec1778 100644 --- a/.agents/ci-caching.md +++ b/.agents/ci-caching.md @@ -390,3 +390,58 @@ When changing `image_build.yml`, `backend_build.yml`, any of the `backend/Docker 5. **Keep `provenance: false` on push-by-digest steps** — multi-registry digest divergence is the Bug We Already Fixed; reintroducing provenance attestation re-breaks the merge. 6. **`install-base-deps.sh` is the single source of truth for base contents.** Both `Dockerfile.base-grpc-builder` (CI) and the variant Dockerfiles' `builder-fromsource` (local) bind-mount and execute it. If you add a package to one path, add it to the script — don't fork the logic into a Dockerfile RUN. 7. **After adding a `base-images.yml` matrix variant, run the workflow on your branch before merging consumer changes** that depend on the new tag — otherwise the consumer's CI fails "image not found." + +## Native validation paths + +The core, build, backend E2E, distributed, UI E2E, add-on test and add-on +package workflows use native workflow-level `paths` on PRs and master pushes. +Their YAML lists the consuming areas and shared inputs (`go.mod`, `go.sum`, +`Makefile`, scripts, local actions and each workflow). Integration workflows +include broad `core/**` and `pkg/**` areas because their fixture binaries import +them. Matching changes run the original complete suites and matrices; there is +no classifier job, dependency graph or focused documentation runner. The +existing backend image selector and matrices are unchanged. + +Ordinary docs and website edits skip these workflows. Executable documentation +contracts remain explicit inputs: + +- `features/addons.md`: packaging, Sandbox tools, add-on endpoints, recovery + and Tables administration (core tests, add-on tests and packaging). +- `features/tables.md`: Tables tools and administration (core and add-on tests). +- `features/image-generation.md`: gallery entries (core tests). +- `features/diagnostics.md`, `reference/cli-reference.md` and + `getting-started/troubleshooting.md`: diagnostics (core tests). + +These paths are under `docs/content/`. Gallery edits still run core gallery +invariants. Host CSS does not trigger package publishing; the package workflow +retains its original add-on inputs plus shared build and documentation inputs. +Package pushes run on master, not every feature branch. Tags remain exempt +from GitHub path filtering, and release events retain the original publishing +jobs and failure dependencies. PR cancellation does not cancel master runs or +replace the per-channel publishing locks. + +**Maintenance rule:** when adding or moving source, generated inputs, fixtures, +build configuration or documentation readers, update both event path lists of +each consuming workflow in the same change. These are closed allowlists: +unknown future directories do not automatically trigger validation. Do not add +a catchall to hide that maintenance requirement. The core workflow has a +manual full-test entry; distributed tests retain their existing manual and +nightly entries. No new scheduled runs are added. + +GitHub considers at most 300 changed files for path filtering; a relevant file +outside that list can be missed. Diffs over 1,000 commits, or diff timeouts, +run workflows regardless of paths. PR and push diff semantics also differ. +The small static path tests cover representative patterns, not GitHub's diff +service or expression engine. + +**Required checks:** a path-skipped workflow can leave its required check +pending. Before merging, maintainers must review branch protection/rulesets +for these workflow checks and the removed classifier/result checks. Preserve +the original suite check names and do not require a path-filtered workflow on +every PR. Repository settings are not verified by these tests. + +Add-on tests build production fixtures once for the original five groups. +Same-run artifacts verify SHA, configuration, tool versions, paths, checksums +and executable modes before rebasing generated timestamps to the consumer +checkout. Subsequent source edits still fail freshness guards. This does not +share or replace the instrumented UI coverage build or alter case inventories. diff --git a/.github/workflows/addons-package.yml b/.github/workflows/addons-package.yml index c1c500c21..a4e3a939b 100644 --- a/.github/workflows/addons-package.yml +++ b/.github/workflows/addons-package.yml @@ -47,27 +47,10 @@ name: 'add-on packages' # local-check build. on: - push: - branches: ['**'] - tags: ['v*'] - paths: - - 'addons/**' - - 'pkg/addons/**' - - 'scripts/addons/**' - - 'gallery/addons/**' - - 'tests/addons/packaging/**' - - 'core/services/addons/**' - - 'core/http/react-ui/package.json' - - 'core/http/react-ui/package-lock.json' - - 'go.mod' - - 'go.sum' - - '.github/workflows/addons-package.yml' - - '.github/workflows/sandbox-images.yml' pull_request: paths: - 'addons/**' - 'pkg/addons/**' - - 'scripts/addons/**' - 'gallery/addons/**' - 'tests/addons/packaging/**' - 'core/services/addons/**' @@ -77,9 +60,40 @@ on: - 'go.sum' - '.github/workflows/addons-package.yml' - '.github/workflows/sandbox-images.yml' + - 'Makefile' + - 'scripts/**' + - 'backend/*.proto' + - 'backend/*/*.proto' + - 'docs/content/features/addons.md' + - '.github/actions/**' + push: + branches: [master] + tags: ['v*'] + paths: + - 'addons/**' + - 'pkg/addons/**' + - 'gallery/addons/**' + - 'tests/addons/packaging/**' + - 'core/services/addons/**' + - 'core/http/react-ui/package.json' + - 'core/http/react-ui/package-lock.json' + - 'go.mod' + - 'go.sum' + - '.github/workflows/addons-package.yml' + - '.github/workflows/sandbox-images.yml' + - 'Makefile' + - 'scripts/**' + - 'backend/*.proto' + - 'backend/*/*.proto' + - 'docs/content/features/addons.md' + - '.github/actions/**' release: types: [published] +concurrency: + group: ci-addons-package-${{ github.event.pull_request.number || github.sha }}-${{ github.repository }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + permissions: contents: read diff --git a/.github/workflows/addons-tests.yml b/.github/workflows/addons-tests.yml index a6e6f133b..1daf0e103 100644 --- a/.github/workflows/addons-tests.yml +++ b/.github/workflows/addons-tests.yml @@ -15,23 +15,50 @@ on: pull_request: paths: - 'addons/**' - - 'tests/addons/**' - - 'tests/e2e-ui/**' - - 'tests/e2e/mock-backend/**' - - 'pkg/addons/**' - - 'core/services/addons/**' - - 'core/http/endpoints/addons/**' - - 'core/http/auth/addons*.go' - - 'core/application/**' - - 'core/library/**' - - 'core/http/react-ui/**' - - 'scripts/addons/**' - 'go.mod' - 'go.sum' - '.github/workflows/addons-tests.yml' + - 'core/**' + - 'pkg/**' + - 'cmd/**' + - 'internal/**' + - 'backend/go/**' + - 'backend/*.proto' + - 'backend/*/*.proto' + - 'tests/**' + - 'configuration/**' + - 'swagger/**' + - 'prompt-templates/**' + - 'gallery/addons/**' + - 'docs/content/features/addons.md' + - 'docs/content/features/tables.md' + - 'Makefile' + - 'scripts/**' + - '.github/actions/**' push: - branches: - - master + branches: [master] + paths: + - 'addons/**' + - 'go.mod' + - 'go.sum' + - '.github/workflows/addons-tests.yml' + - 'core/**' + - 'pkg/**' + - 'cmd/**' + - 'internal/**' + - 'backend/go/**' + - 'backend/*.proto' + - 'backend/*/*.proto' + - 'tests/**' + - 'configuration/**' + - 'swagger/**' + - 'prompt-templates/**' + - 'gallery/addons/**' + - 'docs/content/features/addons.md' + - 'docs/content/features/tables.md' + - 'Makefile' + - 'scripts/**' + - '.github/actions/**' concurrency: group: ci-addons-tests-${{ github.event.pull_request.number || github.sha }}-${{ github.repository }} @@ -41,7 +68,54 @@ permissions: contents: read jobs: + prepare: + name: prepare shared production fixtures + runs-on: ubuntu-24.04 + timeout-minutes: 60 + steps: + - name: Clone + uses: actions/checkout@v7 + with: + persist-credentials: false + - name: Setup Go + uses: actions/setup-go@v6 + with: + # prepare.sh accepts Go 1.26.x only and runs with GOTOOLCHAIN=local. + go-version: '1.26.x' + cache: false + - name: Setup Node.js + uses: actions/setup-node@v7 + with: + # prepare.sh accepts Node 22.12 or later within Node 22. + node-version: '22' + - name: Install system dependencies + run: | + sudo apt-get update + sudo apt-get install -y build-essential libopus-dev unzip curl + - name: Install protoc + # prepare.sh requires exactly libprotoc 26.1. + run: | + curl -L -s https://github.com/protocolbuffers/protobuf/releases/download/v26.1/protoc-26.1-linux-x86_64.zip -o protoc.zip + sudo unzip -j -d /usr/local/bin protoc.zip bin/protoc + rm protoc.zip + protoc --version + - name: Prepare the build toolchain + # The producer installs build inputs only; consumers install Chromium. + run: sh scripts/addons/prepare.sh --prepare-build + - name: Build the UI, the mock backend and the UI test server + run: COVERAGE=false COVERAGE_V8=false sh scripts/addons/prepare.sh --integration + - name: Archive production fixtures + run: | + python3 scripts/ci/fixtures.py pack + - uses: actions/upload-artifact@v7 + with: + name: addon-production-fixtures + path: ${{ runner.temp }}/addon-fixtures.tgz + if-no-files-found: error + retention-days: 1 + suites: + needs: prepare name: ${{ matrix.group }} runs-on: ubuntu-24.04 timeout-minutes: 60 @@ -98,8 +172,14 @@ jobs: run: sh scripts/addons/prepare.sh --prepare - name: Install the browser's system libraries run: sudo env "PATH=$PATH" core/http/react-ui/node_modules/.bin/playwright install-deps chromium - - name: Build the UI, the mock backend and the UI test server - run: sh scripts/addons/prepare.sh --integration + - uses: actions/download-artifact@v8 + with: + name: addon-production-fixtures + path: ${{ runner.temp }} + - name: Restore and check production fixtures + run: | + python3 scripts/ci/fixtures.py unpack + sh scripts/addons/prepare.sh --check - name: Run the suites env: SUITES: ${{ matrix.suites }} diff --git a/.github/workflows/build-test.yaml b/.github/workflows/build-test.yaml index 08fb2d084..9c34121f9 100644 --- a/.github/workflows/build-test.yaml +++ b/.github/workflows/build-test.yaml @@ -1,33 +1,48 @@ name: Build test on: - push: - branches: - - master pull_request: - # GoReleaser and the darwin launcher take no gallery, docs or markdown - # input, so a diff confined to these paths cannot change either binary. - # The darwin job matters here: macOS is the scarcest runner class. - # - # backend/{cpp,go,python}/**: GoReleaser builds ./cmd/local-ai and the - # launcher builds ./cmd/launcher; neither compiles a backend. The - # before-hooks still matter, but their input is backend/backend.proto - # (protogen-go) and go.mod/go.sum (go mod tidy), none of which live under - # these prefixes, so a change to any of those still triggers a full run. - # See .agents/ci-caching.md. - paths-ignore: - - 'gallery/**' - - 'docs/**' - - 'examples/**' - - '**/*.md' - - 'backend/cpp/**' - - 'backend/go/**' - - 'backend/python/**' + paths: + - 'core/**' + - 'pkg/**' + - 'cmd/**' + - 'internal/**' + - 'backend/*.proto' + - 'backend/*/*.proto' + - 'addons/**' + - 'configuration/**' + - 'swagger/**' + - 'prompt-templates/**' + - '.goreleaser*' + - 'go.mod' + - 'go.sum' + - 'Makefile' + - 'scripts/**' + - '.github/actions/**' + - '.github/workflows/build-test.yaml' + - 'Entitlements.plist' + push: + branches: [master] + paths: + - 'core/**' + - 'pkg/**' + - 'cmd/**' + - 'internal/**' + - 'backend/*.proto' + - 'backend/*/*.proto' + - 'addons/**' + - 'configuration/**' + - 'swagger/**' + - 'prompt-templates/**' + - '.goreleaser*' + - 'go.mod' + - 'go.sum' + - 'Makefile' + - 'scripts/**' + - '.github/actions/**' + - '.github/workflows/build-test.yaml' + - 'Entitlements.plist' -# Supersede an in-flight run when a PR gets a new push. Keyed on the PR number -# so every push to the same PR shares a group; on a master push the key falls -# back to github.sha (unique per commit) and cancel-in-progress is false, so -# master runs never cancel each other -- each commit is built on its own. concurrency: group: ci-build-test-${{ github.event.pull_request.number || github.sha }}-${{ github.repository }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} @@ -74,7 +89,7 @@ jobs: name: launcher-macos path: dist/ retention-days: 30 - + launcher-build-linux: runs-on: ubuntu-latest steps: @@ -98,4 +113,4 @@ jobs: with: name: launcher-linux path: local-ai-launcher-linux.tar.xz - retention-days: 30 \ No newline at end of file + retention-days: 30 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8cfe6cfa3..bf8d694db 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -3,11 +3,68 @@ name: 'tests' on: pull_request: + paths: + - 'core/**' + - 'pkg/**' + - 'cmd/**' + - 'internal/**' + - 'backend/go/**' + - 'backend/*.proto' + - 'backend/*/*.proto' + - 'addons/**' + - 'tests/**' + - 'configuration/**' + - 'swagger/**' + - 'prompt-templates/**' + - 'backend/cpp/**' + - 'gallery/**' + - 'docs/content/features/addons.md' + - 'docs/content/features/tables.md' + - 'docs/content/features/image-generation.md' + - 'docs/content/features/diagnostics.md' + - 'docs/content/reference/cli-reference.md' + - 'docs/content/getting-started/troubleshooting.md' + - 'go.mod' + - 'go.sum' + - 'Makefile' + - 'scripts/**' + - '.github/actions/**' + - '.github/workflows/test.yml' + - 'backend/index.yaml' + - 'coverage-baseline.txt' push: - branches: - - master - tags: - - '*' + branches: [master] + tags: ['*'] + paths: + - 'core/**' + - 'pkg/**' + - 'cmd/**' + - 'internal/**' + - 'backend/go/**' + - 'backend/*.proto' + - 'backend/*/*.proto' + - 'addons/**' + - 'tests/**' + - 'configuration/**' + - 'swagger/**' + - 'prompt-templates/**' + - 'backend/cpp/**' + - 'gallery/**' + - 'docs/content/features/addons.md' + - 'docs/content/features/tables.md' + - 'docs/content/features/image-generation.md' + - 'docs/content/features/diagnostics.md' + - 'docs/content/reference/cli-reference.md' + - 'docs/content/getting-started/troubleshooting.md' + - 'go.mod' + - 'go.sum' + - 'Makefile' + - 'scripts/**' + - '.github/actions/**' + - '.github/workflows/test.yml' + - 'backend/index.yaml' + - 'coverage-baseline.txt' + workflow_dispatch: concurrency: group: ci-tests-${{ github.event.pull_request.number || github.sha }}-${{ github.repository }} diff --git a/.github/workflows/tests-e2e-distributed.yml b/.github/workflows/tests-e2e-distributed.yml index 14bbe7ce3..652e38aaf 100644 --- a/.github/workflows/tests-e2e-distributed.yml +++ b/.github/workflows/tests-e2e-distributed.yml @@ -10,28 +10,40 @@ on: pull_request: paths: - 'tests/e2e/distributed/**' - - 'core/services/nodes/**' - - 'core/services/messaging/**' - - 'core/services/testutil/**' - - 'core/services/studio/**' - - 'core/services/collections/**' - - 'core/services/syncstate/**' - - 'core/services/advisorylock/**' - - 'core/services/galleryop/**' - - 'core/services/modeladmin/**' - - 'core/services/failover/**' - - 'core/services/storage/**' - - 'core/application/distributed*.go' - - 'core/cli/**' - - 'pkg/natsauth/**' - - 'scripts/nats-auth-setup.sh' - 'Makefile' - '.github/workflows/tests-e2e-distributed.yml' + - 'core/**' + - 'pkg/**' + - 'cmd/**' + - 'internal/**' + - 'backend/go/**' + - 'backend/*.proto' + - 'backend/*/*.proto' + - 'configuration/**' + - 'go.mod' + - 'go.sum' + - 'scripts/**' + - '.github/actions/**' push: - branches: - - master + branches: [master] + paths: + - 'tests/e2e/distributed/**' + - 'Makefile' + - '.github/workflows/tests-e2e-distributed.yml' + - 'core/**' + - 'pkg/**' + - 'cmd/**' + - 'internal/**' + - 'backend/go/**' + - 'backend/*.proto' + - 'backend/*/*.proto' + - 'configuration/**' + - 'go.mod' + - 'go.sum' + - 'scripts/**' + - '.github/actions/**' schedule: - # Nightly, so a regression that the path filter missed shows within a day. + # Pre-existing nightly coverage of the distributed suites. - cron: '17 3 * * *' workflow_dispatch: diff --git a/.github/workflows/tests-e2e.yml b/.github/workflows/tests-e2e.yml index b5c84d2a8..030e170a7 100644 --- a/.github/workflows/tests-e2e.yml +++ b/.github/workflows/tests-e2e.yml @@ -3,19 +3,47 @@ name: 'E2E Backend Tests' on: pull_request: - # The e2e suite drives backends over gRPC directly and reads none of these - # paths, so a diff confined to them cannot move it. - # See .agents/ci-caching.md. - paths-ignore: - - 'gallery/**' - - 'docs/**' - - 'examples/**' - - '**/*.md' + paths: + - 'core/**' + - 'pkg/**' + - 'cmd/**' + - 'internal/**' + - 'backend/go/**' + - 'backend/*.proto' + - 'backend/*/*.proto' + - 'addons/**' + - 'tests/**' + - 'configuration/**' + - 'swagger/**' + - 'prompt-templates/**' + - 'go.mod' + - 'go.sum' + - 'Makefile' + - 'scripts/**' + - '.github/actions/**' + - '.github/workflows/tests-e2e.yml' push: - branches: - - master - tags: - - '*' + branches: [master] + tags: ['*'] + paths: + - 'core/**' + - 'pkg/**' + - 'cmd/**' + - 'internal/**' + - 'backend/go/**' + - 'backend/*.proto' + - 'backend/*/*.proto' + - 'addons/**' + - 'tests/**' + - 'configuration/**' + - 'swagger/**' + - 'prompt-templates/**' + - 'go.mod' + - 'go.sum' + - 'Makefile' + - 'scripts/**' + - '.github/actions/**' + - '.github/workflows/tests-e2e.yml' concurrency: group: ci-tests-e2e-backend-${{ github.event.pull_request.number || github.sha }}-${{ github.repository }} diff --git a/.github/workflows/tests-ui-e2e.yml b/.github/workflows/tests-ui-e2e.yml index 7ab0a90f0..e26221b17 100644 --- a/.github/workflows/tests-ui-e2e.yml +++ b/.github/workflows/tests-ui-e2e.yml @@ -3,25 +3,46 @@ name: 'UI E2E Tests' on: pull_request: - # The suite builds the add-on UI bundles from addons//ui (global-setup) - # and drives the real chats service, the add-on host and the studio - # services through the test server, so a change to any of them can break a - # spec without touching core/http. paths: - - 'core/http/**' - - 'core/application/**' - - 'core/services/studio/**' - - 'core/services/addons/**' - - 'pkg/addons/**' - 'addons/**' - - 'tests/e2e-ui/**' - - 'tests/e2e/mock-backend/**' - - 'tests/addons/fixture/**' - - 'scripts/ensure-playwright-browser.sh' - '.github/workflows/tests-ui-e2e.yml' + - 'core/**' + - 'pkg/**' + - 'cmd/**' + - 'internal/**' + - 'backend/go/**' + - 'backend/*.proto' + - 'backend/*/*.proto' + - 'tests/**' + - 'configuration/**' + - 'swagger/**' + - 'prompt-templates/**' + - 'go.mod' + - 'go.sum' + - 'Makefile' + - 'scripts/**' + - '.github/actions/**' push: - branches: - - master + branches: [master] + paths: + - 'addons/**' + - '.github/workflows/tests-ui-e2e.yml' + - 'core/**' + - 'pkg/**' + - 'cmd/**' + - 'internal/**' + - 'backend/go/**' + - 'backend/*.proto' + - 'backend/*/*.proto' + - 'tests/**' + - 'configuration/**' + - 'swagger/**' + - 'prompt-templates/**' + - 'go.mod' + - 'go.sum' + - 'Makefile' + - 'scripts/**' + - '.github/actions/**' concurrency: group: ci-tests-ui-e2e-${{ github.event.pull_request.number || github.sha }}-${{ github.repository }} diff --git a/scripts/addons/prepare.sh b/scripts/addons/prepare.sh index 120eaa81a..a9398c481 100644 --- a/scripts/addons/prepare.sh +++ b/scripts/addons/prepare.sh @@ -9,7 +9,7 @@ export GOCACHE="$root/.cache/addons/go-build" GOBIN="$root/.cache/addons/bin" export npm_config_cache="$root/.cache/addons/npm" PLAYWRIGHT_BROWSERS_PATH="$root/.cache/addons/browsers" export PATH="$GOBIN:$PATH" mode=${1:-} -case "$mode" in --check|--prepare|--integration) ;; *) echo 'usage: prepare.sh --check|--prepare|--integration' >&2; exit 2;; esac +case "$mode" in --check|--prepare|--prepare-build|--integration) ;; *) echo 'usage: prepare.sh --check|--prepare|--prepare-build|--integration' >&2; exit 2;; esac [ "$#" -eq 1 ] || exit 2 fail() { echo "$*" >&2; exit 1; } go version 2>/dev/null | grep -Eq '^go version go1\.26\.[0-9]+ ' || fail 'Go 1.26.x required (GOTOOLCHAIN=local)' @@ -28,7 +28,7 @@ space() { available=$(df -Pk "$root" | awk 'END {print $4}') [ "$available" -ge 20971520 ] || fail 'stop: at least 20 GiB free required before setup/build' } -if [ "$mode" = --prepare ]; then +if [ "$mode" = --prepare ] || [ "$mode" = --prepare-build ]; then space mkdir -p "$GOBIN" "$root/.cache/addons/tmp" export TMPDIR="$root/.cache/addons/tmp" @@ -42,7 +42,11 @@ if [ "$mode" = --prepare ]; then (cd core/http/react-ui && npm ci --no-audit --no-fund) # Explicit setup only: the lockfile-pinned local CLI selects the browser # revision. Never use npx or install host OS packages here. - if [ -z "${PLAYWRIGHT_CHROMIUM_PATH:-}" ]; then + # A producer that only compiles fixtures needs no browser. Test consumers + # still use --prepare and the unchanged browser readiness checks. + if [ "$mode" = --prepare-build ]; then + : + elif [ -z "${PLAYWRIGHT_CHROMIUM_PATH:-}" ]; then space core/http/react-ui/node_modules/.bin/playwright install chromium else diff --git a/scripts/ci/fixtures.py b/scripts/ci/fixtures.py new file mode 100644 index 000000000..c6d65e492 --- /dev/null +++ b/scripts/ci/fixtures.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: MIT +"""Same-run production fixtures only. Tar retains executable bits and paths.""" +import hashlib +import json +import os +from pathlib import Path +import subprocess +import sys +import tarfile +import time + +PATHS = ['core/http/react-ui/dist', 'pkg/grpc/proto', 'pkg/motion/proto', + 'tests/e2e/mock-backend/mock-backend', 'tests/e2e-ui/ui-test-server'] +BINARIES = PATHS[-2:] + +def metadata(root): + files = {} + for name in PATHS: + path = root / name + assert path.exists(), f'missing fixture: {name}' + for p in sorted(path.rglob('*')) if path.is_dir() else [path]: + assert not p.is_symlink(), f'fixture symlink: {p}' + if p.is_file(): + # tar's data filter removes group/other write bits. + files[str(p.relative_to(root))] = [hashlib.sha256(p.read_bytes()).hexdigest(), p.stat().st_mode & 0o755] + for name in BINARIES: + assert os.access(root / name, os.X_OK), f'not executable: {name}' + for name in ['core/http/react-ui/dist/index.html', 'pkg/grpc/proto/backend.pb.go', + 'pkg/grpc/proto/backend_grpc.pb.go', 'pkg/motion/proto/motion.pb.go']: + assert (root / name).stat().st_size > 0, f'missing generated input: {name}' + return files + +def transfer(mode, root, archive, sha, tools=None): + identity = {'sha': sha, 'config': 'ubuntu-24.04-amd64-go1.26-auth-production-v1', 'tools': tools or {}} + if mode == 'pack': + manifest = dict(identity, files=metadata(root)) + (root / 'addon-fixtures.json').write_text(json.dumps(manifest, sort_keys=True)) + with tarfile.open(archive, 'w:gz') as tar: + for name in PATHS + ['addon-fixtures.json']: + tar.add(root / name, arcname=name) + assert archive.stat().st_size <= 512 * 1024 * 1024, 'fixture artifact exceeds 512 MiB' + elif mode == 'unpack': + with tarfile.open(archive) as tar: + for member in tar.getmembers(): + name = member.name + assert any(name == p or name.startswith(p + '/') for p in PATHS + ['addon-fixtures.json']), f'unexpected path: {name}' + assert '..' not in Path(name).parts and not Path(name).is_absolute() + assert member.isfile() or member.isdir(), f'unsupported member: {name}' + tar.extractall(root, filter='data') + manifest = json.loads((root / 'addon-fixtures.json').read_text()) + for key, value in identity.items(): + assert manifest[key] == value, f'fixture {key} mismatch' + assert metadata(root) == manifest['files'], 'fixture checksum/mode mismatch' + # The consumer checks out the same SHA later than the producer. Only + # after identity and content verification, rebase generated mtimes onto + # that checkout. Keep the server newer than its embedded dist, and leave + # all source files untouched so a subsequent edit still fails -nt guards. + stamp = time.time_ns() + for name in manifest['files']: + if name not in BINARIES: os.utime(root / name, ns=(stamp, stamp)) + for offset, name in enumerate(BINARIES, 1): + os.utime(root / name, ns=(stamp + offset, stamp + offset)) + else: + raise ValueError(mode) + +if __name__ == '__main__': + root = Path.cwd() + sha = subprocess.check_output(['git', 'rev-parse', 'HEAD'], text=True).strip() + tools = {name: subprocess.check_output(command, text=True).strip() for name, command in + {'go': ['go', 'version'], 'node': ['node', '--version'], 'protoc': ['protoc', '--version']}.items()} + transfer(sys.argv[1], root, Path(os.environ['RUNNER_TEMP']) / 'addon-fixtures.tgz', sha, tools) diff --git a/scripts/ci/test-fixtures.py b/scripts/ci/test-fixtures.py new file mode 100644 index 000000000..346ff0df0 --- /dev/null +++ b/scripts/ci/test-fixtures.py @@ -0,0 +1,105 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: MIT +"""Same-run fixture integrity and source freshness, without product builds.""" +import importlib.util +import io +import os +from pathlib import Path +import subprocess +import sys +import tarfile +import tempfile +import time +import unittest +sys.dont_write_bytecode = True +ROOT = Path(__file__).resolve().parents[2] + +spec = importlib.util.spec_from_file_location('fixtures', ROOT / 'scripts/ci/fixtures.py') +fixtures = importlib.util.module_from_spec(spec) +spec.loader.exec_module(fixtures) + +class Fixtures(unittest.TestCase): + def make(self, root): + for name in ['core/http/react-ui/dist/index.html', 'pkg/grpc/proto/backend.pb.go', + 'pkg/grpc/proto/backend_grpc.pb.go', 'pkg/motion/proto/motion.pb.go'] + fixtures.BINARIES: + path = root / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text('fixture') + if name in fixtures.BINARIES: + path.chmod(0o755) + + def test_roundtrip_modes_paths_sha_and_checksums(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp); src = root/'src'; dst = root/'dst' + src.mkdir(); dst.mkdir(); self.make(src) + archive = root/'fixtures.tgz' + fixtures.transfer('pack', src, archive, 'abc', {'go': '1.26'}) + fixtures.transfer('unpack', dst, archive, 'abc', {'go': '1.26'}) + self.assertEqual(fixtures.metadata(src), fixtures.metadata(dst)) + with self.assertRaisesRegex(AssertionError, 'sha mismatch'): + fixtures.transfer('unpack', dst, archive, 'def', {'go': '1.26'}) + with self.assertRaisesRegex(AssertionError, 'tools mismatch'): + fixtures.transfer('unpack', dst, archive, 'abc', {'go': '1.27'}) + manifest = src/'addon-fixtures.json' + manifest.write_text(manifest.read_text().replace('production-v1', 'production-v2')) + with tarfile.open(root/'config.tgz', 'w:gz') as tar: + for name in fixtures.PATHS + ['addon-fixtures.json']: + tar.add(src/name, arcname=name) + with self.assertRaisesRegex(AssertionError, 'config mismatch'): + fixtures.transfer('unpack', dst, root/'config.tgz', 'abc', {'go': '1.26'}) + with tarfile.open(archive, 'w:gz') as tar: + payload = b'escape'; member = tarfile.TarInfo('../escape'); member.size = len(payload) + tar.addfile(member, io.BytesIO(payload)) + with self.assertRaises(AssertionError): + fixtures.transfer('unpack', dst, archive, 'abc', {'go': '1.26'}) + + def test_restore_rebases_freshness_only_after_identity_validation(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp); src = root/'src'; dst = root/'dst' + src.mkdir(); dst.mkdir(); self.make(src) + old = time.time_ns()-10_000_000_000 + for name in fixtures.metadata(src): os.utime(src/name, ns=(old, old)) + archive = root/'fixtures.tgz'; fixtures.transfer('pack', src, archive, 'abc') + # A separate checkout occurs after the producer built its files. + sources = ['core/http/react-ui/src/components/addons/RecoveryPanel.jsx', + 'core/http/react-ui/src/components/addons/recoveryState.js', + 'core/http/react-ui/src/utils/addonsApi.js', + 'core/services/addons/recovery/backup.go', + 'core/http/endpoints/addons/recovery.go', 'tests/e2e/mock-backend/main.go'] + for name in sources: + target = dst/name; target.parent.mkdir(parents=True, exist_ok=True); target.write_text('source') + fixtures.transfer('unpack', dst, archive, 'abc') + # Execute the exact freshness assertions from recovery.sh 33-39. + text = (ROOT/'scripts/addons/recovery.sh').read_text() + start = text.index('[ "$root/tests/e2e-ui/ui-test-server" -nt "$ui/dist/index.html" ]') + end = text.index('\n\n', start) + guard = text[start:end] + env = dict(os.environ, root=str(dst), ui=str(dst/'core/http/react-ui')) + self.assertEqual(subprocess.run(['sh', '-c', guard], env=env, capture_output=True).returncode, 0) + source = dst/sources[3] + later = (dst/'tests/e2e-ui/ui-test-server').stat().st_mtime_ns+1_000_000 + source.write_text('new edit'); os.utime(source, ns=(later, later)) + self.assertNotEqual(subprocess.run(['sh', '-c', guard], env=env, capture_output=True).returncode, 0) + # Failed identity checks must not mark a stale binary fresh. + with self.assertRaisesRegex(AssertionError, 'sha mismatch'): + fixtures.transfer('unpack', dst, archive, 'different') + self.assertLess((dst/'tests/e2e-ui/ui-test-server').stat().st_mtime_ns, source.stat().st_mtime_ns) + + def test_missing_executable_and_tampered_payload(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp); self.make(root) + binary = root/fixtures.BINARIES[0]; binary.chmod(0o644) + with self.assertRaisesRegex(AssertionError, 'not executable'): + fixtures.transfer('pack', root, root/'fixtures.tgz', 'abc') + binary.chmod(0o755) + fixtures.transfer('pack', root, root/'fixtures.tgz', 'abc') + binary.write_text('tampered') + # Pack files with the old manifest, emulating damaged artifact contents. + with tarfile.open(root/'bad.tgz', 'w:gz') as tar: + for name in fixtures.PATHS + ['addon-fixtures.json']: + tar.add(root/name, arcname=name) + with self.assertRaisesRegex(AssertionError, 'checksum/mode mismatch'): + fixtures.transfer('unpack', root, root/'bad.tgz', 'abc') + +if __name__ == '__main__': + unittest.main() diff --git a/scripts/ci/test-native-paths.py b/scripts/ci/test-native-paths.py new file mode 100644 index 000000000..5af143707 --- /dev/null +++ b/scripts/ci/test-native-paths.py @@ -0,0 +1,91 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: MIT +"""Static native-filter examples, not a GitHub expression evaluator. Needs PyYAML.""" +import fnmatch +import subprocess +import unittest +from pathlib import Path +import yaml + +ROOT = Path(__file__).resolve().parents[2] +NAMES = ['test.yml', 'build-test.yaml', 'tests-ui-e2e.yml', 'tests-e2e.yml', + 'tests-e2e-distributed.yml', 'addons-tests.yml', 'addons-package.yml'] + +def load(name, base=False): + path = '.github/workflows/' + name + text = subprocess.check_output(['git', 'show', '16acc8926:' + path], cwd=ROOT) if base else (ROOT/path).read_text() + return yaml.load(text, Loader=yaml.BaseLoader) + +def matches(name, path, event): + return any(fnmatch.fnmatchcase(path, pattern) for pattern in load(name)['on'][event]['paths']) + +class NativePaths(unittest.TestCase): + def test_shared_inputs_and_original_jobs(self): + for path in (ROOT/'.github/workflows').iterdir(): + if path.suffix in ['.yml', '.yaml']: + yaml.load(path.read_text(), Loader=yaml.BaseLoader) + for name in NAMES: + workflow = load(name); base = load(name, True) + self.assertEqual(workflow['on']['push']['paths'], workflow['on']['pull_request']['paths']) + self.assertEqual(workflow['on'].get('schedule'), base['on'].get('schedule')) + self.assertEqual(set(workflow['jobs']), set(base['jobs']) | ({'prepare'} if name == 'addons-tests.yml' else set())) + for event in ['pull_request', 'push']: + for path in ['go.mod', 'go.sum', 'Makefile', 'scripts/build.sh', '.github/workflows/' + name]: + self.assertTrue(matches(name, path, event), (name, path, event)) + self.assertFalse(matches(name, 'docs/content/news.md', event), name) + self.assertFalse(matches(name, 'website/content/blog/news.md', event), name) + if name != 'addons-tests.yml': + self.assertEqual(workflow['jobs'], base['jobs']) + self.assertNotIn('classify', str(workflow)) + self.assertNotIn('job-result', str(workflow)) + self.assertEqual(workflow['concurrency']['cancel-in-progress'], "${{ github.event_name == 'pull_request' }}") + + def test_representative_consumers(self): + cases = { + 'core/services/chats/store.go': NAMES[:-1], + 'pkg/model/loader.go': NAMES[:-1], + 'tests/e2e/mock-backend/main.go': ['test.yml', 'tests-e2e.yml', 'tests-ui-e2e.yml', 'addons-tests.yml'], + 'backend/go/cloud-proxy/main.go': ['test.yml', 'tests-e2e.yml', 'tests-ui-e2e.yml', 'addons-tests.yml'], + 'gallery/models/example.yaml': ['test.yml'], + 'backend/index.yaml': ['test.yml'], + 'coverage-baseline.txt': ['test.yml'], + 'Entitlements.plist': ['build-test.yaml'], + 'docs/content/features/image-generation.md': ['test.yml'], + 'docs/content/features/addons.md': ['test.yml', 'addons-tests.yml', 'addons-package.yml'], + 'docs/content/features/tables.md': ['test.yml', 'addons-tests.yml'], + 'docs/content/features/diagnostics.md': ['test.yml'], + 'docs/content/reference/cli-reference.md': ['test.yml'], + 'docs/content/getting-started/troubleshooting.md': ['test.yml'], + } + for path, names in cases.items(): + for name in names: + for event in ['pull_request', 'push']: + self.assertTrue(matches(name, path, event), (path, name, event)) + for event in ['pull_request', 'push']: + self.assertFalse(matches('addons-package.yml', 'core/http/react-ui/src/index.css', event)) + self.assertFalse(matches('test.yml', 'future-area/unknown.input', event)) + + def test_suite_and_publish_preservation(self): + old = load('addons-tests.yml', True)['jobs']['suites'] + new = load('addons-tests.yml')['jobs']['suites'] + self.assertEqual(new['strategy'], old['strategy']) + self.assertEqual(new['needs'], 'prepare') + self.assertNotIn('if', new) + for path in ['scripts/addons/suites.json', 'pkg/addons/packaging/policy.go', 'pkg/addons/packaging/packaging_test.go']: + self.assertEqual((ROOT/path).read_bytes(), subprocess.check_output(['git', 'show', '16acc8926:' + path], cwd=ROOT)) + package = load('addons-package.yml'); base = load('addons-package.yml', True) + self.assertEqual(package['jobs'], base['jobs']) + self.assertEqual(package['on']['push']['branches'], ['master']) + self.assertEqual(package['on']['push']['tags'], ['v*']) + self.assertEqual(package['on']['release'], base['on']['release']) + self.assertNotIn('workflow_dispatch', package['on']) + self.assertEqual(load('sandbox-images.yml'), load('sandbox-images.yml', True)) + for name in NAMES: + self.assertEqual(load(name)['on']['push'].get('tags'), load(name, True)['on']['push'].get('tags')) + for name in ['lint.yml', 'tests-ui-unit.yml', 'yaml-check.yml']: + self.assertEqual(load(name), load(name, True)) + self.assertFalse((ROOT/'scripts/lib/ci-select.mjs').exists()) + self.assertFalse((ROOT/'scripts/ci/select.mjs').exists()) + +if __name__ == '__main__': + unittest.main() diff --git a/scripts/ci/test-prepare-build.sh b/scripts/ci/test-prepare-build.sh new file mode 100644 index 000000000..53f386077 --- /dev/null +++ b/scripts/ci/test-prepare-build.sh @@ -0,0 +1,25 @@ +#!/bin/sh +# SPDX-License-Identifier: MIT +set -eu +root=$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd) +tmp=$(mktemp -d) +trap 'rm -rf "$tmp"' EXIT HUP INT TERM +mkdir -p "$tmp/scripts/addons" "$tmp/core/http/react-ui/node_modules/.bin" "$tmp/bin" +cp "$root/scripts/addons/prepare.sh" "$tmp/scripts/addons/" +cat > "$tmp/bin/go" <<'TOOL' +#!/bin/sh +case "$1" in version) echo 'go version go1.26.1 linux/amd64';; env) echo 1;; install|mod) exit 0;; *) exit 1;; esac +TOOL +printf '#!/bin/sh\necho v22.14.0\n' > "$tmp/bin/node" +printf '#!/bin/sh\necho "libprotoc 26.1"\n' > "$tmp/bin/protoc" +printf '#!/bin/sh\necho "fake 1 1 999999999"\n' > "$tmp/bin/df" +printf '#!/bin/sh\nexit 0\n' > "$tmp/bin/npm" +printf '#!/bin/sh\nexit 99\n' > "$tmp/core/http/react-ui/node_modules/.bin/playwright" +chmod +x "$tmp/bin/"* "$tmp/core/http/react-ui/node_modules/.bin/playwright" +unset PLAYWRIGHT_CHROMIUM_PATH +PATH="$tmp/bin:$PATH" sh "$tmp/scripts/addons/prepare.sh" --prepare-build +# Full consumer preparation must reach the browser and propagate its failure. +if PATH="$tmp/bin:$PATH" sh "$tmp/scripts/addons/prepare.sh" --prepare; then + echo 'consumer preparation unexpectedly omitted the browser' >&2 + exit 1 +fi