diff --git a/.github/workflows/ci-windows-cross.yml b/.github/workflows/ci-windows-cross.yml index bbee3951..da760db9 100644 --- a/.github/workflows/ci-windows-cross.yml +++ b/.github/workflows/ci-windows-cross.yml @@ -15,6 +15,8 @@ on: - 'internal/edgeagent/**' - 'cmd/ongrid-edge*/**' - '.github/workflows/ci-windows-cross.yml' + - 'scripts/ci-docs-only.py' + - 'scripts/check-issue-templates.rb' permissions: contents: read @@ -32,8 +34,17 @@ jobs: steps: - name: Checkout uses: actions/checkout@v6 + with: + fetch-depth: 2 + + - name: Classify documentation changes + id: changes + env: + CI_BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }} + run: python3 scripts/ci-docs-only.py - name: Set up Go + if: steps.changes.outputs.docs_only != 'true' id: go uses: actions/setup-go@v6 with: @@ -41,6 +52,7 @@ jobs: cache: false - name: Restore and refresh Go cache + if: steps.changes.outputs.docs_only != 'true' uses: actions/cache@v4 with: path: | @@ -53,6 +65,7 @@ jobs: go-v1-ubuntu24-${{ runner.os }}-${{ runner.arch }}-${{ steps.go.outputs.go-version }}-windows-cross- - name: go build (GOOS=windows) + if: steps.changes.outputs.docs_only != 'true' env: GOOS: windows GOARCH: amd64 @@ -64,6 +77,7 @@ jobs: go build ./cmd/ongrid-edge ./cmd/ongrid-edge-supervisor - name: go vet (GOOS=windows) + if: steps.changes.outputs.docs_only != 'true' env: GOOS: windows GOARCH: amd64 @@ -73,6 +87,7 @@ jobs: go vet ./cmd/ongrid-edge ./cmd/ongrid-edge-supervisor - name: go test (GOOS=windows, no-cgo) + if: steps.changes.outputs.docs_only != 'true' env: GOOS: windows GOARCH: amd64 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5bd32fdd..5eb8a7e1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,7 +34,7 @@ jobs: - name: Classify documentation changes id: changes env: - CI_BASE_SHA: ${{ github.event.pull_request.base.sha }} + CI_BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }} run: python3 scripts/ci-docs-only.py - name: Set up Go @@ -84,10 +84,14 @@ jobs: if: steps.changes.outputs.docs_only != 'true' run: make test-k8s-capabilities - web-test: - name: web test + build + web-checks: + name: web (${{ matrix.target }}) runs-on: ubuntu-24.04 timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + target: [test-web-ci-apm, test-web-ci-platform, build-web] defaults: run: @@ -103,7 +107,7 @@ jobs: id: changes working-directory: . env: - CI_BASE_SHA: ${{ github.event.pull_request.base.sha }} + CI_BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }} run: python3 scripts/ci-docs-only.py - name: Set up Node.js @@ -119,30 +123,25 @@ jobs: run: make test-commit-policy - name: Install dependencies - if: steps.changes.outputs.docs_only != 'true' + if: steps.changes.outputs.docs_only != 'true' && matrix.target != 'build-web' run: npm ci - - name: Test + - name: Run web check if: steps.changes.outputs.docs_only != 'true' - # Keep DOM-heavy suites from competing for CPU within their test timeouts. - run: >- - npm test -- --run --no-file-parallelism - src/api/apm.test.ts - src/components/apm - src/pages/Apm - src/pages/Kubernetes.test.tsx - src/pages/Edges.test.tsx - src/pages/Dashboard.test.tsx - src/pages/settings/Upgrade.test.tsx - src/pages/settings/LLM.test.tsx - src/pages/settings/CommunicationProviders.test.tsx - src/components/icons/Provider.test.tsx - src/pages/kubernetes/edgeAttachments.test.ts - src/pages/kubernetes/registryCommands.test.ts + working-directory: . + run: make ${{ matrix.target }} - - name: Build - if: steps.changes.outputs.docs_only != 'true' - run: npm run build + web-test: + name: web test + build + needs: web-checks + if: always() + runs-on: ubuntu-24.04 + timeout-minutes: 5 + steps: + - name: Require all web checks to succeed + env: + WEB_RESULT: ${{ needs.web-checks.result }} + run: test "$WEB_RESULT" = success deployment-test: name: deployment manifests @@ -152,33 +151,50 @@ jobs: steps: - name: Checkout uses: actions/checkout@v6 + with: + fetch-depth: 2 + + - name: Classify documentation changes + id: changes + env: + CI_BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }} + run: python3 scripts/ci-docs-only.py - name: Set up Helm + if: steps.changes.outputs.docs_only != 'true' uses: azure/setup-helm@v5 - name: Verify documentation-only CI routing run: make test-ci-docs-only - name: Check release scripts + if: steps.changes.outputs.docs_only != 'true' run: bash -n dist/package.sh dist/package-k8s-chart.sh dist/build-edge-attachments.sh deploy/install/install.sh deploy/install/upgrade.sh deploy/install/uninstall.sh deploy/install/public-url.sh deploy/install/data-permissions.sh deploy/install/apply-pending-upgrade.sh deploy/install/edge/fetch-edge-assets.sh deploy/install/edge/verify-edge-deps-archive.sh deploy/install/edge/edge-assets-lib.sh scripts/verify-cnb-compose-images.sh scripts/verify-release-images.sh scripts/publish-release-image.sh scripts/publish-release-image-platform.sh scripts/merge-release-image-manifest.sh scripts/test-publish-release-image.sh scripts/test-release-image-platform-publish.sh scripts/ensure-cnb-release.sh scripts/test-ensure-cnb-release.sh scripts/test-publish-cnb-release-attachments.sh scripts/test-verify-cnb-release-attachments.sh scripts/test-release-workflow.sh scripts/test-edge-assets.sh scripts/test-edge-assets-lib.sh scripts/test-edge-installer-connectivity-guard.sh scripts/test-edge-upgrade-hook-guard.sh scripts/test-public-url.sh scripts/test-upgrade-data-permissions.sh scripts/test-compose-release-package.sh scripts/test-apply-pending-upgrade.sh scripts/publish-helm-chart.sh scripts/test-publish-helm-chart.sh scripts/test-k8s-chart.sh - name: Verify Edge installer connectivity guard + if: steps.changes.outputs.docs_only != 'true' run: bash scripts/test-edge-installer-connectivity-guard.sh - name: Verify Compose image routing + if: steps.changes.outputs.docs_only != 'true' run: make verify-compose-images - name: Verify release image helpers + if: steps.changes.outputs.docs_only != 'true' run: make test-release-image-publish - name: Verify release workflow orchestration + if: steps.changes.outputs.docs_only != 'true' run: make test-release-workflow - name: Verify Helm Chart publish helper + if: steps.changes.outputs.docs_only != 'true' run: make test-publish-k8s-chart - name: Verify Compose-only release package + if: steps.changes.outputs.docs_only != 'true' run: make test-release-package - name: Lint and render Kubernetes chart + if: steps.changes.outputs.docs_only != 'true' run: make test-k8s-chart diff --git a/Makefile b/Makefile index 93a72c8d..5797c735 100644 --- a/Makefile +++ b/Makefile @@ -886,5 +886,24 @@ test-commit-policy: ## Verify PR commit policy and bot feedback node scripts/test-commit-policy.cjs .PHONY: test-ci-docs-only -test-ci-docs-only: ## Verify documentation-only CI routing +test-ci-docs-only: ## Verify lightweight CI routing and the required web check python3 scripts/test-ci-docs-only.py + +.PHONY: check-issue-templates +check-issue-templates: ## Validate GitHub Issue Forms and chooser metadata + ruby scripts/check-issue-templates.rb + +# Keep the two slow DOM suites (APM and Kubernetes) on separate CI runners. +.PHONY: test-web-ci-apm +test-web-ci-apm: ## Run the serial APM frontend CI tests (requires npm ci in web) + cd web && npm test -- --run --no-file-parallelism \ + src/api/apm.test.ts src/components/apm src/pages/Apm + +.PHONY: test-web-ci-platform +test-web-ci-platform: ## Run the serial platform frontend CI tests (requires npm ci in web) + cd web && npm test -- --run --no-file-parallelism \ + src/pages/Kubernetes.test.tsx src/pages/Edges.test.tsx \ + src/pages/Dashboard.test.tsx src/pages/settings/Upgrade.test.tsx \ + src/pages/settings/LLM.test.tsx src/pages/settings/CommunicationProviders.test.tsx \ + src/components/icons/Provider.test.tsx src/pages/kubernetes/edgeAttachments.test.ts \ + src/pages/kubernetes/registryCommands.test.ts diff --git a/scripts/check-issue-templates.rb b/scripts/check-issue-templates.rb new file mode 100644 index 00000000..48f8a571 --- /dev/null +++ b/scripts/check-issue-templates.rb @@ -0,0 +1,68 @@ +#!/usr/bin/env ruby +# Validate the chooser and common Issue Form fields without installing dependencies. +# ponytail: extend these checks when templates adopt more field-specific options. +require 'yaml' +require 'uri' + +def text!(value) + raise 'expected non-empty text' unless value.is_a?(String) && !value.strip.empty? +end + +def boolean!(value) + raise 'expected a boolean' unless [true, false].include?(value) +end + +files = Dir['.github/ISSUE_TEMPLATE/*.{yml,yaml}'].sort +files.each do |file| + begin + data = YAML.safe_load(File.read(file)) + raise 'expected a YAML mapping' unless data.is_a?(Hash) + if File.basename(file).match?(/\Aconfig\.ya?ml\z/) + boolean!(data['blank_issues_enabled']) if data.key?('blank_issues_enabled') + links = data.fetch('contact_links', []) + raise 'contact_links must be a list' unless links.is_a?(Array) + links.each do |link| + %w[name url about].each { |key| text!(link.fetch(key)) } + uri = URI.parse(link['url']) + raise 'contact URL must use HTTP(S)' unless %w[http https].include?(uri.scheme) && uri.host + end + next + end + + %w[name description].each { |key| text!(data.fetch(key)) } + body = data.fetch('body') + raise 'body must contain form fields' unless body.is_a?(Array) && body.any? { |field| field['type'] != 'markdown' } + ids = [] + body.each do |field| + type = field.fetch('type') + raise 'unknown form field type' unless %w[markdown input textarea dropdown checkboxes upload].include?(type) + if field.key?('id') + id = field['id'] + raise 'invalid or duplicate field id' unless id.is_a?(String) && id.match?(/\A[a-zA-Z0-9_-]+\z/) && !ids.include?(id) + ids << id + end + attributes = field.fetch('attributes') + text!(attributes.fetch(type == 'markdown' ? 'value' : 'label')) + validations = field.fetch('validations', {}) + boolean!(validations['required']) if validations.key?('required') + boolean!(attributes['multiple']) if attributes.key?('multiple') + next unless %w[dropdown checkboxes].include?(type) + + options = attributes.fetch('options') + raise 'options must be a non-empty list' unless options.is_a?(Array) && !options.empty? + labels = options.map do |option| + if type == 'checkboxes' + boolean!(option['required']) if option.key?('required') + option.fetch('label') + else + option + end + end + labels.each { |label| text!(label) } + raise 'duplicate options' unless labels.uniq == labels + end + rescue StandardError => error + abort "#{file}: #{error.message}" + end +end +puts "Issue template checks passed (#{files.length} YAML files)" diff --git a/scripts/ci-docs-only.py b/scripts/ci-docs-only.py index 3695ac84..ad3f8fa4 100644 --- a/scripts/ci-docs-only.py +++ b/scripts/ci-docs-only.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Keep heavy PR checks for everything outside the documentation allowlist.""" +"""Keep heavy checks for changes outside known non-runtime documentation.""" import os from pathlib import Path @@ -8,17 +8,27 @@ def docs_only(paths): - root_docs = {"AGENTS.md", "README.md", "CONTRIBUTING.md", "CODE_OF_CONDUCT.md", "SECURITY.md"} + prose = {".md", ".markdown", ".rst"} + assets = {".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp", ".ico", ".avif", ".pdf"} + readme_dirs = {"api", "db", "deploy", "deploy/install", "dist", "examples/apm-languages", + "tests/e2e", "web", "web/e2e"} + # Onboarding.tsx imports this guide as raw text; it changes the shipped UI. + runtime_docs = {"docs/guides/apm-configuration-files.md"} + # ponytail: known documentation locations only; add newly audited locations here. return bool(paths) and all( - path in root_docs - or ("/" not in path and path.startswith("README_") and path.endswith(".md")) - or (path.startswith("docs/") and path.endswith(".md")) + path not in runtime_docs and ( + ("/" not in path and (Path(path).suffix in prose or path == "LICENSE")) + or (path.startswith("docs/") and Path(path).suffix in prose) + or (path.startswith("docs/assets/") and Path(path).suffix in assets) + or (str(Path(path).parent) in readme_dirs and re.fullmatch(r"README(?:[._-][^/]*)?\.md", Path(path).name)) + or re.fullmatch(r"\.github/(?:[^/]+\.md|(?:ISSUE_TEMPLATE|PULL_REQUEST_TEMPLATE)/[^/]+\.md|ISSUE_TEMPLATE/[^/]+\.ya?ml)", path) + ) for path in paths ) def changed_paths(base): - # An absent base (including main pushes) or unavailable history means full CI. + # An absent base, new branch, or unavailable history means full CI. if not re.fullmatch(r"[0-9a-f]{40}", base): return [] try: @@ -33,6 +43,8 @@ def changed_paths(base): def main(): + # Validate metadata even when the PR qualifies for the lightweight path. + subprocess.run(["ruby", str(Path(__file__).with_name("check-issue-templates.rb"))], check=True) base = os.environ.get("CI_BASE_SHA", "") only_docs = docs_only(changed_paths(base)) if only_docs: @@ -40,7 +52,7 @@ def main(): result = f"docs_only={str(only_docs).lower()}\n" with Path(os.environ["GITHUB_OUTPUT"]).open("a") as output: output.write(result) - print("Documentation-only PR: whitespace checked; skipping Go/web builds and tests." + print("Documentation/template-only changes: metadata and whitespace checked; skipping heavy checks." if only_docs else "Running full CI: changes are not documentation-only or base is unavailable.") diff --git a/scripts/test-ci-docs-only.py b/scripts/test-ci-docs-only.py index 565fb0cd..79449167 100644 --- a/scripts/test-ci-docs-only.py +++ b/scripts/test-ci-docs-only.py @@ -1,6 +1,7 @@ #!/usr/bin/env python3 -"""Regression check for the docs fast path, using real Git diffs.""" +"""Regressions for lightweight CI validation and the aggregate web check.""" +import json import os from pathlib import Path import runpy @@ -11,9 +12,22 @@ script = Path(__file__).with_name("ci-docs-only.py").resolve() docs_only = runpy.run_path(str(script))["docs_only"] assert docs_only(["AGENTS.md", "README_zh.md", "docs/design/guide.md"]) +assert docs_only(["CHANGELOG.md", "ROADMAP.zh-CN.md", "LICENSE", "docs/guide.rst"]) +assert docs_only(["web/README.md", "deploy/install/README.md", "api/README.md", "web/e2e/README.md"]) +assert docs_only(["docs/assets/demo.gif", "docs/assets/integrations/openai.svg", "docs/assets/guide.pdf"]) +assert docs_only([".github/pull_request_template.md", ".github/PULL_REQUEST_TEMPLATE/feature.md"]) +assert docs_only([".github/ISSUE_TEMPLATE/legacy.md"]) +assert docs_only([".github/ISSUE_TEMPLATE/config.yml", ".github/ISSUE_TEMPLATE/bug.yaml"]) for paths in [[], ["main.go"], ["Makefile"], [".github/workflows/ci.yml"], ["internal/manager/biz/knowledge/builtin_vault/guide.md"], - ["docs/config.yml"], ["web/README.md"], ["AGENTS.md", "main.go"]]: + ["internal/manager/biz/aiops/chatruntime/testdata/agent_registry/multi/README.md"], + ["docs/guides/apm-configuration-files.md"], ["agents/reviewer.md"], ["skills/bash/SKILL.md"], + ["web/src/guide.md"], ["web/public/logo.svg"], ["new-package/README.md"], + ["docs/config.yml"], ["docs/assets/app.js"], ["docs/example.go"], + ["go.sum"], ["web/package-lock.json"], ["deploy/install/install.sh"], ["VERSION"], + ["AGENTS.md", "main.go"], ["docs/assets/demo.gif", "web/src/App.tsx"], + [".github/ISSUE_TEMPLATE/script.py"], [".github/ISSUE_TEMPLATE/nested/config.yml"], + [".github/ISSUE_TEMPLATE/config.yml", ".github/workflows/ci.yml"]]: assert not docs_only(paths), paths with tempfile.TemporaryDirectory() as directory: @@ -45,6 +59,11 @@ def check(base, expected, success=True): (root / "docs" / "guide.md").write_text("Updated guide\n") commit() check(base, True) + (root / "docs" / "assets").mkdir() + (root / "docs" / "assets" / "demo.gif").write_bytes(b"GIF89a") + (root / "CHANGELOG.md").write_text("Release notes\n") + commit() + check(base, True) for missing in ["", "--help", "0" * 40]: check(missing, False) git("mv", "main.go", "docs/code.md") @@ -56,8 +75,47 @@ def check(base, expected, success=True): (root / "docs" / "space and\nnewline.md").write_text("Guide\n") commit() check(deleted, True) # NUL-separated paths, including whitespace in names. + + templates = root / ".github" / "ISSUE_TEMPLATE" + templates.mkdir(parents=True) + template = templates / "bug.yml" + valid = "name: Bug report\ndescription: Report a bug\nbody:\n - type: input\n id: summary\n attributes:\n label: Summary\n" + template.write_text(valid) + template_base = commit() + check(deleted, True) + for invalid in ["body: [", "name: Bug report\ndescription: Report a bug\nbody: []\n", + valid.replace("type: input", "type: unknown"), + valid + " - type: input\n id: summary\n attributes:\n label: Duplicate\n", + valid.replace("type: input", "type: dropdown") + " options: [true]\n"]: + template.write_text(invalid) + commit() + check(template_base, True, success=False) + check("", False, success=False) # Main pushes must validate templates too. + template.write_text(valid) + commit() + check(template_base, False) + chooser = templates / "config.yml" + chooser.write_text("blank_issues_enabled: true\ncontact_links:\n - name: Help\n url: file:///invalid\n about: Help\n") + commit() + check(template_base, True, success=False) + chooser.unlink() + commit() + (root / "docs" / "bad.md").write_text("Trailing whitespace \n") commit() check(deleted, True, success=False) -print("CI documentation routing checks passed") +# A failed, cancelled, or skipped matrix must never satisfy the old required check. +workflow = script.parent.parent / ".github" / "workflows" / "ci.yml" +jobs = json.loads(subprocess.check_output([ + "ruby", "-ryaml", "-rjson", "-e", "puts JSON.generate(YAML.load_file(ARGV[0]))", str(workflow) +]))["jobs"] +gate = jobs["web-test"] +assert gate["name"] == "web test + build" +assert gate["needs"] == "web-checks" and gate["if"] == "always()" +for result in ["success", "failure", "cancelled", "skipped", ""]: + status = subprocess.run(["bash", "-c", gate["steps"][0]["run"]], + env={**os.environ, "WEB_RESULT": result}).returncode + assert (status == 0) == (result == "success"), result + +print("CI routing, template validation, and web gate checks passed")